From 4f43c4abed68f3d877755aa39f66b33b73c8156e Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Tue, 3 Oct 2023 18:52:10 -0700 Subject: [PATCH 01/79] Imporve /open command: detetive target before open. --- doc/QuickStart zh-CN.md | 1 + src/aios_kernel/workflow_env.py | 5 ----- src/component/agent_manager/agent_manager.py | 6 ++++++ src/component/workflow_manager/workflow_manager.py | 8 +++++++- src/service/aios_shell/aios_shell.py | 12 +++++++++++- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/doc/QuickStart zh-CN.md b/doc/QuickStart zh-CN.md index 326229e..ceddc63 100644 --- a/doc/QuickStart zh-CN.md +++ b/doc/QuickStart zh-CN.md @@ -1,4 +1,5 @@ # OpenDAN Quick Start + OpenDAN (Open and Do Anything Now with AI) is revolutionizing the AI landscape with its Personal AI Operating System. Designed for seamless integration of diverse AI modules, it ensures unmatched interoperability. OpenDAN empowers users to craft powerful AI agents—from butlers and assistants to personal tutors and digital companions—all while retaining control. These agents can team up to tackle complex challenges, integrate with existing services, and command smart(IoT) devices. With OpenDAN, we're putting AI in your hands, making life simpler and smarter. diff --git a/src/aios_kernel/workflow_env.py b/src/aios_kernel/workflow_env.py index c62fc45..9e0e85c 100644 --- a/src/aios_kernel/workflow_env.py +++ b/src/aios_kernel/workflow_env.py @@ -39,11 +39,6 @@ class CalenderEnvironment(Environment): self.add_ai_function(SimpleAIFunction("get_time", "get current time", self._get_now)) - - #self.add_ai_function(SimpleAIFunction("serach_events", - # "search events in calender", - # self._search_events)) - get_param = { "start_time": "start time (UTC) of event", "end_time": "end time (UTC) of event" diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index 083b680..06ed62c 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -48,6 +48,12 @@ class AgentManager: pass + async def is_exist(self,agent_id:str) -> bool: + the_aget = await self.get(agent_id) + if the_aget: + return True + return False + async def get(self,agent_id:str) -> AIAgent: the_agent = self.loaded_agent_instance.get(agent_id) if the_agent: diff --git a/src/component/workflow_manager/workflow_manager.py b/src/component/workflow_manager/workflow_manager.py index ee5ef9e..28bd523 100644 --- a/src/component/workflow_manager/workflow_manager.py +++ b/src/component/workflow_manager/workflow_manager.py @@ -57,7 +57,13 @@ class WorkflowManager: if await self._load_workflow_agents(sub_workflow) is False: return False return True - + + async def is_exist(self,workflow_id:str) -> bool: + the_workflow = await self.get_workflow(workflow_id) + if the_workflow: + return True + return False + async def get_workflow(self,workflow_id:str) -> Workflow: the_workflow : Workflow = self.loaded_workflow.get(workflow_id) if the_workflow: diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index b10ac84..43441b4 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -233,7 +233,7 @@ class AIOS_Shell: match tunnel_type: case "telegram": tunnel_config["type"] = "TelegramTunnel" - input_table["token"] = UserConfigItem("telegram bot token") + input_table["token"] = UserConfigItem("telegram bot token\n You can get it from https://t.me/BotFather ,read https://core.telegram.org/bots#how-do-i-create-a-bot for more details") input_table["allow"] = UserConfigItem("allow group (default is member,you can choose contact or guest)") case "email": tunnel_config["type"] = "EmailTunnel" @@ -491,6 +491,16 @@ class AIOS_Shell: else: topic = "default" + target_exist = False + if await AgentManager.get_instance().is_exist(target_id): + target_exist = True + if await WorkflowManager.get_instance().is_exist(target_id): + target_exist = True + + if target_exist is False: + show_text = FormattedText([("class:error", f"Target {target_id} not exist!")]) + return show_text + self.current_target = target_id self.current_topic = topic show_text = FormattedText([("class:title", f"current session switch to {topic}@{target_id}")]) From 3c31e2f0ecb7e7316be8c3bf39908b5890489856 Mon Sep 17 00:00:00 2001 From: wugren Date: Mon, 9 Oct 2023 11:35:50 +0800 Subject: [PATCH 02/79] Fix bugs --- src/aios_kernel/environment.py | 37 +++++++++++++++++++++++----------- src/aios_kernel/role.py | 15 +++++++------- src/aios_kernel/workflow.py | 21 ++++++++++--------- 3 files changed, 43 insertions(+), 30 deletions(-) diff --git a/src/aios_kernel/environment.py b/src/aios_kernel/environment.py index 1e8cdab..cf5e9b3 100644 --- a/src/aios_kernel/environment.py +++ b/src/aios_kernel/environment.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) class EnvironmentEvent(ABC): @abstractmethod def display(self) -> str: - pass + pass EnvironmentEventHandler = Callable[[str,EnvironmentEvent],Awaitable[Any]] @@ -21,7 +21,7 @@ class Environment: @classmethod def get_env_by_id(cls,env_id:str): return cls._all_env.get(env_id) - + @classmethod def set_env_by_id(cls,id,env): assert id == env.get_id() @@ -39,7 +39,7 @@ class Environment: def get_id(self) -> str: return self.env_id - + def add_owner_env(self,env) -> None: self.owner_env[env.get_id()] = env @@ -51,11 +51,20 @@ class Environment: def add_ai_function(self,func:AIFunction) -> None: if self.functions.get(func.get_name()) is not None: logger.warn(f"add ai_function {func.get_name()} in env {self.env_id}:function already exist") - + self.functions[func.get_name()] = func def get_ai_function(self,func_name:str) -> AIFunction: - return self.functions.get(func_name) + func = self.functions.get(func_name) + if func is not None: + return func + + for owner_env in self.owner_env.values(): + func = owner_env.get_ai_function(func_name) + if func is not None: + return func + + return None #def enable_ai_function(self,func_name:str) -> None: # pass @@ -64,7 +73,11 @@ class Environment: # pass def get_all_ai_functions(self) -> List[AIFunction]: - return self.functions.values() + func_list = [] + func_list.extend(self.functions.values()) + for owner_env in self.owner_env.values(): + func_list.extend(owner_env.get_all_ai_functions()) + return func_list @abstractmethod def _do_get_value(self,key:str) -> Optional[str]: @@ -93,7 +106,7 @@ class Environment: return logger.warn(f"remove event_handler {event_id} in env {self.env_id}:handler not found") - + async def fire_event(self,event_id:str,event:EnvironmentEvent) -> None: handler_list = self.event_handlers.get(event_id) if handler_list is not None: @@ -102,15 +115,15 @@ class Environment: else: logger.debug(f"fire event {event_id} in env {self.env_id}:handler not found") return - + def __getitem__(self, key): return self.get_value(key) - + def get_value(self,key:str) -> Optional[str]: handler = self.get_handlers.get(key) if handler is not None: return handler() - + s = self.values.get(key) if isinstance(s,str): return s @@ -119,7 +132,7 @@ class Environment: s = self._do_get_value(key) if s is not None: - return s + return s if self.owner_env is not None: for env in self.owner_env.values(): s = env.get_value(key) @@ -128,7 +141,7 @@ class Environment: logger.warn(f"get value {key} in env {self.env_id} failed!,not found") return None - + def set_value(self, key: str, str_value: str,is_storage:bool = True): logger.info(f"set value {key} in env {self.env_id} to {str_value}") self.values[key] = str_value diff --git a/src/aios_kernel/role.py b/src/aios_kernel/role.py index 83d64d6..560d4db 100644 --- a/src/aios_kernel/role.py +++ b/src/aios_kernel/role.py @@ -35,18 +35,18 @@ class AIRole: if self.prompt.load_from_config(prompt_node) is False: logging.error("load prompt failed!") return False - + intro_node = config.get("intro") if intro_node is not None: self.introduce = intro_node history_node = config.get("history_len") - if history_node: + if history_node is not None: self.history_len = int(history_node) if config.get("enable_function") is not None: self.enable_function_list = config["enable_function"] - + def get_role_id(self) -> str: return self.role_id @@ -55,15 +55,15 @@ class AIRole: def get_name(self) -> str: return self.role_name - + def get_prompt(self) -> AgentPrompt: return self.prompt - + class AIRoleGroup: def __init__(self) -> None: self.roles : dict[str,AIRole] = {} self.owner_name : str = None - + def load_from_config(self,config:dict) -> bool: for k,v in config.items(): role = AIRole() @@ -72,10 +72,9 @@ class AIRoleGroup: return False role.role_id = self.owner_name + "." + k self.roles[k] = role - + return True def get(self,role_name:str) -> AIRole: return self.roles.get(role_name) - \ No newline at end of file diff --git a/src/aios_kernel/workflow.py b/src/aios_kernel/workflow.py index a25e202..5dae542 100644 --- a/src/aios_kernel/workflow.py +++ b/src/aios_kernel/workflow.py @@ -196,7 +196,7 @@ class Workflow: targets = self._parse_msg_target(msg.target) if len(targets) > 1: return await self._forword_msg(targets,msg) - + #0 we don't support workflow join a group right now, this cloud be a feture in future if msg.mentions is not None: logger.warn(f"workflow {self.workflow_id} recv a group chat message,not support ignore!") @@ -254,8 +254,8 @@ class Workflow: def check_args(func_item:FunctionItem): match func_name: case "send_msg":# sendmsg($target_id,$msg_content) - if len(func_args) != 1: - logger.error(f"parse sendmsg failed! {func_call}") + if len(func_item.args) != 1: + logger.error(f"parse sendmsg failed! {func_item}") return False new_msg = AgentMsg() target_id = func_item.args[0] @@ -266,8 +266,8 @@ class Workflow: is_need_wait = True case "post_msg":# postmsg($target_id,$msg_content) - if len(func_args) != 1: - logger.error(f"parse postmsg failed! {func_call}") + if len(func_item.args) != 1: + logger.error(f"parse postmsg failed! {func_item}") return False new_msg = AgentMsg() target_id = func_item.args[0] @@ -381,7 +381,7 @@ class Workflow: if the_role.enable_function_list is not None: if len(the_role.enable_function_list) > 0: if func_name not in the_role.enable_function_list: - logger.debug(f"agent {self.agent_id} ignore inner func:{func_name}") + logger.debug(f"agent {the_role.agent.agent_id} ignore inner func:{func_name}") continue else: continue @@ -410,6 +410,7 @@ class Workflow: except Exception as e: result_str = f"execute {func_name} error:{str(e)}" logger.error(f"llm execute inner func:{func_name} error:{e}") + logger.exception(e) inner_functions = self._get_inner_functions(the_role) @@ -442,7 +443,7 @@ class Workflow: prompt = AgentPrompt() - prompt.append(the_role.agent.prompt) + prompt.append(the_role.agent.agent_prompt) prompt.append(self.get_workflow_rule_prompt()) prompt.append(the_role.get_prompt()) # prompt.append(self._get_function_prompt(the_role.get_name())) @@ -465,7 +466,7 @@ class Workflow: logger.error(f"llm compute error:{task_result.error_str}") error_resp = msg.create_error_resp(task_result.error_str) return error_resp - + result_str = task_result.result_str logger.info(f"{the_role.role_id} process {msg.sender}:{msg.body},llm str is :{result_str}") @@ -513,7 +514,7 @@ class Workflow: workflow_chat_session.append(resp_msg) #await self.get_bus().resp_message(resp_msg) return resp_msg - case "waiting": + case "waiting": for sendmsg in result.send_msgs: target = sendmsg.target sendmsg.topic = msg.topic @@ -529,7 +530,7 @@ class Workflow: else: # message will be saved in role.process_message pass - + this_llm_resp_prompt = AgentPrompt() this_llm_resp_prompt.messages = [{"role":"assistant","content":result_str}] prompt.append(this_llm_resp_prompt) From b74b86b4d41bfba1d08833592266401cf71ccbf7 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Wed, 18 Oct 2023 11:19:11 -0700 Subject: [PATCH 03/79] =?UTF-8?q?=EF=BB=BFRefactor=20before=20imporve=20kn?= =?UTF-8?q?owledge=20base.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/webui.drawio | 150 ++++++++++++++ rootfs/agents/Lachlan/agent.toml | 1 - src/aios_kernel/__init__.py | 4 +- src/aios_kernel/agent.py | 311 ++++++++++++----------------- src/aios_kernel/agent_base.py | 317 ++++++++++++++++++++++++++++++ src/aios_kernel/agent_message.py | 169 ---------------- src/aios_kernel/bus.py | 2 +- src/aios_kernel/chatsession.py | 2 +- src/aios_kernel/compute_kernel.py | 16 +- src/aios_kernel/email_tunnel.py | 2 +- src/aios_kernel/knowledge_base.py | 3 +- src/aios_kernel/open_ai_node.py | 2 +- src/aios_kernel/role.py | 2 +- src/aios_kernel/tg_tunnel.py | 2 +- src/aios_kernel/tunnel.py | 2 +- src/aios_kernel/workflow.py | 78 +------- src/aios_kernel/workspace_env.py | 50 +++++ src/knowledge/object/object.py | 11 ++ src/knowledge/store.py | 4 + 19 files changed, 683 insertions(+), 445 deletions(-) create mode 100644 doc/webui.drawio create mode 100644 src/aios_kernel/agent_base.py delete mode 100644 src/aios_kernel/agent_message.py diff --git a/doc/webui.drawio b/doc/webui.drawio new file mode 100644 index 0000000..7ae4b10 --- /dev/null +++ b/doc/webui.drawio @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rootfs/agents/Lachlan/agent.toml b/rootfs/agents/Lachlan/agent.toml index b0cf561..87822b4 100644 --- a/rootfs/agents/Lachlan/agent.toml +++ b/rootfs/agents/Lachlan/agent.toml @@ -8,5 +8,4 @@ max_token_size=4000 role = "system" content = """ Your name is Lachlan, and you are my advanced private Spanish tutor. -You are also a local guide familiar with the history of the Inca Empire. While teaching me Spanish, you will introduce some related historical and cultural origins. """ \ No newline at end of file diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index 42d1b65..e19ba35 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -1,7 +1,7 @@ from .environment import Environment,EnvironmentEvent -from .agent_message import AgentMsg,AgentMsgStatus,AgentMsgType +from .agent_base import AgentMsg,AgentMsgStatus,AgentMsgType,AgentPrompt from .chatsession import AIChatSession -from .agent import AIAgent,AIAgentTemplete,AgentPrompt +from .agent import AIAgent,AIAgentTemplete from .compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType from .compute_node import ComputeNode,LocalComputeNode from .open_ai_node import OpenAI_ComputeNode diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 563d49e..5d9c756 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -10,73 +10,20 @@ import shlex import datetime import copy -from .agent_message import AgentMsg, AgentMsgStatus, AgentMsgType,FunctionItem,LLMResult +from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType,FunctionItem,LLMResult,AgentPrompt from .chatsession import AIChatSession from .compute_task import ComputeTaskResult,ComputeTaskResultCode from .ai_function import AIFunction from .environment import Environment from .contact_manager import ContactManager,Contact,FamilyMember +from .knowledge_base import KnowledgeBase +from .compute_kernel import ComputeKernel +from .bus import AIBus + +from knowledge import * logger = logging.getLogger(__name__) -class AgentPrompt: - def __init__(self,prompt_str = None) -> None: - self.messages = [] - if prompt_str: - self.messages.append({"role":"user","content":prompt_str}) - self.system_message = None - - def as_str(self)->str: - result_str = "" - if self.system_message: - result_str += self.system_message.get("role") + ":" + self.system_message.get("content") + "\n" - if self.messages: - for msg in self.messages: - result_str += msg.get("role") + ":" + msg.get("content") + "\n" - - return result_str - - def to_message_list(self): - result = [] - if self.system_message: - result.append(self.system_message) - result.extend(self.messages) - return result - - def append(self,prompt): - if prompt is None: - return - - if prompt.system_message is not None: - if self.system_message is None: - self.system_message = copy.deepcopy(prompt.system_message) - else: - self.system_message["content"] += prompt.system_message.get("content") - - self.messages.extend(prompt.messages) - - def get_prompt_token_len(self): - result = 0 - - if self.system_message: - result += len(self.system_message.get("content")) - for msg in self.messages: - result += len(msg.get("content")) - - return result - - def load_from_config(self,config:list) -> bool: - if isinstance(config,list) is not True: - logger.error("prompt is not list!") - return False - self.messages = [] - for msg in config: - if msg.get("role") == "system": - self.system_message = msg - else: - self.messages.append(msg) - return True - class AIAgentTemplete: def __init__(self) -> None: @@ -106,10 +53,13 @@ class AIAgentTemplete: class AIAgent: def __init__(self) -> None: + self.role_prompt:AgentPrompt = None self.agent_prompt:AgentPrompt = None self.agent_think_prompt:AgentPrompt = None self.llm_model_name:str = None self.max_token_size:int = 3600 + + self.agent_id:str = None self.template_id:str = None self.fullname:str = None @@ -122,6 +72,9 @@ class AIAgent: self.contact_prompt_str = None self.history_len = 10 + self.learn_token_limit = 500 + self.learn_prompt = None + self.chat_db = None self.unread_msg = Queue() # msg from other agent self.owner_env : Environment = None @@ -189,77 +142,31 @@ class AIAgent: if config.get("history_len"): self.history_len = int(config.get("history_len")) return True + + def get_id(self) -> str: + return self.agent_id + def get_fullname(self) -> str: + return self.fullname - def _get_llm_result_type(self,llm_result_str:str) -> LLMResult: - r = LLMResult() - if llm_result_str is None: - r.state = "ignore" - return r - if llm_result_str == "ignore": - r.state = "ignore" - return r + def get_template_id(self) -> str: + return self.template_id - lines = llm_result_str.splitlines() - is_need_wait = False + def get_llm_model_name(self) -> str: + return self.llm_model_name - def check_args(func_item:FunctionItem): - match func_name: - case "send_msg":# sendmsg($target_id,$msg_content) - if len(func_args) != 1: - logger.error(f"parse sendmsg failed! {func_name}") - return False - new_msg = AgentMsg() - target_id = func_item.args[0] - msg_content = func_item.body - new_msg.set(self.agent_id,target_id,msg_content) + def get_max_token_size(self) -> int: + return self.max_token_size + + def get_llm_learn_token_limit(self) -> int: + return self.learn_token_limit + + def get_learn_prompt(self) -> AgentPrompt: + return self.learn_prompt + + def get_agent_role_prompt(self) -> AgentPrompt: + return self.role_prompt - r.send_msgs.append(new_msg) - is_need_wait = True - - case "post_msg":# postmsg($target_id,$msg_content) - if len(func_args) != 1: - logger.error(f"parse postmsg failed! {func_name}") - return False - new_msg = AgentMsg() - target_id = func_item.args[0] - msg_content = func_item.body - new_msg.set(self.agent_id,target_id,msg_content) - r.post_msgs.append(new_msg) - - case "call":# call($func_name,$args_str) - r.calls.append(func_item) - is_need_wait = True - return True - case "post_call": # post_call($func_name,$args_str) - r.post_calls.append(func_item) - return True - - current_func : FunctionItem = None - for line in lines: - if line.startswith("##/"): - if current_func: - if check_args(current_func) is False: - r.resp += current_func.dumps() - - func_name,func_args = AgentMsg.parse_function_call(line[3:]) - current_func = FunctionItem(func_name,func_args) - else: - if current_func: - current_func.append_body(line + "\n") - else: - r.resp += line + "\n" - - if current_func: - if check_args(current_func) is False: - r.resp += current_func.dumps() - - if len(r.send_msgs) > 0 or len(r.calls) > 0: - r.state = "waiting" - else: - r.state = "reponsed" - - return r def _get_remote_user_prompt(self,remote_user:str) -> AgentPrompt: cm = ContactManager.get_instance() @@ -314,18 +221,18 @@ class AIAgent: return result_func,result_len - async def _execute_func(self,inenr_func_call_node:dict,prompt:AgentPrompt,org_msg:AgentMsg,stack_limit = 5) -> [str,int]: - from .compute_kernel import ComputeKernel - - func_name = inenr_func_call_node.get("name") - arguments = json.loads(inenr_func_call_node.get("arguments")) + async def _execute_func(self,inner_func_call_node:dict,prompt:AgentPrompt,inner_functions,org_msg:AgentMsg=None,stack_limit = 5) -> ComputeTaskResult: + func_name = inner_func_call_node.get("name") + arguments = json.loads(inner_func_call_node.get("arguments")) logger.info(f"llm execute inner func:{func_name} ({json.dumps(arguments)})") func_node : AIFunction = self.owner_env.get_ai_function(func_name) if func_node is None: result_str = f"execute {func_name} error,function not found" else: - ineternal_call_record = AgentMsg.create_internal_call_msg(func_name,arguments,org_msg.get_msg_id(),org_msg.target) + if org_msg: + ineternal_call_record = AgentMsg.create_internal_call_msg(func_name,arguments,org_msg.get_msg_id(),org_msg.target) + try: result_str:str = await func_node.execute(**arguments) except Exception as e: @@ -334,27 +241,29 @@ class AIAgent: logger.info("llm execute inner func result:" + result_str) - inner_functions,inner_function_len = self._get_inner_functions() + prompt.messages.append({"role":"function","content":result_str,"name":func_name}) task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"llm compute error:{task_result.error_str}") - return task_result.error_str,1 + return task_result ineternal_call_record.result_str = task_result.result_str ineternal_call_record.done_time = time.time() - org_msg.inner_call_chain.append(ineternal_call_record) + if org_msg: + org_msg.inner_call_chain.append(ineternal_call_record) + inner_func_call_node = None if stack_limit > 0: - result_message = task_result.result.get("message") + result_message : dict = task_result.result.get("message") if result_message: inner_func_call_node = result_message.get("function_call") if inner_func_call_node: return await self._execute_func(inner_func_call_node,prompt,org_msg,stack_limit-1) else: - return task_result.result_str,0 - + return task_result + async def _get_agent_prompt(self) -> AgentPrompt: return self.agent_prompt @@ -384,12 +293,12 @@ class AIAgent: #4) advanced: reload all chatrecord,and think the topic of message. #5) some topic could be end(not be thinked in futured ) return + async def think_chatsession(self,session_id): if self.agent_think_prompt is None: return logger.info(f"agent {self.agent_id} think session {session_id}") - from .compute_kernel import ComputeKernel chatsession = AIChatSession.get_session_by_id(session_id,self.chat_db) while True: @@ -420,10 +329,7 @@ class AIAgent: return - async def _process_group_chat_msg(self,msg:AgentMsg) -> AgentMsg: - from .compute_kernel import ComputeKernel - from .bus import AIBus - + async def _process_group_chat_msg(self,msg:AgentMsg) -> AgentMsg: session_topic = msg.target + "#" + msg.topic chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) need_process = False @@ -453,26 +359,13 @@ class AIAgent: prompt.append(msg_prompt) logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) + task_result = await self._do_llm_complection(prompt,inner_functions,msg) if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"llm compute error:{task_result.error_str}") error_resp = msg.create_error_resp(task_result.error_str) return error_resp final_result = task_result.result_str - - result_message = task_result.result.get("message") - if result_message: - inner_func_call_node = result_message.get("function_call") - if inner_func_call_node: - #TODO to save more token ,can i use msg_prompt? - call_prompt : AgentPrompt = copy.deepcopy(prompt) - final_result,error_code = await self._execute_func(inner_func_call_node,call_prompt,msg) - if error_code != 0: - error_resp = msg.create_error_resp(final_result) - return error_resp - - llm_result : LLMResult = self._get_llm_result_type(final_result) + llm_result : LLMResult = LLMResult.from_str(final_result) is_ignore = False result_prompt_str = "" match llm_result.state: @@ -481,6 +374,7 @@ class AIAgent: case "waiting": for sendmsg in llm_result.send_msgs: target = sendmsg.target + sendmsg.sender = self.agent_id sendmsg.topic = msg.topic sendmsg.prev_msg_id = msg.get_msg_id() send_resp = await AIBus.get_default_bus().send_message(sendmsg) @@ -502,16 +396,12 @@ class AIAgent: return None async def _process_msg(self,msg:AgentMsg) -> AgentMsg: - from .compute_kernel import ComputeKernel - from .bus import AIBus - if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: return await self._process_group_chat_msg(msg) session_topic = msg.get_sender() + "#" + msg.topic chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) - msg_prompt = AgentPrompt() msg_prompt.messages = [{"role":"user","content":msg.body}] @@ -530,26 +420,15 @@ class AIAgent: prompt.append(msg_prompt) logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) + #task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) + task_result = await self._do_llm_complection(prompt,inner_functions,msg) if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"llm compute error:{task_result.error_str}") error_resp = msg.create_error_resp(task_result.error_str) return error_resp final_result = task_result.result_str - result_message = task_result.result.get("message") - if result_message: - inner_func_call_node = result_message.get("function_call") - if inner_func_call_node: - #TODO to save more token ,can i use msg_prompt? - call_prompt : AgentPrompt = copy.deepcopy(prompt) - final_result,error_code = await self._execute_func(inner_func_call_node,call_prompt,msg) - if error_code != 0: - error_resp = msg.create_error_resp(final_result) - return error_resp - - llm_result : LLMResult = self._get_llm_result_type(final_result) + llm_result : LLMResult = LLMResult.from_str(final_result) is_ignore = False result_prompt_str = "" match llm_result.state: @@ -557,6 +436,7 @@ class AIAgent: is_ignore = True case "waiting": for sendmsg in llm_result.send_msgs: + sendmsg.sender = self.agent_id target = sendmsg.target sendmsg.topic = msg.topic sendmsg.prev_msg_id = msg.get_msg_id() @@ -578,20 +458,7 @@ class AIAgent: return None - def get_id(self) -> str: - return self.agent_id - def get_fullname(self) -> str: - return self.fullname - - def get_template_id(self) -> str: - return self.template_id - - def get_llm_model_name(self) -> str: - return self.llm_model_name - - def get_max_token_size(self) -> int: - return self.max_token_size async def _get_history_prompt_for_think(self,chatsession:AIChatSession,summary:str,system_token_len:int,pos:int)->(AgentPrompt,int): history_len = (self.max_token_size * 0.7) - system_token_len @@ -660,6 +527,74 @@ class AIAgent: return result_prompt,result_token_len + async def _do_llm_complection(self,prompt:AgentPrompt,inner_functions:dict,org_msg:AgentMsg=None) -> ComputeTaskResult: + from .compute_kernel import ComputeKernel + #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"llm compute error:{task_result.error_str}") + #error_resp = msg.create_error_resp(task_result.error_str) + return task_result + + result_message = task_result.result.get("message") + inner_func_call_node = None + if result_message: + inner_func_call_node = result_message.get("function_call") + + if inner_func_call_node: + call_prompt : AgentPrompt = copy.deepcopy(prompt) + task_result = await self._execute_func(inner_func_call_node,call_prompt,inner_functions,org_msg) + + return task_result + + def parser_learn_llm_result(self,llm_result:str): + pass + + async def _llm_read_article(self,kb:KnowledgeBase,item:KnowledgeObject) -> ComputeTaskResult: + #kb_env = KnowledgeBaseFileSystemEnvironment() + full_content = item.get_article_full_content() + full_content_len = ComputeKernel.llm_num_tokens_from_text(full_content,self.get_llm_model_name()) + if full_content_len < self.get_llm_learn_token_limit(): + + # 短文章不用总结catelog + #path_list,summary = llm_get_summary(summary,full_content) + prompt = self.get_agent_role_prompt() + learn_prompt = self.get_learn_prompt() + cotent_prompt = AgentPrompt(full_content) + prompt.append(learn_prompt) + prompt.append(cotent_prompt) + + env_functions = self._get_inner_functions() + + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,env_functions) + if task_result.result_code != ComputeTaskResultCode.OK: + return task_result + path_list,summary = self.parser_learn_llm_result(task_result.result_str) + + else: + # 用传统方法对文章进行一些处理,目的是尽可能减少LLM调用的次数 + catelog = item.get_articl_catelog() + chunk_content = full_content.read(self.get_llm_learn_token_limit()) + summary = kb.try_get_summary(catelog,full_content) + + while chunk_content is not None: + #path_list,summarycatelog = llm_get_summary(summary,chunk_content) + #learn_prompt = self.get_learn_prompt_with_summary() + + prompt = AgentPrompt("summary") + learn_prompt.append(prompt) + prompt = AgentPrompt(chunk_content) + learn_prompt.append(prompt) + + #llm_result = self.do_llm_competion(learn_prompt) + #path_list,summary,catelog = parser_learn_llm_result(llm_result) + + #chunk_content = full_content.read(self.get_llm_learn_token_limit()) + + kb.insert_item(path_list,item,catelog,summary) + + + async def _get_prompt_from_session(self,chatsession:AIChatSession,system_token_len,input_token_len) -> AgentPrompt: # TODO: get prompt from group chat is different from single chat diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py new file mode 100644 index 0000000..cbd9bef --- /dev/null +++ b/src/aios_kernel/agent_base.py @@ -0,0 +1,317 @@ +import copy +import logging +from enum import Enum +import uuid +import time +import re +import shlex +from typing import List +from .ai_function import FunctionItem + +logger = logging.getLogger(__name__) + +class AgentMsgType(Enum): + TYPE_MSG = 0 + TYPE_GROUPMSG = 1 + TYPE_INTERNAL_CALL = 10 + TYPE_ACTION = 20 + TYPE_EVENT = 30 + TYPE_SYSTEM = 40 + + +class AgentMsgStatus(Enum): + RESPONSED = 0 + INIT = 1 + SENDING = 2 + PROCESSING = 3 + ERROR = 4 + RECVED = 5 + EXECUTED = 6 + +# msg is a msg / msg resp +# msg body可以有内容类型(MIME标签),text, image, voice, video, file,以及富文本(html) +# msg is a inner function call with result +# msg is a Action with result + +# qutoe Msg +# forword msg +# reply msg + +# 逻辑上的同一个Message在同一个session中看到的msgid相同 +# 在不同的session中看到的msgid不同 + +class AgentMsg: + def __init__(self,msg_type=AgentMsgType.TYPE_MSG) -> None: + self.msg_id = "msg#" + uuid.uuid4().hex + self.msg_type:AgentMsgType = msg_type + + self.prev_msg_id:str = None + self.quote_msg_id:str = None + self.rely_msg_id:str = None # if not none means this is a respone msg + self.session_id:str = None + + #forword info + + + self.create_time = 0 + self.done_time = 0 + self.topic:str = None # topic is use to find session, not store in db + + self.sender:str = None # obj_id.sub_objid@tunnel_id + self.target:str = None + self.mentions:[] = None #use in group chat only + #self.title:str = None + self.body:str = None + self.body_mime:str = None #//default is "text/plain",encode is utf8 + + #type is call / action + self.func_name = None + self.args = None + self.result_str = None + + #type is event + self.event_name = None + self.event_args = None + + self.status = AgentMsgStatus.INIT + self.inner_call_chain = [] + self.resp_msg = None + + @classmethod + def create_internal_call_msg(self,func_name:str,args:dict,prev_msg_id:str,caller:str): + msg = AgentMsg(AgentMsgType.TYPE_INTERNAL_CALL) + msg.create_time = time.time() + msg.func_name = func_name + msg.args = args + msg.prev_msg_id = prev_msg_id + msg.sender = caller + return msg + + def create_action_msg(self,action_name:str,args:dict,caller:str): + msg = AgentMsg(AgentMsgType.TYPE_ACTION) + msg.create_time = time.time() + msg.func_name = action_name + msg.args = args + msg.prev_msg_id = self.msg_id + msg.topic = self.topic + msg.sender = caller + return msg + + def create_error_resp(self,error_msg:str): + resp_msg = AgentMsg(AgentMsgType.TYPE_SYSTEM) + resp_msg.create_time = time.time() + + resp_msg.rely_msg_id = self.msg_id + resp_msg.body = error_msg + resp_msg.topic = self.topic + resp_msg.sender = self.target + resp_msg.target = self.sender + + return resp_msg + + def create_resp_msg(self,resp_body): + resp_msg = AgentMsg() + resp_msg.create_time = time.time() + + resp_msg.rely_msg_id = self.msg_id + resp_msg.sender = self.target + resp_msg.target = self.sender + resp_msg.body = resp_body + resp_msg.topic = self.topic + + return resp_msg + + def create_group_resp_msg(self,sender_id,resp_body): + resp_msg = AgentMsg(AgentMsgType.TYPE_GROUPMSG) + resp_msg.create_time = time.time() + + resp_msg.rely_msg_id = self.msg_id + resp_msg.target = self.target + resp_msg.sender = sender_id + resp_msg.body = resp_body + resp_msg.topic = self.topic + + return resp_msg + + def set(self,sender:str,target:str,body:str,topic:str=None) -> None: + self.sender = sender + self.target = target + self.body = body + self.create_time = time.time() + if topic: + self.topic = topic + + def get_msg_id(self) -> str: + return self.msg_id + + def get_sender(self) -> str: + return self.sender + + def get_target(self) -> str: + return self.target + + def get_prev_msg_id(self) -> str: + return self.prev_msg_id + + def get_quote_msg_id(self) -> str: + return self.quote_msg_id + + @classmethod + def parse_function_call(cls,func_string:str): + str_list = shlex.split(func_string) + func_name = str_list[0] + params = str_list[1:] + return func_name, params + +class AgentPrompt: + def __init__(self,prompt_str = None) -> None: + self.messages = [] + if prompt_str: + self.messages.append({"role":"user","content":prompt_str}) + self.system_message = None + + def as_str(self)->str: + result_str = "" + if self.system_message: + result_str += self.system_message.get("role") + ":" + self.system_message.get("content") + "\n" + if self.messages: + for msg in self.messages: + result_str += msg.get("role") + ":" + msg.get("content") + "\n" + + return result_str + + def to_message_list(self): + result = [] + if self.system_message: + result.append(self.system_message) + result.extend(self.messages) + return result + + def append(self,prompt): + if prompt is None: + return + + if prompt.system_message is not None: + if self.system_message is None: + self.system_message = copy.deepcopy(prompt.system_message) + else: + self.system_message["content"] += prompt.system_message.get("content") + + self.messages.extend(prompt.messages) + + def get_prompt_token_len(self): + result = 0 + + if self.system_message: + result += len(self.system_message.get("content")) + for msg in self.messages: + result += len(msg.get("content")) + + return result + + def load_from_config(self,config:list) -> bool: + if isinstance(config,list) is not True: + logger.error("prompt is not list!") + return False + self.messages = [] + for msg in config: + if msg.get("role") == "system": + self.system_message = msg + else: + self.messages.append(msg) + return True + +class LLMResult: + def __init__(self) -> None: + self.state : str = "ignore" + self.resp : str = "" + self.paragraphs : dict[str,FunctionItem] = [] + self.post_msgs : List[AgentMsg] = [] + self.send_msgs : List[AgentMsg] = [] + self.calls : List[FunctionItem] = [] + self.post_calls : List[FunctionItem] = [] + + @classmethod + def from_str(self,llm_result_str:str,valid_func:List[str]=None) -> 'LLMResult': + r = LLMResult() + if llm_result_str is None: + r.state = "ignore" + return r + if llm_result_str == "ignore": + r.state = "ignore" + return r + + lines = llm_result_str.splitlines() + is_need_wait = False + + def check_args(func_item:FunctionItem): + match func_name: + case "send_msg":# /send_msg $target_id + if len(func_args) != 1: + return False + + new_msg = AgentMsg() + target_id = func_item.args[0] + msg_content = func_item.body + new_msg.set("",target_id,msg_content) + + r.send_msgs.append(new_msg) + is_need_wait = True + return True + + case "post_msg":# /post_msg $target_id + if len(func_args) != 1: + return False + + new_msg = AgentMsg() + target_id = func_item.args[0] + msg_content = func_item.body + new_msg.set("",target_id,msg_content) + r.post_msgs.append(new_msg) + return True + + case "call":# /call $func_name $args_str + r.calls.append(func_item) + is_need_wait = True + return True + case "post_call": # /post_call $func_name,$args_str + r.post_calls.append(func_item) + return True + case _: + if valid_func is not None: + if func_name in valid_func: + r.paragraphs[func_name] = func_item + return True + + return False + + + current_func : FunctionItem = None + for line in lines: + if line.startswith("##/"): + if current_func: + if check_args(current_func) is False: + r.resp += current_func.dumps() + + func_name,func_args = AgentMsg.parse_function_call(line[3:]) + current_func = FunctionItem(func_name,func_args) + else: + if current_func: + current_func.append_body(line + "\n") + else: + r.resp += line + "\n" + + if current_func: + if check_args(current_func) is False: + r.resp += current_func.dumps() + + if len(r.send_msgs) > 0 or len(r.calls) > 0: + r.state = "waiting" + else: + r.state = "reponsed" + + return r + +class BaseAIAgent: + def __init__(self) -> None: + pass \ No newline at end of file diff --git a/src/aios_kernel/agent_message.py b/src/aios_kernel/agent_message.py deleted file mode 100644 index c5dc26d..0000000 --- a/src/aios_kernel/agent_message.py +++ /dev/null @@ -1,169 +0,0 @@ -from enum import Enum -import uuid -import time -import re -import shlex -from typing import List -from .ai_function import FunctionItem - -class AgentMsgType(Enum): - TYPE_MSG = 0 - TYPE_GROUPMSG = 1 - TYPE_INTERNAL_CALL = 10 - TYPE_ACTION = 20 - TYPE_EVENT = 30 - TYPE_SYSTEM = 40 - - -class AgentMsgStatus(Enum): - RESPONSED = 0 - INIT = 1 - SENDING = 2 - PROCESSING = 3 - ERROR = 4 - RECVED = 5 - EXECUTED = 6 - -# msg is a msg / msg resp -# msg body可以有内容类型(MIME标签),text, image, voice, video, file,以及富文本(html) -# msg is a inner function call with result -# msg is a Action with result - -# qutoe Msg -# forword msg -# reply msg - -# 逻辑上的同一个Message在同一个session中看到的msgid相同 -# 在不同的session中看到的msgid不同 - -class AgentMsg: - def __init__(self,msg_type=AgentMsgType.TYPE_MSG) -> None: - self.msg_id = "msg#" + uuid.uuid4().hex - self.msg_type:AgentMsgType = msg_type - - self.prev_msg_id:str = None - self.quote_msg_id:str = None - self.rely_msg_id:str = None # if not none means this is a respone msg - self.session_id:str = None - - #forword info - - - self.create_time = 0 - self.done_time = 0 - self.topic:str = None # topic is use to find session, not store in db - - self.sender:str = None # obj_id.sub_objid@tunnel_id - self.target:str = None - self.mentions:[] = None #use in group chat only - #self.title:str = None - self.body:str = None - self.body_mime:str = None #//default is "text/plain",encode is utf8 - - #type is call / action - self.func_name = None - self.args = None - self.result_str = None - - #type is event - self.event_name = None - self.event_args = None - - self.status = AgentMsgStatus.INIT - self.inner_call_chain = [] - self.resp_msg = None - - @classmethod - def create_internal_call_msg(self,func_name:str,args:dict,prev_msg_id:str,caller:str): - msg = AgentMsg(AgentMsgType.TYPE_INTERNAL_CALL) - msg.create_time = time.time() - msg.func_name = func_name - msg.args = args - msg.prev_msg_id = prev_msg_id - msg.sender = caller - return msg - - def create_action_msg(self,action_name:str,args:dict,caller:str): - msg = AgentMsg(AgentMsgType.TYPE_ACTION) - msg.create_time = time.time() - msg.func_name = action_name - msg.args = args - msg.prev_msg_id = self.msg_id - msg.topic = self.topic - msg.sender = caller - return msg - - def create_error_resp(self,error_msg:str): - resp_msg = AgentMsg(AgentMsgType.TYPE_SYSTEM) - resp_msg.create_time = time.time() - - resp_msg.rely_msg_id = self.msg_id - resp_msg.body = error_msg - resp_msg.topic = self.topic - resp_msg.sender = self.target - resp_msg.target = self.sender - - return resp_msg - - def create_resp_msg(self,resp_body): - resp_msg = AgentMsg() - resp_msg.create_time = time.time() - - resp_msg.rely_msg_id = self.msg_id - resp_msg.sender = self.target - resp_msg.target = self.sender - resp_msg.body = resp_body - resp_msg.topic = self.topic - - return resp_msg - - def create_group_resp_msg(self,sender_id,resp_body): - resp_msg = AgentMsg(AgentMsgType.TYPE_GROUPMSG) - resp_msg.create_time = time.time() - - resp_msg.rely_msg_id = self.msg_id - resp_msg.target = self.target - resp_msg.sender = sender_id - resp_msg.body = resp_body - resp_msg.topic = self.topic - - return resp_msg - - def set(self,sender:str,target:str,body:str,topic:str=None) -> None: - self.sender = sender - self.target = target - self.body = body - self.create_time = time.time() - if topic: - self.topic = topic - - def get_msg_id(self) -> str: - return self.msg_id - - def get_sender(self) -> str: - return self.sender - - def get_target(self) -> str: - return self.target - - def get_prev_msg_id(self) -> str: - return self.prev_msg_id - - def get_quote_msg_id(self) -> str: - return self.quote_msg_id - - @classmethod - def parse_function_call(cls,func_string:str): - str_list = shlex.split(func_string) - func_name = str_list[0] - params = str_list[1:] - return func_name, params - -class LLMResult: - def __init__(self) -> None: - self.state : str = "ignore" - self.resp : str = "" - self.post_msgs : List[AgentMsg] = [] - self.send_msgs : List[AgentMsg] = [] - self.calls : List[FunctionItem] = [] - self.post_calls : List[FunctionItem] = [] \ No newline at end of file diff --git a/src/aios_kernel/bus.py b/src/aios_kernel/bus.py index 2731702..ecbb676 100644 --- a/src/aios_kernel/bus.py +++ b/src/aios_kernel/bus.py @@ -1,5 +1,5 @@ from typing import Coroutine,Dict,Any -from .agent_message import AgentMsg,AgentMsgStatus,AgentMsgType +from .agent_base import AgentMsg,AgentMsgStatus,AgentMsgType import asyncio from asyncio import Queue diff --git a/src/aios_kernel/chatsession.py b/src/aios_kernel/chatsession.py index 1290d80..e5edcfc 100644 --- a/src/aios_kernel/chatsession.py +++ b/src/aios_kernel/chatsession.py @@ -7,7 +7,7 @@ import datetime import uuid import json -from .agent_message import AgentMsgType, AgentMsg, AgentMsgStatus +from .agent_base import AgentMsgType, AgentMsg, AgentMsgStatus class ChatSessionDB: def __init__(self, db_file): diff --git a/src/aios_kernel/compute_kernel.py b/src/aios_kernel/compute_kernel.py index 3f97708..f88a835 100644 --- a/src/aios_kernel/compute_kernel.py +++ b/src/aios_kernel/compute_kernel.py @@ -3,10 +3,12 @@ import random from typing import Optional import logging import asyncio +import tiktoken + from asyncio import Queue from knowledge import ObjectID -from .agent import AgentPrompt +from .agent_base import AgentPrompt from .compute_node import ComputeNode from .compute_task import ComputeTask, ComputeTaskState, ComputeTaskResult, ComputeTaskType,ComputeTaskResultCode @@ -104,6 +106,18 @@ class ComputeKernel: def is_task_support(self, task: ComputeTask) -> bool: return True + @staticmethod + def llm_num_tokens_from_text(text:str,model:str) -> int: + try: + encoding = tiktoken.encoding_for_model(model) + except KeyError: + logger.debug("Warning: model not found. Using cl100k_base encoding.") + encoding = tiktoken.get_encoding("cl100k_base") + + token_count = len(encoding.encode(text)) + return token_count + + # friendly interface for use: def llm_completion(self, prompt: AgentPrompt, mode_name: Optional[str] = None, max_token: int = 0,inner_functions = None): # craete a llm_work_task ,push on queue's end diff --git a/src/aios_kernel/email_tunnel.py b/src/aios_kernel/email_tunnel.py index c4f75b4..6190a0f 100644 --- a/src/aios_kernel/email_tunnel.py +++ b/src/aios_kernel/email_tunnel.py @@ -8,7 +8,7 @@ import logging import time import datetime from .tunnel import AgentTunnel -from .agent_message import AgentMsg +from .agent_base import AgentMsg from email.message import EmailMessage diff --git a/src/aios_kernel/knowledge_base.py b/src/aios_kernel/knowledge_base.py index 63c7d04..ac94459 100644 --- a/src/aios_kernel/knowledge_base.py +++ b/src/aios_kernel/knowledge_base.py @@ -1,7 +1,8 @@ # define a knowledge base class import json import logging -from .agent import AgentPrompt + +from .agent_base import AgentPrompt from .compute_kernel import ComputeKernel from .storage import AIStorage from .environment import Environment diff --git a/src/aios_kernel/open_ai_node.py b/src/aios_kernel/open_ai_node.py index de68656..e529b92 100644 --- a/src/aios_kernel/open_ai_node.py +++ b/src/aios_kernel/open_ai_node.py @@ -118,7 +118,7 @@ class OpenAI_ComputeNode(ComputeNode): #max_tokens=result_token, temperature=0.7) else: - logger.info(f"call openai {mode_name} prompts: {prompts} functions: {json.dumps(llm_inner_functions)}") + logger.info(f"call openai {mode_name} prompts: \n\t {prompts} \nfunctions: \n\t{json.dumps(llm_inner_functions)}") resp = openai.ChatCompletion.create(model=mode_name, messages=prompts, functions=llm_inner_functions, diff --git a/src/aios_kernel/role.py b/src/aios_kernel/role.py index 560d4db..272815f 100644 --- a/src/aios_kernel/role.py +++ b/src/aios_kernel/role.py @@ -1,6 +1,6 @@ import logging -from .agent import AIAgent,AgentPrompt +from .agent_base import AgentPrompt class AIRole: def __init__(self) -> None: diff --git a/src/aios_kernel/tg_tunnel.py b/src/aios_kernel/tg_tunnel.py index 53aed7a..dcc7abd 100644 --- a/src/aios_kernel/tg_tunnel.py +++ b/src/aios_kernel/tg_tunnel.py @@ -17,7 +17,7 @@ from .knowledge_base import KnowledgeBase from .tunnel import AgentTunnel from .storage import AIStorage from .contact_manager import ContactManager,Contact,FamilyMember -from .agent_message import AgentMsg,AgentMsgType +from .agent_base import AgentMsg,AgentMsgType logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/tunnel.py b/src/aios_kernel/tunnel.py index a3691cd..ed5f760 100644 --- a/src/aios_kernel/tunnel.py +++ b/src/aios_kernel/tunnel.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod import logging from typing import Coroutine -from .agent_message import AgentMsg +from .agent_base import AgentMsg from .bus import AIBus logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/workflow.py b/src/aios_kernel/workflow.py index 5dae542..8589035 100644 --- a/src/aios_kernel/workflow.py +++ b/src/aios_kernel/workflow.py @@ -8,8 +8,7 @@ from typing import Optional,Tuple,List from abc import ABC, abstractmethod from .environment import Environment,EnvironmentEvent -from .agent_message import AgentMsg,AgentMsgStatus,FunctionItem,LLMResult -from .agent import AgentPrompt,AgentMsg +from .agent_base import AgentMsg,AgentMsgStatus,FunctionItem,LLMResult,AgentPrompt from .chatsession import AIChatSession from .role import AIRole,AIRoleGroup from .ai_function import AIFunction,FunctionItem @@ -238,77 +237,6 @@ class Workflow: error_resp = msg.create_error_resp(err_str) return error_resp - @classmethod - def prase_llm_result(cls,llm_result_str:str)->LLMResult: - r = LLMResult() - if llm_result_str is None: - r.state = "ignore" - return r - if llm_result_str == "ignore": - r.state = "ignore" - return r - - lines = llm_result_str.splitlines() - is_need_wait = False - - def check_args(func_item:FunctionItem): - match func_name: - case "send_msg":# sendmsg($target_id,$msg_content) - if len(func_item.args) != 1: - logger.error(f"parse sendmsg failed! {func_item}") - return False - new_msg = AgentMsg() - target_id = func_item.args[0] - msg_content = func_item.body - new_msg.set("_",target_id,msg_content) - - r.send_msgs.append(new_msg) - is_need_wait = True - - case "post_msg":# postmsg($target_id,$msg_content) - if len(func_item.args) != 1: - logger.error(f"parse postmsg failed! {func_item}") - return False - new_msg = AgentMsg() - target_id = func_item.args[0] - msg_content = func_item.body - new_msg.set("_",target_id,msg_content) - r.post_msgs.append(new_msg) - - case "call":# call($func_name,$args_str) - r.calls.append(func_item) - is_need_wait = True - return True - case "post_call": # post_call($func_name,$args_str) - r.post_calls.append(func_item) - return True - - current_func : FunctionItem = None - for line in lines: - if line.startswith("##/"): - if current_func: - if check_args(current_func) is False: - r.resp += current_func.dumps() - - func_name,func_args = AgentMsg.parse_function_call(line[3:]) - current_func = FunctionItem(func_name,func_args) - else: - if current_func: - current_func.append_body(line + "\n") - else: - r.resp += line + "\n" - - if current_func: - if check_args(current_func) is False: - r.resp += current_func.dumps() - - if len(r.send_msgs) > 0 or len(r.calls) > 0: - r.state = "waiting" - else: - r.state = "reponsed" - - return r - async def role_post_msg(self,msg:AgentMsg,the_role:AIRole,workflow_chat_session:AIChatSession): msg.sender = the_role.get_role_id() @@ -395,7 +323,6 @@ class Workflow: return None async def _role_execute_func(self,the_role:AIRole,inenr_func_call_node:dict,prompt:AgentPrompt,org_msg:AgentMsg,stack_limit = 5) -> [str,int]: - from .compute_kernel import ComputeKernel func_name = inenr_func_call_node.get("name") arguments = json.loads(inenr_func_call_node.get("arguments")) @@ -441,7 +368,6 @@ class Workflow: async def role_process_msg(self,msg:AgentMsg,the_role:AIRole,workflow_chat_session:AIChatSession) -> AgentMsg: msg.target = the_role.get_role_id() - prompt = AgentPrompt() prompt.append(the_role.agent.agent_prompt) prompt.append(self.get_workflow_rule_prompt()) @@ -481,7 +407,7 @@ class Workflow: error_resp = msg.create_error_resp(result_str) return error_resp - result : LLMResult = Workflow.prase_llm_result(result_str) + result : LLMResult = LLMResult.from_str(result_str) for postmsg in result.post_msgs: postmsg.prev_msg_id = msg.get_msg_id() # might be craete a new msg.topic for this postmsg diff --git a/src/aios_kernel/workspace_env.py b/src/aios_kernel/workspace_env.py index 8bfe00e..ae87217 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios_kernel/workspace_env.py @@ -1,5 +1,6 @@ # this env is designed for workflow owner filesystem, support file/directory operations +import json import subprocess import tempfile import threading @@ -9,6 +10,9 @@ import ast import sys import os import re +import asyncio +import aiofiles.os +import chardet from .environment import Environment,EnvironmentEvent from .ai_function import AIFunction,SimpleAIFunction @@ -170,4 +174,50 @@ class WorkspaceEnvironment(Environment): async def run_code(self,pycode:str) -> str: interpreter = CodeInterpreter("python",True) return interpreter.run(pycode) + + + +class KnowledgeBaseFileSystemEnvironment(Environment): + def __init__(self, env_id: str) -> None: + super().__init__(env_id) + self.root_path = "." + + operator_param = { + "path": "full path of target directory", + } + self.add_ai_function(SimpleAIFunction("list", + "list the files and sub directory in target directory,result is a json array", + self.list,operator_param)) + + operator_param = { + "path": "full path of target file", + } + self.add_ai_function(SimpleAIFunction("cat", + "cat the file content in target path,result is a string", + self.cat,operator_param)) + + def set_root_path(self,path:str): + self.root_path = path + + + async def list(self,path:str) -> str: + directory_path = self.root_path + path + items = [] + + with await aiofiles.os.scandir(directory_path) as entries: + async for entry in entries: + item_type = "directory" if entry.is_dir() else "file" + items.append({"name": entry.name, "type": item_type}) + + return json.dumps(items) + + async def cat(self,path:str) -> str: + file_path = self.root_path + path + cur_encode = "utf-8" + async with aiofiles.open(file_path,'rb') as f: + cur_encode = chardet.detect(await f.read())['encoding'] + + async with aiofiles.open(file_path, mode='r', encoding=cur_encode) as f: + content = await f.read(2048) + return content diff --git a/src/knowledge/object/object.py b/src/knowledge/object/object.py index ac6f2af..75852c3 100644 --- a/src/knowledge/object/object.py +++ b/src/knowledge/object/object.py @@ -47,6 +47,17 @@ class KnowledgeObject(ABC): def get_body(self) -> dict: return self.body + + def get_summary(self) -> str: + return self.desc.get("summary") + + def get_articl_catelog(self) -> str: + assert self.object_type == ObjectType.Document + return self.desc.get("catelog") + + def get_article_full_content(self) -> str: + assert self.object_type == ObjectType.Document + return self.body def calculate_id(self): # Convert the object_type and desc to string and compute the SHA256 hash diff --git a/src/knowledge/store.py b/src/knowledge/store.py index d8b4bbb..abcf496 100644 --- a/src/knowledge/store.py +++ b/src/knowledge/store.py @@ -6,6 +6,8 @@ from .vector import ChromaVectorStore, VectorBase import logging + + # KnowledgeStore class, which aggregates ChunkStore, ChunkTracker, and ObjectStore, and is a global singleton that makes it easy to use these three built-in store examples class KnowledgeStore: _instance = None @@ -41,6 +43,8 @@ class KnowledgeStore: self.chunk_list_writer = ChunkListWriter(self.chunk_store, self.chunk_tracker) self.chunk_reader = ChunkReader(self.chunk_store, self.chunk_tracker) self.vector_store = {} + + def get_relation_store(self) -> ObjectRelationStore: return self.relation_store From ce033c2381ac46b5720714a76d3dbd04cfacc48b Mon Sep 17 00:00:00 2001 From: tsukasa Date: Thu, 19 Oct 2023 10:05:08 +0800 Subject: [PATCH 04/79] add knowledge pipeline manager --- rootfs/agents/FindPhoto/agent.toml | 21 + rootfs/agents/FindPhoto/enviroment.py | 0 rootfs/email/config.toml | 7 - .../local_photos_embedding/pipeline.toml | 6 + rootfs/knowledge_pipelines/pipelines.toml | 2 + src/aios_kernel/knowledge_base.py | 296 ------------- src/aios_kernel/knowledge_pipeline.py | 411 ------------------ src/knowledge/__init__.py | 3 +- src/knowledge/input/__init__.py | 1 + src/knowledge/input/email.py | 158 +++++++ src/knowledge/input/local_dir.py | 61 +++ src/knowledge/parser/__init__.py | 0 src/knowledge/parser/embedding.py | 93 ++++ src/knowledge/pipeline.py | 165 +++++++ src/knowledge/query/embedding.py | 82 ++++ src/knowledge/store.py | 53 ++- src/service/aios_shell/aios_shell.py | 10 +- 17 files changed, 641 insertions(+), 728 deletions(-) create mode 100644 rootfs/agents/FindPhoto/agent.toml create mode 100644 rootfs/agents/FindPhoto/enviroment.py delete mode 100644 rootfs/email/config.toml create mode 100644 rootfs/knowledge_pipelines/local_photos_embedding/pipeline.toml create mode 100644 rootfs/knowledge_pipelines/pipelines.toml delete mode 100644 src/aios_kernel/knowledge_base.py delete mode 100644 src/aios_kernel/knowledge_pipeline.py create mode 100644 src/knowledge/input/__init__.py create mode 100644 src/knowledge/input/email.py create mode 100644 src/knowledge/input/local_dir.py create mode 100644 src/knowledge/parser/__init__.py create mode 100644 src/knowledge/parser/embedding.py create mode 100644 src/knowledge/pipeline.py create mode 100644 src/knowledge/query/embedding.py diff --git a/rootfs/agents/FindPhoto/agent.toml b/rootfs/agents/FindPhoto/agent.toml new file mode 100644 index 0000000..ddb3f3f --- /dev/null +++ b/rootfs/agents/FindPhoto/agent.toml @@ -0,0 +1,21 @@ +instance_id = "FindPhoto" +fullname = "FindPhoto" +llm_model_name = "gpt-4" +max_token_size = 16000 +enable_timestamp = "false" +owner_prompt = "我是你的主人{name}" +contact_prompt = "我是你的朋友{name}" +owner_env = "environment.py" + +[[prompt]] +role = "system" +content = """ +你是FindPhoto,你可以访问我的照片目录。 + +*** +你在收到我的信息后,按如下规则处理 +1. 在第一次接受到一条信息时,优先尝试用合适的关键字查询去查询知识库。 +2. 如果信息中包含一段知识库的查询结果,尝试用查询结果处理,如果还是不能处理,尝试递增index继续查询。 +3. 如果要返回知识库结果条目,在消息开头附上他的json字符串。 +""" + diff --git a/rootfs/agents/FindPhoto/enviroment.py b/rootfs/agents/FindPhoto/enviroment.py new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/email/config.toml b/rootfs/email/config.toml deleted file mode 100644 index 1bf4bca..0000000 --- a/rootfs/email/config.toml +++ /dev/null @@ -1,7 +0,0 @@ - - -EMAIL_IMAP_SERVER = "imap.gmail.com" -EMAIL_ADDRESS = '<>' -EMAIL_PASSWORD = '<>' -EMAIL_IMAP_PORT = 993 -LOCAL_DIR = 'rootfs/data' \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/local_photos_embedding/pipeline.toml b/rootfs/knowledge_pipelines/local_photos_embedding/pipeline.toml new file mode 100644 index 0000000..e26e7a8 --- /dev/null +++ b/rootfs/knowledge_pipelines/local_photos_embedding/pipeline.toml @@ -0,0 +1,6 @@ +name = "LocalPhotoEmbedding" +input.module = "local_dir" +input.params.path = "~/myai/photos" +parser.module = "embedding" +parser.params.path = "~/myai/knowledge/indices/photo_embedding" + diff --git a/rootfs/knowledge_pipelines/pipelines.toml b/rootfs/knowledge_pipelines/pipelines.toml new file mode 100644 index 0000000..99d5b59 --- /dev/null +++ b/rootfs/knowledge_pipelines/pipelines.toml @@ -0,0 +1,2 @@ +[[pipelines]] +"local_photos_embedding" \ No newline at end of file diff --git a/src/aios_kernel/knowledge_base.py b/src/aios_kernel/knowledge_base.py deleted file mode 100644 index ac94459..0000000 --- a/src/aios_kernel/knowledge_base.py +++ /dev/null @@ -1,296 +0,0 @@ -# define a knowledge base class -import json -import logging - -from .agent_base import AgentPrompt -from .compute_kernel import ComputeKernel -from .storage import AIStorage -from .environment import Environment -from .ai_function import SimpleAIFunction -from knowledge import * - - -class KnowledgeBase: - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance.__singleton_init__() - - return cls._instance - - def __singleton_init__(self) -> None: - self.store = KnowledgeStore() - self.compute_kernel = ComputeKernel.get_instance() - self._default_text_model = "all-MiniLM-L6-v2" - self._default_image_model = "clip-ViT-B-32" - - async def __embedding_document(self, document: DocumentObject): - for chunk_id in document.get_chunk_list(): - chunk = self.store.get_chunk_reader().get_chunk(chunk_id) - if chunk is None: - raise ValueError(f"text chunk not found: {chunk_id}") - - text = chunk.read().decode("utf-8") - vector = await self.compute_kernel.do_text_embedding(text, self._default_text_model) - if vector: - await self.store.get_vector_store(self._default_text_model).insert(vector, chunk_id) - - async def __embedding_image(self, image: ImageObject): - # desc = {} - # if not not image.get_meta(): - # desc["meta"] = image.get_meta() - # if not not image.get_exif(): - # desc["exif"] = image.get_exif() - # if not not image.get_tags(): - # desc["tags"] = image.get_tags() - # vector = await self.compute_kernel.do_text_embedding(json.dumps(desc), self._default_text_model) - vector = await self.compute_kernel.do_image_embedding(image.calculate_id(), self._default_image_model) - if vector: - await self.store.get_vector_store(self._default_image_model).insert(vector, image.calculate_id()) - - async def __embedding_video(self, vedio: VideoObject): - desc = {} - if not not vedio.get_meta(): - desc["meta"] = vedio.get_meta() - if not not vedio.get_info(): - desc["info"] = vedio.get_info() - if not not vedio.get_tags(): - desc["tags"] = vedio.get_tags() - vector = await self.compute_kernel.do_text_embedding(json.dumps(desc), self._default_text_model) - await self.store.get_vector_store(self._default_text_model).insert(vector, vedio.calculate_id()) - - async def __embedding_rich_text(self, rich_text: RichTextObject): - for document_id in rich_text.get_documents().values(): - document = DocumentObject.decode(self.store.get_object_store().get_object(document_id)) - await self.__embedding_document(document) - for image_id in rich_text.get_images().values(): - image = ImageObject.decode(self.store.get_object_store().get_object(image_id)) - await self.__embedding_image(image) - for video_id in rich_text.get_videos().values(): - video = VideoObject.decode(self.store.get_object_store().get_object(video_id)) - await self.__embedding_video(video) - for rich_text_id in rich_text.get_rich_texts().values(): - rich_text = RichTextObject.decode(self.store.get_object_store().get_object(rich_text_id)) - await self.__embedding_rich_text(rich_text) - - async def __embedding_email(self, email: EmailObject): - vector = await self.compute_kernel.do_text_embedding(json.dumps(email.get_desc()), self._default_text_model) - await self.store.get_vector_store(self._default_text_model).insert(vector, email.calculate_id()) - await self.__embedding_rich_text(email.get_rich_text()) - - - async def __do_embedding(self, object: KnowledgeObject): - if object.get_object_type() == ObjectType.Document: - await self.__embedding_document(object) - if object.get_object_type() == ObjectType.Image: - await self.__embedding_image(object) - if object.get_object_type() == ObjectType.Video: - await self.__embedding_video(object) - if object.get_object_type() == ObjectType.RichText: - await self.__embedding_rich_text(object) - if object.get_object_type() == ObjectType.Email: - await self.__embedding_email(object) - else: - pass - - # def __save_document(self, document: DocumentObject): - # doc_id = document.calculate_id() - # self.store.get_object_store().put_object(doc_id, document.encode()) - # for chunk_id in document.get_chunk_list(): - # self.store.get_relation_store().add_relation(chunk_id, doc_id) - - # def __save_image(self, image: ImageObject): - # image_id = image.calculate_id() - # self.store.get_object_store().put_object(image_id, image.encode()) - - # def __save_video(self, video: VideoObject): - # video_id = video.calculate_id() - # self.store.get_object_store().put_object(video_id, video.encode()) - - # def __save_rich_text(self, rich_text: RichTextObject): - # rich_text_id = rich_text.calculate_id() - # # rich_text_enc = dict() - # # rich_text_enc["desc"] = rich_text.desc - # # rich_text_enc["body"] = {"documents": {}, "images": {}, "videos": {}, "rich_texts": {}} - # for key, document in rich_text.get_documents().items(): - # self.__save_document(document) - # doc_id = document.calculate_id() - # self.store.get_relation_store().add_relation(doc_id, rich_text_id) - # # rich_text_enc["body"]["documents"][key] = doc_id - # for key, image in rich_text.get_images().items(): - # self.__save_image(image) - # image_id = image.calculate_id() - # self.store.get_relation_store().add_relation(image_id, rich_text_id) - # # rich_text_enc["body"]["images"][key] = image_id - # for key, video in rich_text.get_videos().items(): - # self.__save_video(video) - # video_id = video.calculate_id() - # self.store.get_relation_store().add_relation(video_id, rich_text_id) - # # rich_text_enc["body"]["videos"][key] = video_id - # for key, rich_text in rich_text.get_rich_texts().items(): - # self.__save_rich_text(rich_text) - # rich_text_id = rich_text.calculate_id() - # self.store.get_relation_store().add_relation(rich_text_id, rich_text_id) - # # rich_text_enc["body"]["rich_texts"][key] = rich_text_id - - - # self.store.get_object_store().put_object(rich_text_id, rich_text.encode()) - - # def __save_email(self, email: EmailObject): - # email_id = email.calculate_id() - # # email_enc = dict() - # # email_enc["desc"] = email.desc - # # email_enc["body"] = {"content": None} - # self.__save_rich_text(email.get_rich_text()) - # rich_text_id = email.get_rich_text().calculate_id() - # self.store.get_relation_store().add_relation(rich_text_id, email_id) - # # email_enc["body"]["content"] = rich_text_id - # self.store.get_object_store().put_object(email_id, email.encode()) - - - # def __save_object(self, object: KnowledgeObject): - # if object.get_object_type() == ObjectType.Document: - # self.__save_document(object) - # if object.get_object_type() == ObjectType.Image: - # self.__save_image(object) - # if object.get_object_type() == ObjectType.Video: - # self.__save_video(object) - # if object.get_object_type() == ObjectType.RichText: - # self.__save_rich_text(object) - # if object.get_object_type() == ObjectType.Email: - # self.__save_email(object) - # else: - # pass - - async def insert_object(self, object: KnowledgeObject): - self.store.get_object_store().put_object(object.calculate_id(), object.encode()) - await self.__do_embedding(object) - - async def query_objects(self, tokens: str, types: list[str], topk: int) -> [ObjectID]: - texts = [] - if "text" in types: - vector = await self.compute_kernel.do_text_embedding(tokens, self._default_text_model) - texts = await self.store.get_vector_store(self._default_text_model).query(vector, topk) - images = [] - if "image" in types: - vector = await self.compute_kernel.do_text_embedding(tokens, self._default_image_model) - images = await self.store.get_vector_store(self._default_image_model).query(vector, topk) - return texts + images - - def load_object(self, object_id: ObjectID) -> KnowledgeObject: - if object_id.get_object_type() == ObjectType.Document: - return DocumentObject.decode(self.store.get_object_store().get_object(object_id)) - if object_id.get_object_type() == ObjectType.Image: - return ImageObject.decode(self.store.get_object_store().get_object(object_id)) - if object_id.get_object_type() == ObjectType.Video: - return VideoObject.decode(self.store.get_object_store().get_object(object_id)) - if object_id.get_object_type() == ObjectType.RichText: - return RichTextObject.decode(self.store.get_object_store().get_object(object_id)) - if object_id.get_object_type() == ObjectType.Email: - return EmailObject.decode(self.store.get_object_store().get_object(object_id)) - else: - pass - - - def tokens_from_objects(self, object_ids: [ObjectID]) -> list[str]: - results = dict() - for object_id in object_ids: - parents = self.store.get_relation_store().get_related_root_objects(object_id) - # last parent is the root object - root_object_id = parents[0] if parents else object_id - logging.info(f"object_id: {str(object_id)} root_object_id: {str(root_object_id)}") - if str(root_object_id) in results: - results[str(root_object_id)].append(object_id) - else: - results[str(root_object_id)] = [root_object_id, object_id] - content = "" - result_desc = [] - for result in results.values(): - # first element in result is the root object - root_object_id = result[0] - if root_object_id.get_object_type() == ObjectType.Email: - email = self.load_object(root_object_id) - desc = email.get_desc() - desc["type"] = "email" - desc["contents"] = [] - result_desc.append(desc) - upper_list = desc["contents"] - result = result[1:] - else: - upper_list = result_desc - - for object_id in result: - if object_id.get_object_type() == ObjectType.Chunk: - upper_list.append({"type": "text", "content": self.store.get_chunk_reader().get_chunk(object_id).read().decode("utf-8")}) - if object_id.get_object_type() == ObjectType.Image: - # image = self.load_object(object_id) - desc = dict() - desc["id"] = str(object_id) - desc["type"] = "image" - upper_list.append(desc) - if object_id.get_object_type() == ObjectType.Video: - video = self.load_object(object_id) - desc = video.get_desc() - desc["type"] = "video" - upper_list.append(desc) - else: - pass - content += json.dumps(result_desc) - content += ".\n" - - return content - - def parse_object_in_message(self, message: str) -> KnowledgeObject: - # get message's first line - logging.info(f"tg parse resp message: {message}") - lines = message.split("\n") - if len(lines) > 0: - message = lines[0] - try: - desc = json.loads(message) - if isinstance(desc, dict): - object_id = desc["id"] - else: - object_id = desc[0]["id"] - except Exception as e: - return None - - if object_id is not None: - return self.load_object(ObjectID.from_base58(object_id)) - - - def bytes_from_object(self, object: KnowledgeObject) -> bytes: - if object.get_object_type() == ObjectType.Image: - image_object = object - return self.store.get_chunk_reader().read_chunk_list_to_single_bytes(image_object.get_chunk_list()) - - - - - - - -class KnowledgeEnvironment(Environment): - def __init__(self, env_id: str) -> None: - super().__init__(env_id) - - query_param = { - "tokens": "key words to query", - "types": "prefered knowledge types, one or more of [text, image]", - "index": "index of query result" - } - self.add_ai_function(SimpleAIFunction("query_knowledge", - "vector query content from local knowledge base", - self._query, - query_param)) - - async def _query(self, tokens: str, types: list[str] = ["text"], index: str=0): - index = int(index) - object_ids = await KnowledgeBase().query_objects(tokens, types, 4) - if len(object_ids) <= index: - return "*** I have no more information for your reference.\n" - else: - content = "*** I have provided the following known information for your reference with json format:\n" - return content + KnowledgeBase().tokens_from_objects(object_ids[index:index+1]) \ No newline at end of file diff --git a/src/aios_kernel/knowledge_pipeline.py b/src/aios_kernel/knowledge_pipeline.py deleted file mode 100644 index 759793a..0000000 --- a/src/aios_kernel/knowledge_pipeline.py +++ /dev/null @@ -1,411 +0,0 @@ -""" -Capture your email locally, and parse out the pictures in the email body and the pictures, videos and other files in the attachment. Subsequently, it supports vectorized analysis of your personal data and serves as a knowledge base to enable large language model answers. Better results. - -An example of a local file is as follows: -├── data -│ └── alex0072@gmail.com -│ └── 5de3e52f3a6b90cabe6cbdd4ae3a5c5b -│ ├── email.txt -│ ├── meta.json -│ ├── image -│ │ ├── 0648B869@99C03070.DB94B354.jpg -│ └── body_image -│ ├── 11044884873.jpg -│ ├── 282985198265470.gif -│ └── dd-login-service-min.png - -""" -import asyncio -import datetime -import sqlite3 -import imaplib -import logging -import mailparser -import hashlib -import json -import base64 -import chardet -import aiofiles - -from bs4 import BeautifulSoup -import requests -import os -import toml -from .storage import AIStorage, UserConfigItem -from .knowledge_base import KnowledgeBase, ImageObjectBuilder, ObjectID, ObjectType, DocumentObjectBuilder, EmailObjectBuilder, EmailObject - -class KnowledgeJournal: - def __init__(self, source_type: str, source_id: str, item_id: str, object_id: str, timestamp=None): - # define a timestamp variable - self.timestamp = datetime.datetime.now() if timestamp is None else timestamp - self.object_id = object_id - self.source_type = source_type - self.source_id = source_id - self.item_id = item_id - - def __str__(self) -> str: - if self.source_type == "dir": - object_id = ObjectID.from_base58(self.object_id) - object_type = None - if object_id.get_object_type() == ObjectType.Image: - object_type = "image" - else: - pass - return f"Add {object_type} from {os.path.join(self.source_id, self.item_id)}" - if self.source_type == "email": - object_id = ObjectID.from_base58(self.object_id) - email = EmailObject.decode(KnowledgeBase().store.get_object_store().get_object(object_id)) - meta = email.get_meta() - return f'Add email from {os.path.join(self.source_id)} subject {meta["subject"]}' - - -# init sqlite3 client -class KnowledgeJournalClient: - def __init__(self): - knowledge_dir = os.path.join(AIStorage.get_instance().get_myai_dir(), "knowledge") - if not os.path.exists(knowledge_dir): - os.makedirs(knowledge_dir) - self.journal_path = os.path.join(knowledge_dir, "journal.db") - - conn = sqlite3.connect(self.journal_path) - conn.execute( - '''CREATE TABLE IF NOT EXISTS journal ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - time DATETIME DEFAULT CURRENT_TIMESTAMP, - source_type TEXT, - source_id TEXT, - item_id TEXT, - object_id TEXT)''' - ) - conn.commit() - - def insert(self, journal: KnowledgeJournal): - conn = sqlite3.connect(self.journal_path) - conn.execute( - "INSERT INTO journal (time, source_type, source_id, item_id, object_id) VALUES (?, ?, ?, ?, ?)", - (journal.timestamp, journal.source_type, journal.source_id, journal.item_id, journal.object_id), - ) - conn.commit() - - def latest_journal(self, source_id: str) -> KnowledgeJournal: - conn = sqlite3.connect(self.journal_path) - cursor = conn.cursor() - cursor.execute("SELECT * FROM journal WHERE source_id = ? ORDER BY id DESC LIMIT 1", (source_id,)) - result = cursor.fetchone() - if result is None: - return None - else: - (_, timestamp, source_type, sorce_id, item_id, object_id) = result - return KnowledgeJournal(source_type, sorce_id, item_id, object_id, timestamp) - - def latest_journals(self, topn) -> [KnowledgeJournal]: - conn = sqlite3.connect(self.journal_path) - cursor = conn.cursor() - cursor.execute("SELECT * FROM journal ORDER BY id DESC LIMIT ?", (topn,)) - return [KnowledgeJournal(source_type, sorce_id, item_id, object_id, timestamp) for (_, timestamp, source_type, sorce_id, item_id, object_id) in cursor.fetchall()] - - -class KnowledgeEmailSource: - def __init__(self, config:dict): - self.config = config - self.config["type"] = "email" - - def id(self): - return self.config["address"] - - @classmethod - def user_config_items(cls): - return [("address", "email address"), - ("password", "email password"), - ("imap_server", "imap server"), - ("imap_port", "imap port") - ] - - @classmethod - def local_root(cls): - user_data_dir = AIStorage.get_instance().get_myai_dir() - return os.path.abspath(f"{user_data_dir}/knowledge/email") - - async def run_once(self): - # read config from toml file - # and read from config config.local.toml if exists (config.local.toml is ignored by git) - logging.debug(f"knowledge email source {self.id()} run once") - filter = "ALL" - self.client = self.email_client() - await self.read_emails(imap_keyword=filter) - - def email_client(self) -> imaplib.IMAP4_SSL: - logging.info(f"read email config from {self.config.get('imap_server')}") - client = imaplib.IMAP4_SSL( - host=self.config.get('imap_server'), - port=self.config.get('imap_port') - ) - client.login(self.config.get('address'), self.config.get('password')) - return client - - async def read_emails(self, folder: str = 'INBOX', imap_keyword: str = "UNSEEN"): - journal_client = KnowledgeJournalClient() - latest_journal = journal_client.latest_journal(self.id()) - latest_uid = 0 if latest_journal is None else int(latest_journal.item_id) - self.client.select(folder) - _, data = self.client.uid('search', None, imap_keyword) - - # get email uid list - email_list = data[0].split() - logging.info(f"got {len(email_list)} emails") - journal_client = KnowledgeJournalClient() - for uid in email_list: - _uid = int.from_bytes(uid) - if _uid > latest_uid: - email_dir = self.check_email_saved(uid) - if email_dir is not None: - logging.info(f"email uid {uid} already saved") - else: - email_dir = self.read_and_save_email(uid) - logging.info(f"email uid {uid} saved") - email_object = EmailObjectBuilder({}, email_dir).build() - await KnowledgeBase().insert_object(email_object) - journal_client.insert(KnowledgeJournal("email", self.id(), str(int.from_bytes(uid)), str(email_object.calculate_id()))) - - - def read_and_save_email(self, uid: str) -> str: - message_parts = "(BODY.PEEK[])" - _, email_data = self.client.uid('fetch', uid, message_parts) - mail = mailparser.parse_from_bytes(email_data[0][1]) - logging.info(f"got email subject [{mail.subject}]") - self.save_email(mail) - return self.get_local_dir_name(mail) - - def get_local_dir_name(self, mail: mailparser.MailParser) -> str: - dir = f"{self.local_root()}/{self.config.get('address')}" - name = f"{mail.subject}__{mail.date}" - name = hashlib.md5(name.encode('utf-8')).hexdigest() - return f"{dir}/{name}" - - def check_email_saved(self, uid: str) -> str: - message_parts = "(BODY[HEADER])" - _, email_data = self.client.uid('fetch', uid, message_parts) - mail = mailparser.parse_from_bytes(email_data[0][1]) - logging.info(f"[{uid}]check email subject [{mail.subject}]") - dir = self.get_local_dir_name(mail) - logging.info(f"check email saved {dir}") - file = f"{dir}/email.txt" - if os.path.exists(file): - return dir - return None - - # save email attachment(images) - def save_email_attachment(self, mail: mailparser.MailParser, email_dir: str): - for attachment in mail.attachments: - if attachment['mail_content_type'] in ['image/png', 'image/jpeg', 'image/gif']: - print('current mail have image attachment') - img_dir = f"{email_dir}/image" - if not os.path.exists(img_dir): - os.makedirs(img_dir) - filename = attachment['filename'] - filefullname = f"{img_dir}/{filename}" - image_data = attachment['payload'] - try: - image_data = base64.b64decode(image_data) - except base64.binascii.Error: - image_data = image_data.encode() - with open(filefullname, 'wb') as f: - f.write(image_data) - logging.info(f"save email image {filename} success") - - # save email body images(html content) - def save_body_images(self, html_content: str, email_dir: str): - # get all image urls - soup = BeautifulSoup(html_content, 'html.parser') - img_tags = soup.find_all('img') - img_urls = [img['src'] for img in img_tags if 'src' in img.attrs] - logging.info(f'Found {len(img_urls)} images in email body') - - name_count = 0 - - if not os.path.exists(email_dir): - os.makedirs(email_dir) - - for img_url in img_urls: - # keep the original image filename(last of url) - ext = img_url.split('/')[-1].split('.')[-1] - img_filename = os.path.join(email_dir, f"{name_count}.{ext}") - name_count += 1 - # download image - response = requests.get(img_url, stream=True) - if response.status_code == 200: - with open(img_filename, 'wb') as img_file: - for chunk in response.iter_content(1024): - img_file.write(chunk) - logging.info(f'Downloaded {img_url} to {img_filename}') - else: - logging.info(f'Failed to download {img_url}') - - # save email content to local dir - def save_email(self, mail: mailparser.MailParser): - dir = f"{self.local_root()}/{self.config.get('address')}" - if not os.path.exists(dir): - os.makedirs(dir) - email_dir = self.get_local_dir_name(mail) - logging.info(f"save email to {email_dir}") - if not os.path.exists(email_dir): - os.makedirs(email_dir) - with open(f"{email_dir}/email.txt", "w", encoding='utf-8') as f: - # soup = BeautifulSoup(mail.body, 'html.parser') - f.write(mail.body) - with open(f"{email_dir}/meta.json", "w", encoding='utf-8') as f: - mail_dict = json.loads(mail.mail_json) - if 'body' in mail_dict: - del mail_dict['body'] - json.dump(mail_dict, f, ensure_ascii=False, indent=4) - logging.info(f"save email meta info {f.name}") - - self.save_email_attachment(mail, email_dir) - self.save_body_images(mail.body, f"{email_dir}/body_image") - - -class KnowledgeDirSource: - def __init__(self, config): - self.config = config - config["path"] = os.path.abspath(config["path"]) - self.config["type"] = "dir" - - @classmethod - def user_config_items(cls): - return [("path", "local dir path")] - - def id(self): - return self.config["path"] - - def path(self): - return self.config["path"] - - @staticmethod - async def read_txt_file(file_path:str)->str: - cur_encode = "utf-8" - async with aiofiles.open(file_path,'rb') as f: - cur_encode = chardet.detect(await f.read())['encoding'] - - async with aiofiles.open(file_path,'r',encoding=cur_encode) as f: - return await f.read() - - async def run_once(self): - logging.debug(f"knowledge dir source {self.id()} run once") - journal_client = KnowledgeJournalClient() - latest_journal = journal_client.latest_journal(self.id()) - if latest_journal is not None: - if os.path.getmtime(self.path()) <= latest_journal.timestamp: - logging.debug(f"knowledge dir source {self.id()} ingnored for no update") - return - file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) - for rel_path in file_pathes: - file_path = os.path.join(self.path(), rel_path) - timestamp = os.path.getctime(file_path) - if latest_journal is not None: - if timestamp <= latest_journal.timestamp: - continue - ext = os.path.splitext(file_path)[1].lower() - if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']: - logging.info(f"knowledge dir source {self.id()} found image file {file_path}") - image = ImageObjectBuilder({}, {}, file_path).build() - await KnowledgeBase().insert_object(image) - journal_client.insert(KnowledgeJournal("dir", self.id(), rel_path, str(image.calculate_id()), timestamp)) - if ext in ['.txt']: - logging.info(f"knowledge dir source {self.id()} found text file {file_path}") - text = await self.read_txt_file(file_path) - - document = DocumentObjectBuilder({}, {}, text).build() - await KnowledgeBase().insert_object(document) - journal_client.insert(KnowledgeJournal("dir", self.id(), rel_path, str(document.calculate_id()), timestamp)) - - - - -# define singleton class knowledge pipline -class KnowledgePipline: - _instance = None - - @classmethod - def get_instance(cls): - if cls._instance is None: - cls._instance = KnowledgePipline() - cls._instance.__singleton_init__() - - return cls._instance - - def initial(self): - config_path = self.__config_path() - logging.info(f"initial knowledge pipline from {config_path}") - if os.path.exists(config_path): - config = toml.load(self.__config_path()) - for source_config in config["sources"]: - if source_config['type'] == 'email': - self.add_email_source(KnowledgeEmailSource(source_config)) - if source_config['type'] == 'dir': - self.add_dir_source(KnowledgeDirSource(source_config)) - user_data_dir = AIStorage.get_instance().get_myai_dir() - default_dir = os.path.abspath(f"{user_data_dir}/data") - if not os.path.exists(default_dir): - os.makedirs(default_dir) - self.add_dir_source(KnowledgeDirSource({"path": default_dir})) - - return True - - def __singleton_init__(self): - self.knowledge_base = KnowledgeBase() - self.email_sources = dict() - self.dir_sources = dict() - self.source_queue = list() - self.run_lock = asyncio.Lock() - asyncio.create_task(self.run_loop()) - - - def save_config(self): - config = dict() - config["sources"] = [source.config for source in self.source_queue] - with open(self.__config_path(), "w") as f: - toml.dump(config, f) - - - @classmethod - def __config_path(cls) -> str: - user_data_dir = AIStorage.get_instance().get_myai_dir() - return os.path.abspath(f"{user_data_dir}/etc/knowledge.cfg.toml") - - - def add_email_source(self, source: KnowledgeEmailSource): - if self.email_sources.get(source.id()) is not None: - return "already exists" - self.email_sources[source.id()] = source - self.source_queue.append(source) - return None - - def add_dir_source(self, source: KnowledgeDirSource): - if self.dir_sources.get(source.id()) is not None: - logging.info(f"knowledge add source {source.id()} failed for already exists") - return "already exists" - logging.info(f"knowledge added source {source.id()}") - self.dir_sources[source.id()] = source - self.source_queue.append(source) - return None - - def get_latest_journals(self, topn) -> [KnowledgeJournal]: - return KnowledgeJournalClient().latest_journals(topn) - - async def run_loop(self): - while True: - await self.run_once() - await asyncio.sleep(5) - - async def run_once(self): - logging.info(f"knowledge pipeline started") - # sources = list() - # async with self.run_lock: - # for source in self.source_queue: - # sources.append(source) - # for source in sources: - # await source.run_once() - for source in self.source_queue: - await source.run_once() - - logging.info(f"knowledge pipeline finished") \ No newline at end of file diff --git a/src/knowledge/__init__.py b/src/knowledge/__init__.py index 680c632..361a67d 100644 --- a/src/knowledge/__init__.py +++ b/src/knowledge/__init__.py @@ -2,4 +2,5 @@ from .object import * from .vector import * from .data import * from .store import KnowledgeStore -from .core_object import * \ No newline at end of file +from .core_object import * +from .pipeline import * \ No newline at end of file diff --git a/src/knowledge/input/__init__.py b/src/knowledge/input/__init__.py new file mode 100644 index 0000000..0e9e2a5 --- /dev/null +++ b/src/knowledge/input/__init__.py @@ -0,0 +1 @@ +import local_dir \ No newline at end of file diff --git a/src/knowledge/input/email.py b/src/knowledge/input/email.py new file mode 100644 index 0000000..a8eb353 --- /dev/null +++ b/src/knowledge/input/email.py @@ -0,0 +1,158 @@ + +class KnowledgeEmailSource: + def __init__(self, config:dict): + self.config = config + self.config["type"] = "email" + + def id(self): + return self.config["address"] + + @classmethod + def user_config_items(cls): + return [("address", "email address"), + ("password", "email password"), + ("imap_server", "imap server"), + ("imap_port", "imap port") + ] + + @classmethod + def local_root(cls): + user_data_dir = AIStorage.get_instance().get_myai_dir() + return os.path.abspath(f"{user_data_dir}/knowledge/email") + + async def run_once(self): + # read config from toml file + # and read from config config.local.toml if exists (config.local.toml is ignored by git) + logging.debug(f"knowledge email source {self.id()} run once") + filter = "ALL" + self.client = self.email_client() + await self.read_emails(imap_keyword=filter) + + def email_client(self) -> imaplib.IMAP4_SSL: + logging.info(f"read email config from {self.config.get('imap_server')}") + client = imaplib.IMAP4_SSL( + host=self.config.get('imap_server'), + port=self.config.get('imap_port') + ) + client.login(self.config.get('address'), self.config.get('password')) + return client + + async def read_emails(self, folder: str = 'INBOX', imap_keyword: str = "UNSEEN"): + journal_client = KnowledgeJournalClient() + latest_journal = journal_client.latest_journal(self.id()) + latest_uid = 0 if latest_journal is None else int(latest_journal.item_id) + self.client.select(folder) + _, data = self.client.uid('search', None, imap_keyword) + + # get email uid list + email_list = data[0].split() + logging.info(f"got {len(email_list)} emails") + journal_client = KnowledgeJournalClient() + for uid in email_list: + _uid = int.from_bytes(uid) + if _uid > latest_uid: + email_dir = self.check_email_saved(uid) + if email_dir is not None: + logging.info(f"email uid {uid} already saved") + else: + email_dir = self.read_and_save_email(uid) + logging.info(f"email uid {uid} saved") + email_object = EmailObjectBuilder({}, email_dir).build() + await KnowledgeBase().insert_object(email_object) + journal_client.insert(KnowledgeJournal("email", self.id(), str(int.from_bytes(uid)), str(email_object.calculate_id()))) + + + def read_and_save_email(self, uid: str) -> str: + message_parts = "(BODY.PEEK[])" + _, email_data = self.client.uid('fetch', uid, message_parts) + mail = mailparser.parse_from_bytes(email_data[0][1]) + logging.info(f"got email subject [{mail.subject}]") + self.save_email(mail) + return self.get_local_dir_name(mail) + + def get_local_dir_name(self, mail: mailparser.MailParser) -> str: + dir = f"{self.local_root()}/{self.config.get('address')}" + name = f"{mail.subject}__{mail.date}" + name = hashlib.md5(name.encode('utf-8')).hexdigest() + return f"{dir}/{name}" + + def check_email_saved(self, uid: str) -> str: + message_parts = "(BODY[HEADER])" + _, email_data = self.client.uid('fetch', uid, message_parts) + mail = mailparser.parse_from_bytes(email_data[0][1]) + logging.info(f"[{uid}]check email subject [{mail.subject}]") + dir = self.get_local_dir_name(mail) + logging.info(f"check email saved {dir}") + file = f"{dir}/email.txt" + if os.path.exists(file): + return dir + return None + + # save email attachment(images) + def save_email_attachment(self, mail: mailparser.MailParser, email_dir: str): + for attachment in mail.attachments: + if attachment['mail_content_type'] in ['image/png', 'image/jpeg', 'image/gif']: + print('current mail have image attachment') + img_dir = f"{email_dir}/image" + if not os.path.exists(img_dir): + os.makedirs(img_dir) + filename = attachment['filename'] + filefullname = f"{img_dir}/{filename}" + image_data = attachment['payload'] + try: + image_data = base64.b64decode(image_data) + except base64.binascii.Error: + image_data = image_data.encode() + with open(filefullname, 'wb') as f: + f.write(image_data) + logging.info(f"save email image {filename} success") + + # save email body images(html content) + def save_body_images(self, html_content: str, email_dir: str): + # get all image urls + soup = BeautifulSoup(html_content, 'html.parser') + img_tags = soup.find_all('img') + img_urls = [img['src'] for img in img_tags if 'src' in img.attrs] + logging.info(f'Found {len(img_urls)} images in email body') + + name_count = 0 + + if not os.path.exists(email_dir): + os.makedirs(email_dir) + + for img_url in img_urls: + # keep the original image filename(last of url) + ext = img_url.split('/')[-1].split('.')[-1] + img_filename = os.path.join(email_dir, f"{name_count}.{ext}") + name_count += 1 + # download image + response = requests.get(img_url, stream=True) + if response.status_code == 200: + with open(img_filename, 'wb') as img_file: + for chunk in response.iter_content(1024): + img_file.write(chunk) + logging.info(f'Downloaded {img_url} to {img_filename}') + else: + logging.info(f'Failed to download {img_url}') + + # save email content to local dir + def save_email(self, mail: mailparser.MailParser): + dir = f"{self.local_root()}/{self.config.get('address')}" + if not os.path.exists(dir): + os.makedirs(dir) + email_dir = self.get_local_dir_name(mail) + logging.info(f"save email to {email_dir}") + if not os.path.exists(email_dir): + os.makedirs(email_dir) + with open(f"{email_dir}/email.txt", "w", encoding='utf-8') as f: + # soup = BeautifulSoup(mail.body, 'html.parser') + f.write(mail.body) + with open(f"{email_dir}/meta.json", "w", encoding='utf-8') as f: + mail_dict = json.loads(mail.mail_json) + if 'body' in mail_dict: + del mail_dict['body'] + json.dump(mail_dict, f, ensure_ascii=False, indent=4) + logging.info(f"save email meta info {f.name}") + + self.save_email_attachment(mail, email_dir) + self.save_body_images(mail.body, f"{email_dir}/body_image") \ No newline at end of file diff --git a/src/knowledge/input/local_dir.py b/src/knowledge/input/local_dir.py new file mode 100644 index 0000000..2bbafe1 --- /dev/null +++ b/src/knowledge/input/local_dir.py @@ -0,0 +1,61 @@ +import os +import aiofiles +import chardet +from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgeBase + +class KnowledgeDirSource: + def __init__(self, config): + self.config = config + config["path"] = os.path.abspath(config["path"]) + + # @classmethod + # def user_config_items(cls): + # return [("path", "local dir path")] + + def path(self): + return self.config["path"] + + @staticmethod + async def read_txt_file(file_path:str)->str: + cur_encode = "utf-8" + async with aiofiles.open(file_path,'rb') as f: + cur_encode = chardet.detect(await f.read())['encoding'] + + async with aiofiles.open(file_path,'r',encoding=cur_encode) as f: + return await f.read() + + async def next(self): + # logging.debug(f"knowledge dir source {self.id()} run once") + # journal_client = KnowledgeJournalClient() + # latest_journal = journal_client.latest_journal(self.id()) + # if latest_journal is not None: + # if os.path.getmtime(self.path()) <= latest_journal.timestamp: + # logging.debug(f"knowledge dir source {self.id()} ingnored for no update") + # return + while True: + file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) + for rel_path in file_pathes: + file_path = os.path.join(self.path(), rel_path) + # timestamp = os.path.getctime(file_path) + # if latest_journal is not None: + # if timestamp <= latest_journal.timestamp: + # continue + ext = os.path.splitext(file_path)[1].lower() + if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']: + # logging.info(f"knowledge dir source {self.id()} found image file {file_path}") + image = ImageObjectBuilder({}, {}, file_path).build() + await KnowledgeBase().insert_object(image) + yield image.calculate_id() + # journal_client.insert(KnowledgeJournal("dir", self.id(), rel_path, str(image.calculate_id()), timestamp)) + if ext in ['.txt']: + # logging.info(f"knowledge dir source {self.id()} found text file {file_path}") + text = await self.read_txt_file(file_path) + document = DocumentObjectBuilder({}, {}, text).build() + await KnowledgeBase().insert_object(document) + yield document.calculate_id() + # journal_client.insert(KnowledgeJournal("dir", self.id(), rel_path, str(document.calculate_id()), timestamp)) + yield 0 + + +def init(params: dict) -> KnowledgeDirSource: + return KnowledgeDirSource(params) \ No newline at end of file diff --git a/src/knowledge/parser/__init__.py b/src/knowledge/parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/knowledge/parser/embedding.py b/src/knowledge/parser/embedding.py new file mode 100644 index 0000000..e512eea --- /dev/null +++ b/src/knowledge/parser/embedding.py @@ -0,0 +1,93 @@ +# define a knowledge base class +import json +from aios_kernel import ComputeKernel, AIStorage +from knowledge import * + + +class EmbeddingParser: + def __init__(self, params: dict): + self.store = KnowledgeStore() + self.compute_kernel = ComputeKernel.get_instance() + self.knowledge_base = KnowledgeBase() + self._default_text_model = "all-MiniLM-L6-v2" + self._default_image_model = "clip-ViT-B-32" + self.vector_store = ChromaVectorStore(AIStorage().get_myai_dir() / "knowledge", self._default_text_model) + + def __get_vector_store(self, model_name: str) -> ChromaVectorStore: + return ChromaVectorStore(AIStorage().get_myai_dir() / "knowledge", self._default_text_model) + + async def __embedding_document(self, document: DocumentObject): + for chunk_id in document.get_chunk_list(): + chunk = self.knowledge_base.store.get_chunk_reader().get_chunk(chunk_id) + if chunk is None: + raise ValueError(f"text chunk not found: {chunk_id}") + + text = chunk.read().decode("utf-8") + vector = await self.compute_kernel.do_text_embedding(text, self._default_text_model) + if vector: + await self.get_vector_store(self._default_text_model).insert(vector, chunk_id) + + async def __embedding_image(self, image: ImageObject): + # desc = {} + # if not not image.get_meta(): + # desc["meta"] = image.get_meta() + # if not not image.get_exif(): + # desc["exif"] = image.get_exif() + # if not not image.get_tags(): + # desc["tags"] = image.get_tags() + # vector = await self.compute_kernel.do_text_embedding(json.dumps(desc), self._default_text_model) + vector = await self.compute_kernel.do_image_embedding(image.calculate_id(), self._default_image_model) + if vector: + await self.get_vector_store(self._default_image_model).insert(vector, image.calculate_id()) + + async def __embedding_video(self, vedio: VideoObject): + desc = {} + if not not vedio.get_meta(): + desc["meta"] = vedio.get_meta() + if not not vedio.get_info(): + desc["info"] = vedio.get_info() + if not not vedio.get_tags(): + desc["tags"] = vedio.get_tags() + vector = await self.compute_kernel.do_text_embedding(json.dumps(desc), self._default_text_model) + await self.get_vector_store(self._default_text_model).insert(vector, vedio.calculate_id()) + + async def __embedding_rich_text(self, rich_text: RichTextObject): + for document_id in rich_text.get_documents().values(): + document = DocumentObject.decode(self.store.get_object_store().get_object(document_id)) + await self.__embedding_document(document) + for image_id in rich_text.get_images().values(): + image = ImageObject.decode(self.store.get_object_store().get_object(image_id)) + await self.__embedding_image(image) + for video_id in rich_text.get_videos().values(): + video = VideoObject.decode(self.store.get_object_store().get_object(video_id)) + await self.__embedding_video(video) + for rich_text_id in rich_text.get_rich_texts().values(): + rich_text = RichTextObject.decode(self.store.get_object_store().get_object(rich_text_id)) + await self.__embedding_rich_text(rich_text) + + async def __embedding_email(self, email: EmailObject): + vector = await self.compute_kernel.do_text_embedding(json.dumps(email.get_desc()), self._default_text_model) + await self.get_vector_store(self._default_text_model).insert(vector, email.calculate_id()) + await self.__embedding_rich_text(email.get_rich_text()) + + + async def __do_embedding(self, object: KnowledgeObject): + if object.get_object_type() == ObjectType.Document: + await self.__embedding_document(object) + if object.get_object_type() == ObjectType.Image: + await self.__embedding_image(object) + if object.get_object_type() == ObjectType.Video: + await self.__embedding_video(object) + if object.get_object_type() == ObjectType.RichText: + await self.__embedding_rich_text(object) + if object.get_object_type() == ObjectType.Email: + await self.__embedding_email(object) + else: + pass + + async def parse(self, object: ObjectID): + obj = self.knowledge_base.load_object(object) + await self.__do_embedding(obj) + +def init(params: dict) -> EmbeddingParser: + return EmbeddingParser(params) \ No newline at end of file diff --git a/src/knowledge/pipeline.py b/src/knowledge/pipeline.py new file mode 100644 index 0000000..9c7e349 --- /dev/null +++ b/src/knowledge/pipeline.py @@ -0,0 +1,165 @@ + +# class KnowledgePipelineTemplate +import runpy +import toml +import datetime +import sqlite3 +import os +from . import ObjectID, KnowledgeStore +import asyncio + +class KnowledgePipelineJournal: + def __init__(self, time: datetime.datetime, object_id: str, input: str, parser: str): + self.time = time + self.object_id = None if object_id is None else ObjectID.from_base58(object_id) + self.input = input + self.parser = parser + + def is_finish(self) -> bool: + self.object_id is None + + + +# init sqlite3 client +class KnowledgePipelineJournalClient: + def __init__(self, pipeline_path: str = None): + if not os.path.exists(pipeline_path): + os.makedirs(pipeline_path) + self.journal_path = os.path.join(pipeline_path, "journal.db") + + conn = sqlite3.connect(self.journal_path) + conn.execute( + '''CREATE TABLE IF NOT EXISTS journal ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time DATETIME DEFAULT CURRENT_TIMESTAMP, + object_id TEXT, + input TEXT, + parser TEXT)''' + ) + conn.commit() + + def insert(self, object_id: ObjectID, input: str, parser: str, timestamp: datetime.datetime = None): + timestamp = datetime.datetime.now() if timestamp is None else timestamp + conn = sqlite3.connect(self.journal_path) + conn.execute( + "INSERT INTO journal (time, object_id, input, parser) VALUES (?, ?, ?)", + (timestamp, str(object_id), input, parser), + ) + conn.commit() + + def latest_journals(self, topn) -> [KnowledgePipelineJournal]: + conn = sqlite3.connect(self.journal_path) + cursor = conn.cursor() + cursor.execute("SELECT * FROM journal ORDER BY id DESC LIMIT ?", (topn,)) + return [KnowledgePipelineJournal(time, object_id, input, parser) for (_, time, object_id, input, parser) in cursor.fetchall()] + +class KnowledgePipelineEnvironment: + def __init__(self, pipeline_path: str): + self.knowledge_base = KnowledgeStore() + self.pipeline_path = pipeline_path + self.journal = KnowledgePipelineJournalClient(pipeline_path) + +class KnowledgePipelineState(Enum): + INIT = 0 + RUNNING = 1 + STOPPED = 2 + FINISHED = 3 + +class KnowledgePipeline: + def __init__(self, name: str, env: KnowledgePipelineEnvironment, input_init, input_params, parser_init, parser_params): + self.name = name + self.state = KnowledgePipelineState.INIT + self.input_init = input_init + self.input_params = input_params + self.parser_init = parser_init + self.parser_params = parser_params + self.env = env + self.input = None + self.parser = None + + async def run(self): + if self.state == KnowledgePipelineState.INIT: + self.input = self.input_init(self.env, self.input_params) + self.parser = self.parser_init(self.env, self.parser_params) + self.state = KnowledgePipelineState.RUNNING + if self.state == KnowledgePipelineState.RUNNING: + for input in await self.input.next(): + if input is None: + self.state = KnowledgePipelineState.FINISHED + self.env.journal.insert(None, "finished", "finished") + return + (object_id, input_journal) = input + if object_id is None: + parser_journal = await self.parser.parse(object_id) + self.env.journal.insert(object_id, input_journal, parser_journal) + if self.state == KnowledgePipelineState.STOPPED: + return + if self.state == KnowledgePipelineState.FINISHED: + return + +class KnowledgePipelineManager: + def __init__(self, root_dir: str): + self.root_dir = root_dir + self.input_modules = {} + self.parser_modules = {} + self.pipelines = { + "names": {}, + "running": [] + } + from .input import local_dir + self.register_input("local_dir", local_dir.init) + + def register_input(self, name: str, init_method): + self.input_modules[name] = init_method + + def register_parser(self, name: str, parser_method): + self.parser_modules[name] = parser_method + + def add_pipeline(self, config: dict, path: str): + name = config["name"] + if name in self.pipelines["names"]: + return + + input_module = self.input_modules[config["input"]["module"]] + _, ext = os.path.splitext(input_module) + if ext == ".py": + input_module = os.path.abspath(path, input_module) + input_init = runpy.run_path(input_module)["init"] + else: + input_init = self.input_modules.get(input_module) + input_params = config["input"]["params"] + + parser_module = self.parser_modules[config["parser"]["module"]] + _, ext = os.path.splitext(parser_module) + if ext == ".py": + parser_module = os.path.abspath(path, parser_module) + parser_init = runpy.run_path(parser_module)["init"] + else: + parser_init = self.parser_modules.get(parser_module) + parser_params = config["parser"]["params"] + + + data_path = self.root_dir / name + env = KnowledgePipelineEnvironment(data_path) + pipeline = KnowledgePipeline(name, env, input_init, input_params, parser_init, parser_params) + self.pipelines["names"][name] = pipeline + self.pipelines["running"].append(pipeline) + + async def run(self): + while True: + for pipeline in self.pipelines["running"]: + await pipeline.run() + await asyncio.sleep(5) + + def load_dir(self, root: str): + config_path = os.path.join(root, "pipelines.toml") + with open(config_path, "r") as f: + config = toml.load(f) + for path in config["pipelines"]: + pipeline_path = os.path.join(root, path) + with open(os.path.join(pipeline_path, "pipeline.toml")) as f: + pipeline_config = toml.load(f) + self.add_pipeline(pipeline_config, pipeline_path) + + + \ No newline at end of file diff --git a/src/knowledge/query/embedding.py b/src/knowledge/query/embedding.py new file mode 100644 index 0000000..f29bb43 --- /dev/null +++ b/src/knowledge/query/embedding.py @@ -0,0 +1,82 @@ + +class KnowledgeEnvironment(Environment): + def __init__(self, env_id: str) -> None: + super().__init__(env_id) + + query_param = { + "tokens": "key words to query", + "types": "prefered knowledge types, one or more of [text, image]", + "index": "index of query result" + } + self.add_ai_function(SimpleAIFunction("query_knowledge", + "vector query content from local knowledge base", + self._query, + query_param)) + async def query_objects(self, tokens: str, types: list[str], topk: int) -> [ObjectID]: + texts = [] + if "text" in types: + vector = await self.compute_kernel.do_text_embedding(tokens, self._default_text_model) + texts = await self.store.get_vector_store(self._default_text_model).query(vector, topk) + images = [] + if "image" in types: + vector = await self.compute_kernel.do_text_embedding(tokens, self._default_image_model) + images = await self.store.get_vector_store(self._default_image_model).query(vector, topk) + return texts + images + + + def tokens_from_objects(self, object_ids: [ObjectID]) -> list[str]: + results = dict() + for object_id in object_ids: + parents = self.store.get_relation_store().get_related_root_objects(object_id) + # last parent is the root object + root_object_id = parents[0] if parents else object_id + logging.info(f"object_id: {str(object_id)} root_object_id: {str(root_object_id)}") + if str(root_object_id) in results: + results[str(root_object_id)].append(object_id) + else: + results[str(root_object_id)] = [root_object_id, object_id] + content = "" + result_desc = [] + for result in results.values(): + # first element in result is the root object + root_object_id = result[0] + if root_object_id.get_object_type() == ObjectType.Email: + email = self.load_object(root_object_id) + desc = email.get_desc() + desc["type"] = "email" + desc["contents"] = [] + result_desc.append(desc) + upper_list = desc["contents"] + result = result[1:] + else: + upper_list = result_desc + + for object_id in result: + if object_id.get_object_type() == ObjectType.Chunk: + upper_list.append({"type": "text", "content": self.store.get_chunk_reader().get_chunk(object_id).read().decode("utf-8")}) + if object_id.get_object_type() == ObjectType.Image: + # image = self.load_object(object_id) + desc = dict() + desc["id"] = str(object_id) + desc["type"] = "image" + upper_list.append(desc) + if object_id.get_object_type() == ObjectType.Video: + video = self.load_object(object_id) + desc = video.get_desc() + desc["type"] = "video" + upper_list.append(desc) + else: + pass + content += json.dumps(result_desc) + content += ".\n" + + return content + + async def _query(self, tokens: str, types: list[str] = ["text"], index: str=0): + index = int(index) + object_ids = await KnowledgeBase().query_objects(tokens, types, 4) + if len(object_ids) <= index: + return "*** I have no more information for your reference.\n" + else: + content = "*** I have provided the following known information for your reference with json format:\n" + return content + KnowledgeBase().tokens_from_objects(object_ids[index:index+1]) \ No newline at end of file diff --git a/src/knowledge/store.py b/src/knowledge/store.py index abcf496..ebfda27 100644 --- a/src/knowledge/store.py +++ b/src/knowledge/store.py @@ -1,8 +1,9 @@ import os -from .object import ObjectStore, ObjectRelationStore +from .object import ObjectStore, ObjectRelationStore, ObjectID, ObjectType, KnowledgeObject, DocumentObject, ImageObject, VideoObject, RichTextObject, EmailObject +from core_object import DocumentObject, ImageObject, VideoObject, RichTextObject, EmailObject from .data import ChunkStore, ChunkTracker, ChunkListWriter, ChunkReader -from .vector import ChromaVectorStore, VectorBase +import json import logging @@ -17,7 +18,7 @@ class KnowledgeStore: cls._instance = super().__new__(cls) import aios_kernel - knowledge_dir = aios_kernel.storage.AIStorage().get_myai_dir() / "knowledge" + knowledge_dir = aios_kernel.storage.AIStorage().get_myai_dir() / "knowledge" / "objects" if not os.path.exists(knowledge_dir): os.makedirs(knowledge_dir) @@ -42,10 +43,8 @@ class KnowledgeStore: self.chunk_tracker = ChunkTracker(chunk_store_dir) self.chunk_list_writer = ChunkListWriter(self.chunk_store, self.chunk_tracker) self.chunk_reader = ChunkReader(self.chunk_store, self.chunk_tracker) - self.vector_store = {} - def get_relation_store(self) -> ObjectRelationStore: return self.relation_store @@ -64,7 +63,43 @@ class KnowledgeStore: def get_chunk_reader(self) -> ChunkReader: return self.chunk_reader - def get_vector_store(self, model_name: str) -> VectorBase: - if model_name not in self.vector_store: - self.vector_store[model_name] = ChromaVectorStore(self.root, model_name) - return self.vector_store[model_name] + async def insert_object(self, object: KnowledgeObject): + self.object_store.put_object(object.calculate_id(), object.encode()) + + def load_object(self, object_id: ObjectID) -> KnowledgeObject: + if object_id.get_object_type() == ObjectType.Document: + return DocumentObject.decode(self.store.get_object_store().get_object(object_id)) + if object_id.get_object_type() == ObjectType.Image: + return ImageObject.decode(self.store.get_object_store().get_object(object_id)) + if object_id.get_object_type() == ObjectType.Video: + return VideoObject.decode(self.store.get_object_store().get_object(object_id)) + if object_id.get_object_type() == ObjectType.RichText: + return RichTextObject.decode(self.store.get_object_store().get_object(object_id)) + if object_id.get_object_type() == ObjectType.Email: + return EmailObject.decode(self.store.get_object_store().get_object(object_id)) + else: + pass + + def parse_object_in_message(self, message: str) -> KnowledgeObject: + # get message's first line + logging.info(f"tg parse resp message: {message}") + lines = message.split("\n") + if len(lines) > 0: + message = lines[0] + try: + desc = json.loads(message) + if isinstance(desc, dict): + object_id = desc["id"] + else: + object_id = desc[0]["id"] + except Exception as e: + return None + + if object_id is not None: + return self.load_object(ObjectID.from_base58(object_id)) + + + def bytes_from_object(self, object: KnowledgeObject) -> bytes: + if object.get_object_type() == ObjectType.Image: + image_object = object + return self.get_chunk_reader().read_chunk_list_to_single_bytes(image_object.get_chunk_list()) \ No newline at end of file diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 43441b4..191e0d1 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -112,9 +112,6 @@ class AIOS_Shell: cm.add_family_member(self.username,owenr) - knowledge_env = KnowledgeEnvironment("knowledge") - Environment.set_env_by_id("knowledge",knowledge_env) - cal_env = CalenderEnvironment("calender") await cal_env.start() Environment.set_env_by_id("calender",cal_env) @@ -186,7 +183,12 @@ class AIOS_Shell: AIBus().get_default_bus().register_unhandle_message_handler(self._handle_no_target_msg) AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) - KnowledgePipline.get_instance().initial() + + + pipelines = KnowledgePipelineManager(AIStorage().get_instance().get_myai_dir() / "knowledge" / "pipelines") + pipelines.load_dir(AIStorage().get_instance().get_system_app_dir() / "knowledge_pipelines") + pipelines.load_dir(AIStorage().get_instance().get_myai_dir() / "knowledge_pipelines") + asyncio.create_task(pipelines.run()) TelegramTunnel.register_to_loader() EmailTunnel.register_to_loader() From f80ec04272248ca25f04d8951bac76d2ca349897 Mon Sep 17 00:00:00 2001 From: tsukasa Date: Thu, 19 Oct 2023 10:47:45 +0800 Subject: [PATCH 05/79] add knowledge pipeline manager --- src/aios_kernel/__init__.py | 2 -- src/aios_kernel/agent.py | 4 +--- src/aios_kernel/tg_tunnel.py | 8 +++----- src/knowledge/core_object/document_object.py | 7 +++---- src/knowledge/core_object/email_object.py | 7 +++---- src/knowledge/core_object/image_object.py | 5 ++--- src/knowledge/core_object/video_object.py | 5 ++--- src/knowledge/pipeline.py | 1 + src/knowledge/store.py | 4 ++-- 9 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index e19ba35..fc11ad0 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -5,8 +5,6 @@ from .agent import AIAgent,AIAgentTemplete from .compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType from .compute_node import ComputeNode,LocalComputeNode from .open_ai_node import OpenAI_ComputeNode -from .knowledge_base import KnowledgeBase, KnowledgeEnvironment -from .knowledge_pipeline import KnowledgeEmailSource, KnowledgeDirSource, KnowledgePipline from .role import AIRole,AIRoleGroup from .workflow import Workflow from .bus import AIBus diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 5d9c756..bacafe5 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -16,7 +16,6 @@ from .compute_task import ComputeTaskResult,ComputeTaskResultCode from .ai_function import AIFunction from .environment import Environment from .contact_manager import ContactManager,Contact,FamilyMember -from .knowledge_base import KnowledgeBase from .compute_kernel import ComputeKernel from .bus import AIBus @@ -550,8 +549,7 @@ class AIAgent: def parser_learn_llm_result(self,llm_result:str): pass - async def _llm_read_article(self,kb:KnowledgeBase,item:KnowledgeObject) -> ComputeTaskResult: - #kb_env = KnowledgeBaseFileSystemEnvironment() + async def _llm_read_article(self,item:KnowledgeObject) -> ComputeTaskResult: full_content = item.get_article_full_content() full_content_len = ComputeKernel.llm_num_tokens_from_text(full_content,self.get_llm_model_name()) if full_content_len < self.get_llm_learn_token_limit(): diff --git a/src/aios_kernel/tg_tunnel.py b/src/aios_kernel/tg_tunnel.py index dcc7abd..923fb14 100644 --- a/src/aios_kernel/tg_tunnel.py +++ b/src/aios_kernel/tg_tunnel.py @@ -10,9 +10,7 @@ from telegram import Bot from telegram.ext import Updater from telegram.error import Forbidden, NetworkError -from knowledge.object.object_id import ObjectType - -from .knowledge_base import KnowledgeBase +from knowledge import ObjectType, KnowledgeStore from .tunnel import AgentTunnel from .storage import AIStorage @@ -236,10 +234,10 @@ class TelegramTunnel(AgentTunnel): await update.message.reply_text("") return - knowledge_object = KnowledgeBase().parse_object_in_message(resp_msg.body) + knowledge_object = KnowledgeStore().parse_object_in_message(resp_msg.body) if knowledge_object is not None: if knowledge_object.get_object_type() == ObjectType.Image: - image = KnowledgeBase().bytes_from_object(knowledge_object) + image = KnowledgeStore().bytes_from_object(knowledge_object) try: async with aiofiles.open("tg_send_temp.png", mode='wb') as local_file: if local_file: diff --git a/src/knowledge/core_object/document_object.py b/src/knowledge/core_object/document_object.py index b69f59f..43e8b9e 100644 --- a/src/knowledge/core_object/document_object.py +++ b/src/knowledge/core_object/document_object.py @@ -1,7 +1,6 @@ from ..object import KnowledgeObject, ObjectRelationStore from ..data import ChunkList, ChunkListWriter from ..object import ObjectType -from .. import KnowledgeStore # desc # meta @@ -49,13 +48,13 @@ class DocumentObjectBuilder: self.text = text return self - def build(self) -> DocumentObject: - chunk_list = KnowledgeStore().get_chunk_list_writer().create_chunk_list_from_text(self.text) + def build(self, store) -> DocumentObject: + chunk_list = store.get_chunk_list_writer().create_chunk_list_from_text(self.text) doc = DocumentObject(self.meta, self.tags, chunk_list) doc_id = doc.calculate_id() # Add relation to store for chunk_id in chunk_list.chunk_list: - KnowledgeStore().get_relation_store().add_relation(chunk_id, doc_id) + store.get_relation_store().add_relation(chunk_id, doc_id) return doc diff --git a/src/knowledge/core_object/email_object.py b/src/knowledge/core_object/email_object.py index 108c9cc..64d78b3 100644 --- a/src/knowledge/core_object/email_object.py +++ b/src/knowledge/core_object/email_object.py @@ -1,4 +1,3 @@ -from .. import KnowledgeStore from .rich_text_object import RichTextObject, RichTextObjectBuilder from ..object import ObjectID, ObjectType, KnowledgeObject from .document_object import DocumentObjectBuilder @@ -68,11 +67,11 @@ class EmailObjectBuilder: self.folder = folder return self - def build(self) -> EmailObject: + def build(self, store) -> EmailObject: # Just get the object store and relation store from global KnowledgeStore - store = KnowledgeStore().get_object_store() - relation = KnowledgeStore().get_relation_store() + store = store.get_object_store() + relation = store.get_relation_store() # Read meta.json meta = {} diff --git a/src/knowledge/core_object/image_object.py b/src/knowledge/core_object/image_object.py index 340bcc2..1033bf7 100644 --- a/src/knowledge/core_object/image_object.py +++ b/src/knowledge/core_object/image_object.py @@ -1,7 +1,6 @@ from ..object import KnowledgeObject from ..data import ChunkList, ChunkListWriter from ..object import ObjectType -from .. import KnowledgeStore import os # desc @@ -86,10 +85,10 @@ class ImageObjectBuilder: self.restore_file = restore_file return self - def build(self) -> ImageObject: + def build(self, store) -> ImageObject: file_size = os.path.getsize(self.image_file) - chunk_list = KnowledgeStore().get_chunk_list_writer().create_chunk_list_from_file( + chunk_list = store.get_chunk_list_writer().create_chunk_list_from_file( self.image_file, 1024 * 1024 * 4, self.restore_file ) exif = get_exif_data(self.image_file) diff --git a/src/knowledge/core_object/video_object.py b/src/knowledge/core_object/video_object.py index 56e9829..1ac9338 100644 --- a/src/knowledge/core_object/video_object.py +++ b/src/knowledge/core_object/video_object.py @@ -1,7 +1,6 @@ from ..object import KnowledgeObject from ..data import ChunkList, ChunkListWriter from ..object import ObjectType -from .. import KnowledgeStore # desc # meta @@ -76,8 +75,8 @@ class VideoObjectBuilder: self.restore_file = restore_file return self - def build(self) -> VideoObject: - chunk_list = KnowledgeStore().get_chunk_list_writer().create_chunk_list_from_file( + def build(self, store) -> VideoObject: + chunk_list = store.get_chunk_list_writer().create_chunk_list_from_file( self.video_file, 1024 * 1024 * 4, self.restore_file ) info = get_video_info(self.video_file) diff --git a/src/knowledge/pipeline.py b/src/knowledge/pipeline.py index 9c7e349..e051925 100644 --- a/src/knowledge/pipeline.py +++ b/src/knowledge/pipeline.py @@ -7,6 +7,7 @@ import sqlite3 import os from . import ObjectID, KnowledgeStore import asyncio +from enum import Enum class KnowledgePipelineJournal: def __init__(self, time: datetime.datetime, object_id: str, input: str, parser: str): diff --git a/src/knowledge/store.py b/src/knowledge/store.py index ebfda27..803c3b6 100644 --- a/src/knowledge/store.py +++ b/src/knowledge/store.py @@ -1,7 +1,7 @@ import os -from .object import ObjectStore, ObjectRelationStore, ObjectID, ObjectType, KnowledgeObject, DocumentObject, ImageObject, VideoObject, RichTextObject, EmailObject -from core_object import DocumentObject, ImageObject, VideoObject, RichTextObject, EmailObject +from .object import ObjectStore, ObjectRelationStore, ObjectID, ObjectType, KnowledgeObject +from .core_object import DocumentObject, ImageObject, VideoObject, RichTextObject, EmailObject from .data import ChunkStore, ChunkTracker, ChunkListWriter, ChunkReader import json import logging From 030077e4d3e44ab9d34f3381cad29c9962d4b70a Mon Sep 17 00:00:00 2001 From: tsukasa Date: Thu, 19 Oct 2023 17:09:27 +0800 Subject: [PATCH 06/79] knowledge pipeline manager init --- .../local_embedding/pipeline.toml | 6 + .../local_photos_embedding/pipeline.toml | 6 - rootfs/knowledge_pipelines/pipelines.toml | 5 +- src/component/knowledge_manager/__init__.py | 1 + .../knowledge_manager}/input/email.py | 0 .../knowledge_manager/input/local_dir.py | 65 +++++++ .../knowledge_manager}/parser/embedding.py | 55 +++--- src/component/knowledge_manager/pipeline.py | 73 ++++++++ .../knowledge_manager}/query/embedding.py | 0 src/knowledge/input/__init__.py | 1 - src/knowledge/input/local_dir.py | 61 ------- src/knowledge/parser/__init__.py | 0 src/knowledge/pipeline.py | 91 ++-------- src/knowledge/store.py | 10 +- src/service/aios_shell/aios_shell.py | 7 +- src/service/email_spider/converter.py | 24 --- src/service/email_spider/main.py | 12 -- src/service/email_spider/spider.py | 17 -- src/service/spider/email_spider.py | 171 ------------------ 19 files changed, 206 insertions(+), 399 deletions(-) create mode 100644 rootfs/knowledge_pipelines/local_embedding/pipeline.toml delete mode 100644 rootfs/knowledge_pipelines/local_photos_embedding/pipeline.toml create mode 100644 src/component/knowledge_manager/__init__.py rename src/{knowledge => component/knowledge_manager}/input/email.py (100%) create mode 100644 src/component/knowledge_manager/input/local_dir.py rename src/{knowledge => component/knowledge_manager}/parser/embedding.py (55%) create mode 100644 src/component/knowledge_manager/pipeline.py rename src/{knowledge => component/knowledge_manager}/query/embedding.py (100%) delete mode 100644 src/knowledge/input/__init__.py delete mode 100644 src/knowledge/input/local_dir.py delete mode 100644 src/knowledge/parser/__init__.py delete mode 100644 src/service/email_spider/converter.py delete mode 100644 src/service/email_spider/main.py delete mode 100644 src/service/email_spider/spider.py delete mode 100644 src/service/spider/email_spider.py diff --git a/rootfs/knowledge_pipelines/local_embedding/pipeline.toml b/rootfs/knowledge_pipelines/local_embedding/pipeline.toml new file mode 100644 index 0000000..ee8c5b0 --- /dev/null +++ b/rootfs/knowledge_pipelines/local_embedding/pipeline.toml @@ -0,0 +1,6 @@ +name = "LocalEmbedding" +input.module = "local_dir" +input.params.path = "${myai_dir}/data" +parser.module = "embedding" +parser.params.path = "${myai_dir}/knowledge/indices/embedding" + diff --git a/rootfs/knowledge_pipelines/local_photos_embedding/pipeline.toml b/rootfs/knowledge_pipelines/local_photos_embedding/pipeline.toml deleted file mode 100644 index e26e7a8..0000000 --- a/rootfs/knowledge_pipelines/local_photos_embedding/pipeline.toml +++ /dev/null @@ -1,6 +0,0 @@ -name = "LocalPhotoEmbedding" -input.module = "local_dir" -input.params.path = "~/myai/photos" -parser.module = "embedding" -parser.params.path = "~/myai/knowledge/indices/photo_embedding" - diff --git a/rootfs/knowledge_pipelines/pipelines.toml b/rootfs/knowledge_pipelines/pipelines.toml index 99d5b59..e6bf006 100644 --- a/rootfs/knowledge_pipelines/pipelines.toml +++ b/rootfs/knowledge_pipelines/pipelines.toml @@ -1,2 +1,3 @@ -[[pipelines]] -"local_photos_embedding" \ No newline at end of file +pipelines = [ + "local_embedding" +] \ No newline at end of file diff --git a/src/component/knowledge_manager/__init__.py b/src/component/knowledge_manager/__init__.py new file mode 100644 index 0000000..8f6fd1f --- /dev/null +++ b/src/component/knowledge_manager/__init__.py @@ -0,0 +1 @@ +from .pipeline import KnowledgePipelineManager \ No newline at end of file diff --git a/src/knowledge/input/email.py b/src/component/knowledge_manager/input/email.py similarity index 100% rename from src/knowledge/input/email.py rename to src/component/knowledge_manager/input/email.py diff --git a/src/component/knowledge_manager/input/local_dir.py b/src/component/knowledge_manager/input/local_dir.py new file mode 100644 index 0000000..76c1137 --- /dev/null +++ b/src/component/knowledge_manager/input/local_dir.py @@ -0,0 +1,65 @@ +import os +import aiofiles +import chardet +import logging +import string +from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal +from aios_kernel.storage import AIStorage + +class KnowledgeDirSource: + def __init__(self, env: KnowledgePipelineEnvironment, config): + self.env = env + path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + config["path"] = path + self.config = config + + # @classmethod + # def user_config_items(cls): + # return [("path", "local dir path")] + + def path(self): + return self.config["path"] + + @staticmethod + async def read_txt_file(file_path:str)->str: + cur_encode = "utf-8" + async with aiofiles.open(file_path,'rb') as f: + cur_encode = chardet.detect(await f.read())['encoding'] + + async with aiofiles.open(file_path,'r',encoding=cur_encode) as f: + return await f.read() + + async def next(self): + journals = self.env.journal.latest_journals(1) + from_time = 0 + if len(journals) == 1: + latest_journal = journals[0] + if latest_journal.is_finish(): + yield None + from_time = os.path.getctime(latest_journal.get_input()) + if os.path.getmtime(self.path()) <= from_time: + yield (None, None) + while True: + file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) + for rel_path in file_pathes: + file_path = os.path.join(self.path(), rel_path) + timestamp = os.path.getctime(file_path) + if timestamp <= from_time: + continue + ext = os.path.splitext(file_path)[1].lower() + if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']: + logging.info(f"knowledge dir source found image file {file_path}") + image = ImageObjectBuilder({}, {}, file_path).build(self.env.get_knowledge_store()) + await self.env.get_knowledge_store().insert_object(image) + yield (image.calculate_id(), file_path) + if ext in ['.txt']: + logging.info(f"knowledge dir source found text file {file_path}") + text = await self.read_txt_file(file_path) + document = DocumentObjectBuilder({}, {}, text).build(self.env.get_knowledge_store()) + await self.env.get_knowledge_store().insert_object(document) + yield (document.calculate_id(), file_path) + yield (None, None) + + +def init(env: KnowledgePipelineEnvironment, params: dict) -> KnowledgeDirSource: + return KnowledgeDirSource(env, params) \ No newline at end of file diff --git a/src/knowledge/parser/embedding.py b/src/component/knowledge_manager/parser/embedding.py similarity index 55% rename from src/knowledge/parser/embedding.py rename to src/component/knowledge_manager/parser/embedding.py index e512eea..cb8b5f8 100644 --- a/src/knowledge/parser/embedding.py +++ b/src/component/knowledge_manager/parser/embedding.py @@ -1,31 +1,39 @@ # define a knowledge base class import json +import string from aios_kernel import ComputeKernel, AIStorage from knowledge import * class EmbeddingParser: - def __init__(self, params: dict): - self.store = KnowledgeStore() - self.compute_kernel = ComputeKernel.get_instance() - self.knowledge_base = KnowledgeBase() + def __init__(self, env: KnowledgePipelineEnvironment, config: dict): self._default_text_model = "all-MiniLM-L6-v2" self._default_image_model = "clip-ViT-B-32" - self.vector_store = ChromaVectorStore(AIStorage().get_myai_dir() / "knowledge", self._default_text_model) + + path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + if not os.path.exists(path): + os.makedirs(path) + config["path"] = path + + self.env = env + self.config = config + + def get_path(self) -> str: + return self.config["path"] def __get_vector_store(self, model_name: str) -> ChromaVectorStore: - return ChromaVectorStore(AIStorage().get_myai_dir() / "knowledge", self._default_text_model) + return ChromaVectorStore(self.get_path(), model_name) async def __embedding_document(self, document: DocumentObject): for chunk_id in document.get_chunk_list(): - chunk = self.knowledge_base.store.get_chunk_reader().get_chunk(chunk_id) + chunk = self.env.get_knowledge_store().get_chunk_reader().get_chunk(chunk_id) if chunk is None: raise ValueError(f"text chunk not found: {chunk_id}") text = chunk.read().decode("utf-8") - vector = await self.compute_kernel.do_text_embedding(text, self._default_text_model) + vector = await ComputeKernel.get_instance().do_text_embedding(text, self._default_text_model) if vector: - await self.get_vector_store(self._default_text_model).insert(vector, chunk_id) + await self.__get_vector_store(self._default_text_model).insert(vector, chunk_id) async def __embedding_image(self, image: ImageObject): # desc = {} @@ -36,9 +44,9 @@ class EmbeddingParser: # if not not image.get_tags(): # desc["tags"] = image.get_tags() # vector = await self.compute_kernel.do_text_embedding(json.dumps(desc), self._default_text_model) - vector = await self.compute_kernel.do_image_embedding(image.calculate_id(), self._default_image_model) + vector = await ComputeKernel.get_instance().do_image_embedding(image.calculate_id(), self._default_image_model) if vector: - await self.get_vector_store(self._default_image_model).insert(vector, image.calculate_id()) + await self.__get_vector_store(self._default_image_model).insert(vector, image.calculate_id()) async def __embedding_video(self, vedio: VideoObject): desc = {} @@ -48,26 +56,26 @@ class EmbeddingParser: desc["info"] = vedio.get_info() if not not vedio.get_tags(): desc["tags"] = vedio.get_tags() - vector = await self.compute_kernel.do_text_embedding(json.dumps(desc), self._default_text_model) - await self.get_vector_store(self._default_text_model).insert(vector, vedio.calculate_id()) + vector = await ComputeKernel.get_instance().do_text_embedding(json.dumps(desc), self._default_text_model) + await self.__get_vector_store(self._default_text_model).insert(vector, vedio.calculate_id()) async def __embedding_rich_text(self, rich_text: RichTextObject): for document_id in rich_text.get_documents().values(): - document = DocumentObject.decode(self.store.get_object_store().get_object(document_id)) + document = DocumentObject.decode(self.env.get_knowledge_store().get_object_store().get_object(document_id)) await self.__embedding_document(document) for image_id in rich_text.get_images().values(): - image = ImageObject.decode(self.store.get_object_store().get_object(image_id)) + image = ImageObject.decode(self.env.get_knowledge_store().get_object_store().get_object(image_id)) await self.__embedding_image(image) for video_id in rich_text.get_videos().values(): - video = VideoObject.decode(self.store.get_object_store().get_object(video_id)) + video = VideoObject.decode(self.env.get_knowledge_store().get_object_store().get_object(video_id)) await self.__embedding_video(video) for rich_text_id in rich_text.get_rich_texts().values(): - rich_text = RichTextObject.decode(self.store.get_object_store().get_object(rich_text_id)) + rich_text = RichTextObject.decode(self.env.get_knowledge_store().get_object_store().get_object(rich_text_id)) await self.__embedding_rich_text(rich_text) async def __embedding_email(self, email: EmailObject): - vector = await self.compute_kernel.do_text_embedding(json.dumps(email.get_desc()), self._default_text_model) - await self.get_vector_store(self._default_text_model).insert(vector, email.calculate_id()) + vector = await ComputeKernel.get_instance().do_text_embedding(json.dumps(email.get_desc()), self._default_text_model) + await self.__get_vector_store(self._default_text_model).insert(vector, email.calculate_id()) await self.__embedding_rich_text(email.get_rich_text()) @@ -85,9 +93,10 @@ class EmbeddingParser: else: pass - async def parse(self, object: ObjectID): - obj = self.knowledge_base.load_object(object) + async def parse(self, object: ObjectID) -> str: + obj = self.env.get_knowledge_store().load_object(object) await self.__do_embedding(obj) + return "insert into vector store" -def init(params: dict) -> EmbeddingParser: - return EmbeddingParser(params) \ No newline at end of file +def init(env: KnowledgePipelineEnvironment, params: dict) -> EmbeddingParser: + return EmbeddingParser(env, params) \ No newline at end of file diff --git a/src/component/knowledge_manager/pipeline.py b/src/component/knowledge_manager/pipeline.py new file mode 100644 index 0000000..653ea9c --- /dev/null +++ b/src/component/knowledge_manager/pipeline.py @@ -0,0 +1,73 @@ +import os +import runpy +import toml +import asyncio +from knowledge import KnowledgePipelineEnvironment, KnowledgePipeline + +class KnowledgePipelineManager: + def __init__(self, root_dir: str): + self.root_dir = root_dir + self.input_modules = {} + self.parser_modules = {} + self.pipelines = { + "names": {}, + "running": [] + } + from .input import local_dir + self.register_input("local_dir", local_dir.init) + from .parser import embedding + self.register_parser("embedding", embedding.init) + + def register_input(self, name: str, init_method): + self.input_modules[name] = init_method + + def register_parser(self, name: str, parser_method): + self.parser_modules[name] = parser_method + + def add_pipeline(self, config: dict, path: str): + name = config["name"] + if name in self.pipelines["names"]: + return + + input_module = config["input"]["module"] + _, ext = os.path.splitext(input_module) + if ext == ".py": + input_module = os.path.abspath(path, input_module) + input_init = runpy.run_path(input_module)["init"] + else: + input_init = self.input_modules.get(input_module) + input_params = config["input"]["params"] + + parser_module = config["parser"]["module"] + _, ext = os.path.splitext(parser_module) + if ext == ".py": + parser_module = os.path.abspath(path, parser_module) + parser_init = runpy.run_path(parser_module)["init"] + else: + parser_init = self.parser_modules.get(parser_module) + parser_params = config["parser"]["params"] + + + data_path = os.path.join(self.root_dir, name) + env = KnowledgePipelineEnvironment(data_path) + pipeline = KnowledgePipeline(name, env, input_init, input_params, parser_init, parser_params) + self.pipelines["names"][name] = pipeline + self.pipelines["running"].append(pipeline) + + async def run(self): + while True: + for pipeline in self.pipelines["running"]: + await pipeline.run() + await asyncio.sleep(5) + + def load_dir(self, root: str): + config_path = os.path.join(root, "pipelines.toml") + if not os.path.exists(config_path): + return + with open(config_path, "r") as f: + config = toml.load(f) + for path in config["pipelines"]: + pipeline_path = os.path.join(root, path) + with open(os.path.join(pipeline_path, "pipeline.toml")) as f: + pipeline_config = toml.load(f) + self.add_pipeline(pipeline_config, pipeline_path) diff --git a/src/knowledge/query/embedding.py b/src/component/knowledge_manager/query/embedding.py similarity index 100% rename from src/knowledge/query/embedding.py rename to src/component/knowledge_manager/query/embedding.py diff --git a/src/knowledge/input/__init__.py b/src/knowledge/input/__init__.py deleted file mode 100644 index 0e9e2a5..0000000 --- a/src/knowledge/input/__init__.py +++ /dev/null @@ -1 +0,0 @@ -import local_dir \ No newline at end of file diff --git a/src/knowledge/input/local_dir.py b/src/knowledge/input/local_dir.py deleted file mode 100644 index 2bbafe1..0000000 --- a/src/knowledge/input/local_dir.py +++ /dev/null @@ -1,61 +0,0 @@ -import os -import aiofiles -import chardet -from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgeBase - -class KnowledgeDirSource: - def __init__(self, config): - self.config = config - config["path"] = os.path.abspath(config["path"]) - - # @classmethod - # def user_config_items(cls): - # return [("path", "local dir path")] - - def path(self): - return self.config["path"] - - @staticmethod - async def read_txt_file(file_path:str)->str: - cur_encode = "utf-8" - async with aiofiles.open(file_path,'rb') as f: - cur_encode = chardet.detect(await f.read())['encoding'] - - async with aiofiles.open(file_path,'r',encoding=cur_encode) as f: - return await f.read() - - async def next(self): - # logging.debug(f"knowledge dir source {self.id()} run once") - # journal_client = KnowledgeJournalClient() - # latest_journal = journal_client.latest_journal(self.id()) - # if latest_journal is not None: - # if os.path.getmtime(self.path()) <= latest_journal.timestamp: - # logging.debug(f"knowledge dir source {self.id()} ingnored for no update") - # return - while True: - file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) - for rel_path in file_pathes: - file_path = os.path.join(self.path(), rel_path) - # timestamp = os.path.getctime(file_path) - # if latest_journal is not None: - # if timestamp <= latest_journal.timestamp: - # continue - ext = os.path.splitext(file_path)[1].lower() - if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']: - # logging.info(f"knowledge dir source {self.id()} found image file {file_path}") - image = ImageObjectBuilder({}, {}, file_path).build() - await KnowledgeBase().insert_object(image) - yield image.calculate_id() - # journal_client.insert(KnowledgeJournal("dir", self.id(), rel_path, str(image.calculate_id()), timestamp)) - if ext in ['.txt']: - # logging.info(f"knowledge dir source {self.id()} found text file {file_path}") - text = await self.read_txt_file(file_path) - document = DocumentObjectBuilder({}, {}, text).build() - await KnowledgeBase().insert_object(document) - yield document.calculate_id() - # journal_client.insert(KnowledgeJournal("dir", self.id(), rel_path, str(document.calculate_id()), timestamp)) - yield 0 - - -def init(params: dict) -> KnowledgeDirSource: - return KnowledgeDirSource(params) \ No newline at end of file diff --git a/src/knowledge/parser/__init__.py b/src/knowledge/parser/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/knowledge/pipeline.py b/src/knowledge/pipeline.py index e051925..e637fb7 100644 --- a/src/knowledge/pipeline.py +++ b/src/knowledge/pipeline.py @@ -1,12 +1,7 @@ - -# class KnowledgePipelineTemplate -import runpy -import toml import datetime import sqlite3 import os from . import ObjectID, KnowledgeStore -import asyncio from enum import Enum class KnowledgePipelineJournal: @@ -17,9 +12,13 @@ class KnowledgePipelineJournal: self.parser = parser def is_finish(self) -> bool: - self.object_id is None + return self.object_id is None + def get_input(self) -> str: + return self.input + def get_parser(self) -> str: + return self.parser # init sqlite3 client class KnowledgePipelineJournalClient: @@ -43,7 +42,7 @@ class KnowledgePipelineJournalClient: timestamp = datetime.datetime.now() if timestamp is None else timestamp conn = sqlite3.connect(self.journal_path) conn.execute( - "INSERT INTO journal (time, object_id, input, parser) VALUES (?, ?, ?)", + "INSERT INTO journal (time, object_id, input, parser) VALUES (?, ?, ?, ?)", (timestamp, str(object_id), input, parser), ) conn.commit() @@ -56,10 +55,18 @@ class KnowledgePipelineJournalClient: class KnowledgePipelineEnvironment: def __init__(self, pipeline_path: str): - self.knowledge_base = KnowledgeStore() + self.knowledge_store = KnowledgeStore() + if not os.path.exists(pipeline_path): + os.makedirs(pipeline_path) self.pipeline_path = pipeline_path self.journal = KnowledgePipelineJournalClient(pipeline_path) + def get_journal(self) -> KnowledgePipelineJournalClient: + return self.journal + + def get_knowledge_store(self) -> KnowledgeStore: + return self.knowledge_store + class KnowledgePipelineState(Enum): INIT = 0 RUNNING = 1 @@ -84,13 +91,13 @@ class KnowledgePipeline: self.parser = self.parser_init(self.env, self.parser_params) self.state = KnowledgePipelineState.RUNNING if self.state == KnowledgePipelineState.RUNNING: - for input in await self.input.next(): + async for input in self.input.next(): if input is None: self.state = KnowledgePipelineState.FINISHED self.env.journal.insert(None, "finished", "finished") return (object_id, input_journal) = input - if object_id is None: + if object_id is not None: parser_journal = await self.parser.parse(object_id) self.env.journal.insert(object_id, input_journal, parser_journal) if self.state == KnowledgePipelineState.STOPPED: @@ -98,69 +105,5 @@ class KnowledgePipeline: if self.state == KnowledgePipelineState.FINISHED: return -class KnowledgePipelineManager: - def __init__(self, root_dir: str): - self.root_dir = root_dir - self.input_modules = {} - self.parser_modules = {} - self.pipelines = { - "names": {}, - "running": [] - } - from .input import local_dir - self.register_input("local_dir", local_dir.init) - - def register_input(self, name: str, init_method): - self.input_modules[name] = init_method - - def register_parser(self, name: str, parser_method): - self.parser_modules[name] = parser_method - - def add_pipeline(self, config: dict, path: str): - name = config["name"] - if name in self.pipelines["names"]: - return - - input_module = self.input_modules[config["input"]["module"]] - _, ext = os.path.splitext(input_module) - if ext == ".py": - input_module = os.path.abspath(path, input_module) - input_init = runpy.run_path(input_module)["init"] - else: - input_init = self.input_modules.get(input_module) - input_params = config["input"]["params"] - - parser_module = self.parser_modules[config["parser"]["module"]] - _, ext = os.path.splitext(parser_module) - if ext == ".py": - parser_module = os.path.abspath(path, parser_module) - parser_init = runpy.run_path(parser_module)["init"] - else: - parser_init = self.parser_modules.get(parser_module) - parser_params = config["parser"]["params"] - - - data_path = self.root_dir / name - env = KnowledgePipelineEnvironment(data_path) - pipeline = KnowledgePipeline(name, env, input_init, input_params, parser_init, parser_params) - self.pipelines["names"][name] = pipeline - self.pipelines["running"].append(pipeline) - - async def run(self): - while True: - for pipeline in self.pipelines["running"]: - await pipeline.run() - await asyncio.sleep(5) - - def load_dir(self, root: str): - config_path = os.path.join(root, "pipelines.toml") - with open(config_path, "r") as f: - config = toml.load(f) - for path in config["pipelines"]: - pipeline_path = os.path.join(root, path) - with open(os.path.join(pipeline_path, "pipeline.toml")) as f: - pipeline_config = toml.load(f) - self.add_pipeline(pipeline_config, pipeline_path) - \ No newline at end of file diff --git a/src/knowledge/store.py b/src/knowledge/store.py index 803c3b6..a2ebd77 100644 --- a/src/knowledge/store.py +++ b/src/knowledge/store.py @@ -68,15 +68,15 @@ class KnowledgeStore: def load_object(self, object_id: ObjectID) -> KnowledgeObject: if object_id.get_object_type() == ObjectType.Document: - return DocumentObject.decode(self.store.get_object_store().get_object(object_id)) + return DocumentObject.decode(self.object_store.get_object(object_id)) if object_id.get_object_type() == ObjectType.Image: - return ImageObject.decode(self.store.get_object_store().get_object(object_id)) + return ImageObject.decode(self.object_store.get_object(object_id)) if object_id.get_object_type() == ObjectType.Video: - return VideoObject.decode(self.store.get_object_store().get_object(object_id)) + return VideoObject.decode(self.object_store.get_object(object_id)) if object_id.get_object_type() == ObjectType.RichText: - return RichTextObject.decode(self.store.get_object_store().get_object(object_id)) + return RichTextObject.decode(self.object_store.get_object(object_id)) if object_id.get_object_type() == ObjectType.Email: - return EmailObject.decode(self.store.get_object_store().get_object(object_id)) + return EmailObject.decode(self.object_store.get_object(object_id)) else: pass diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 191e0d1..0dc47ea 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -32,6 +32,7 @@ from aios_kernel import * sys.path.append(directory + '/../../component/') from agent_manager import AgentManager from workflow_manager import WorkflowManager +from knowledge_manager import KnowledgePipelineManager logger = logging.getLogger(__name__) @@ -185,9 +186,9 @@ class AIOS_Shell: AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) - pipelines = KnowledgePipelineManager(AIStorage().get_instance().get_myai_dir() / "knowledge" / "pipelines") - pipelines.load_dir(AIStorage().get_instance().get_system_app_dir() / "knowledge_pipelines") - pipelines.load_dir(AIStorage().get_instance().get_myai_dir() / "knowledge_pipelines") + pipelines = KnowledgePipelineManager(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge/pipelines")) + pipelines.load_dir(os.path.join(AIStorage().get_instance().get_system_app_dir(), "knowledge_pipelines")) + pipelines.load_dir(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge_pipelines")) asyncio.create_task(pipelines.run()) TelegramTunnel.register_to_loader() diff --git a/src/service/email_spider/converter.py b/src/service/email_spider/converter.py deleted file mode 100644 index 193b220..0000000 --- a/src/service/email_spider/converter.py +++ /dev/null @@ -1,24 +0,0 @@ -from aios_kernel.knowledge import KnowledgeBase, EmailObject - -# define a email converter class - -class EmailConverter: - # define init method - def __init__(self, local_dir, knowledge_base: KnowledgeBase) -> None: - pass - - async def run(self): - # convert the email to knowledge object - for email_dir in self._next(): - # convert the email to knowledge object - knowledge_object = self._convert(email_dir) - # insert the knowledge object to knowledge base - await self.knowledge_base.insert(knowledge_object) - - def _next(self) -> str: - pass - - def _convert(self, email_dir) -> EmailObject: - pass - - \ No newline at end of file diff --git a/src/service/email_spider/main.py b/src/service/email_spider/main.py deleted file mode 100644 index cb75abd..0000000 --- a/src/service/email_spider/main.py +++ /dev/null @@ -1,12 +0,0 @@ -import asyncio -from .spider import EmailSpider, EmailConverter - - -if __name__ == "__main__": - spider = EmailSpider("smtp.163.com","user","pwd","./email") - asyncio.run(spider.run()) - - converter = EmailConverter("./email",KnowledgeBase()) - asyncio.run(converter.run()) - - diff --git a/src/service/email_spider/spider.py b/src/service/email_spider/spider.py deleted file mode 100644 index 0b5a212..0000000 --- a/src/service/email_spider/spider.py +++ /dev/null @@ -1,17 +0,0 @@ -# define a email spider class - -class EmailSpider: - def __init__(self, address, account, pwd, local_dir) -> None: - pass - - async def run(self): - # spide the email from the email server - for email_link in self._next(): - # save the email to local directory - self._save(email_link) - - def _next(self): - pass - - def _save(self, email_link) -> str: - pass \ No newline at end of file diff --git a/src/service/spider/email_spider.py b/src/service/spider/email_spider.py deleted file mode 100644 index cd44097..0000000 --- a/src/service/spider/email_spider.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -Capture your email locally, and parse out the pictures in the email body and the pictures, videos and other files in the attachment. Subsequently, it supports vectorized analysis of your personal data and serves as a knowledge base to enable large language model answers. Better results. - -An example of a local file is as follows: -├── data -│ └── alex0072@gmail.com -│ └── 5de3e52f3a6b90cabe6cbdd4ae3a5c5b -│ ├── email.txt -│ ├── meta.json -│ ├── image -│ │ ├── 0648B869@99C03070.DB94B354.jpg -│ └── body_image -│ ├── 11044884873.jpg -│ ├── 282985198265470.gif -│ └── dd-login-service-min.png - -""" - -import imaplib -import os -import toml -import logging -import mailparser -import hashlib -import json -import base64 -from bs4 import BeautifulSoup -import requests - -class EmailSpider: - def __init__(self): - # logger config - self.logger = logging.getLogger('email spider') - self.logger.setLevel(logging.DEBUG) - ch = logging.StreamHandler() - formatter = logging.Formatter('%(asctime)s [%(name)s] [%(levelname)s] %(message)s') - ch.setFormatter(formatter) - self.logger.addHandler(ch) - - # read config from toml file - # and read from config config.local.toml if exists (config.local.toml is ignored by git) - self.config = toml.load('./rootfs/email/config.toml') - if os.path.exists('./rootfs/email/config.local.toml'): - self.config = toml.load('./rootfs/email/config.local.toml') - - self.client = self.email_client() - - def email_client(self) -> imaplib.IMAP4_SSL: - self.logger.info(f"read email config from {self.config.get('EMAIL_IMAP_SERVER')}") - client = imaplib.IMAP4_SSL( - host=self.config.get('EMAIL_IMAP_SERVER'), - port=self.config.get('EMAIL_IMAP_PORT') - ) - client.login(self.config.get('EMAIL_ADDRESS'), self.config.get('EMAIL_PASSWORD')) - return client - - def list_box(self): - _, mailbox_list = self.client.list() - for mailbox in mailbox_list: - print(mailbox.decode()) - - def read_emails(self, folder: str = 'INBOX', imap_keyword: str = "UNSEEN"): - self.client.select(folder) - _, data = self.client.uid('search', None, imap_keyword) - - # get email uid list - email_list = data[0].split() - self.logger.info(f"got {len(email_list)} emails") - email_list.reverse() - for uid in email_list: - if self.check_email_saved(uid): - self.logger.info(f"email uid {uid} already saved") - else: - self.read_and_save_email(uid) - self.logger.info(f"email uid {uid} saved") - - def read_and_save_email(self, uid: str): - message_parts = "(BODY.PEEK[])" - _, email_data = self.client.uid('fetch', uid, message_parts) - mail = mailparser.parse_from_bytes(email_data[0][1]) - self.logger.info(f"got email subject [{mail.subject}]") - self.save_email(mail) - - def get_local_dir_name(self, mail: mailparser.MailParser) -> str: - dir = f"{self.config.get('LOCAL_DIR')}/{self.config.get('EMAIL_ADDRESS')}" - name = f"{mail.subject}__{mail.date}" - name = hashlib.md5(name.encode('utf-8')).hexdigest() - return f"{dir}/{name}" - - def check_email_saved(self, uid: str): - message_parts = "(BODY[HEADER])" - _, email_data = self.client.uid('fetch', uid, message_parts) - mail = mailparser.parse_from_bytes(email_data[0][1]) - self.logger.info(f"[{uid}]check email subject [{mail.subject}]") - dir = self.get_local_dir_name(mail) - self.logger.info(f"check email saved {dir}") - file = f"{dir}/email.txt" - if os.path.exists(file): - return False - return False - - # save email attachment(images) - def save_email_attachment(self, mail: mailparser.MailParser, email_dir: str): - for attachment in mail.attachments: - if attachment['mail_content_type'] in ['image/png', 'image/jpeg', 'image/gif']: - print('current mail have image attachment') - img_dir = f"{email_dir}/image" - if not os.path.exists(img_dir): - os.makedirs(img_dir) - filename = attachment['filename'] - filefullname = f"{img_dir}/{filename}" - image_data = attachment['payload'] - try: - image_data = base64.b64decode(image_data) - except base64.binascii.Error: - image_data = image_data.encode() - with open(filefullname, 'wb') as f: - f.write(image_data) - self.logger.info(f"save email image {filename} success") - - # save email body images(html content) - def save_body_images(self, html_content: str, email_dir: str): - # get all image urls - soup = BeautifulSoup(html_content, 'html.parser') - img_tags = soup.find_all('img') - img_urls = [img['src'] for img in img_tags if 'src' in img.attrs] - self.logger.info(f'Found {len(img_urls)} images in email body') - - if not os.path.exists(email_dir): - os.makedirs(email_dir) - - for img_url in img_urls: - # keep the original image filename(last of url) - img_filename = os.path.join(email_dir, img_url.split('/')[-1]) - # download image - response = requests.get(img_url, stream=True) - if response.status_code == 200: - with open(img_filename, 'wb') as img_file: - for chunk in response.iter_content(1024): - img_file.write(chunk) - self.logger.info(f'Downloaded {img_url} to {img_filename}') - else: - self.logger.info(f'Failed to download {img_url}') - - # save email content to local dir - def save_email(self, mail: mailparser.MailParser): - dir = f"{self.config.get('LOCAL_DIR')}/{self.config.get('EMAIL_ADDRESS')}" - if not os.path.exists(dir): - os.makedirs(dir) - email_dir = self.get_local_dir_name(mail) - self.logger.info(f"save email to {email_dir}") - if not os.path.exists(email_dir): - os.makedirs(email_dir) - with open(f"{email_dir}/email.txt", "w") as f: - f.write(mail.body) - with open(f"{email_dir}/meta.json", "w", encoding='utf-8') as f: - mail_dict = json.loads(mail.mail_json) - if 'body' in mail_dict: - del mail_dict['body'] - json.dump(mail_dict, f, ensure_ascii=False, indent=4) - self.logger.info(f"save email meta info {f.name}") - - self.save_email_attachment(mail, email_dir) - self.save_body_images(mail.body, f"{email_dir}/body_image") - - -if __name__ == "__main__": - spider = EmailSpider() - folder = 'INBOX' - imap_keyword = "ALL" - spider.read_emails(folder, imap_keyword) \ No newline at end of file From d0b74545ebddd7db3724fc9791fda2fdfc195c89 Mon Sep 17 00:00:00 2001 From: tsukasa Date: Fri, 20 Oct 2023 14:19:41 +0800 Subject: [PATCH 07/79] change /knowledge commands in shell --- rootfs/agents/Mia/agent.toml | 6 +- rootfs/knowledge_pipelines/Mia/input.py | 68 ++++++++++++ rootfs/knowledge_pipelines/Mia/parser.py | 102 ++++++++++++++++++ .../{local_embedding => Mia}/pipeline.toml | 6 +- .../knowledge_pipelines/Mia/query.py | 35 ++++-- rootfs/knowledge_pipelines/pipelines.toml | 2 +- src/aios_kernel/__init__.py | 1 + src/aios_kernel/agent.py | 3 +- src/component/agent_manager/agent_manager.py | 16 ++- .../knowledge_manager/input/local_dir.py | 21 ++-- src/component/knowledge_manager/pipeline.py | 20 +++- src/knowledge/pipeline.py | 14 +++ src/service/aios_shell/aios_shell.py | 59 +++------- 13 files changed, 279 insertions(+), 74 deletions(-) create mode 100644 rootfs/knowledge_pipelines/Mia/input.py create mode 100644 rootfs/knowledge_pipelines/Mia/parser.py rename rootfs/knowledge_pipelines/{local_embedding => Mia}/pipeline.toml (56%) rename src/component/knowledge_manager/query/embedding.py => rootfs/knowledge_pipelines/Mia/query.py (68%) diff --git a/rootfs/agents/Mia/agent.toml b/rootfs/agents/Mia/agent.toml index 770c07e..9dce4b0 100644 --- a/rootfs/agents/Mia/agent.toml +++ b/rootfs/agents/Mia/agent.toml @@ -1,13 +1,9 @@ instance_id = "Mia" fullname = "Mia" #llm_model_name = "gpt-4" -#max_token_size = 16000 -#enable_function =["add_event"] -#enable_kb = "true" -#enable_timestamp = "false" owner_prompt = "我是你的主人{name}" contact_prompt = "我是你的朋友{name}" -owner_env = "knowledge" +owner_env = "../../knowledge_pipelines/Mia/query.py" [[prompt]] role = "system" diff --git a/rootfs/knowledge_pipelines/Mia/input.py b/rootfs/knowledge_pipelines/Mia/input.py new file mode 100644 index 0000000..e9a54f4 --- /dev/null +++ b/rootfs/knowledge_pipelines/Mia/input.py @@ -0,0 +1,68 @@ +import os +import aiofiles +import chardet +import logging +import string +from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal +from aios_kernel.storage import AIStorage + +class KnowledgeDirSource: + def __init__(self, env: KnowledgePipelineEnvironment, config): + self.env = env + path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + config["path"] = path + self.config = config + + # @classmethod + # def user_config_items(cls): + # return [("path", "local dir path")] + + def path(self): + return self.config["path"] + + @staticmethod + async def read_txt_file(file_path:str)->str: + cur_encode = "utf-8" + async with aiofiles.open(file_path,'rb') as f: + cur_encode = chardet.detect(await f.read())['encoding'] + + async with aiofiles.open(file_path,'r',encoding=cur_encode) as f: + return await f.read() + + async def next(self): + while True: + journals = self.env.journal.latest_journals(1) + from_time = 0 + if len(journals) == 1: + latest_journal = journals[0] + if latest_journal.is_finish(): + yield None + continue + from_time = os.path.getctime(latest_journal.get_input()) + if os.path.getmtime(self.path()) <= from_time: + yield (None, None) + continue + + file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) + for rel_path in file_pathes: + file_path = os.path.join(self.path(), rel_path) + timestamp = os.path.getctime(file_path) + if timestamp <= from_time: + continue + ext = os.path.splitext(file_path)[1].lower() + if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']: + logging.info(f"knowledge dir source found image file {file_path}") + image = ImageObjectBuilder({}, {}, file_path).build(self.env.get_knowledge_store()) + await self.env.get_knowledge_store().insert_object(image) + yield (image.calculate_id(), file_path) + if ext in ['.txt']: + logging.info(f"knowledge dir source found text file {file_path}") + text = await self.read_txt_file(file_path) + document = DocumentObjectBuilder({}, {}, text).build(self.env.get_knowledge_store()) + await self.env.get_knowledge_store().insert_object(document) + yield (document.calculate_id(), file_path) + yield (None, None) + + +def init(env: KnowledgePipelineEnvironment, params: dict) -> KnowledgeDirSource: + return KnowledgeDirSource(env, params) \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/Mia/parser.py b/rootfs/knowledge_pipelines/Mia/parser.py new file mode 100644 index 0000000..cb8b5f8 --- /dev/null +++ b/rootfs/knowledge_pipelines/Mia/parser.py @@ -0,0 +1,102 @@ +# define a knowledge base class +import json +import string +from aios_kernel import ComputeKernel, AIStorage +from knowledge import * + + +class EmbeddingParser: + def __init__(self, env: KnowledgePipelineEnvironment, config: dict): + self._default_text_model = "all-MiniLM-L6-v2" + self._default_image_model = "clip-ViT-B-32" + + path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + if not os.path.exists(path): + os.makedirs(path) + config["path"] = path + + self.env = env + self.config = config + + def get_path(self) -> str: + return self.config["path"] + + def __get_vector_store(self, model_name: str) -> ChromaVectorStore: + return ChromaVectorStore(self.get_path(), model_name) + + async def __embedding_document(self, document: DocumentObject): + for chunk_id in document.get_chunk_list(): + chunk = self.env.get_knowledge_store().get_chunk_reader().get_chunk(chunk_id) + if chunk is None: + raise ValueError(f"text chunk not found: {chunk_id}") + + text = chunk.read().decode("utf-8") + vector = await ComputeKernel.get_instance().do_text_embedding(text, self._default_text_model) + if vector: + await self.__get_vector_store(self._default_text_model).insert(vector, chunk_id) + + async def __embedding_image(self, image: ImageObject): + # desc = {} + # if not not image.get_meta(): + # desc["meta"] = image.get_meta() + # if not not image.get_exif(): + # desc["exif"] = image.get_exif() + # if not not image.get_tags(): + # desc["tags"] = image.get_tags() + # vector = await self.compute_kernel.do_text_embedding(json.dumps(desc), self._default_text_model) + vector = await ComputeKernel.get_instance().do_image_embedding(image.calculate_id(), self._default_image_model) + if vector: + await self.__get_vector_store(self._default_image_model).insert(vector, image.calculate_id()) + + async def __embedding_video(self, vedio: VideoObject): + desc = {} + if not not vedio.get_meta(): + desc["meta"] = vedio.get_meta() + if not not vedio.get_info(): + desc["info"] = vedio.get_info() + if not not vedio.get_tags(): + desc["tags"] = vedio.get_tags() + vector = await ComputeKernel.get_instance().do_text_embedding(json.dumps(desc), self._default_text_model) + await self.__get_vector_store(self._default_text_model).insert(vector, vedio.calculate_id()) + + async def __embedding_rich_text(self, rich_text: RichTextObject): + for document_id in rich_text.get_documents().values(): + document = DocumentObject.decode(self.env.get_knowledge_store().get_object_store().get_object(document_id)) + await self.__embedding_document(document) + for image_id in rich_text.get_images().values(): + image = ImageObject.decode(self.env.get_knowledge_store().get_object_store().get_object(image_id)) + await self.__embedding_image(image) + for video_id in rich_text.get_videos().values(): + video = VideoObject.decode(self.env.get_knowledge_store().get_object_store().get_object(video_id)) + await self.__embedding_video(video) + for rich_text_id in rich_text.get_rich_texts().values(): + rich_text = RichTextObject.decode(self.env.get_knowledge_store().get_object_store().get_object(rich_text_id)) + await self.__embedding_rich_text(rich_text) + + async def __embedding_email(self, email: EmailObject): + vector = await ComputeKernel.get_instance().do_text_embedding(json.dumps(email.get_desc()), self._default_text_model) + await self.__get_vector_store(self._default_text_model).insert(vector, email.calculate_id()) + await self.__embedding_rich_text(email.get_rich_text()) + + + async def __do_embedding(self, object: KnowledgeObject): + if object.get_object_type() == ObjectType.Document: + await self.__embedding_document(object) + if object.get_object_type() == ObjectType.Image: + await self.__embedding_image(object) + if object.get_object_type() == ObjectType.Video: + await self.__embedding_video(object) + if object.get_object_type() == ObjectType.RichText: + await self.__embedding_rich_text(object) + if object.get_object_type() == ObjectType.Email: + await self.__embedding_email(object) + else: + pass + + async def parse(self, object: ObjectID) -> str: + obj = self.env.get_knowledge_store().load_object(object) + await self.__do_embedding(obj) + return "insert into vector store" + +def init(env: KnowledgePipelineEnvironment, params: dict) -> EmbeddingParser: + return EmbeddingParser(env, params) \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/local_embedding/pipeline.toml b/rootfs/knowledge_pipelines/Mia/pipeline.toml similarity index 56% rename from rootfs/knowledge_pipelines/local_embedding/pipeline.toml rename to rootfs/knowledge_pipelines/Mia/pipeline.toml index ee8c5b0..9b9e062 100644 --- a/rootfs/knowledge_pipelines/local_embedding/pipeline.toml +++ b/rootfs/knowledge_pipelines/Mia/pipeline.toml @@ -1,6 +1,6 @@ -name = "LocalEmbedding" -input.module = "local_dir" +name = "Mia" +input.module = "input.py" input.params.path = "${myai_dir}/data" -parser.module = "embedding" +parser.module = "parser.py" parser.params.path = "${myai_dir}/knowledge/indices/embedding" diff --git a/src/component/knowledge_manager/query/embedding.py b/rootfs/knowledge_pipelines/Mia/query.py similarity index 68% rename from src/component/knowledge_manager/query/embedding.py rename to rootfs/knowledge_pipelines/Mia/query.py index f29bb43..cc4b557 100644 --- a/src/component/knowledge_manager/query/embedding.py +++ b/rootfs/knowledge_pipelines/Mia/query.py @@ -1,7 +1,15 @@ +import os +import logging +import json +from aios_kernel import * +from knowledge import * class KnowledgeEnvironment(Environment): def __init__(self, env_id: str) -> None: super().__init__(env_id) + self.path = os.path.join(AIStorage.get_instance().get_myai_dir(), "knowledge/indices/embedding") + self._default_text_model = "all-MiniLM-L6-v2" + self._default_image_model = "clip-ViT-B-32" query_param = { "tokens": "key words to query", @@ -12,22 +20,26 @@ class KnowledgeEnvironment(Environment): "vector query content from local knowledge base", self._query, query_param)) + + def __get_vector_store(self, model_name: str) -> ChromaVectorStore: + return ChromaVectorStore(self.path, model_name) + async def query_objects(self, tokens: str, types: list[str], topk: int) -> [ObjectID]: texts = [] if "text" in types: - vector = await self.compute_kernel.do_text_embedding(tokens, self._default_text_model) - texts = await self.store.get_vector_store(self._default_text_model).query(vector, topk) + vector = await ComputeKernel.get_instance().do_text_embedding(tokens, self._default_text_model) + texts = await self.__get_vector_store(self._default_text_model).query(vector, topk) images = [] if "image" in types: - vector = await self.compute_kernel.do_text_embedding(tokens, self._default_image_model) - images = await self.store.get_vector_store(self._default_image_model).query(vector, topk) + vector = await ComputeKernel.get_instance().do_text_embedding(tokens, self._default_image_model) + images = await self.__get_vector_store(self._default_image_model).query(vector, topk) return texts + images def tokens_from_objects(self, object_ids: [ObjectID]) -> list[str]: results = dict() for object_id in object_ids: - parents = self.store.get_relation_store().get_related_root_objects(object_id) + parents = KnowledgeStore().get_relation_store().get_related_root_objects(object_id) # last parent is the root object root_object_id = parents[0] if parents else object_id logging.info(f"object_id: {str(object_id)} root_object_id: {str(root_object_id)}") @@ -41,7 +53,7 @@ class KnowledgeEnvironment(Environment): # first element in result is the root object root_object_id = result[0] if root_object_id.get_object_type() == ObjectType.Email: - email = self.load_object(root_object_id) + email = KnowledgeStore().load_object(root_object_id) desc = email.get_desc() desc["type"] = "email" desc["contents"] = [] @@ -53,7 +65,7 @@ class KnowledgeEnvironment(Environment): for object_id in result: if object_id.get_object_type() == ObjectType.Chunk: - upper_list.append({"type": "text", "content": self.store.get_chunk_reader().get_chunk(object_id).read().decode("utf-8")}) + upper_list.append({"type": "text", "content": KnowledgeStore().get_chunk_reader().get_chunk(object_id).read().decode("utf-8")}) if object_id.get_object_type() == ObjectType.Image: # image = self.load_object(object_id) desc = dict() @@ -61,7 +73,7 @@ class KnowledgeEnvironment(Environment): desc["type"] = "image" upper_list.append(desc) if object_id.get_object_type() == ObjectType.Video: - video = self.load_object(object_id) + video = KnowledgeStore().load_object(object_id) desc = video.get_desc() desc["type"] = "video" upper_list.append(desc) @@ -74,9 +86,12 @@ class KnowledgeEnvironment(Environment): async def _query(self, tokens: str, types: list[str] = ["text"], index: str=0): index = int(index) - object_ids = await KnowledgeBase().query_objects(tokens, types, 4) + object_ids = await self.query_objects(tokens, types, 4) if len(object_ids) <= index: return "*** I have no more information for your reference.\n" else: content = "*** I have provided the following known information for your reference with json format:\n" - return content + KnowledgeBase().tokens_from_objects(object_ids[index:index+1]) \ No newline at end of file + return content + self.tokens_from_objects(object_ids[index:index+1]) + +def init() -> KnowledgeEnvironment: + return KnowledgeEnvironment("embedding") \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/pipelines.toml b/rootfs/knowledge_pipelines/pipelines.toml index e6bf006..3c78084 100644 --- a/rootfs/knowledge_pipelines/pipelines.toml +++ b/rootfs/knowledge_pipelines/pipelines.toml @@ -1,3 +1,3 @@ pipelines = [ - "local_embedding" + "Mia" ] \ No newline at end of file diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index fc11ad0..f632e03 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -23,5 +23,6 @@ from .local_stability_node import Local_Stability_ComputeNode from .stability_node import Stability_ComputeNode from .local_st_compute_node import LocalSentenceTransformer_Text_ComputeNode,LocalSentenceTransformer_Image_ComputeNode from .compute_node_config import ComputeNodeConfig +from .ai_function import SimpleAIFunction AIOS_Version = "0.5.1, build 2023-9-28" diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index bacafe5..34d8967 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -122,7 +122,8 @@ class AIAgent: self.contact_prompt_str = config["contact_prompt"] if config.get("owner_env") is not None: - self.owner_env = Environment.get_env_by_id(config["owner_env"]) + self.owner_env = config.get("owner_env") + if config.get("powerby") is not None: self.powerby = config["powerby"] diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index 06ed62c..ef18253 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -1,9 +1,11 @@ import logging import toml +import os +import runpy from typing import Any, Callable, Dict, List, Optional, Union -from aios_kernel import AIAgent,AIAgentTemplete,AIStorage +from aios_kernel import AIAgent,AIAgentTemplete,AIStorage,Environment from package_manager import PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask logger = logging.getLogger(__name__) @@ -105,6 +107,18 @@ class AgentManager: config_data = await config_file.read() config = toml.loads(config_data) result_agent = AIAgent() + + if "owner_env" in config: + owner_env = config["owner_env"] + _, ext = os.path.splitext(owner_env) + if ext == ".py": + env_path = os.path.join(agent_media.full_path, owner_env) + owner_env = runpy.run_path(env_path)["init"]() + config["owner_env"] = owner_env + else: + owner_env = Environment.get_env_by_id(config["owner_env"]) + config["owner_env"] = owner_env + if result_agent.load_from_config(config) is False: logger.error(f"load agent from {agent_media} failed!") return None diff --git a/src/component/knowledge_manager/input/local_dir.py b/src/component/knowledge_manager/input/local_dir.py index 76c1137..e9a54f4 100644 --- a/src/component/knowledge_manager/input/local_dir.py +++ b/src/component/knowledge_manager/input/local_dir.py @@ -30,16 +30,19 @@ class KnowledgeDirSource: return await f.read() async def next(self): - journals = self.env.journal.latest_journals(1) - from_time = 0 - if len(journals) == 1: - latest_journal = journals[0] - if latest_journal.is_finish(): - yield None - from_time = os.path.getctime(latest_journal.get_input()) - if os.path.getmtime(self.path()) <= from_time: - yield (None, None) while True: + journals = self.env.journal.latest_journals(1) + from_time = 0 + if len(journals) == 1: + latest_journal = journals[0] + if latest_journal.is_finish(): + yield None + continue + from_time = os.path.getctime(latest_journal.get_input()) + if os.path.getmtime(self.path()) <= from_time: + yield (None, None) + continue + file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) for rel_path in file_pathes: file_path = os.path.join(self.path(), rel_path) diff --git a/src/component/knowledge_manager/pipeline.py b/src/component/knowledge_manager/pipeline.py index 653ea9c..55a5105 100644 --- a/src/component/knowledge_manager/pipeline.py +++ b/src/component/knowledge_manager/pipeline.py @@ -4,7 +4,17 @@ import toml import asyncio from knowledge import KnowledgePipelineEnvironment, KnowledgePipeline + class KnowledgePipelineManager: + @classmethod + def initial(cls, root_dir: str): + cls._instance = KnowledgePipelineManager(root_dir) + return cls._instance + + @classmethod + def get_instance(cls): + return cls._instance + def __init__(self, root_dir: str): self.root_dir = root_dir self.input_modules = {} @@ -32,7 +42,7 @@ class KnowledgePipelineManager: input_module = config["input"]["module"] _, ext = os.path.splitext(input_module) if ext == ".py": - input_module = os.path.abspath(path, input_module) + input_module = os.path.join(path, input_module) input_init = runpy.run_path(input_module)["init"] else: input_init = self.input_modules.get(input_module) @@ -41,7 +51,7 @@ class KnowledgePipelineManager: parser_module = config["parser"]["module"] _, ext = os.path.splitext(parser_module) if ext == ".py": - parser_module = os.path.abspath(path, parser_module) + parser_module = os.path.join(path, parser_module) parser_init = runpy.run_path(parser_module)["init"] else: parser_init = self.parser_modules.get(parser_module) @@ -54,6 +64,12 @@ class KnowledgePipelineManager: self.pipelines["names"][name] = pipeline self.pipelines["running"].append(pipeline) + def get_pipelines(self) -> [KnowledgePipeline]: + return self.pipelines["running"] + + def get_pipeline(self, name: str) -> KnowledgePipeline: + return self.pipelines["names"].get(name) + async def run(self): while True: for pipeline in self.pipelines["running"]: diff --git a/src/knowledge/pipeline.py b/src/knowledge/pipeline.py index e637fb7..af3b665 100644 --- a/src/knowledge/pipeline.py +++ b/src/knowledge/pipeline.py @@ -19,6 +19,12 @@ class KnowledgePipelineJournal: def get_parser(self) -> str: return self.parser + + def __str__(self) -> str: + if self.is_finish(): + return f"{self.time}: finished)" + else: + return f"{self.time}: object:{self.object_id} input:{self.input}, parser:{self.parser})" # init sqlite3 client class KnowledgePipelineJournalClient: @@ -84,6 +90,12 @@ class KnowledgePipeline: self.env = env self.input = None self.parser = None + + def get_name(self): + return self.name + + def get_journal(self) -> KnowledgePipelineJournalClient: + return self.env.journal async def run(self): if self.state == KnowledgePipelineState.INIT: @@ -100,6 +112,8 @@ class KnowledgePipeline: if object_id is not None: parser_journal = await self.parser.parse(object_id) self.env.journal.insert(object_id, input_journal, parser_journal) + else: + return if self.state == KnowledgePipelineState.STOPPED: return if self.state == KnowledgePipelineState.FINISHED: diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 0dc47ea..b8ce7a6 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -27,6 +27,7 @@ sys.path.append(directory + '/../../') import proxy from aios_kernel import * +from knowledge import * sys.path.append(directory + '/../../component/') @@ -186,7 +187,7 @@ class AIOS_Shell: AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) - pipelines = KnowledgePipelineManager(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge/pipelines")) + pipelines = KnowledgePipelineManager.initial(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge/pipelines")) pipelines.load_dir(os.path.join(AIStorage().get_instance().get_system_app_dir(), "knowledge_pipelines")) pipelines.load_dir(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge_pipelines")) asyncio.create_task(pipelines.run()) @@ -333,46 +334,21 @@ class AIOS_Shell: cm.add_contact(contact_name,contact) async def handle_knowledge_commands(self, args): - show_text = FormattedText([("class:title", "sub command not support!\n" - "/knowledge add email | dir\n" - "/knowledge journal [$topn]\n" + show_text = FormattedText([("class:title", "sub command not support!\n" + "/knowledge pipelines\n" + "/knowledge journal $pipeline [$topn]\n" "/knowledge query $object_id\n")]) if len(args) < 1: return show_text sub_cmd = args[0] - if sub_cmd == "add": - if len(args) < 2: - return show_text - if args[1] == "email": - config = dict() - for key, item in KnowledgeEmailSource.user_config_items(): - user_input = await try_get_input(f"{key} : {item}") - if user_input is None: - return show_text - config[key] = user_input - error = KnowledgePipline.get_instance().add_email_source(KnowledgeEmailSource(config)) - if error is not None: - return FormattedText([("class:title", f"/knowledge add email failed {error}\n")]) - else: - KnowledgePipline.get_instance().save_cosnfig() - if args[1] == "dir": - config = dict() - for key, item in KnowledgeDirSource.user_config_items(): - user_input = await try_get_input(f"{key} : {item}") - if user_input is None: - return show_text - config[key] = user_input - error = KnowledgePipline.get_instance().add_dir_source(KnowledgeDirSource(config)) - if error is not None: - return FormattedText([("class:title", f"/knowledge add dir failed {error}\n")]) - else: - KnowledgePipline.get_instance().save_config() - else: - return show_text + if sub_cmd == "pipelines": + pipelines = KnowledgePipelineManager.get_instance().get_pipelines() + print_formatted_text("\r\n".join(pipeline.get_name() for pipeline in pipelines)) if sub_cmd == "journal": - topn = 10 if len(args) == 1 else int(args[1]) - journals = [str(journal) for journal in KnowledgePipline.get_instance().get_latest_journals(topn)] - print_formatted_text("\r\n".join(journals)) + name = args[1] + topn = 10 if len(args) == 2 else int(args[2]) + journals = [str(journal) for journal in KnowledgePipelineManager.get_instance().get_pipeline(name).get_journal().latest_journals(topn)] + print_formatted_text("\r\n".join(str(journal) for journal in journals)) if sub_cmd == "query": if len(args) < 2: return show_text @@ -381,8 +357,8 @@ class AIOS_Shell: if object_id.get_object_type() == ObjectType.Image: from PIL import Image import io - image = KnowledgeBase().load_object(object_id) - image_data = KnowledgeBase().bytes_from_object(image) + image = KnowledgeStore().load_object(object_id) + image_data = KnowledgeStore().bytes_from_object(image) image = Image.open(io.BytesIO(image_data)) image.show() @@ -671,9 +647,8 @@ def print_welcome_screen(): \033[1;94m\tGive your Agent a Telegram account :\033[0m /connect $agent_name \033[1;94m\tAdd personal files to the AI Knowledge Base. \033[0m \t\t1) Copy your file to ~/myai/data -\t\t2) /knowlege add dir \033[1;94m\tSearch your knowledge base :\033[0m /open Mia -\033[1;94m\tCheck the progress of AI reading personal data :\033[0m /knowledge journal +\033[1;94m\tCheck the progress of AI reading personal data :\033[0m /knowledge $pipeline journal \033[1;94m\tQuery object with ID in knowledge base :\033[0m /knowledge query $object_id \033[1;94m\tOpen AI Bash (For Developer Only):\033[0m /open ai_bash \033[1;94m\tEnable AIGC Feature :\033[0m /enable aigc @@ -752,8 +727,8 @@ async def main(): '/history $num $offset', '/connect $target', '/contact $name', - '/knowledge add email | dir', - '/knowledge journal [$topn]', + '/knowledge pipelines', + '/knowledge journal $pipeline [$topn]', '/knowledge query $object_id', '/set_config $key', '/enable $feature', From 8bc63c912a255798ee2de17acbd9da0c5d8ad569 Mon Sep 17 00:00:00 2001 From: tsukasa Date: Fri, 20 Oct 2023 15:08:01 +0800 Subject: [PATCH 08/79] add pipeline issue --- doc/knowledge_pipeline.md | 87 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 doc/knowledge_pipeline.md diff --git a/doc/knowledge_pipeline.md b/doc/knowledge_pipeline.md new file mode 100644 index 0000000..fedb5aa --- /dev/null +++ b/doc/knowledge_pipeline.md @@ -0,0 +1,87 @@ +# 配置knowledge pipeline +knowledge pipeline 扫描指定的输入,把输入的内容构建为结构化的knowledge object,之后依照使用knowledge的应用场景,为object创建各种各样的索引。 +## Input +输入定义从个人数据来源转换成结构化的knowledge object的过程,并且定义在object上调用parser的粒度。比如典型的几种Input的实现: ++ 本地目录:指定本地目录,扫描本地目录的所有文件,并且监听他的更新;对每个一个文件生成object并且写入object store;对每一个新产生的object调用parser; ++ 个人邮箱:扫描个人邮箱收件箱,并且监听新的邮件;对每一封邮件生成email object并且写入object store;对每一个新产生的email object调用parser; ++ 浏览器上下文:实现浏览器插件,对当前浏览的页面元素通过rpc传入对应的input 后端实现,生成rich text object;对每一个新产生的rich text object调用parser; + +## Parser +Parser定义从input 输入的object 创建索引的过程;包括但不限于以下主要手段,以及他们的组合: ++ 向量化之后写入vector store ++ 创建各种维度的RDB,NoSQL索引 ++ 向Agent send object + +配置Pipeline 应当包含以下几个部分: ++ Input method:包含实现input的 python module ++ Input params:inpu module的参数,比如本地路径,邮箱地址 ++ Parser method:包含实现parser的 python module;如果Parser是指向Agent,这个配置是可以简化成Agent instance name; + +# Knowledge pipeline manager +pipeline 管理会类似agent manager,manager管理pipeline config,从config 创建instance在后台持续运行, knowledge pipeline manager 也需要处理pipeline instance的状态管理. + +集成到aios shell中,加入如下命令: ++ knowledge pipelines: 返回当前运行中的pipeline实例 ++ knowledge journal $pipeline [$topn]: 查询当前pipeline运行的journal日志 ++ knowledge query $object_id: 查询指定knowledge object的内容 + +# 在aios shell中添加新的knowledge pipeline +在$home/myai/knowledge_pipelines/, 或者开发模式下在 $source_root/rootfs/knowledge_pipelines/ 目录中,添加新的pipeline 目录, 以下以内建的pipeline Mia为例说明: + +## pipeline.toml +创建pipeline.toml配置文件 ++ name字段指定全局唯一的pipeline name ++ input.module字段指向相对pipeline目录的input实现 ++ input.params字段定义input的输入参数,不同的input实现可以有不同的参数格式 ++ parser 部分也是类似 +``` toml +name = "Mia" +input.module = "input.py" +input.params.path = "${myai_dir}/data" +parser.module = "parser.py" +parser.params.path = "${myai_dir}/knowledge/indices/embedding" +``` + +## input +input模块至少应当实现: +```python +async def next(self): +``` +定义input class,实现异步迭代生成器方法next,扫描输入,对其中的每一个元素生成结构化的knowledge object; ++ 如果input中的所有元素都扫描完成了,返回None, pipeline会被标记为finish ++ 如果input可pending,等待新的输入,返回(None, None) ++ 如果要把创建的object传递到parser,返回(object_id, journal_str),其中journal_str是产生的journal 日志中的input 部分; +Mia中的实现就是扫描目录中的文件,对文本和图片创建object; +```python +def init(env: KnowledgePipelineEnvironment, params: dict) +``` +创建input class的实例并返回 + +## parser +parser模块至少应当实现 +```python +async def parse(self, object: ObjectID) -> str: +``` +定义parser class,实现parse成员方法,对input中返回的object_id创建索引,返回journal_str. +Mia中的实现就是对输入的object内容embedding,并且保存到chromadb中; +```python +def init(env: KnowledgePipelineEnvironment, params: dict) +``` +创建parser class的实例并返回 + +# 使用pipeline创建的索引 +pipeline定义了创建knowledge object 和索引的过程,对应的要使用pipeline创建的索引完成工作。 +还是以内建的Mia为例,不止创建名为Mia的pipeline,还在Agent中加入了查询Mia pipeline创建出来的chromadb的 Agent Mia; +## query.py +query 模块并不是pipeline的一部分,其逻辑是跟parser是一致的,在query中定义了一个agent可访问的query function,输入prompt,返回chromadb中embedding相近的object id; + +## agent.toml +```toml +owner_env = "../../knowledge_pipelines/Mia/query.py" +``` +在Mia的agent template配置里,引用query模块创建的query function;并且编辑好让Mia推理调用query方法的提示词。 + + + + + From 5eced91432fa61a7b5add5161f5cad60743fe8a9 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Wed, 1 Nov 2023 19:29:55 -0700 Subject: [PATCH 09/79] commit Agent Work Cycle frame codes --- src/aios_kernel/__init__.py | 2 +- src/aios_kernel/agent.py | 703 ++++++++++++++++++++------- src/aios_kernel/agent_base.py | 46 +- src/aios_kernel/contact_manager.py | 5 + src/aios_kernel/email_tunnel.py | 1 + src/aios_kernel/workspace_env.py | 117 ++++- src/service/aios_shell/aios_shell.py | 2 +- test/workflow_test.py | 4 +- 8 files changed, 703 insertions(+), 177 deletions(-) diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index f632e03..89797fc 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -18,7 +18,7 @@ from .email_tunnel import EmailTunnel from .storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem from .contact_manager import ContactManager,Contact,FamilyMember from .text_to_speech_function import TextToSpeechFunction -from .workspace_env import WorkspaceEnvironment +from .workspace_env import ShellEnvironment from .local_stability_node import Local_Stability_ComputeNode from .stability_node import Stability_ComputeNode from .local_st_compute_node import LocalSentenceTransformer_Text_ComputeNode,LocalSentenceTransformer_Image_ComputeNode diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 34d8967..b9a7695 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -10,7 +10,7 @@ import shlex import datetime import copy -from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType,FunctionItem,LLMResult,AgentPrompt +from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType,FunctionItem,LLMResult,AgentPrompt,AgentReport,AgentTodo,AgentGoal,AgentTodoResult,AgentWorkLog from .chatsession import AIChatSession from .compute_task import ComputeTaskResult,ComputeTaskResultCode from .ai_function import AIFunction @@ -18,12 +18,50 @@ from .environment import Environment from .contact_manager import ContactManager,Contact,FamilyMember from .compute_kernel import ComputeKernel from .bus import AIBus +from .workspace_env import WorkspaceEnvironment from knowledge import * logger = logging.getLogger(__name__) +DEFAULT_AGENT_READ_REPORT_PROMPT = """ +""" + +DEFAULT_AGENT_DO_PROMPT = """ +You are a helpful AI assistant. +Solve tasks using your coding and language skills. +In the following cases, suggest python code (in a python coding block) for the user to execute. + 1. When you need to collect info, use the code to output the info you need, for example, browse or search the web, download/read a file, print the content of a webpage or a file, get the current date/time, check the operating system. After sufficient info is printed and the task is ready to be solved based on your language skill, you can solve the task by yourself. + 2. When you need to perform some task with code, use the code to perform the task and output the result. Finish the task smartly. +Solve the task step by step if you need to. If a plan is not provided, explain your plan first. Be clear which step uses code, and which step uses your language skill. +When using code, you must indicate the script type in the code block. The user cannot provide any other feedback or perform any other action beyond executing the code you suggest. The user can't modify your code. So do not suggest incomplete code which requires users to modify. Don't use a code block if it's not intended to be executed by the user. +If you want the user to save the code in a file before executing it, put # filename: inside the code block as the first line. Don't include multiple code blocks in one response. Do not ask users to copy and paste the result. Instead, use 'print' function for the output when relevant. Check the execution result returned by the user. +If the result indicates there is an error, fix the error and output the code again. Suggest the full code instead of partial code or code changes. If the error can't be fixed or if the task is not solved even after the code is executed successfully, analyze the problem, revisit your assumption, collect additional info you need, and think of a different approach to try. +When you find an answer, verify the answer carefully. Include verifiable evidence in your response if possible. +Reply "TERMINATE" in the end when everything is done. +""" + +DEFAULT_AGENT_SELF_CHECK_PROMPT = """ + +""" + +DEFAULT_AGENT_GOAL_TO_TODO_PROMPT = """ +我会给你一个目标,你需要结合自己的角色思考如何将其拆解成多个TODO。请直接返回json来表达这些TODO +""" + +DEFAULT_AGENT_LEARN_PROMPT = """ +你拥有非常优秀的资料整理技能。我给你一段内容,你会尝试对其进行摘要,并在已有的资料库中找到合适的位置存放该文章。 +1. 结合你的角色和组织的工作目标构建摘要,尽量精简,长度不要超过256个字 +2. 资料库以文件系统的形式组织,浏览知识库是成本高昂的操作,应尝试从根目录往子目录深入来找到最合适的信息。必要的情况下,你可以在合适的位置创建新的目录。为了方便浏览,每一层目录的文件夹数不超过32个,名称长度不超过16个字符,目录深度不超过6层 +3. 你可以从不同的角度给出最多3个合适的位置 +4. 返回一个json来保存摘要和建议保存位置信息 +""" + +DEFAULT_AGENT_LEARN_LONG_CONENT_PROMPT = """ +我给你一段内容,尝试为期建立目录。目录的标题不能超过16个字, +目录要指向正文的位置(用字符偏移即可),整个目录的文本长度不能超过256个字节。并用json表达这个目录 +""" class AIAgentTemplete: def __init__(self) -> None: self.llm_model_name:str = "gpt-4-0613" @@ -57,6 +95,9 @@ class AIAgent: self.agent_think_prompt:AgentPrompt = None self.llm_model_name:str = None self.max_token_size:int = 3600 + self.agent_energy = 15 + self.agent_task = None + self.last_recover_time = time.time() self.agent_id:str = None @@ -71,8 +112,17 @@ class AIAgent: self.contact_prompt_str = None self.history_len = 10 + self.review_todo_prompt = None + + self.read_report_prompt = AgentPrompt(DEFAULT_AGENT_READ_REPORT_PROMPT) + + self.do_prompt = AgentPrompt(DEFAULT_AGENT_DO_PROMPT) + self.self_check_prompt = AgentPrompt(DEFAULT_AGENT_SELF_CHECK_PROMPT) + + self.goal_to_todo_prompt = AgentPrompt(DEFAULT_AGENT_GOAL_TO_TODO_PROMPT) + self.learn_token_limit = 500 - self.learn_prompt = None + self.learn_prompt = AgentPrompt(DEFAULT_AGENT_LEARN_PROMPT) self.chat_db = None self.unread_msg = Queue() # msg from other agent @@ -264,7 +314,7 @@ class AIAgent: else: return task_result - async def _get_agent_prompt(self) -> AgentPrompt: + def get_agent_prompt(self) -> AgentPrompt: return self.agent_prompt async def _get_agent_think_prompt(self) -> AgentPrompt: @@ -280,183 +330,189 @@ class AIAgent: async def _handle_event(self,event): if event.type == "AgentThink": - return await self._do_think() + return await self.do_self_think() - async def _do_think(self): - #1) load all sessions - session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) - #2) get history from session in token limit - for session_id in session_id_list: - await self.think_chatsession(session_id) - #4) advanced: reload all chatrecord,and think the topic of message. - #5) some topic could be end(not be thinked in futured ) - return - - - async def think_chatsession(self,session_id): - if self.agent_think_prompt is None: - return - logger.info(f"agent {self.agent_id} think session {session_id}") - chatsession = AIChatSession.get_session_by_id(session_id,self.chat_db) - while True: - cur_pos = chatsession.summarize_pos - summary = chatsession.summary - prompt:AgentPrompt = AgentPrompt() - #prompt.append(self._get_agent_prompt()) - prompt.append(await self._get_agent_think_prompt()) - system_prompt_len = prompt.get_prompt_token_len() - #think env? - history_prompt,next_pos = await self._get_history_prompt_for_think(chatsession,summary,system_prompt_len,cur_pos) - prompt.append(history_prompt) - is_finish = next_pos - cur_pos < 2 - if is_finish: - logger.info(f"agent {self.agent_id} think session {session_id} is finished!,no more history") - break - #3) llm summarize chat history - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,None) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"llm compute error:{task_result.error_str}") - break - else: - new_summary= task_result.result_str - logger.info(f"agent {self.agent_id} think session {session_id} from {cur_pos} to {next_pos} summary:{new_summary}") - chatsession.update_think_progress(next_pos,new_summary) - + # async def _process_group_chat_msg(self,msg:AgentMsg) -> AgentMsg: + # session_topic = msg.target + "#" + msg.topic + # chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) + # workspace = self.get_current_workspace() + # need_process = False + # if msg.mentions is not None: + # if self.agent_id in msg.mentions: + # need_process = True + # logger.info(f"agent {self.agent_id} recv a group chat message from {msg.sender},but is not mentioned,ignore!") - - return + # if need_process is not True: + # chatsession.append(msg) + # resp_msg = msg.create_group_resp_msg(self.agent_id,"") + # return resp_msg + # else: + # msg_prompt = AgentPrompt() + # msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] - async def _process_group_chat_msg(self,msg:AgentMsg) -> AgentMsg: - session_topic = msg.target + "#" + msg.topic - chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) - need_process = False - if msg.mentions is not None: - if self.agent_id in msg.mentions: - need_process = True - logger.info(f"agent {self.agent_id} recv a group chat message from {msg.sender},but is not mentioned,ignore!") + # prompt = AgentPrompt() + # prompt.append(self.get_agent_prompt()) - if need_process is not True: - chatsession.append(msg) - resp_msg = msg.create_group_resp_msg(self.agent_id,"") - return resp_msg - else: - msg_prompt = AgentPrompt() - msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] + # if workspace: + # prompt.append(workspace.get_prompt()) + # prompt.append(workspace.get_role_prompt(self.agent_id)) - prompt = AgentPrompt() - prompt.append(await self._get_agent_prompt()) - self._format_msg_by_env_value(prompt) - inner_functions,function_token_len = self._get_inner_functions() + # if self.need_session_summmary(msg,chatsession): + # # get relate session(todos) summary + # summary = self.llm_select_session_summary(msg,chatsession) + # prompt.append(AgentPrompt(summary)) + + # self._format_msg_by_env_value(prompt) + # inner_functions,function_token_len = self._get_inner_functions() - system_prompt_len = prompt.get_prompt_token_len() - input_len = len(msg.body) + # system_prompt_len = prompt.get_prompt_token_len() + # input_len = len(msg.body) - history_prmpt,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) - prompt.append(history_prmpt) # chat context - prompt.append(msg_prompt) + # history_prmpt,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) + # prompt.append(history_prmpt) # chat context + # prompt.append(msg_prompt) - logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - task_result = await self._do_llm_complection(prompt,inner_functions,msg) - if task_result.result_code != ComputeTaskResultCode.OK: - error_resp = msg.create_error_resp(task_result.error_str) - return error_resp + # logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") + # task_result = await self._do_llm_complection(prompt,inner_functions,msg) + # if task_result.result_code != ComputeTaskResultCode.OK: + # error_resp = msg.create_error_resp(task_result.error_str) + # return error_resp - final_result = task_result.result_str - llm_result : LLMResult = LLMResult.from_str(final_result) - is_ignore = False - result_prompt_str = "" - match llm_result.state: - case "ignore": - is_ignore = True - case "waiting": - for sendmsg in llm_result.send_msgs: - target = sendmsg.target - sendmsg.sender = self.agent_id - sendmsg.topic = msg.topic - sendmsg.prev_msg_id = msg.get_msg_id() - send_resp = await AIBus.get_default_bus().send_message(sendmsg) - if send_resp is not None: - result_prompt_str += f"\n{target} response is :{send_resp.body}" - agent_sesion = AIChatSession.get_session(self.agent_id,f"{sendmsg.target}#{sendmsg.topic}",self.chat_db) - agent_sesion.append(sendmsg) - agent_sesion.append(send_resp) + # final_result = task_result.result_str + # llm_result : LLMResult = LLMResult.from_str(final_result) + # is_ignore = False + # result_prompt_str = "" + # match llm_result.state: + # case "ignore": + # is_ignore = True + # case "waiting": + # for sendmsg in llm_result.send_msgs: + # target = sendmsg.target + # sendmsg.sender = self.agent_id + # sendmsg.topic = msg.topic + # sendmsg.prev_msg_id = msg.get_msg_id() + # send_resp = await AIBus.get_default_bus().send_message(sendmsg) + # if send_resp is not None: + # result_prompt_str += f"\n{target} response is :{send_resp.body}" + # agent_sesion = AIChatSession.get_session(self.agent_id,f"{sendmsg.target}#{sendmsg.topic}",self.chat_db) + # agent_sesion.append(sendmsg) + # agent_sesion.append(send_resp) - final_result = llm_result.resp + result_prompt_str + # final_result = llm_result.resp + result_prompt_str - if is_ignore is not True: - resp_msg = msg.create_group_resp_msg(self.agent_id,final_result) + # if is_ignore is not True: + # resp_msg = msg.create_group_resp_msg(self.agent_id,final_result) + # chatsession.append(msg) + # chatsession.append(resp_msg) + + # return resp_msg + + # return None + def get_workspace_by_msg(self,msg:AgentMsg) -> WorkspaceEnvironment: + return None + + def need_session_summmary(self,msg:AgentMsg,session:AIChatSession) -> bool: + return False + + async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: + msg_prompt = AgentPrompt() + if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: + need_process = False + msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] + session_topic = msg.target + "#" + msg.topic + chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) + + if msg.mentions is not None: + if self.agent_id in msg.mentions: + need_process = True + logger.info(f"agent {self.agent_id} recv a group chat message from {msg.sender},but is not mentioned,ignore!") + + if need_process is not True: chatsession.append(msg) - chatsession.append(resp_msg) - + resp_msg = msg.create_group_resp_msg(self.agent_id,"") return resp_msg - - return None - - async def _process_msg(self,msg:AgentMsg) -> AgentMsg: - if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: - return await self._process_group_chat_msg(msg) - + else: + msg_prompt.messages = [{"role":"user","content":msg.body}] session_topic = msg.get_sender() + "#" + msg.topic chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) - msg_prompt = AgentPrompt() - msg_prompt.messages = [{"role":"user","content":msg.body}] + workspace = self.get_workspace_by_msg(msg) - prompt = AgentPrompt() - prompt.append(await self._get_agent_prompt()) - self._format_msg_by_env_value(prompt) - prompt.append(self._get_remote_user_prompt(msg.sender)) + prompt = AgentPrompt() + if workspace: + prompt.append(workspace.get_prompt()) + prompt.append(workspace.get_role_prompt(self.agent_id)) - inner_functions,function_token_len = self._get_inner_functions() - - system_prompt_len = prompt.get_prompt_token_len() - input_len = len(msg.body) + prompt.append(self.get_agent_prompt()) + prompt.append(self._get_remote_user_prompt(msg.sender)) + self._format_msg_by_env_value(prompt) + + if self.need_session_summmary(msg,chatsession): + # get relate session(todos) summary + summary = self.llm_select_session_summary(msg,chatsession) + prompt.append(AgentPrompt(summary)) + + inner_functions,function_token_len = self._get_inner_functions() + system_prompt_len = prompt.get_prompt_token_len() + input_len = len(msg.body) + if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: + history_prmpt,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) + else: + history_prmpt,history_token_len = await self.get_prompt_from_session(chatsession,system_prompt_len + function_token_len,input_len) + prompt.append(history_prmpt) # chat context - history_prmpt,history_token_len = await self._get_prompt_from_session(chatsession,system_prompt_len + function_token_len,input_len) - prompt.append(history_prmpt) # chat context - prompt.append(msg_prompt) + prompt.append(msg_prompt) - logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - #task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) - task_result = await self._do_llm_complection(prompt,inner_functions,msg) - if task_result.result_code != ComputeTaskResultCode.OK: - error_resp = msg.create_error_resp(task_result.error_str) - return error_resp - - final_result = task_result.result_str + + logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") + #task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) + task_result = await self._do_llm_complection(prompt,inner_functions,msg) + if task_result.result_code != ComputeTaskResultCode.OK: + error_resp = msg.create_error_resp(task_result.error_str) + return error_resp + + final_result = task_result.result_str - llm_result : LLMResult = LLMResult.from_str(final_result) - is_ignore = False - result_prompt_str = "" - match llm_result.state: - case "ignore": - is_ignore = True - case "waiting": - for sendmsg in llm_result.send_msgs: - sendmsg.sender = self.agent_id - target = sendmsg.target - sendmsg.topic = msg.topic - sendmsg.prev_msg_id = msg.get_msg_id() - send_resp = await AIBus.get_default_bus().send_message(sendmsg) - if send_resp is not None: - result_prompt_str += f"\n{target} response is :{send_resp.body}" - agent_sesion = AIChatSession.get_session(self.agent_id,f"{sendmsg.target}#{sendmsg.topic}",self.chat_db) - agent_sesion.append(sendmsg) - agent_sesion.append(send_resp) + llm_result : LLMResult = LLMResult.from_str(final_result) + + # extra_info include the operation about workspace + if llm_result.extra_info is not None: + await workspace.update_state_by_msg(msg,llm_result.extra_info) - final_result = llm_result.resp + result_prompt_str + is_ignore = False + result_prompt_str = "" + match llm_result.state: + case "ignore": + is_ignore = True + case "waiting": # like inner call + for sendmsg in llm_result.send_msgs: + sendmsg.sender = self.agent_id + target = sendmsg.target + sendmsg.topic = msg.topic + sendmsg.prev_msg_id = msg.get_msg_id() + send_resp = await AIBus.get_default_bus().send_message(sendmsg) + if send_resp is not None: + result_prompt_str += f"\n{target} response is :{send_resp.body}" + agent_sesion = AIChatSession.get_session(self.agent_id,f"{sendmsg.target}#{sendmsg.topic}",self.chat_db) + agent_sesion.append(sendmsg) + agent_sesion.append(send_resp) - if is_ignore is not True: + final_result = llm_result.resp + result_prompt_str + + if is_ignore is not True: + if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: + resp_msg = msg.create_group_resp_msg(self.agent_id,final_result) + else: resp_msg = msg.create_resp_msg(final_result) - chatsession.append(msg) - chatsession.append(resp_msg) + chatsession.append(msg) + chatsession.append(resp_msg) - return resp_msg + return resp_msg - return None + return None @@ -527,27 +583,219 @@ class AIAgent: return result_prompt,result_token_len - async def _do_llm_complection(self,prompt:AgentPrompt,inner_functions:dict,org_msg:AgentMsg=None) -> ComputeTaskResult: - from .compute_kernel import ComputeKernel - #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"llm compute error:{task_result.error_str}") - #error_resp = msg.create_error_resp(task_result.error_str) - return task_result - result_message = task_result.result.get("message") - inner_func_call_node = None - if result_message: - inner_func_call_node = result_message.get("function_call") - if inner_func_call_node: - call_prompt : AgentPrompt = copy.deepcopy(prompt) - task_result = await self._execute_func(inner_func_call_node,call_prompt,inner_functions,org_msg) + async def _llm_summary_work(self,workspace:WorkspaceEnvironment): + # read report ,and update work summary of + # build todo list from work summary and goals + # + report_list = self.get_unread_reports() + + for report in report_list: + if self.agent_energy <= 0: + break + # merge report to work summary + await self._llm_read_report(report,workspace) + self.agent_energy -= 1 + + if workspace.is_mgr(self.agent_id): + # manager can do more work + await self._llm_review_team(workspace) + self.agent_energy -= 5 + await self._llm_review_unassigned_todos(workspace) + self.agent_energy -= 5 + + + async def _llm_review_team(self,workspace:WorkspaceEnvironment): + pass + + async def _llm_review_unassigned_todos(self,workspace:WorkspaceEnvironment): + pass + + async def _llm_read_report(self,report:AgentReport,worksapce:WorkspaceEnvironment): + work_summary = worksapce.get_work_summary(self.agent_id) + prompt : AgentPrompt = AgentPrompt() + prompt.append(self.agent_prompt) + prompt.append(worksapce.get_role_prompt(self.agent_id)) + prompt.append(self.read_report_prompt) + # report is a message from other agent(human) about work + prompt.append(AgentPrompt(work_summary)) + prompt.append(AgentPrompt(report.content)) + + task_result:ComputeTaskResult = await self._do_llm_complection(prompt) + + if task_result.error_str is not None: + logger.error(f"_llm_read_report compute error:{task_result.error_str}") + return + + worksapce.set_work_summary(self.agent_id,task_result.result_str) + + + # 尝试完成自己的TOOD (不依赖任何其他Agnet) + async def do_my_work(self) -> None: + workspace = self.get_current_workspace() + + # review todo能更整体的思考一次todo的优先级 + if await self.need_review_todos(): + await self._llm_review_todos(workspace) + + todo_list = workspace.get_todo_list(self.agent_id) + + for todo in todo_list: + if self.agent_energy <= 0: + break + if await self.can_do(todo,workspace) is False: + continue + + if todo.try_count() < 2: + need_think_todo_from_goal = False + do_result : AgentTodoResult = await self._llm_do(todo,workspace) + self.agent_energy -= 1 + if do_result.result_state == "done": + await self._llm_check_todo(todo,workspace) + self.agent_energy -= 1 + + def get_review_todo_prompt(self) -> AgentPrompt: + return self.review_todo_prompt + + async def need_review_todos(self) -> bool: + if self.get_review_todo_prompt() is None: + return False + return True + + async def _llm_review_todos(self,workspace:WorkspaceEnvironment): + prompt = AgentPrompt() + + prompt.append(workspace.get_prompt()) + prompt.append(workspace.get_role_prompt(self.agent_id)) + prompt.append(self.get_review_todo_prompt()) + + todo_tree = workspace.get_todo_tree("/") + prompt.append(AgentPrompt(todo_tree)) + inner_functions,function_token_len = self._get_inner_functions() + + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,inner_functions) + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"_llm_review_todos compute error:{task_result.error_str}") + return + + return + + def get_do_prompt(self,todo_type:str) -> AgentPrompt: + return self.do_prompt + + async def can_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: + return True + + async def _llm_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> AgentTodoResult: + prompt : AgentPrompt = AgentPrompt() + prompt.append(self.agent_prompt) + prompt.append(workspace.get_role_prompt(self.agent_id)) + + do_prompt = workspace.get_do_prompt(todo.type) + if do_prompt is None: + do_prompt = self.get_do_prompt(todo.type) + + prompt.append(do_prompt) + + # 有通用的todo执行方法,也有定制的,针对特定类型TODO更高效的执行方法 + # 根据经验,Agent可以自主掌握/整理更多类型的TODO的执行方法 + + #prompt.append(do_log_prompt) + prompt.append(self.get_prompt_from_todo(todo)) + + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,workspace.get_inner_functions(todo.type)) + + if task_result.error_str is not None: + logger.error(f"_llm_do compute error:{task_result.error_str}") + + llm_result = LLMResult.from_str(task_result.result_str) + todo.append_do_result(self.agent_id,llm_result) + + + return task_result - def parser_learn_llm_result(self,llm_result:str): + async def _llm_check_todo(self, todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: + if self.get_check_prompt(todo) is None: + return True + + prompt : AgentPrompt = AgentPrompt() + prompt.append(self.agent_prompt) + prompt.append(workspace.get_role_prompt(self.agent_id)) + prompt.append(self.get_check_prompt(todo)) + if todo.last_check_result: + prompt.append(AgentPrompt(todo.last_check_result)) + + prompt.append(todo.detail) + prompt.append(todo.result) + + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,workspace.get_inner_functions()) + + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"_llm_check_todo compute error:{task_result.error_str}") + return False + + if task_result.result_str == "OK": + return True + todo.last_check_result = task_result.result_str + return False + + # 尝试自我学习,会主动获取、读取资料并进行整理 + # LLM的本质能力是处理海量知识,应该让LLM能基于知识把自己的工作处理的更好 + def do_self_learn(self) -> None: + # 不同的workspace是否应该有不同的学习方法? + learn_power = self.get_learn_power() + kb = self.get_knowledge_base() + for item in kb.un_learn_items(): + if learn_power <= 0: + break + match item.type(): + case "book": + self.llm_read_book(kb,item) + learn_power -= 1 + case "article": + # 可以用vdb 对不同目录的名字进行选择后,先进行一次快速的插入。有时间再慢慢用LLM整理 + self.llm_read_article(kb,item) + learn_power -= 1 + case "video": + self.llm_watch_video(kb,item) + learn_power -= 1 + case "audio": + self.llm_listen_audio(kb,item) + learn_power -= 1 + case "code_project": + self.llm_read_code_project(kb,item) + learn_power -= 1 + case "image": + self.llm_view_image(kb,item) + learn_power -= 1 + case "other": + self.llm_read_other(kb,item) + learn_power -= 1 + case _: + self.llm_learn_any(kb,item) + pass + + # 整理自己的知识库(让分类更平衡,更由于自己以后的工作),并尝试更新学习目标 + current_path = "/" + current_list = kb.get_list(current_path) + self_assessment_with_goal = self.get_self_assessment_with_goal() + learn_goal = {} + + + llm_blance_knowledge_base(current_path,current_list,self_assessment_with_goal,learn_goal,learn_power) + + # 主动学习 + # 方法目前只有使用搜索引擎一种? + for goal in learn_goal.items(): + self.llm_learn_with_search_engine(kb,goal,learn_power) + if learn_power <= 0: + break + + + def parser_learn_llm_result(self,llm_result:LLMResult): pass async def _llm_read_article(self,item:KnowledgeObject) -> ComputeTaskResult: @@ -568,7 +816,8 @@ class AIAgent: task_result:ComputeTaskResult = await self._do_llm_complection(prompt,env_functions) if task_result.result_code != ComputeTaskResultCode.OK: return task_result - path_list,summary = self.parser_learn_llm_result(task_result.result_str) + llm_result = LLMResult.from_str(task_result.result_str) + path_list,summary = self.parser_learn_llm_result(llm_result) else: # 用传统方法对文章进行一些处理,目的是尽可能减少LLM调用的次数 @@ -592,9 +841,59 @@ class AIAgent: kb.insert_item(path_list,item,catelog,summary) + async def do_self_think(self): + session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) + for session_id in session_id_list: + if self.agent_energy <= 0: + break + used_energy = await self.think_chatsession(session_id) + self.agent_energy -= used_energy + todo_logs = await self.get_todo_logs() + for todo_log in todo_logs: + if self.agent_energy <= 0: + break + used_energy = await self.think_todo_log(todo_log) + self.agent_energy -= used_energy - async def _get_prompt_from_session(self,chatsession:AIChatSession,system_token_len,input_token_len) -> AgentPrompt: + return + + + async def think_todo_log(self,todo_log:AgentWorkLog): + pass + + async def think_chatsession(self,session_id): + if self.agent_think_prompt is None: + return + logger.info(f"agent {self.agent_id} think session {session_id}") + chatsession = AIChatSession.get_session_by_id(session_id,self.chat_db) + + while True: + cur_pos = chatsession.summarize_pos + summary = chatsession.summary + prompt:AgentPrompt = AgentPrompt() + #prompt.append(self._get_agent_prompt()) + prompt.append(await self._get_agent_think_prompt()) + system_prompt_len = prompt.get_prompt_token_len() + #think env? + history_prompt,next_pos = await self._get_history_prompt_for_think(chatsession,summary,system_prompt_len,cur_pos) + prompt.append(history_prompt) + is_finish = next_pos - cur_pos < 2 + if is_finish: + logger.info(f"agent {self.agent_id} think session {session_id} is finished!,no more history") + break + #3) llm summarize chat history + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,None) + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"llm compute error:{task_result.error_str}") + break + else: + new_summary= task_result.result_str + logger.info(f"agent {self.agent_id} think session {session_id} from {cur_pos} to {next_pos} summary:{new_summary}") + chatsession.update_think_progress(next_pos,new_summary) + return + + async def get_prompt_from_session(self,chatsession:AIChatSession,system_token_len,input_token_len) -> AgentPrompt: # TODO: get prompt from group chat is different from single chat history_len = (self.max_token_size * 0.7) - system_token_len - input_token_len @@ -614,7 +913,6 @@ class AIAgent: formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') if msg.sender == self.agent_id: - if self.enable_timestamp: result_prompt.messages.append({"role":"assistant","content":f"(create on {formatted_time}) {msg.body} "}) else: @@ -633,4 +931,69 @@ class AIAgent: break return result_prompt,result_token_len + + async def _do_llm_complection(self,prompt:AgentPrompt,inner_functions:dict=None,org_msg:AgentMsg=None) -> ComputeTaskResult: + from .compute_kernel import ComputeKernel + #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"llm compute error:{task_result.error_str}") + #error_resp = msg.create_error_resp(task_result.error_str) + return task_result + result_message = task_result.result.get("message") + inner_func_call_node = None + if result_message: + inner_func_call_node = result_message.get("function_call") + + if inner_func_call_node: + call_prompt : AgentPrompt = copy.deepcopy(prompt) + task_result = await self._execute_func(inner_func_call_node,call_prompt,inner_functions,org_msg) + + return task_result + + def need_work(self) -> bool: + return True + + def need_self_think(self) -> bool: + return True + + def need_self_learn(self) -> bool: + return True + + def wake_up(self) -> None: + if self.agent_task is None: + self.agent_task = asyncio.create_task(self._on_timer) + else: + logger.warning(f"agent {self.agent_id} is already wake up!") + + # agent loop + async def _on_timer(self): + while True: + await asyncio.sleep(1) + now = time.time() + if now - self.last_recover_time > 60: + self.agent_energy += (now - self.last_recover_time) / 60 + self.last_recover_time = now + else: + return + + # complete todo + if self.need_work(): + await self.do_my_work() + + # review other's todo + # self.review_other_works() + + # do work summary + if self.need_self_think(): + await self.do_self_think() + + # + if self.need_self_learn(): + await self.do_self_learn() + + + + + diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index cbd9bef..4a75aee 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -7,6 +7,7 @@ import re import shlex from typing import List from .ai_function import FunctionItem +from .compute_task import ComputeTaskResult logger = logging.getLogger(__name__) @@ -230,6 +231,7 @@ class LLMResult: self.send_msgs : List[AgentMsg] = [] self.calls : List[FunctionItem] = [] self.post_calls : List[FunctionItem] = [] + self.extra_info = None @classmethod def from_str(self,llm_result_str:str,valid_func:List[str]=None) -> 'LLMResult': @@ -310,7 +312,49 @@ class LLMResult: else: r.state = "reponsed" - return r + return r + +class AgentGoal: + def __init__(self) -> None: + self.description = None + + +class AgentReport: + def __init__(self): + pass + +class AgentTodoResult: + def __init__(self) -> None: + self.result_state = "error" + +class AgentTodo: + def __init__(self): + self.todo_id = "todo#" + uuid.uuid4().hex + self.title = None + self.detail = None + self.todo_path = None # get parent todo,sub todo by path + + self.depend_todo_ids = [] + + self.need_check = True + self.result : ComputeTaskResult = None + self.last_check_result = None + + self.worker = None + self.checker = None + self.createor = None + + self.retry_count = 0 + + def can_do(self) -> bool: + return True + + async def save(self): + pass + +class AgentWorkLog: + def __init__(self) -> None: + pass class BaseAIAgent: def __init__(self) -> None: diff --git a/src/aios_kernel/contact_manager.py b/src/aios_kernel/contact_manager.py index 39c5711..db5fe4f 100644 --- a/src/aios_kernel/contact_manager.py +++ b/src/aios_kernel/contact_manager.py @@ -151,3 +151,8 @@ class ContactManager: def list_family_members(self): return self.family_members + + + #async def process_msg(self,msg:AgentMsg): + # # forword message to contact + # pass diff --git a/src/aios_kernel/email_tunnel.py b/src/aios_kernel/email_tunnel.py index 6190a0f..0267ba3 100644 --- a/src/aios_kernel/email_tunnel.py +++ b/src/aios_kernel/email_tunnel.py @@ -141,3 +141,4 @@ class EmailTunnel(AgentTunnel): async def _process_message(self, msg: AgentMsg) -> None: logger.warn(f"process message {msg.msg_id} from {msg.sender} to {msg.target}") + diff --git a/src/aios_kernel/workspace_env.py b/src/aios_kernel/workspace_env.py index ae87217..f2be11b 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios_kernel/workspace_env.py @@ -2,6 +2,7 @@ import json import subprocess +import logging import tempfile import threading import traceback @@ -11,12 +12,124 @@ import sys import os import re import asyncio +from typing import Any,List import aiofiles.os import chardet +from .agent_base import AgentMsg,AgentTodo from .environment import Environment,EnvironmentEvent from .ai_function import AIFunction,SimpleAIFunction +logger = logging.getLogger(__name__) + +class WorkspaceEnvironment(Environment): + def __init__(self, env_id: str) -> None: + super().__init__(env_id) + self.root_path = f"./workspace/{env_id}" + + def set_root_path(self,path:str): + self.root_path = path + + def get_knowledge_base(self) -> str: + pass + + def exec_op_list(self,oplist:List)->None: + for op in oplist: + if op["op"] == "create": + self.create(op["path"],op["content"]) + elif op["op"] == "write": + self.write(op["path"],op["content"],op["mode"]) + elif op["op"] == "delete": + self.delete(op["path"]) + elif op["op"] == "rename": + self.rename(op["path"],op["new_name"]) + + else: + logger.error(f"execute op list failed: unknown op:{op['op']}") + + async def list(self,path:str,only_dir:bool=False) -> str: + directory_path = self.root_path + path + items = [] + + with await aiofiles.os.scandir(directory_path) as entries: + async for entry in entries: + is_dir = entry.is_dir() + if only_dir and not is_dir: + continue + item_type = "directory" if is_dir else "file" + items.append({"name": entry.name, "type": item_type}) + + return json.dumps(items) + + async def read(self,path:str) -> str: + file_path = self.root_path + path + cur_encode = "utf-8" + async with aiofiles.open(file_path,'rb') as f: + cur_encode = chardet.detect(await f.read())['encoding'] + + async with aiofiles.open(file_path, mode='r', encoding=cur_encode) as f: + content = await f.read(2048) + return content + + async def write(self,path:str,content:str,is_append:bool=False) -> str: + file_path = self.root_path + path + if is_append: + async with aiofiles.open(file_path, mode='a', encoding="utf-8") as f: + await f.write(content) + else: + async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: + await f.write(content) + return "success" + + async def create(self,path:str,content:str=None) -> bool: + if content is None: + # create dir + dir_path = self.root_path + path + os.makedirs(dir_path) + return True + else: + file_path = self.root_path + path + async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: + await f.write(content) + return True + + async def delete(self,path:str) -> bool: + file_path = self.root_path + path + os.remove(file_path) + return True + + async def rename(self,path:str,new_name:str) -> bool: + file_path = self.root_path + path + new_path = self.root_path + new_name + os.rename(file_path,new_path) + return True + + + #easy use functions + async def update_state_by_msg(self, msg: AgentMsg,extra_info:dict) -> None: + # add todo + # update todo status + pass + + async def update_todos(self,oplist:list) -> None: + # add todo + # update todo status + pass + + async def get_todo_tree(self,path:str,deep:int = 4) -> str: + pass + + async def get_todo_by_path(self,path:str) -> AgentTodo: + pass + + async def get_todo(self,id:str) -> AgentTodo: + pass + + async def save_new_todo(self,path:str,todo:AgentTodo) -> None: + pass + + async def update_todo(self,path:str,todo:AgentTodo)->None: + pass class CodeInterpreter: def __init__(self, language, debug_mode): @@ -138,7 +251,7 @@ class CodeInterpreter: -class WorkspaceEnvironment(Environment): +class ShellEnvironment(Environment): def __init__(self, env_id: str) -> None: super().__init__(env_id) @@ -176,7 +289,7 @@ class WorkspaceEnvironment(Environment): return interpreter.run(pycode) - +# merge to standard workspace env, **ABANDON this!** class KnowledgeBaseFileSystemEnvironment(Environment): def __init__(self, env_id: str) -> None: super().__init__(env_id) diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index b8ce7a6..9c2b1e7 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -118,7 +118,7 @@ class AIOS_Shell: await cal_env.start() Environment.set_env_by_id("calender",cal_env) - workspace_env = WorkspaceEnvironment("bash") + workspace_env = ShellEnvironment("bash") Environment.set_env_by_id("bash",workspace_env) paint_env = PaintEnvironment("paint") diff --git a/test/workflow_test.py b/test/workflow_test.py index 3e5e586..37b9d44 100644 --- a/test/workflow_test.py +++ b/test/workflow_test.py @@ -4,10 +4,10 @@ import asyncio directory = os.path.dirname(__file__) sys.path.append(directory + '/../src') -from aios_kernel import WorkspaceEnvironment +from aios_kernel import ShellEnvironment async def test_workflow(): - env = WorkspaceEnvironment("test") + env = ShellEnvironment("test") test_code =""" import toml From 1fbe5ae1ea9e1cc195119ecfbe178f80fd27ec1b Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Fri, 3 Nov 2023 01:16:32 -0700 Subject: [PATCH 10/79] Agent can create todo by op_list --- rootfs/agents/JarvisPlus/agent.toml | 37 +++++++++ src/aios_kernel/agent.py | 74 +++++++++++------ src/aios_kernel/agent_base.py | 86 ++++++++++++++++++-- src/aios_kernel/contact_manager.py | 6 +- src/aios_kernel/workspace_env.py | 86 +++++++++++++++----- src/component/agent_manager/agent_manager.py | 1 + 6 files changed, 237 insertions(+), 53 deletions(-) create mode 100644 rootfs/agents/JarvisPlus/agent.toml diff --git a/rootfs/agents/JarvisPlus/agent.toml b/rootfs/agents/JarvisPlus/agent.toml new file mode 100644 index 0000000..1f18b52 --- /dev/null +++ b/rootfs/agents/JarvisPlus/agent.toml @@ -0,0 +1,37 @@ +instance_id = "JarvisPlus" +fullname = "JarvisPlus" +max_token_size = 4000 +#enable_kb = "true" +enable_timestamp = "true" +owner_prompt = "I am your master {name} , now is {now}" +contact_prompt = "I am your master's friend {name}" + +[[prompt]] +role = "system" +content = """ +你的名字是Jarvis,是超级个人助理。收到消息后,根据以下规则处理: +1. 如果你觉得对话中产生了潜在的todo,可通过op_list来创建这些todo,todo的title是必须的,并尽量包含时间地点人物事件的关键信息 +2. 如果不是直接的创建TODO指令,你应先和我确认后再创建TODO +3. 你可能会得到几条已知信息,其中可能有已有的todo,注意在适当的时候检索文件系统,避免重复创建todo +3. 检索文件系统是代价高昂的操作,请尽量减少检索次数 +4. 注意你正在与之聊天的人的身份,并根据他们的地位提供相应的服务。 +5. 当存在已知信息时,请以已知信息为准 + +回复的消息必须能被python的json.loads直接解析。下面是一个返回的例子: +{ + resp: 'Hello', + op_list: [{ + op: 'create_todo', + path: '/todos/parent_todo', # optional, default is '/todos + todo: { + title: 'test_todo', + detail: 'test', + creator: 'agent#JarvisPlus', + due_date: '2019-01-01 14:23:11' + } + }] +} + +""" + + diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index b9a7695..3d4945f 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -129,6 +129,7 @@ class AIAgent: self.owner_env : Environment = None self.owenr_bus = None self.enable_function_list = None + @classmethod def create_from_templete(cls,templete:AIAgentTemplete, fullname:str): @@ -148,6 +149,7 @@ class AIAgent: logger.error("agent instance_id is None!") return False self.agent_id = config["instance_id"] + self.agent_workspace = WorkspaceEnvironment(self.agent_id) if config.get("fullname") is None: logger.error(f"agent {self.agent_id} fullname is None!") @@ -412,7 +414,7 @@ class AIAgent: # return None def get_workspace_by_msg(self,msg:AgentMsg) -> WorkspaceEnvironment: - return None + return self.agent_workspace def need_session_summmary(self,msg:AgentMsg,session:AIChatSession) -> bool: return False @@ -455,14 +457,26 @@ class AIAgent: summary = self.llm_select_session_summary(msg,chatsession) prompt.append(AgentPrompt(summary)) + known_info_str = "# 已知信息\n" + have_known_info = False + todos_str,todo_count = await workspace.get_todo_tree() + if todo_count > 0: + have_known_info = True + known_info_str += f"## 已有todo\n{todos_str}\n" inner_functions,function_token_len = self._get_inner_functions() system_prompt_len = prompt.get_prompt_token_len() input_len = len(msg.body) if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: - history_prmpt,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) + history_str,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) else: - history_prmpt,history_token_len = await self.get_prompt_from_session(chatsession,system_prompt_len + function_token_len,input_len) - prompt.append(history_prmpt) # chat context + history_str,history_token_len = await self.get_prompt_from_session(chatsession,system_prompt_len + function_token_len,input_len) + if history_str: + have_known_info = True + known_info_str += history_str + + if have_known_info: + known_info_prompt = AgentPrompt(known_info_str) + prompt.append(known_info_prompt) # chat context prompt.append(msg_prompt) @@ -475,12 +489,18 @@ class AIAgent: return error_resp final_result = task_result.result_str + if final_result is not None: + if final_result[0] == "{": + llm_result = LLMResult.from_json_str(final_result) + else: + llm_result : LLMResult = LLMResult.from_str(final_result) + else: + llm_result = LLMResult() + llm_result.state = "ignore" - llm_result : LLMResult = LLMResult.from_str(final_result) - - # extra_info include the operation about workspace - if llm_result.extra_info is not None: - await workspace.update_state_by_msg(msg,llm_result.extra_info) + final_result = llm_result.resp + + await workspace.exec_op_list(llm_result.op_list) is_ignore = False result_prompt_str = "" @@ -899,38 +919,37 @@ class AIAgent: history_len = (self.max_token_size * 0.7) - system_token_len - input_token_len messages = chatsession.read_history(self.history_len) # read result_token_len = 0 - result_prompt = AgentPrompt() + read_history_msg = 0 - + have_known_info = False + + known_info = "" if chatsession.summary is not None: if len(chatsession.summary) > 1: - result_prompt.messages.append({"role":"user","content":chatsession.summary}) + known_info += f"## 最近交流的总结 \n {chatsession.summary}\n" result_token_len -= len(chatsession.summary) + have_known_info = True + histroy_str = "" for msg in reversed(messages): read_history_msg += 1 dt = datetime.datetime.fromtimestamp(float(msg.create_time)) formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') - - if msg.sender == self.agent_id: - if self.enable_timestamp: - result_prompt.messages.append({"role":"assistant","content":f"(create on {formatted_time}) {msg.body} "}) - else: - result_prompt.messages.append({"role":"assistant","content":msg.body}) - - else: - if self.enable_timestamp: - result_prompt.messages.append({"role":"user","content":f"(create on {formatted_time}) {msg.body} "}) - else: - result_prompt.messages.append({"role":"user","content":msg.body}) + record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" + have_known_info = True + histroy_str = histroy_str + record_str history_len -= len(msg.body) result_token_len += len(msg.body) if history_len < 0: logger.warning(f"_get_prompt_from_session reach limit of token,just read {read_history_msg} history message.") break - - return result_prompt,result_token_len + + known_info += f"## 最近的沟通记录 \n {histroy_str}\n" + + if have_known_info: + return known_info,result_token_len + return None,0 async def _do_llm_complection(self,prompt:AgentPrompt,inner_functions:dict=None,org_msg:AgentMsg=None) -> ComputeTaskResult: from .compute_kernel import ComputeKernel @@ -952,6 +971,9 @@ class AIAgent: return task_result + async def execute_op_list(self,oplist:list,workspace:WorkspaceEnvironment): + pass + def need_work(self) -> bool: return True diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index 4a75aee..d23b2a1 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -1,10 +1,12 @@ import copy +from datetime import datetime import logging from enum import Enum import uuid import time import re import shlex +import json from typing import List from .ai_function import FunctionItem from .compute_task import ComputeTaskResult @@ -78,6 +80,12 @@ class AgentMsg: self.inner_call_chain = [] self.resp_msg = None + @classmethod + def from_json(cls,json_obj:dict) -> 'AgentMsg': + msg = AgentMsg() + + return msg + @classmethod def create_internal_call_msg(self,func_name:str,args:dict,prev_msg_id:str,caller:str): msg = AgentMsg(AgentMsgType.TYPE_INTERNAL_CALL) @@ -216,22 +224,49 @@ class AgentPrompt: return False self.messages = [] for msg in config: - if msg.get("role") == "system": - self.system_message = msg + if msg.get("content"): + if msg.get("role") == "system": + self.system_message = msg + else: + self.messages.append(msg) else: - self.messages.append(msg) + logger.error("prompt message has no content!") return True - + class LLMResult: def __init__(self) -> None: self.state : str = "ignore" self.resp : str = "" self.paragraphs : dict[str,FunctionItem] = [] + self.post_msgs : List[AgentMsg] = [] self.send_msgs : List[AgentMsg] = [] self.calls : List[FunctionItem] = [] self.post_calls : List[FunctionItem] = [] - self.extra_info = None + self.op_list : List[FunctionItem] = [] + + @classmethod + def from_json_str(self,llm_json_str:str) -> 'LLMResult': + r = LLMResult() + if llm_json_str is None: + r.state = "ignore" + return r + if llm_json_str == "ignore": + r.state = "ignore" + return r + + llm_json = json.loads(llm_json_str) + r.state = llm_json.get("state") + r.resp = llm_json.get("resp") + + r.post_msgs = llm_json.get("post_msgs") + r.send_msgs = llm_json.get("send_msgs") + + r.calls = llm_json.get("calls") + r.post_calls = llm_json.get("post_calls") + r.op_list = llm_json.get("op_list") + + return r @classmethod def from_str(self,llm_result_str:str,valid_func:List[str]=None) -> 'LLMResult': @@ -328,11 +363,52 @@ class AgentTodoResult: self.result_state = "error" class AgentTodo: + @classmethod + def from_dict(cls,json_obj:dict) -> 'AgentTodo': + todo = AgentTodo() + if json_obj.get("id") is not None: + todo.todo_id = json_obj.get("id") + todo.parent_id = json_obj.get("parent_id") + todo.title = json_obj.get("title") + todo.detail = json_obj.get("detail") + due_date = json_obj.get("due_date") + if due_date: + todo.due_date = datetime.fromisoformat(due_date).timestamp() + #todo.todo_path = json_obj.get("todo_path") + todo.depend_todo_ids = json_obj.get("depend_todo_ids") + todo.need_check = json_obj.get("need_check") + #todo.result = json_obj.get("result") + #todo.last_check_result = json_obj.get("last_check_result") + todo.worker = json_obj.get("worker") + todo.checker = json_obj.get("checker") + todo.createor = json_obj.get("createor") + #todo.retry_count = json_obj.get("retry_count") + + return todo + + def to_dict(self) -> dict: + result = {} + result["id"] = self.todo_id + result["parent_id"] = self.parent_id + result["title"] = self.title + result["detail"] = self.detail + result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() + result["depend_todo_ids"] = self.depend_todo_ids + result["need_check"] = self.need_check + result["worker"] = self.worker + result["checker"] = self.checker + result["createor"] = self.createor + result["retry_count"] = self.retry_count + + return result + def __init__(self): self.todo_id = "todo#" + uuid.uuid4().hex self.title = None self.detail = None self.todo_path = None # get parent todo,sub todo by path + self.create_time = time.time() + self.due_date = time.time() + 3600 * 24 * 2 self.depend_todo_ids = [] diff --git a/src/aios_kernel/contact_manager.py b/src/aios_kernel/contact_manager.py index db5fe4f..5cd1d6c 100644 --- a/src/aios_kernel/contact_manager.py +++ b/src/aios_kernel/contact_manager.py @@ -1,6 +1,7 @@ from typing import List import toml - +import time +from datetime import datetime class Contact: def __init__(self, name, phone=None, email=None, telegram=None,added_by=None, tags=[], notes=""): self.name = name @@ -21,7 +22,8 @@ class Contact: "added_by": self.added_by, "tags": self.tags, - "notes": self.notes + "notes": self.notes, + "now" : datetime.now().strftime('%Y-%m-%d %H:%M:%S') } @classmethod diff --git a/src/aios_kernel/workspace_env.py b/src/aios_kernel/workspace_env.py index f2be11b..ff268e0 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios_kernel/workspace_env.py @@ -12,37 +12,58 @@ import sys import os import re import asyncio +import aiofiles from typing import Any,List -import aiofiles.os +import os import chardet from .agent_base import AgentMsg,AgentTodo from .environment import Environment,EnvironmentEvent from .ai_function import AIFunction,SimpleAIFunction +from .storage import AIStorage,ResourceLocation logger = logging.getLogger(__name__) class WorkspaceEnvironment(Environment): def __init__(self, env_id: str) -> None: super().__init__(env_id) - self.root_path = f"./workspace/{env_id}" + myai_path = AIStorage.get_instance().get_myai_dir() + self.root_path = f"{myai_path}/workspace/{env_id}" + if not os.path.exists(self.root_path): + os.makedirs(self.root_path+"/todos") + def set_root_path(self,path:str): self.root_path = path + def get_prompt(self) -> AgentMsg: + return None + + def get_role_prompt(self,role_id:str) -> AgentMsg: + return None + def get_knowledge_base(self) -> str: pass - def exec_op_list(self,oplist:List)->None: + async def exec_op_list(self,oplist:List)->None: + if oplist is None: + return + for op in oplist: if op["op"] == "create": - self.create(op["path"],op["content"]) + return await self.create(op["path"],op["content"]) elif op["op"] == "write": - self.write(op["path"],op["content"],op["mode"]) + return await self.write(op["path"],op["content"],op["mode"]) elif op["op"] == "delete": - self.delete(op["path"]) + return await self.delete(op["path"]) elif op["op"] == "rename": - self.rename(op["path"],op["new_name"]) + return await self.rename(op["path"],op["new_name"]) + elif op["op"] == "mkdir": + return await self.mkdir(op["path"]) + elif op["op"] == "create_todo": + todoObj = AgentTodo.from_dict(op["todo"]) + path = op.get("path") + return await self.create_todo(path,todoObj) else: logger.error(f"execute op list failed: unknown op:{op['op']}") @@ -98,26 +119,44 @@ class WorkspaceEnvironment(Environment): os.remove(file_path) return True + async def mkdir(self,path:str) -> bool: + dir_path = self.root_path + path + os.makedirs(dir_path) + return True + async def rename(self,path:str,new_name:str) -> bool: file_path = self.root_path + path new_path = self.root_path + new_name os.rename(file_path,new_path) return True + async def get_todo_tree(self,path:str = None,deep:int = 4): + if path: + directory_path = self.root_path + "/todos/" + path + else: + directory_path = self.root_path + "/todos" - #easy use functions - async def update_state_by_msg(self, msg: AgentMsg,extra_info:dict) -> None: - # add todo - # update todo status - pass + str_result:str = "/todos\n" + todo_count:int = 0 - async def update_todos(self,oplist:list) -> None: - # add todo - # update todo status - pass + async def scan_dir(directory_path:str,deep:int): + nonlocal str_result + nonlocal todo_count + if deep <= 0: + return + + for entry in os.scandir(directory_path): + is_dir = entry.is_dir() + if not is_dir: + continue + + todo_count = todo_count + 1 + str_result = str_result + f"{' '*(4-deep)}└─{entry.name}\n" + await scan_dir(entry.path,deep-1) + + await scan_dir(directory_path,deep) + return str_result,todo_count - async def get_todo_tree(self,path:str,deep:int = 4) -> str: - pass async def get_todo_by_path(self,path:str) -> AgentTodo: pass @@ -125,8 +164,15 @@ class WorkspaceEnvironment(Environment): async def get_todo(self,id:str) -> AgentTodo: pass - async def save_new_todo(self,path:str,todo:AgentTodo) -> None: - pass + async def create_todo(self,path:str,todo:AgentTodo) -> None: + if path is None: + dir_path = f"/todos/{todo.title}" + else: + dir_path = f"/todos/{path}/{todo.title}" + + os.makedirs(self.root_path + dir_path) + detail_path = f"{dir_path}/detail" + await self.create(detail_path,json.dumps(todo.to_dict())) async def update_todo(self,path:str,todo:AgentTodo)->None: pass diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index ef18253..9b838fb 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -69,6 +69,7 @@ class AgentManager: the_agent : AIAgent = await self._load_agent_from_media(agent_media_info) if the_agent is None: logger.warn(f"load agent {agent_id} from media failed!") + return None the_agent.chat_db = self.db_path return the_agent From 7d293c93770ca6516fb6d0a1d19eb2e5fdc4d253 Mon Sep 17 00:00:00 2001 From: alexsunxl Date: Fri, 3 Nov 2023 21:55:25 +0800 Subject: [PATCH 11/79] Modify the telegram return message --- src/aios_kernel/tg_tunnel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aios_kernel/tg_tunnel.py b/src/aios_kernel/tg_tunnel.py index 923fb14..97289e9 100644 --- a/src/aios_kernel/tg_tunnel.py +++ b/src/aios_kernel/tg_tunnel.py @@ -196,7 +196,7 @@ class TelegramTunnel(AgentTunnel): else: if self.allow_group != "guest": - await update.message.reply_text(f"You're not supposed to talk to me! Please contact my father~") + await update.message.reply_text(f"The current Telegram account is not in the contact list. If you want to receive a reply, you can add the configuration in the contacts.toml file or switch tunnel to guest mode.") return if cm.is_auto_create_contact_from_telegram: From 575d37486c4ecaeb9319cebf3ed2d10f8b323f2e Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Fri, 3 Nov 2023 22:31:23 -0700 Subject: [PATCH 12/79] Agent can do TODOs. --- rootfs/agents/JarvisPlus/agent.toml | 68 +++++++++++-- src/aios_kernel/agent.py | 99 +++++++++++-------- src/aios_kernel/agent_base.py | 79 +++++++++------ src/aios_kernel/workspace_env.py | 138 +++++++++++++++++++++++---- src/service/aios_shell/aios_shell.py | 6 ++ 5 files changed, 292 insertions(+), 98 deletions(-) diff --git a/rootfs/agents/JarvisPlus/agent.toml b/rootfs/agents/JarvisPlus/agent.toml index 1f18b52..9797af7 100644 --- a/rootfs/agents/JarvisPlus/agent.toml +++ b/rootfs/agents/JarvisPlus/agent.toml @@ -6,27 +6,79 @@ enable_timestamp = "true" owner_prompt = "I am your master {name} , now is {now}" contact_prompt = "I am your master's friend {name}" +[[do_prompt]] +role = "system" +content = """ +My name is JarvisPlus, I am the master's super personal assistant. I think hard and try my best to complete TODOs. +The types of TODO I can handle include: +- Scheduling, where I will try to contact the relevant personnel of the plan and confirm the details of the schedule with them. +- Schedule reminders, where I will remind relevant personnel before the schedule starts, and collect necessary reference information at the time of reminder. +- Using the post_msg function to contact relevant personnel. +- Writing documents/letters, using op:'create' to save my work results. + +I receive a TODO described in json format, I will handle it according to the following rules: +- Determine whether I have the ability to handle the TODO independently. If not, I will try to break the TODO down into smaller sub-TODOs, or hand it over to someone more suitable that I know. +- I will plan the steps to solve the TODO in combination with known information, and break down the generalized TODO into more specific sub-todos. The title of the sub-todo should contain step number like #1, #2 +- sub-todo must set parent +- A specific sub-todo refers to a task that can be completed in one execution within my ability range. +- After each execution, I will decide whether to update the status of the TODO. And use op:'update_todo' to update when necessary. + +The result of my planned execution must be directly parsed by `python json.loads`. Here is an example: +{ + resp: 'My Plan is ...', + post_msg : [ + { + target:'$target_name', + content:'$msg_content' + } + ], + op_list: [{ + op: 'create_todo', + parent: '$parent_id', # optional + todo: { + title: '#1 sub_todo', + detail: 'this is a sub todo', + creator: 'JarvisPlus', + worker: 'lzc', + due_date: '2019-01-01 14:23:11' + } + }, + { + op: 'update_todo', + id: '$todo_id', + state: 'doing' # pending,doing,done,cancel,failed,expired + }, + { + op: 'write_file', + path: '/todos/$todo_path/.result/doc_name', + content:'doc content' + } + ] +} + +""" + [[prompt]] role = "system" content = """ -你的名字是Jarvis,是超级个人助理。收到消息后,根据以下规则处理: +你的名字是JarvisPlus,是超级个人助理。收到消息后,根据以下规则处理: 1. 如果你觉得对话中产生了潜在的todo,可通过op_list来创建这些todo,todo的title是必须的,并尽量包含时间地点人物事件的关键信息 -2. 如果不是直接的创建TODO指令,你应先和我确认后再创建TODO +2. 非直接创建TODO指令,你应先和我确认后再创建使用op_list创建TODO 3. 你可能会得到几条已知信息,其中可能有已有的todo,注意在适当的时候检索文件系统,避免重复创建todo -3. 检索文件系统是代价高昂的操作,请尽量减少检索次数 -4. 注意你正在与之聊天的人的身份,并根据他们的地位提供相应的服务。 -5. 当存在已知信息时,请以已知信息为准 +4. 检索文件系统是代价高昂的操作,请尽量减少检索次数 +5. 注意你正在与之聊天的人的身份,并根据他们的地位提供相应的服务。 + 回复的消息必须能被python的json.loads直接解析。下面是一个返回的例子: { resp: 'Hello', op_list: [{ op: 'create_todo', - path: '/todos/parent_todo', # optional, default is '/todos + parent: '$parent_todo_id', # optional todo: { title: 'test_todo', detail: 'test', - creator: 'agent#JarvisPlus', + creator: 'JarvisPlus', due_date: '2019-01-01 14:23:11' } }] @@ -35,3 +87,5 @@ content = """ """ + + diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 3d4945f..f44d18c 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -114,12 +114,12 @@ class AIAgent: self.review_todo_prompt = None - self.read_report_prompt = AgentPrompt(DEFAULT_AGENT_READ_REPORT_PROMPT) + self.read_report_prompt = None - self.do_prompt = AgentPrompt(DEFAULT_AGENT_DO_PROMPT) - self.self_check_prompt = AgentPrompt(DEFAULT_AGENT_SELF_CHECK_PROMPT) + self.do_prompt = None + self.check_prompt = None - self.goal_to_todo_prompt = AgentPrompt(DEFAULT_AGENT_GOAL_TO_TODO_PROMPT) + self.goal_to_todo_prompt = None self.learn_token_limit = 500 self.learn_prompt = AgentPrompt(DEFAULT_AGENT_LEARN_PROMPT) @@ -164,6 +164,11 @@ class AIAgent: self.agent_think_prompt = AgentPrompt() self.agent_think_prompt.load_from_config(config["think_prompt"]) + if config.get("do_prompt") is not None: + self.do_prompt = AgentPrompt() + self.do_prompt.load_from_config(config["do_prompt"]) + self.wake_up() + if config.get("guest_prompt") is not None: self.guest_prompt_str = config["guest_prompt"] @@ -500,7 +505,7 @@ class AIAgent: final_result = llm_result.resp - await workspace.exec_op_list(llm_result.op_list) + await workspace.exec_op_list(llm_result.op_list,self.agent_id) is_ignore = False result_prompt_str = "" @@ -653,13 +658,13 @@ class AIAgent: # 尝试完成自己的TOOD (不依赖任何其他Agnet) async def do_my_work(self) -> None: - workspace = self.get_current_workspace() + workspace : WorkspaceEnvironment = self.get_workspace_by_msg(None) # review todo能更整体的思考一次todo的优先级 if await self.need_review_todos(): await self._llm_review_todos(workspace) - todo_list = workspace.get_todo_list(self.agent_id) + todo_list = await workspace.get_todo_list(self.agent_id) for todo in todo_list: if self.agent_energy <= 0: @@ -668,11 +673,11 @@ class AIAgent: if await self.can_do(todo,workspace) is False: continue - if todo.try_count() < 2: + if todo.retry_count < 2: need_think_todo_from_goal = False - do_result : AgentTodoResult = await self._llm_do(todo,workspace) + await self._llm_do(todo,workspace) self.agent_energy -= 1 - if do_result.result_state == "done": + if todo.state == "done": await self._llm_check_todo(todo,workspace) self.agent_energy -= 1 @@ -702,20 +707,24 @@ class AIAgent: return - def get_do_prompt(self,todo_type:str) -> AgentPrompt: + def get_do_prompt(self,todo_type:str=None) -> AgentPrompt: return self.do_prompt + + def get_prompt_from_todo(self,todo:AgentTodo) -> AgentPrompt: + json_str = json.dumps(todo.raw_obj) + return AgentPrompt(json_str) async def can_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: - return True + return todo.can_do() async def _llm_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> AgentTodoResult: prompt : AgentPrompt = AgentPrompt() - prompt.append(self.agent_prompt) + #prompt.append(self.agent_prompt) prompt.append(workspace.get_role_prompt(self.agent_id)) - do_prompt = workspace.get_do_prompt(todo.type) + do_prompt = workspace.get_do_prompt() if do_prompt is None: - do_prompt = self.get_do_prompt(todo.type) + do_prompt = self.get_do_prompt() prompt.append(do_prompt) @@ -725,17 +734,18 @@ class AIAgent: #prompt.append(do_log_prompt) prompt.append(self.get_prompt_from_todo(todo)) - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,workspace.get_inner_functions(todo.type)) - + task_result:ComputeTaskResult = await self._do_llm_complection(prompt) if task_result.error_str is not None: logger.error(f"_llm_do compute error:{task_result.error_str}") - llm_result = LLMResult.from_str(task_result.result_str) - todo.append_do_result(self.agent_id,llm_result) - + await workspace.exec_op_list(llm_result.op_list,self.agent_id) + await workspace.append_do_result(self.agent_id,llm_result) return task_result + + def get_check_prompt(self) -> AgentPrompt: + return self.check_prompt async def _llm_check_todo(self, todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: if self.get_check_prompt(todo) is None: @@ -978,14 +988,14 @@ class AIAgent: return True def need_self_think(self) -> bool: - return True + return False def need_self_learn(self) -> bool: - return True + return False def wake_up(self) -> None: if self.agent_task is None: - self.agent_task = asyncio.create_task(self._on_timer) + self.agent_task = asyncio.create_task(self._on_timer()) else: logger.warning(f"agent {self.agent_id} is already wake up!") @@ -993,27 +1003,36 @@ class AIAgent: async def _on_timer(self): while True: await asyncio.sleep(1) - now = time.time() - if now - self.last_recover_time > 60: - self.agent_energy += (now - self.last_recover_time) / 60 - self.last_recover_time = now - else: - return + try: + now = time.time() + if self.last_recover_time is None: + self.last_recover_time = now + else: + if now - self.last_recover_time > 60: + self.agent_energy += (now - self.last_recover_time) / 60 + self.last_recover_time = now - # complete todo - if self.need_work(): - await self.do_my_work() - # review other's todo - # self.review_other_works() + if self.agent_energy <= 1: + continue - # do work summary - if self.need_self_think(): - await self.do_self_think() + # complete todo + if self.need_work(): + await self.do_my_work() - # - if self.need_self_learn(): - await self.do_self_learn() + # review other's todo + # self.review_other_works() + + # do work summary + if self.need_self_think(): + await self.do_self_think() + + # + if self.need_self_learn(): + await self.do_self_learn() + except Exception as e: + logger.error(f"agent {self.agent_id} on timer error:{e}") + continue diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index d23b2a1..6870488 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -1,5 +1,5 @@ import copy -from datetime import datetime +from datetime import datetime, timedelta import logging from enum import Enum import uuid @@ -243,8 +243,7 @@ class LLMResult: self.send_msgs : List[AgentMsg] = [] self.calls : List[FunctionItem] = [] self.post_calls : List[FunctionItem] = [] - self.op_list : List[FunctionItem] = [] - + self.op_list : List[FunctionItem] = [] # op_list is a optimize design for saving token @classmethod def from_json_str(self,llm_json_str:str) -> 'LLMResult': r = LLMResult() @@ -271,12 +270,16 @@ class LLMResult: @classmethod def from_str(self,llm_result_str:str,valid_func:List[str]=None) -> 'LLMResult': r = LLMResult() + if llm_result_str is None: r.state = "ignore" return r if llm_result_str == "ignore": r.state = "ignore" return r + + if llm_result_str[0] == "{": + return LLMResult.from_json_str(llm_result_str) lines = llm_result_str.splitlines() is_need_wait = False @@ -361,15 +364,44 @@ class AgentReport: class AgentTodoResult: def __init__(self) -> None: self.result_state = "error" + class AgentTodo: + + + def __init__(self): + self.todo_id = "todo#" + uuid.uuid4().hex + self.title = None + self.detail = None + self.todo_path = None # get parent todo,sub todo by path + self.create_time = time.time() + self.due_date = time.time() + 3600 * 24 * 2 + self.state = "pending" + + self.depend_todo_ids = [] + self.sub_todos = [] + + self.need_check = True + self.result : ComputeTaskResult = None + self.last_check_result = None + + self.worker = None + self.checker = None + self.createor = None + + self.retry_count = 0 + self.raw_obj = None + @classmethod def from_dict(cls,json_obj:dict) -> 'AgentTodo': todo = AgentTodo() if json_obj.get("id") is not None: todo.todo_id = json_obj.get("id") - todo.parent_id = json_obj.get("parent_id") + todo.title = json_obj.get("title") + create_time = json_obj.get("create_time") + if create_time: + todo.create_time = datetime.fromisoformat(create_time).timestamp() todo.detail = json_obj.get("detail") due_date = json_obj.get("due_date") if due_date: @@ -383,14 +415,16 @@ class AgentTodo: todo.checker = json_obj.get("checker") todo.createor = json_obj.get("createor") #todo.retry_count = json_obj.get("retry_count") + todo.raw_obj = json_obj return todo def to_dict(self) -> dict: result = {} result["id"] = self.todo_id - result["parent_id"] = self.parent_id + #result["parent_id"] = self.parent_id result["title"] = self.title + result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() result["detail"] = self.detail result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() result["depend_todo_ids"] = self.depend_todo_ids @@ -401,33 +435,18 @@ class AgentTodo: result["retry_count"] = self.retry_count return result - - def __init__(self): - self.todo_id = "todo#" + uuid.uuid4().hex - self.title = None - self.detail = None - self.todo_path = None # get parent todo,sub todo by path - self.create_time = time.time() - self.due_date = time.time() + 3600 * 24 * 2 - - self.depend_todo_ids = [] - - self.need_check = True - self.result : ComputeTaskResult = None - self.last_check_result = None - - self.worker = None - self.checker = None - self.createor = None - - self.retry_count = 0 - def can_do(self) -> bool: + for sub_todo in self.sub_todos: + if sub_todo.state != "done": + return False + + now = datetime.now().timestamp() + time_diff = now - self.due_date + + if time_diff > 7*24*3600: + return False return True - - async def save(self): - pass - + class AgentWorkLog: def __init__(self) -> None: pass diff --git a/src/aios_kernel/workspace_env.py b/src/aios_kernel/workspace_env.py index ff268e0..a55a44b 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios_kernel/workspace_env.py @@ -17,7 +17,7 @@ from typing import Any,List import os import chardet -from .agent_base import AgentMsg,AgentTodo +from .agent_base import AgentMsg,AgentTodo,AgentPrompt from .environment import Environment,EnvironmentEvent from .ai_function import AIFunction,SimpleAIFunction from .storage import AIStorage,ResourceLocation @@ -31,6 +31,8 @@ class WorkspaceEnvironment(Environment): self.root_path = f"{myai_path}/workspace/{env_id}" if not os.path.exists(self.root_path): os.makedirs(self.root_path+"/todos") + + self.known_todo = {} def set_root_path(self,path:str): @@ -39,20 +41,23 @@ class WorkspaceEnvironment(Environment): def get_prompt(self) -> AgentMsg: return None - def get_role_prompt(self,role_id:str) -> AgentMsg: + def get_role_prompt(self,role_id:str) -> AgentPrompt: return None def get_knowledge_base(self) -> str: pass - async def exec_op_list(self,oplist:List)->None: + def get_do_prompt(self,todo_type:str=None)->AgentPrompt: + return None + + async def exec_op_list(self,oplist:List,agent_id:str)->None: if oplist is None: return for op in oplist: if op["op"] == "create": return await self.create(op["path"],op["content"]) - elif op["op"] == "write": + elif op["op"] == "write_file": return await self.write(op["path"],op["content"],op["mode"]) elif op["op"] == "delete": return await self.delete(op["path"]) @@ -62,8 +67,14 @@ class WorkspaceEnvironment(Environment): return await self.mkdir(op["path"]) elif op["op"] == "create_todo": todoObj = AgentTodo.from_dict(op["todo"]) - path = op.get("path") - return await self.create_todo(path,todoObj) + todoObj.worker = agent_id + todoObj.createor = agent_id + parent_id = op.get("parent") + await self.create_todo(parent_id,todoObj) + elif op["op"] == "update_todo": + todo_id = op["id"] + new_stat = op["state"] + await self.update_todo(todo_id,new_stat) else: logger.error(f"execute op list failed: unknown op:{op['op']}") @@ -92,6 +103,10 @@ class WorkspaceEnvironment(Environment): content = await f.read(2048) return content + # use diff to update large file content + async def write_diff(self,path:str,diff): + pass + async def write(self,path:str,content:str,is_append:bool=False) -> str: file_path = self.root_path + path if is_append: @@ -136,6 +151,7 @@ class WorkspaceEnvironment(Environment): else: directory_path = self.root_path + "/todos" + str_result:str = "/todos\n" todo_count:int = 0 @@ -145,10 +161,16 @@ class WorkspaceEnvironment(Environment): if deep <= 0: return + if os.path.exists(directory_path) is False: + return + for entry in os.scandir(directory_path): is_dir = entry.is_dir() if not is_dir: continue + + if entry.name.startswith("."): + continue todo_count = todo_count + 1 str_result = str_result + f"{' '*(4-deep)}└─{entry.name}\n" @@ -157,25 +179,99 @@ class WorkspaceEnvironment(Environment): await scan_dir(directory_path,deep) return str_result,todo_count - - async def get_todo_by_path(self,path:str) -> AgentTodo: - pass - - async def get_todo(self,id:str) -> AgentTodo: - pass - - async def create_todo(self,path:str,todo:AgentTodo) -> None: - if path is None: - dir_path = f"/todos/{todo.title}" + async def get_todo_list(self,agent_id:str,path:str = None)->List[AgentTodo]: + if path: + directory_path = self.root_path + "/todos/" + path else: - dir_path = f"/todos/{path}/{todo.title}" + directory_path = self.root_path + "/todos" - os.makedirs(self.root_path + dir_path) + result_list:List[AgentTodo] = [] + + async def scan_dir(directory_path:str,deep:int,parent:AgentTodo=None): + nonlocal result_list + if os.path.exists(directory_path) is False: + return + + for entry in os.scandir(directory_path): + is_dir = entry.is_dir() + if not is_dir: + continue + + if entry.name.startswith("."): + continue + + todo = await self.get_todo_by_fullpath(entry.path) + if todo.worker != agent_id: + continue + + todo.rank = int(todo.create_time)>>deep + + if todo: + if parent: + parent.sub_todos.append(todo) + result_list.append(todo) + + await scan_dir(entry.path,deep + 1,todo) + + return + + await scan_dir(directory_path,0) + #sort by rank + result_list.sort(key=lambda x:(x.rank,x.title)) + + return result_list + + async def get_todo_by_fullpath(self,path:str) -> AgentTodo: + logger.info("get_todo_by_fullpath:%s",path) + detail_path = path + "/detail" + + async with aiofiles.open(detail_path, mode='r', encoding="utf-8") as f: + content = await f.read(4096) + logger.debug("get_todo_by_fullpath:%s,content:%s",path,content) + todo_dict = json.loads(content) + result_todo = AgentTodo.from_dict(todo_dict) + if result_todo: + result_todo.todo_path = path + self.known_todo[result_todo.todo_id] = result_todo + else: + logger.error("get_todo_by_path:%s,parse failed!",path) + + return result_todo + + async def get_todo(self,id:str) -> AgentTodo: + return self.known_todo.get(id) + + async def create_todo(self,parent_id:str,todo:AgentTodo) -> None: + if parent_id: + parent_path = self.known_todo.get(parent_id).todo_path + dir_path = f"{parent_path}/{todo.title}" + else: + dir_path = f"{self.root_path}/todos/{todo.title}" + + os.makedirs(dir_path) detail_path = f"{dir_path}/detail" - await self.create(detail_path,json.dumps(todo.to_dict())) + logger.info("create_todo %s",detail_path) + async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(todo.to_dict())) + return True - async def update_todo(self,path:str,todo:AgentTodo)->None: - pass + async def update_todo(self,todo_id:str,new_stat:str)->bool: + todo : AgentTodo = self.known_todo.get(todo_id) + if todo: + todo.status = new_stat + if todo.raw_obj is None: + todo.raw_obj = todo.to_dict() + todo.raw_obj["status"] = new_stat + + detail_path = todo.todo_path + "/detail" + async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(todo.raw_obj)) + return True + + return False + + async def append_do_result(self,todo:AgentTodo,result): + return True class CodeInterpreter: def __init__(self, language, debug_mode): diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 9c2b1e7..18d9839 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -458,6 +458,12 @@ class AIOS_Shell: the_agent = await AgentManager.get_instance().get(target_id) if the_agent is not None: await the_agent._do_think() + case 'wakeup': + if len(args) >= 1: + target_id = args[0] + the_agent = await AgentManager.get_instance().get(target_id) + if the_agent is not None: + the_agent.wake_up() case 'open': if len(args) >= 1: target_id = args[0] From 7fd3749a4069271f500f9f7b651feb726d382cfd Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sun, 5 Nov 2023 15:21:27 -0800 Subject: [PATCH 13/79] 1) Fix bugs. 2) openai_node support async/await. --- rootfs/agents/JarvisPlus/agent.toml | 2 +- src/aios_kernel/agent.py | 14 +++++++----- src/aios_kernel/agent_base.py | 19 ++++++++++------ src/aios_kernel/compute_kernel.py | 4 +++- src/aios_kernel/open_ai_node.py | 12 ++++++---- src/aios_kernel/workspace_env.py | 34 +++++++++++++++++++++-------- src/requirements.txt | 6 ++--- 7 files changed, 61 insertions(+), 30 deletions(-) diff --git a/rootfs/agents/JarvisPlus/agent.toml b/rootfs/agents/JarvisPlus/agent.toml index 9797af7..19c621c 100644 --- a/rootfs/agents/JarvisPlus/agent.toml +++ b/rootfs/agents/JarvisPlus/agent.toml @@ -19,7 +19,7 @@ The types of TODO I can handle include: I receive a TODO described in json format, I will handle it according to the following rules: - Determine whether I have the ability to handle the TODO independently. If not, I will try to break the TODO down into smaller sub-TODOs, or hand it over to someone more suitable that I know. - I will plan the steps to solve the TODO in combination with known information, and break down the generalized TODO into more specific sub-todos. The title of the sub-todo should contain step number like #1, #2 -- sub-todo must set parent +- Sub-todo must set parent, The maximum depth of sub-todo is 4. - A specific sub-todo refers to a task that can be completed in one execution within my ability range. - After each execution, I will decide whether to update the status of the TODO. And use op:'update_todo' to update when necessary. diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index f44d18c..cf3ae75 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -1,3 +1,4 @@ +import traceback from typing import Optional from asyncio import Queue @@ -9,6 +10,7 @@ import json import shlex import datetime import copy +import sys from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType,FunctionItem,LLMResult,AgentPrompt,AgentReport,AgentTodo,AgentGoal,AgentTodoResult,AgentWorkLog from .chatsession import AIChatSession @@ -659,6 +661,7 @@ class AIAgent: # 尝试完成自己的TOOD (不依赖任何其他Agnet) async def do_my_work(self) -> None: workspace : WorkspaceEnvironment = self.get_workspace_by_msg(None) + logger.info(f"agent {self.agent_id} do my work start!") # review todo能更整体的思考一次todo的优先级 if await self.need_review_todos(): @@ -680,6 +683,8 @@ class AIAgent: if todo.state == "done": await self._llm_check_todo(todo,workspace) self.agent_energy -= 1 + + logger.info(f"agent {self.agent_id} do my work done!") def get_review_todo_prompt(self) -> AgentPrompt: return self.review_todo_prompt @@ -981,9 +986,6 @@ class AIAgent: return task_result - async def execute_op_list(self,oplist:list,workspace:WorkspaceEnvironment): - pass - def need_work(self) -> bool: return True @@ -1002,7 +1004,7 @@ class AIAgent: # agent loop async def _on_timer(self): while True: - await asyncio.sleep(1) + await asyncio.sleep(15) try: now = time.time() if self.last_recover_time is None: @@ -1030,8 +1032,10 @@ class AIAgent: # if self.need_self_learn(): await self.do_self_learn() + except Exception as e: - logger.error(f"agent {self.agent_id} on timer error:{e}") + tb_str = traceback.format_exc() + logger.error(f"agent {self.agent_id} on timer error:{tb_str},{e}") continue diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index 6870488..e1e2c6e 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -374,12 +374,13 @@ class AgentTodo: self.title = None self.detail = None self.todo_path = None # get parent todo,sub todo by path + #self.parent = None self.create_time = time.time() self.due_date = time.time() + 3600 * 24 * 2 self.state = "pending" self.depend_todo_ids = [] - self.sub_todos = [] + self.sub_todos = {} self.need_check = True self.result : ComputeTaskResult = None @@ -406,7 +407,7 @@ class AgentTodo: due_date = json_obj.get("due_date") if due_date: todo.due_date = datetime.fromisoformat(due_date).timestamp() - #todo.todo_path = json_obj.get("todo_path") + todo.todo_path = json_obj.get("todo_path") todo.depend_todo_ids = json_obj.get("depend_todo_ids") todo.need_check = json_obj.get("need_check") #todo.result = json_obj.get("result") @@ -414,7 +415,9 @@ class AgentTodo: todo.worker = json_obj.get("worker") todo.checker = json_obj.get("checker") todo.createor = json_obj.get("createor") - #todo.retry_count = json_obj.get("retry_count") + if json_obj.get("retry_count"): + todo.retry_count = json_obj.get("retry_count") + todo.raw_obj = json_obj return todo @@ -422,6 +425,7 @@ class AgentTodo: def to_dict(self) -> dict: result = {} result["id"] = self.todo_id + result["todo_path"] = self.todo_path #result["parent_id"] = self.parent_id result["title"] = self.title result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() @@ -434,11 +438,12 @@ class AgentTodo: result["createor"] = self.createor result["retry_count"] = self.retry_count - return result + return result def can_do(self) -> bool: - for sub_todo in self.sub_todos: - if sub_todo.state != "done": - return False + for sub_todo in self.sub_todos.values(): + if sub_todo.state: + if sub_todo.state != "done": + return False now = datetime.now().timestamp() time_diff = now - self.due_date diff --git a/src/aios_kernel/compute_kernel.py b/src/aios_kernel/compute_kernel.py index f88a835..eb09574 100644 --- a/src/aios_kernel/compute_kernel.py +++ b/src/aios_kernel/compute_kernel.py @@ -151,7 +151,9 @@ class ComputeKernel: time_out_result = ComputeTaskResult() time_out_result.result_code = ComputeTaskResultCode.TIMEOUT time_out_result.set_from_task(task_req) - ## craete timeout task_result + task_req.result = time_out_result + return time_out_result + async def do_llm_completion(self, prompt: AgentPrompt, mode_name: Optional[str] = None, max_token: int = 0, inner_functions = None) -> str: task_req = self.llm_completion(prompt, mode_name, max_token,inner_functions) diff --git a/src/aios_kernel/open_ai_node.py b/src/aios_kernel/open_ai_node.py index e529b92..206d223 100644 --- a/src/aios_kernel/open_ai_node.py +++ b/src/aios_kernel/open_ai_node.py @@ -4,11 +4,13 @@ import asyncio from asyncio import Queue import logging import json +import aiohttp from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeTaskResultCode from .compute_node import ComputeNode from .storage import AIStorage,UserConfig + logger = logging.getLogger(__name__) @@ -57,7 +59,8 @@ class OpenAI_ComputeNode(ComputeNode): async def remove_task(self, task_id: str): pass - def _run_task(self, task: ComputeTask): + + async def _run_task(self, task: ComputeTask): task.state = ComputeTaskState.RUNNING result = ComputeTaskResult() @@ -110,16 +113,17 @@ class OpenAI_ComputeNode(ComputeNode): max_token_size = 4000 result_token = max_token_size + try: if llm_inner_functions is None: logger.info(f"call openai {mode_name} prompts: {prompts}") - resp = openai.ChatCompletion.create(model=mode_name, + resp = await openai.ChatCompletion.acreate(model=mode_name, messages=prompts, #max_tokens=result_token, temperature=0.7) else: logger.info(f"call openai {mode_name} prompts: \n\t {prompts} \nfunctions: \n\t{json.dumps(llm_inner_functions)}") - resp = openai.ChatCompletion.create(model=mode_name, + resp = await openai.ChatCompletion.acreate(model=mode_name, messages=prompts, functions=llm_inner_functions, #max_tokens=result_token, @@ -171,7 +175,7 @@ class OpenAI_ComputeNode(ComputeNode): while True: task = await self.task_queue.get() logger.info(f"openai_node get task: {task.display()}") - result = self._run_task(task) + result = await self._run_task(task) if result is not None: task.state = ComputeTaskState.DONE task.result = result diff --git a/src/aios_kernel/workspace_env.py b/src/aios_kernel/workspace_env.py index a55a44b..8c67446 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios_kernel/workspace_env.py @@ -58,7 +58,10 @@ class WorkspaceEnvironment(Environment): if op["op"] == "create": return await self.create(op["path"],op["content"]) elif op["op"] == "write_file": - return await self.write(op["path"],op["content"],op["mode"]) + is_append = op.get("is_append") + if is_append is None: + is_append = False + return await self.write(op["path"],op["content"],is_append) elif op["op"] == "delete": return await self.delete(op["path"]) elif op["op"] == "rename": @@ -173,13 +176,14 @@ class WorkspaceEnvironment(Environment): continue todo_count = todo_count + 1 - str_result = str_result + f"{' '*(4-deep)}└─{entry.name}\n" + str_result = str_result + f"{' '*(4-deep)}{entry.name}\n" await scan_dir(entry.path,deep-1) await scan_dir(directory_path,deep) return str_result,todo_count async def get_todo_list(self,agent_id:str,path:str = None)->List[AgentTodo]: + logger.info("get_todo_list:%s,%s",agent_id,path) if path: directory_path = self.root_path + "/todos/" + path else: @@ -208,7 +212,7 @@ class WorkspaceEnvironment(Environment): if todo: if parent: - parent.sub_todos.append(todo) + parent.sub_todos[todo.todo_id] = todo result_list.append(todo) await scan_dir(entry.path,deep + 1,todo) @@ -218,11 +222,11 @@ class WorkspaceEnvironment(Environment): await scan_dir(directory_path,0) #sort by rank result_list.sort(key=lambda x:(x.rank,x.title)) - + logger.info("get_todo_list return,todolist.length() is %d",len(result_list)) return result_list async def get_todo_by_fullpath(self,path:str) -> AgentTodo: - logger.info("get_todo_by_fullpath:%s",path) + #logger.info("get_todo_by_fullpath:%s",path) detail_path = path + "/detail" async with aiofiles.open(detail_path, mode='r', encoding="utf-8") as f: @@ -231,7 +235,10 @@ class WorkspaceEnvironment(Environment): todo_dict = json.loads(content) result_todo = AgentTodo.from_dict(todo_dict) if result_todo: - result_todo.todo_path = path + relative_path = os.path.relpath(path, self.root_path + "/todos/") + if not relative_path.startswith('/'): + relative_path = '/' + relative_path + result_todo.todo_path = relative_path self.known_todo[result_todo.todo_id] = result_todo else: logger.error("get_todo_by_path:%s,parse failed!",path) @@ -243,16 +250,25 @@ class WorkspaceEnvironment(Environment): async def create_todo(self,parent_id:str,todo:AgentTodo) -> None: if parent_id: + if parent_id not in self.known_todo: + logger.error("create_todo failed: parent_id not found!") + return False + parent_path = self.known_todo.get(parent_id).todo_path - dir_path = f"{parent_path}/{todo.title}" + todo_path = f"{parent_path}/{todo.title}" else: - dir_path = f"{self.root_path}/todos/{todo.title}" + todo_path = todo.title + dir_path = f"{self.root_path}/todos/{todo_path}" + os.makedirs(dir_path) detail_path = f"{dir_path}/detail" + if todo.todo_path is None: + todo.todo_path = todo_path logger.info("create_todo %s",detail_path) async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: await f.write(json.dumps(todo.to_dict())) + self.known_todo[todo.todo_id] = todo return True async def update_todo(self,todo_id:str,new_stat:str)->bool: @@ -263,7 +279,7 @@ class WorkspaceEnvironment(Environment): todo.raw_obj = todo.to_dict() todo.raw_obj["status"] = new_stat - detail_path = todo.todo_path + "/detail" + detail_path = f"{self.root_path}/todos/{todo.todo_path}/detail" async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: await f.write(json.dumps(todo.raw_obj)) return True diff --git a/src/requirements.txt b/src/requirements.txt index b83e564..0c452e5 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -19,7 +19,7 @@ click>=8.1.7 colorama>=0.4.6 coloredlogs>=15.0.1 decorator>=4.4.2 -fastapi>=0.99.1 +fastapi filelock>=3.12.3 flatbuffers>=23.5.26 frozenlist>=1.4.0 @@ -59,7 +59,7 @@ proto-plus>=1.22.3 pulsar-client>=3.3.0 pyasn1>=0.5.0 pyasn1-modules>=0.3.0 -pydantic>=1.10.12 +pydantic PyPika>=0.48.9 pyreadline3>=3.4.1 python-dateutil>=2.8.2 @@ -137,4 +137,4 @@ python-telegram-bot pydub stability_sdk sentence-transformers==2.2.2 -tiktoken \ No newline at end of file +tiktoken From de685da38b6d857d8923f1af8f3cbe7307d25285 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Wed, 8 Nov 2023 22:13:18 -0800 Subject: [PATCH 14/79] Refactor the tunnel-related code to make the logic of the Agent sending messages to the Human more reasonable. --- rootfs/agents/JarvisPlus/agent.toml | 10 +- rootfs/agents/Thinker/agent.toml | 4 +- rootfs/agents/Tracy/agent.toml | 1 - src/aios_kernel/__init__.py | 2 +- src/aios_kernel/agent.py | 199 +++++++++++++++++------ src/aios_kernel/agent_base.py | 147 +++++++++++++---- src/aios_kernel/bus.py | 3 + src/aios_kernel/chatsession.py | 33 +++- src/aios_kernel/contact.py | 84 ++++++++++ src/aios_kernel/contact_manager.py | 49 +----- src/aios_kernel/email_tunnel.py | 37 ++++- src/aios_kernel/tg_tunnel.py | 52 ++++++- src/aios_kernel/tunnel.py | 11 ++ src/aios_kernel/workspace_env.py | 225 ++++++++++++++++----------- src/service/aios_shell/aios_shell.py | 27 +++- 15 files changed, 652 insertions(+), 232 deletions(-) create mode 100644 src/aios_kernel/contact.py diff --git a/rootfs/agents/JarvisPlus/agent.toml b/rootfs/agents/JarvisPlus/agent.toml index 19c621c..dc249a2 100644 --- a/rootfs/agents/JarvisPlus/agent.toml +++ b/rootfs/agents/JarvisPlus/agent.toml @@ -13,7 +13,7 @@ My name is JarvisPlus, I am the master's super personal assistant. I think hard The types of TODO I can handle include: - Scheduling, where I will try to contact the relevant personnel of the plan and confirm the details of the schedule with them. - Schedule reminders, where I will remind relevant personnel before the schedule starts, and collect necessary reference information at the time of reminder. -- Using the post_msg function to contact relevant personnel. +- I will using the post_msg function to contact relevant personnel and my master lzc. - Writing documents/letters, using op:'create' to save my work results. I receive a TODO described in json format, I will handle it according to the following rules: @@ -25,7 +25,7 @@ I receive a TODO described in json format, I will handle it according to the fol The result of my planned execution must be directly parsed by `python json.loads`. Here is an example: { - resp: 'My Plan is ...', + resp: '$what_did_I_do', post_msg : [ { target:'$target_name', @@ -46,12 +46,12 @@ The result of my planned execution must be directly parsed by `python json.loads { op: 'update_todo', id: '$todo_id', - state: 'doing' # pending,doing,done,cancel,failed,expired + state: 'cancel' # pending,cancel }, { op: 'write_file', - path: '/todos/$todo_path/.result/doc_name', - content:'doc content' + path: '/todos/$todo_path/.result/$doc_name', + content:'$doc_content' } ] } diff --git a/rootfs/agents/Thinker/agent.toml b/rootfs/agents/Thinker/agent.toml index b4016d5..05332f6 100644 --- a/rootfs/agents/Thinker/agent.toml +++ b/rootfs/agents/Thinker/agent.toml @@ -1,7 +1,7 @@ instance_id = "Thinker" fullname = "Thinker" -llm_model_name = "gpt-3.5-turbo-16k-0613" -max_token_size = 14000 +llm_model_name = "Llama-2-13b-chat" +max_token_size = 2000 #enable_function =["add_event"] #enable_kb = "true" diff --git a/rootfs/agents/Tracy/agent.toml b/rootfs/agents/Tracy/agent.toml index dfd3cd9..8d44476 100644 --- a/rootfs/agents/Tracy/agent.toml +++ b/rootfs/agents/Tracy/agent.toml @@ -1,6 +1,5 @@ instance_id = "Tracy" fullname = "Tracy" - [[prompt]] role = "system" content = """ diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index 89797fc..2b1b8ef 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -24,5 +24,5 @@ from .stability_node import Stability_ComputeNode from .local_st_compute_node import LocalSentenceTransformer_Text_ComputeNode,LocalSentenceTransformer_Image_ComputeNode from .compute_node_config import ComputeNodeConfig from .ai_function import SimpleAIFunction -AIOS_Version = "0.5.1, build 2023-9-28" +AIOS_Version = "0.5.2, build 2023-11-1" diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index cf3ae75..9e2ca15 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -12,7 +12,7 @@ import datetime import copy import sys -from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType,FunctionItem,LLMResult,AgentPrompt,AgentReport,AgentTodo,AgentGoal,AgentTodoResult,AgentWorkLog +from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType,FunctionItem,LLMResult,AgentPrompt,AgentReport,AgentTodo,AgentTodoResult,AgentWorkLog from .chatsession import AIChatSession from .compute_task import ComputeTaskResult,ComputeTaskResultCode from .ai_function import AIFunction @@ -100,6 +100,8 @@ class AIAgent: self.agent_energy = 15 self.agent_task = None self.last_recover_time = time.time() + self.enable_thread = False + self.can_do_unassigned_task = True self.agent_id:str = None @@ -158,6 +160,9 @@ class AIAgent: return False self.fullname = config["fullname"] + if config.get("enable_thread") is not None: + self.enable_thread = bool(config["enable_thread"]) + if config.get("prompt") is not None: self.agent_prompt = AgentPrompt() self.agent_prompt.load_from_config(config["prompt"]) @@ -304,7 +309,7 @@ class AIAgent: prompt.messages.append({"role":"function","content":result_str,"name":func_name}) task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"llm compute error:{task_result.error_str}") + logger.error(f"_execute_func llm compute error:{task_result.error_str}") return task_result ineternal_call_record.result_str = task_result.result_str @@ -425,6 +430,9 @@ class AIAgent: def need_session_summmary(self,msg:AgentMsg,session:AIChatSession) -> bool: return False + + async def _create_openai_thread(self) -> str: + return None async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: msg_prompt = AgentPrompt() @@ -447,6 +455,19 @@ class AIAgent: msg_prompt.messages = [{"role":"user","content":msg.body}] session_topic = msg.get_sender() + "#" + msg.topic chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) + if self.enable_thread: + need_create_thread = False + if chatsession.openai_thread_id is not None: + if len(chatsession.openai_thread_id) < 1: + need_create_thread = True + else: + need_create_thread = True + + if need_create_thread: + openai_thread_id = await self._create_openai_thread() + if openai_thread_id is not None: + chatsession.update_openai_thread_id(openai_thread_id) + workspace = self.get_workspace_by_msg(msg) @@ -544,6 +565,7 @@ class AIAgent: async def _get_history_prompt_for_think(self,chatsession:AIChatSession,summary:str,system_token_len:int,pos:int)->(AgentPrompt,int): + history_len = (self.max_token_size * 0.7) - system_token_len messages = chatsession.read_history(self.history_len,pos,"natural") # read @@ -663,43 +685,65 @@ class AIAgent: workspace : WorkspaceEnvironment = self.get_workspace_by_msg(None) logger.info(f"agent {self.agent_id} do my work start!") - # review todo能更整体的思考一次todo的优先级 - if await self.need_review_todos(): - await self._llm_review_todos(workspace) + # review todolist + #if await self.need_review_todolist(): + # await self._llm_review_todolist(workspace) todo_list = await workspace.get_todo_list(self.agent_id) + check_count = 0 + do_count = 0 for todo in todo_list: if self.agent_energy <= 0: break - - if await self.can_do(todo,workspace) is False: - continue - if todo.retry_count < 2: - need_think_todo_from_goal = False - await self._llm_do(todo,workspace) + if await self.need_review_todo(todo,workspace): + review_result = await self._llm_review_todo(todo,workspace) + todo.last_review_time = datetime.datetime.now().timestamp() + + elif await self.can_check(todo,workspace): + check_result : AgentTodoResult = await self._llm_check_todo(todo,workspace) + todo.last_check_time = datetime.datetime.now().timestamp() + + match check_result.result_code: + case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: + continue + case AgentTodoResult.TODO_RESULT_CODE_OK: + await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_DONE) + case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: + await workspace.update_todo(todo.todo_id,AgentTodo.TDDO_STATE_CHECKFAILED) + + await workspace.append_worklog(todo,check_result) self.agent_energy -= 1 - if todo.state == "done": - await self._llm_check_todo(todo,workspace) - self.agent_energy -= 1 - - logger.info(f"agent {self.agent_id} do my work done!") + check_count += 1 + elif await self.can_do(todo,workspace): + do_result : AgentTodoResult = await self._llm_do(todo,workspace) + todo.last_do_time = datetime.datetime.now().timestamp() + todo.retry_count += 1 - def get_review_todo_prompt(self) -> AgentPrompt: + match do_result.result_code: + case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: + continue + case AgentTodoResult.TODO_RESULT_CODE_OK: + await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_WAITING_CHECK) + case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: + await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_EXEC_FAILED) + + await workspace.append_worklog(todo,do_result) + self.agent_energy -= 2 + do_count += 1 + + logger.info(f"agent {self.agent_id} ,check:{check_count} todo,do:{do_count} todo.") + + def get_review_todo_prompt(self,todo:AgentTodo) -> AgentPrompt: return self.review_todo_prompt - async def need_review_todos(self) -> bool: - if self.get_review_todo_prompt() is None: - return False - return True - - async def _llm_review_todos(self,workspace:WorkspaceEnvironment): + async def _llm_review_todo(self,todo:AgentTodo,workspace:WorkspaceEnvironment): prompt = AgentPrompt() prompt.append(workspace.get_prompt()) prompt.append(workspace.get_role_prompt(self.agent_id)) - prompt.append(self.get_review_todo_prompt()) + prompt.append(self.get_review_todo_prompt(todo)) todo_tree = workspace.get_todo_tree("/") prompt.append(AgentPrompt(todo_tree)) @@ -712,49 +756,105 @@ class AIAgent: return - def get_do_prompt(self,todo_type:str=None) -> AgentPrompt: + def get_do_prompt(self,todo:AgentTodo) -> AgentPrompt: return self.do_prompt def get_prompt_from_todo(self,todo:AgentTodo) -> AgentPrompt: json_str = json.dumps(todo.raw_obj) return AgentPrompt(json_str) + + async def need_review_todo(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: + return False + + async def can_check(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: + if self.get_check_prompt(todo) is None: + return False + + if todo.can_check() is False: + return False + + if todo.checker is not None: + if todo.checker != self.agent_id: + return False + else: + if self.can_do_unassigned_task is False: + return False + else: + todo.checker = self.agent_id + + return True async def can_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: - return todo.can_do() + if todo.can_do() is False: + return False + + if todo.worker is not None: + if todo.worker != self.agent_id: + return False + else: + if self.can_do_unassigned_task is False: + return False + else: + todo.worker = self.agent_id + + return True async def _llm_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> AgentTodoResult: + result = AgentTodoResult() prompt : AgentPrompt = AgentPrompt() #prompt.append(self.agent_prompt) prompt.append(workspace.get_role_prompt(self.agent_id)) - do_prompt = workspace.get_do_prompt() + do_prompt = workspace.get_do_prompt(todo) if do_prompt is None: - do_prompt = self.get_do_prompt() + do_prompt = self.get_do_prompt(todo) prompt.append(do_prompt) - # 有通用的todo执行方法,也有定制的,针对特定类型TODO更高效的执行方法 - # 根据经验,Agent可以自主掌握/整理更多类型的TODO的执行方法 + # There are general methods for executing todos, as well as customized ones that are more efficient for specific types of TODOS. + # Based on experience, an Agent can autonomously master/organize execution methods for a greater variety of TODO types. - #prompt.append(do_log_prompt) + #prompt.append(work_log_prompt) prompt.append(self.get_prompt_from_todo(todo)) task_result:ComputeTaskResult = await self._do_llm_complection(prompt) if task_result.error_str is not None: logger.error(f"_llm_do compute error:{task_result.error_str}") + result.result_code = AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR + result.error_str = task_result.error_str + return result + llm_result = LLMResult.from_str(task_result.result_str) + # result_str is the explain of how to do this todo + result.result_str = llm_result.resp + result.op_list = llm_result.op_list + if llm_result.post_msgs is not None: + for msg in llm_result.post_msgs: + msg.sender = self.agent_id + msg.topic = f"{todo.title}##{todo.todo_id}" + #msg.prev_msg_id = todo.todo_id + chatsession = AIChatSession.get_session(self.agent_id,f"{msg.target}#{msg.topic}",self.chat_db) + chatsession.append(msg) + resp = await AIBus.get_default_bus().post_message(msg) + logging.info(f"agent {self.agent_id} send msg to {msg.target} result:{resp}") - await workspace.exec_op_list(llm_result.op_list,self.agent_id) - await workspace.append_do_result(self.agent_id,llm_result) - - return task_result + op_errors,have_error = await workspace.exec_op_list(llm_result.op_list,self.agent_id) + if have_error: + result.result_code = AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR + #result.error_str = error_str + return result + + return result - def get_check_prompt(self) -> AgentPrompt: + async def append_toddo_result(self,todo,worksapce,llm_result,result_str): + pass + + def get_check_prompt(self,todo:AgentTodo) -> AgentPrompt: return self.check_prompt - async def _llm_check_todo(self, todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: + async def _llm_check_todo(self, todo:AgentTodo,workspace:WorkspaceEnvironment) : if self.get_check_prompt(todo) is None: - return True + return None prompt : AgentPrompt = AgentPrompt() prompt.append(self.agent_prompt) @@ -920,7 +1020,7 @@ class AIAgent: #3) llm summarize chat history task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,None) if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"llm compute error:{task_result.error_str}") + logger.error(f"think_chatsession llm compute error:{task_result.error_str}") break else: new_summary= task_result.result_str @@ -930,6 +1030,8 @@ class AIAgent: async def get_prompt_from_session(self,chatsession:AIChatSession,system_token_len,input_token_len) -> AgentPrompt: # TODO: get prompt from group chat is different from single chat + if self.enable_thread: + return None history_len = (self.max_token_size * 0.7) - system_token_len - input_token_len messages = chatsession.read_history(self.history_len) # read @@ -971,7 +1073,7 @@ class AIAgent: #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"llm compute error:{task_result.error_str}") + logger.error(f"_do_llm_complection llm compute error:{task_result.error_str}") #error_resp = msg.create_error_resp(task_result.error_str) return task_result @@ -987,7 +1089,15 @@ class AIAgent: return task_result def need_work(self) -> bool: - return True + if self.do_prompt is not None: + return True + if self.check_prompt is not None: + return True + + if self.agent_energy > 2: + return True + + return False def need_self_think(self) -> bool: return False @@ -1014,28 +1124,25 @@ class AIAgent: self.agent_energy += (now - self.last_recover_time) / 60 self.last_recover_time = now - if self.agent_energy <= 1: continue - # complete todo + # complete & check todo if self.need_work(): await self.do_my_work() # review other's todo # self.review_other_works() - # do work summary if self.need_self_think(): await self.do_self_think() - # if self.need_self_learn(): await self.do_self_learn() except Exception as e: tb_str = traceback.format_exc() - logger.error(f"agent {self.agent_id} on timer error:{tb_str},{e}") + logger.error(f"agent {self.agent_id} on timer error:{e},{tb_str}") continue diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index e1e2c6e..43575d2 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -258,8 +258,16 @@ class LLMResult: r.state = llm_json.get("state") r.resp = llm_json.get("resp") - r.post_msgs = llm_json.get("post_msgs") - r.send_msgs = llm_json.get("send_msgs") + post_msgs = llm_json.get("post_msg") + r.post_msgs = [] + if post_msgs: + for msg in post_msgs: + new_msg = AgentMsg() + target_id = msg.get("target") + msg_content = msg.get("content") + new_msg.set("",target_id,msg_content) + r.post_msgs.append(new_msg) + #new_msg.msg_type = AgentMsgType.TYPE_MSG r.calls = llm_json.get("calls") r.post_calls = llm_json.get("post_calls") @@ -352,23 +360,44 @@ class LLMResult: return r -class AgentGoal: - def __init__(self) -> None: - self.description = None - - class AgentReport: def __init__(self): pass class AgentTodoResult: + TODO_RESULT_CODE_OK = 0, + TODO_RESULT_CODE_LLM_ERROR = 1, + TODO_RESULT_CODE_EXEC_OP_ERROR = 2 + + def __init__(self) -> None: - self.result_state = "error" + self.result_code = AgentTodoResult.TODO_RESULT_CODE_OK + self.result_str = None + self.error_str = None + self.op_list = None + + def to_dict(self) -> dict: + result = {} + result["result_code"] = self.result_code + result["result_str"] = self.result_str + result["error_str"] = self.error_str + result["op_list"] = self.op_list + return result class AgentTodo: + TODO_STATE_WAIT_ASSIGN = "wait_assign" + TODO_STATE_INIT = "init" + TODO_STATE_PENDING = "pending" + TODO_STATE_WAITING_CHECK = "wait_check" + TODO_STATE_EXEC_FAILED = "exec_failed" + TDDO_STATE_CHECKFAILED = "check_failed" + TODO_STATE_CASNCEL = "cancel" + TODO_STATE_DONE = "done" + TODO_STATE_EXPIRED = "expired" + def __init__(self): self.todo_id = "todo#" + uuid.uuid4().hex self.title = None @@ -376,20 +405,23 @@ class AgentTodo: self.todo_path = None # get parent todo,sub todo by path #self.parent = None self.create_time = time.time() - self.due_date = time.time() + 3600 * 24 * 2 - self.state = "pending" - - self.depend_todo_ids = [] - self.sub_todos = {} - - self.need_check = True - self.result : ComputeTaskResult = None - self.last_check_result = None + self.state = "wait_assign" self.worker = None self.checker = None self.createor = None + self.need_check = True + self.due_date = time.time() + 3600 * 24 * 2 + self.last_do_time = None + self.last_check_time = None + self.last_review_time = None + + self.depend_todo_ids = [] + self.sub_todos = {} + + self.result : AgentTodoResult = None + self.last_check_result = None self.retry_count = 0 self.raw_obj = None @@ -400,14 +432,26 @@ class AgentTodo: todo.todo_id = json_obj.get("id") todo.title = json_obj.get("title") + todo.state = json_obj.get("state") create_time = json_obj.get("create_time") if create_time: todo.create_time = datetime.fromisoformat(create_time).timestamp() + todo.detail = json_obj.get("detail") due_date = json_obj.get("due_date") if due_date: todo.due_date = datetime.fromisoformat(due_date).timestamp() - todo.todo_path = json_obj.get("todo_path") + + last_do_time = json_obj.get("last_do_time") + if last_do_time: + todo.last_do_time = datetime.fromisoformat(last_do_time).timestamp() + last_check_time = json_obj.get("last_check_time") + if last_check_time: + todo.last_check_time = datetime.fromisoformat(last_check_time).timestamp() + last_review_time = json_obj.get("last_review_time") + if last_review_time: + todo.last_review_time = datetime.fromisoformat(last_review_time).timestamp() + todo.depend_todo_ids = json_obj.get("depend_todo_ids") todo.need_check = json_obj.get("need_check") #todo.result = json_obj.get("result") @@ -423,14 +467,21 @@ class AgentTodo: return todo def to_dict(self) -> dict: - result = {} + if self.raw_obj: + result = self.raw_obj + else: + result = {} + result["id"] = self.todo_id - result["todo_path"] = self.todo_path #result["parent_id"] = self.parent_id result["title"] = self.title + result["state"] = self.state result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() result["detail"] = self.detail result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() + result["last_do_time"] = datetime.fromtimestamp(self.last_do_time).isoformat() if self.last_do_time else None + result["last_check_time"] = datetime.fromtimestamp(self.last_check_time).isoformat() if self.last_check_time else None + result["last_review_time"] = datetime.fromtimestamp(self.last_review_time).isoformat() if self.last_review_time else None result["depend_todo_ids"] = self.depend_todo_ids result["need_check"] = self.need_check result["worker"] = self.worker @@ -439,19 +490,57 @@ class AgentTodo: result["retry_count"] = self.retry_count return result - def can_do(self) -> bool: - for sub_todo in self.sub_todos.values(): - if sub_todo.state: - if sub_todo.state != "done": - return False + + def can_check(self)->bool: + if self.state != AgentTodo.TODO_STATE_WAITING_CHECK: + return False now = datetime.now().timestamp() - time_diff = now - self.due_date - - if time_diff > 7*24*3600: - return False + if self.last_check_time: + time_diff = now - self.last_check_time + if time_diff < 60*15: + logger.info(f"todo {self.title} is already checked, ignore") + return False + return True + def can_do(self) -> bool: + match self.state: + case AgentTodo.TODO_STATE_DONE: + logger.info(f"todo {self.title} is done, ignore") + return False + case AgentTodo.TODO_STATE_CASNCEL: + logger.info(f"todo {self.title} is cancel, ignore") + return False + case AgentTodo.TODO_STATE_EXPIRED: + logger.info(f"todo {self.title} is expired, ignore") + return False + case AgentTodo.TODO_STATE_EXEC_FAILED: + if self.retry_count > 3: + logger.info(f"todo {self.title} retry count ({self.retry_count}) is too many, ignore") + return False + + now = datetime.now().timestamp() + time_diff = self.due_date - now + if time_diff < 0: + logger.info(f"todo {self.title} is expired, ignore") + self.state = AgentTodo.TODO_STATE_EXPIRED + return False + + if time_diff > 7*24*3600: + logger.info(f"todo {self.title} is far before due date, ignore") + return False + + if self.last_do_time: + time_diff = now - self.last_do_time + if time_diff < 60*15: + logger.info(f"todo {self.title} is already do ignore") + return False + + logger.info(f"todo {self.title} can do.") + return True + + class AgentWorkLog: def __init__(self) -> None: pass diff --git a/src/aios_kernel/bus.py b/src/aios_kernel/bus.py index ecbb676..94f789e 100644 --- a/src/aios_kernel/bus.py +++ b/src/aios_kernel/bus.py @@ -109,6 +109,9 @@ class AIBus: # means sub def register_message_handler(self,handler_name:str,handler:Any) -> Queue: handler_node = AIBusHandler(handler,self) + if self.handlers.get(handler_name) is not None: + logger.warn(f"handler {handler_name} already register on AI_BUS!") + self.handlers[handler_name] = handler_node return handler_node.queue diff --git a/src/aios_kernel/chatsession.py b/src/aios_kernel/chatsession.py index e5edcfc..9d704c8 100644 --- a/src/aios_kernel/chatsession.py +++ b/src/aios_kernel/chatsession.py @@ -52,7 +52,8 @@ class ChatSessionDB: SessionTopic TEXT, StartTime TEXT, SummarizePos INTEGER, - Summary TEXT + Summary TEXT, + ThreadID TEXT ); """) @@ -89,14 +90,14 @@ class ChatSessionDB: except Error as e: logging.error("Error occurred while creating tables: %s", e) - def insert_chatsession(self, session_id, session_owner,session_topic, start_time): + def insert_chatsession(self, session_id, session_owner,session_topic, start_time,thread_id = ""): """ insert a new session into the ChatSessions table """ try: conn = self._get_conn() conn.execute(""" - INSERT INTO ChatSessions (SessionID, SessionOwner,SessionTopic, StartTime,SummarizePos,Summary) - VALUES (?,?, ?, ?,0,"") - """, (session_id, session_owner,session_topic, start_time)) + INSERT INTO ChatSessions (SessionID, SessionOwner,SessionTopic, StartTime,SummarizePos,Summary,ThreadID) + VALUES (?,?, ?, ?,0,"",?) + """, (session_id, session_owner,session_topic, start_time,thread_id)) conn.commit() return 0 # return 0 if successful except Error as e: @@ -253,6 +254,21 @@ class ChatSessionDB: except Error as e: logging.error("Error occurred while updating session summary: %s", e) return -1 + + def update_session_thread_id(self, session_id, thread_id): + """ update the threadid of a session """ + try: + conn = self._get_conn() + conn.execute(""" + UPDATE ChatSessions + SET ThreadID = ? + WHERE SessionID = ? + """, (thread_id, session_id)) + conn.commit() + return 0 # return 0 if successful + except Error as e: + logging.error("Error occurred while updating session threadid: %s", e) + return -1 # chat session store the chat history between owner and agent # chat session might be large, so can read / write at stream mode. @@ -286,6 +302,7 @@ class AIChatSession: result.topic = session_topic result.summarize_pos = session[4] result.summary = session[5] + result.openai_thread_id = session[6] return result @@ -305,6 +322,7 @@ class AIChatSession: result.topic = session[2] result.summarize_pos = session[4] result.summary = session[5] + result.openai_thread_id = session[6] return result @@ -331,6 +349,7 @@ class AIChatSession: self.start_time : str = None self.summarize_pos : int = 0 self.summary = None + self.openai_thread_id = None def get_owner_id(self) -> str: return self.owner_id @@ -376,6 +395,10 @@ class AIChatSession: self.summarize_pos = progress self.summary = new_summary + def update_openai_thread_id(self,thread_id:str) -> None: + self.db.update_session_thread_id(self.session_id,thread_id) + self.openai_thread_id = thread_id + #def attach_event_handler(self,handler) -> None: # """chat session changed event handler""" # pass diff --git a/src/aios_kernel/contact.py b/src/aios_kernel/contact.py new file mode 100644 index 0000000..ba80df5 --- /dev/null +++ b/src/aios_kernel/contact.py @@ -0,0 +1,84 @@ +from typing import List +import logging +from datetime import datetime + +from .agent_base import AgentMsg +from .tunnel import AgentTunnel + + +logger = logging.getLogger(__name__) +class Contact: + def __init__(self, name, phone=None, email=None, telegram=None,added_by=None, tags=[], notes=""): + self.name = name + self.phone = phone + self.email = email + self.telegram = telegram + self.added_by = added_by + self.tags = tags + self.notes = notes + self.is_family_member = False + self.active_tunnels = {} + + def to_dict(self): + return { + "name": self.name, + "phone": self.phone, + "email": self.email, + "telegram" : self.telegram, + + "added_by": self.added_by, + "tags": self.tags, + "notes": self.notes, + "now" : datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + + async def _process_msg(self,msg:AgentMsg): + tunnel : AgentTunnel = self.get_active_tunnel(msg.sender) + if tunnel is not None: + await tunnel.post_message(msg) + return None + else: + tunnel = await self.create_default_tunnel(msg.sender) + if tunnel is not None: + self.active_tunnels[msg.sender] = tunnel + await tunnel.post_message(msg) + return None + + logger.warn(f"contact {self.name} cann't get tunnel,post message failed!") + + def get_active_tunnel(self,agent_id) -> AgentTunnel: + tunnel = self.active_tunnels.get(agent_id) + return tunnel + + def set_active_tunnel(self,agent_id,tunnel:AgentTunnel): + self.active_tunnels[agent_id] = tunnel + + async def create_default_tunnel(self,agent_id:str) -> AgentTunnel: + from .email_tunnel import EmailTunnel + + result_tunnels = AgentTunnel.get_tunnel_by_agentid(agent_id) + for tunnel in result_tunnels: + if isinstance(tunnel,EmailTunnel): + return tunnel + + return None + + @classmethod + def from_dict(cls, data): + return Contact(data.get("name"), data.get("phone"), data.get("email"), data.get("telegram"),data.get("added_by"), data.get("tags"), data.get("notes")) + +class FamilyMember(Contact): + def __init__(self, name, relationship,phone=None, email=None,telegram=None): + super().__init__(name, phone, email, telegram) + self.name = name + self.relationship = relationship + self.is_family_member = True + + def to_dict(self): + result = super().to_dict() + result["relationship"] = self.relationship + return result + + @classmethod + def from_dict(cls, data): + return FamilyMember(data.get("name"),data.get("relationship"),data.get("phone"), data.get("email"),data.get("telegram")) diff --git a/src/aios_kernel/contact_manager.py b/src/aios_kernel/contact_manager.py index 5cd1d6c..daeced6 100644 --- a/src/aios_kernel/contact_manager.py +++ b/src/aios_kernel/contact_manager.py @@ -2,49 +2,13 @@ from typing import List import toml import time from datetime import datetime -class Contact: - def __init__(self, name, phone=None, email=None, telegram=None,added_by=None, tags=[], notes=""): - self.name = name - self.phone = phone - self.email = email - self.telegram = telegram - self.added_by = added_by - self.tags = tags - self.notes = notes - self.is_family_member = False +from .agent_base import AgentMsg +from .tunnel import AgentTunnel +from .contact import Contact,FamilyMember +import logging - def to_dict(self): - return { - "name": self.name, - "phone": self.phone, - "email": self.email, - "telegram" : self.telegram, +logger = logging.getLogger(__name__) - "added_by": self.added_by, - "tags": self.tags, - "notes": self.notes, - "now" : datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - @classmethod - def from_dict(cls, data): - return Contact(data.get("name"), data.get("phone"), data.get("email"), data.get("telegram"),data.get("added_by"), data.get("tags"), data.get("notes")) - -class FamilyMember(Contact): - def __init__(self, name, relationship,phone=None, email=None,telegram=None): - super().__init__(name, phone, email, telegram) - self.name = name - self.relationship = relationship - self.is_family_member = True - - def to_dict(self): - result = super().to_dict() - result["relationship"] = self.relationship - return result - - @classmethod - def from_dict(cls, data): - return FamilyMember(data.get("name"),data.get("relationship"),data.get("phone"), data.get("email"),data.get("telegram")) class ContactManager: _instance = None @@ -154,6 +118,9 @@ class ContactManager: def list_family_members(self): return self.family_members + #def register_to_ai_bus(self, ai_bus:AIBus): + # ai_bus.register_message_handler("contact_manager", self.process_msg) + #async def process_msg(self,msg:AgentMsg): # # forword message to contact diff --git a/src/aios_kernel/email_tunnel.py b/src/aios_kernel/email_tunnel.py index 0267ba3..c0be9d2 100644 --- a/src/aios_kernel/email_tunnel.py +++ b/src/aios_kernel/email_tunnel.py @@ -9,6 +9,7 @@ import time import datetime from .tunnel import AgentTunnel from .agent_base import AgentMsg +from .contact_manager import ContactManager from email.message import EmailMessage @@ -30,7 +31,7 @@ class EmailTunnel(AgentTunnel): self.target_id = config["target"] self.tunnel_id = config["tunnel_id"] - self.type = "TelegramTunnel" + self.type = "EmailTunnel" self.email = config["email"] self.imap_server = config["imap"] s = self.imap_server.split(":") @@ -46,8 +47,10 @@ class EmailTunnel(AgentTunnel): self.login_user = config["user"] self.login_password = config["password"] - self.folder = config["folder"] - self.check_interval = config["interval"] + if config.get("folder") is not None: + self.folder = config["folder"] + if config.get("interval") is not None: + self.check_interval = config["interval"] return True @@ -55,6 +58,8 @@ class EmailTunnel(AgentTunnel): super().__init__() self.is_start = False self.read_email = {} + self.folder = "INBOX" + self.check_interval = 60 async def on_new_email(self,mail:mailparser.MailParser) -> None: remote_email_addr = mail.from_[0][1] @@ -86,7 +91,32 @@ class EmailTunnel(AgentTunnel): password=self.login_password, ) + async def post_message(self, msg: AgentMsg) -> None: + cm = ContactManager.get_instance() + contact = cm.find_contact_by_name(msg.target) + if contact is None: + logger.error(f"can't find contact {msg.target} , post message through email_tunnel failed!") + return + + target_email = contact.email + if target_email is None: + logger.error(f"contact {msg.target} has no email, post message through email_tunnel failed!") + return + + email_msg = EmailMessage() + email_msg['Subject'] = f"{msg.topic},From AIAgent {msg.sender}" + email_msg['From'] = self.email + email_msg['To'] = target_email + email_msg.set_content(msg) + await aiosmtplib.send( + email_msg, + hostname = self.smtp_server, + port=self.smtp_port, + username=self.login_user, + password=self.login_password, + ) + def conver_mail_to_agent_msg(self,mail:mailparser.MailParser) -> AgentMsg: msg = AgentMsg() @@ -141,4 +171,5 @@ class EmailTunnel(AgentTunnel): async def _process_message(self, msg: AgentMsg) -> None: logger.warn(f"process message {msg.msg_id} from {msg.sender} to {msg.target}") + diff --git a/src/aios_kernel/tg_tunnel.py b/src/aios_kernel/tg_tunnel.py index 97289e9..f454d41 100644 --- a/src/aios_kernel/tg_tunnel.py +++ b/src/aios_kernel/tg_tunnel.py @@ -21,7 +21,8 @@ from .agent_base import AgentMsg,AgentMsgType logger = logging.getLogger(__name__) class TelegramTunnel(AgentTunnel): - + all_bots = {} + default_chatid = {} @classmethod def register_to_loader(cls): async def load_tg_tunnel(config:dict) -> AgentTunnel: @@ -55,6 +56,7 @@ class TelegramTunnel(AgentTunnel): self.update_queue = None self.allow_group = "contact" self.in_process_tg_msg = {} + self.chatid_record = {} async def _do_process_raw_message(self,bot: Bot, update_id: int) -> int: # Request updates after the last update_id @@ -81,6 +83,7 @@ class TelegramTunnel(AgentTunnel): self.update_queue = asyncio.Queue() self.bot_updater = Updater(self.bot,update_queue=self.update_queue) + TelegramTunnel.all_bots[self.target_id] = self.bot async def _run_app(): try: @@ -110,8 +113,41 @@ class TelegramTunnel(AgentTunnel): async def close(self) -> None: pass - async def _process_message(self, msg: AgentMsg) -> None: - logger.warn(f"process message {msg.msg_id} from {msg.sender} to {msg.target}") + async def _process_message(self, msg: AgentMsg) -> bool: + logger.warn(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target}") + + # async def _process_message(self, msg: AgentMsg) -> bool: + # logger.info(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target}") + # cm = ContactManager.get_instance() + # contact = cm.find_contact_by_name(msg.target) + # bot = TelegramTunnel.all_bots.get(msg.sender) + # chatid_index = f"{self.target_id}#{msg.target}" + # chatid = TelegramTunnel.default_chatid.get(chatid_index) + # if chatid is None: + # logger.warning(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target} failed! chatid not found!") + # return None + + # if bot is None: + # logger.warning(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target} failed! bot not found!") + # return None + + # if contact: + # if contact.telegram: + # await bot.send_message(chat_id=chatid,text=msg.body) + # logging.info(f"tg_tunnel send message {msg.msg_id} from agent {msg.sender} to human {msg.target} @ chatid:{chatid}success!") + # return None + + # logger.warning(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target} failed! contact not found!") + # return None + + async def post_message(self, msg: AgentMsg) -> None: + chatid = self.chatid_record.get(msg.target) + if chatid: + # TODO: support image and audio + await self.bot.send_message(chat_id=chatid,text=msg.body) + logging.info(f"tg_tunnel send message {msg.msg_id} from agent {msg.sender} to human {msg.target} @ chatid:{chatid}success!") + else: + logger.warning(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target} failed! chatid not found!") async def conver_tg_msg_to_agent_msg(self,message:Message) -> AgentMsg: @@ -189,6 +225,8 @@ class TelegramTunnel(AgentTunnel): if contact is not None: reomte_user_name = contact.name + + #TelegramTunnel.default_chatid[f"{self.target_id}#{reomte_user_name}"] = update.effective_chat.id if not contact.is_family_member: if self.allow_group != "contact" and self.allow_group !="guest": await update.message.reply_text(f"You're not supposed to talk to me! Please contact my father~") @@ -209,7 +247,11 @@ class TelegramTunnel(AgentTunnel): contact.added_by = self.target_id cm.add_contact(contact.name, contact) reomte_user_name = contact.name - + + if contact is not None: + contact.set_active_tunnel(self.target_id,self) + self.chatid_record[reomte_user_name] = update.effective_chat.id + self.ai_bus.register_message_handler(reomte_user_name,contact._process_msg) agent_msg.sender = reomte_user_name logger.info(f"process message {agent_msg.msg_id} from {agent_msg.sender} to {agent_msg.target}") @@ -217,7 +259,7 @@ class TelegramTunnel(AgentTunnel): self.ai_bus.register_message_handler(agent_msg.target, self._process_message) resp_msg = await self.ai_bus.send_message(agent_msg,self.target_id,agent_msg.target) else: - self.ai_bus.register_message_handler(reomte_user_name, self._process_message) + #self.ai_bus.register_message_handler(reomte_user_name, self._process_message) resp_msg = await self.ai_bus.send_message(agent_msg) #await bot.send_chat_action(chat_id=update.effective_chat.id, action="typing") diff --git a/src/aios_kernel/tunnel.py b/src/aios_kernel/tunnel.py index ed5f760..2300c2c 100644 --- a/src/aios_kernel/tunnel.py +++ b/src/aios_kernel/tunnel.py @@ -54,6 +54,14 @@ class AgentTunnel(ABC): return False + @classmethod + async def get_tunnel_by_agentid(cls,agent_id:str): + result = [] + for tunnel in cls._all_tunnels.values(): + if tunnel.target_id == agent_id: + result.append(tunnel) + return result + def __init__(self) -> None: super().__init__() @@ -75,6 +83,9 @@ class AgentTunnel(ABC): self.ai_bus = ai_bus self.is_connected = True + @abstractmethod + def post_message(self, msg: AgentMsg) -> None: + pass @abstractmethod diff --git a/src/aios_kernel/workspace_env.py b/src/aios_kernel/workspace_env.py index 8c67446..7aedfb5 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios_kernel/workspace_env.py @@ -17,7 +17,7 @@ from typing import Any,List import os import chardet -from .agent_base import AgentMsg,AgentTodo,AgentPrompt +from .agent_base import AgentMsg,AgentTodo,AgentPrompt,AgentTodoResult from .environment import Environment,EnvironmentEvent from .ai_function import AIFunction,SimpleAIFunction from .storage import AIStorage,ResourceLocation @@ -47,40 +47,53 @@ class WorkspaceEnvironment(Environment): def get_knowledge_base(self) -> str: pass - def get_do_prompt(self,todo_type:str=None)->AgentPrompt: + def get_do_prompt(self,todo:AgentTodo=None)->AgentPrompt: return None - async def exec_op_list(self,oplist:List,agent_id:str)->None: + # result mean: list[op_error_str],have_error + async def exec_op_list(self,oplist:List,agent_id:str)->tuple[List[str],bool]: + result_str = "op list is none" if oplist is None: - return + return None,False + result_str = [] + have_error = False for op in oplist: if op["op"] == "create": - return await self.create(op["path"],op["content"]) + await self.create(op["path"],op["content"]) elif op["op"] == "write_file": is_append = op.get("is_append") if is_append is None: is_append = False - return await self.write(op["path"],op["content"],is_append) + error_str = await self.write(op["path"],op["content"],is_append) elif op["op"] == "delete": - return await self.delete(op["path"]) + error_str = await self.delete(op["path"]) elif op["op"] == "rename": - return await self.rename(op["path"],op["new_name"]) + error_str = await self.rename(op["path"],op["new_name"]) elif op["op"] == "mkdir": - return await self.mkdir(op["path"]) + error_str = await self.mkdir(op["path"]) elif op["op"] == "create_todo": todoObj = AgentTodo.from_dict(op["todo"]) todoObj.worker = agent_id todoObj.createor = agent_id parent_id = op.get("parent") - await self.create_todo(parent_id,todoObj) + error_str = await self.create_todo(parent_id,todoObj) elif op["op"] == "update_todo": todo_id = op["id"] new_stat = op["state"] - await self.update_todo(todo_id,new_stat) - + error_str = await self.update_todo(todo_id,new_stat) else: logger.error(f"execute op list failed: unknown op:{op['op']}") + error_str = f"execute op list failed: unknown op:{op['op']}" + + if error_str: + have_error = True + result_str.append(error_str) + else: + result_str.append(f"execute success!") + + + return result_str,have_error async def list(self,path:str,only_dir:bool=False) -> str: directory_path = self.root_path + path @@ -112,13 +125,16 @@ class WorkspaceEnvironment(Environment): async def write(self,path:str,content:str,is_append:bool=False) -> str: file_path = self.root_path + path - if is_append: - async with aiofiles.open(file_path, mode='a', encoding="utf-8") as f: - await f.write(content) - else: - async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: - await f.write(content) - return "success" + try: + if is_append: + async with aiofiles.open(file_path, mode='a', encoding="utf-8") as f: + await f.write(content) + else: + async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: + await f.write(content) + except Exception as e: + return str(e) + return None async def create(self,path:str,content:str=None) -> bool: if content is None: @@ -132,21 +148,29 @@ class WorkspaceEnvironment(Environment): await f.write(content) return True - async def delete(self,path:str) -> bool: - file_path = self.root_path + path - os.remove(file_path) - return True + async def delete(self,path:str) -> str: + try: + file_path = self.root_path + path + os.remove(file_path) + except Exception as e: + return str(e) + + return None async def mkdir(self,path:str) -> bool: dir_path = self.root_path + path os.makedirs(dir_path) return True - async def rename(self,path:str,new_name:str) -> bool: - file_path = self.root_path + path - new_path = self.root_path + new_name - os.rename(file_path,new_path) - return True + async def rename(self,path:str,new_name:str) -> str: + try: + file_path = self.root_path + path + new_path = self.root_path + new_name + os.rename(file_path,new_path) + except Exception as e: + return str(e) + + return None async def get_todo_tree(self,path:str = None,deep:int = 4): if path: @@ -205,17 +229,17 @@ class WorkspaceEnvironment(Environment): continue todo = await self.get_todo_by_fullpath(entry.path) - if todo.worker != agent_id: - continue - - todo.rank = int(todo.create_time)>>deep - if todo: + if todo.worker: + if todo.worker != agent_id: + continue + if parent: parent.sub_todos[todo.todo_id] = todo + result_list.append(todo) - - await scan_dir(entry.path,deep + 1,todo) + todo.rank = int(todo.create_time)>>deep + await scan_dir(entry.path,deep + 1,todo) return @@ -226,68 +250,89 @@ class WorkspaceEnvironment(Environment): return result_list async def get_todo_by_fullpath(self,path:str) -> AgentTodo: - #logger.info("get_todo_by_fullpath:%s",path) - detail_path = path + "/detail" + logger.info("get_todo_by_fullpath:%s",path) - async with aiofiles.open(detail_path, mode='r', encoding="utf-8") as f: - content = await f.read(4096) - logger.debug("get_todo_by_fullpath:%s,content:%s",path,content) - todo_dict = json.loads(content) - result_todo = AgentTodo.from_dict(todo_dict) - if result_todo: - relative_path = os.path.relpath(path, self.root_path + "/todos/") - if not relative_path.startswith('/'): - relative_path = '/' + relative_path - result_todo.todo_path = relative_path - self.known_todo[result_todo.todo_id] = result_todo - else: - logger.error("get_todo_by_path:%s,parse failed!",path) - - return result_todo + detail_path = path + "/detail" + try: + async with aiofiles.open(detail_path, mode='r', encoding="utf-8") as f: + content = await f.read(4096) + logger.debug("get_todo_by_fullpath:%s,content:%s",path,content) + todo_dict = json.loads(content) + result_todo = AgentTodo.from_dict(todo_dict) + if result_todo: + relative_path = os.path.relpath(path, self.root_path + "/todos/") + if not relative_path.startswith('/'): + relative_path = '/' + relative_path + result_todo.todo_path = relative_path + self.known_todo[result_todo.todo_id] = result_todo + else: + logger.error("get_todo_by_path:%s,parse failed!",path) + + return result_todo + except Exception as e: + logger.error("get_todo_by_path:%s,failed:%s",path,e) + return None async def get_todo(self,id:str) -> AgentTodo: return self.known_todo.get(id) - async def create_todo(self,parent_id:str,todo:AgentTodo) -> None: - if parent_id: - if parent_id not in self.known_todo: - logger.error("create_todo failed: parent_id not found!") - return False - - parent_path = self.known_todo.get(parent_id).todo_path - todo_path = f"{parent_path}/{todo.title}" - else: - todo_path = todo.title + async def create_todo(self,parent_id:str,todo:AgentTodo) -> str: + try: + if parent_id: + if parent_id not in self.known_todo: + logger.error("create_todo failed: parent_id not found!") + return False + + parent_path = self.known_todo.get(parent_id).todo_path + todo_path = f"{parent_path}/{todo.title}" + else: + todo_path = todo.title - dir_path = f"{self.root_path}/todos/{todo_path}" - - os.makedirs(dir_path) - detail_path = f"{dir_path}/detail" - if todo.todo_path is None: - todo.todo_path = todo_path - logger.info("create_todo %s",detail_path) - async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.to_dict())) - self.known_todo[todo.todo_id] = todo - return True - - async def update_todo(self,todo_id:str,new_stat:str)->bool: - todo : AgentTodo = self.known_todo.get(todo_id) - if todo: - todo.status = new_stat - if todo.raw_obj is None: - todo.raw_obj = todo.to_dict() - todo.raw_obj["status"] = new_stat - - detail_path = f"{self.root_path}/todos/{todo.todo_path}/detail" - async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.raw_obj)) - return True - - return False + dir_path = f"{self.root_path}/todos/{todo_path}" - async def append_do_result(self,todo:AgentTodo,result): - return True + os.makedirs(dir_path) + detail_path = f"{dir_path}/detail" + if todo.todo_path is None: + todo.todo_path = todo_path + logger.info("create_todo %s",detail_path) + async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(todo.to_dict())) + self.known_todo[todo.todo_id] = todo + except Exception as e: + logger.error("create_todo failed:%s",e) + return str(e) + + return None + + async def update_todo(self,todo_id:str,new_stat:str)->str: + try: + todo : AgentTodo = self.known_todo.get(todo_id) + if todo: + todo.state = new_stat + detail_path = f"{self.root_path}/todos/{todo.todo_path}/detail" + async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(todo.to_dict())) + return None + else: + return "todo not found." + except Exception as e: + return str(e) + + async def append_worklog(self,todo:AgentTodo,result:AgentTodoResult): + worklog = f"{self.root_path}/todos/{todo.todo_path}/.worklog" + + async with aiofiles.open(worklog, mode='w+', encoding="utf-8") as f: + content = await f.read() + if len(content) > 0: + json_obj = json.loads(content) + else: + json_obj = {} + logs = json_obj.get("logs") + if logs is None: + logs = [] + logs.append(result.to_dict()) + json_obj["logs"] = logs + await f.write(json.dumps(json_obj)) class CodeInterpreter: def __init__(self, language, debug_mode): diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 18d9839..1f56dbe 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -92,6 +92,11 @@ class AIOS_Shell: if a_workflow is not None: bus.register_message_handler(target_id,a_workflow._process_msg) return True + + a_contact = await ContactManager.get_instance().get_contact(target_id) + if a_contact is not None: + bus.register_message_handler(target_id,a_contact._process_msg) + return True return False @@ -184,7 +189,7 @@ class AIOS_Shell: await ComputeKernel.get_instance().start() AIBus().get_default_bus().register_unhandle_message_handler(self._handle_no_target_msg) - AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) + #AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) pipelines = KnowledgePipelineManager.initial(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge/pipelines")) @@ -212,6 +217,7 @@ class AIOS_Shell: return "0.5.1" async def send_msg(self,msg:str,target_id:str,topic:str,sender:str = None) -> str: + #AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) agent_msg = AgentMsg() agent_msg.set(sender,target_id,msg) agent_msg.topic = topic @@ -241,6 +247,11 @@ class AIOS_Shell: input_table["allow"] = UserConfigItem("allow group (default is member,you can choose contact or guest)") case "email": tunnel_config["type"] = "EmailTunnel" + input_table["email"] = UserConfigItem("email address agent will use \n") + input_table["imap"] = UserConfigItem("imap server address,like hostname:port") + input_table["smtp"] = UserConfigItem("smtp server address,like hostname:port") + input_table["user"] = UserConfigItem("mail server login user name") + input_table["password"] = UserConfigItem("main server login password") case _: error_text = FormattedText([("class:error", f"tunnel type {tunnel_type}not support!")]) print_formatted_text(error_text,style=shell_style) @@ -406,13 +417,21 @@ class AIOS_Shell: match func_name: case 'send': show_text = FormattedText([("class:error", f'send args error,/send Tracy "Hello! It is a good day!" default')]) + sender = None if len(args) == 3: target_id = args[0] msg_content = args[1] topic = args[2] - resp = await self.send_msg(msg_content,target_id,topic,self.username) - show_text = FormattedText([("class:title", f"{self.current_topic}@{self.current_target} >>> "), - ("class:content", resp)]) + sender = self.username + elif len(args) == 4: + target_id = args[0] + msg_content = args[1] + topic = args[2] + sender = args[3] + + resp = await self.send_msg(msg_content,target_id,topic,sender) + show_text = FormattedText([("class:title", f"{self.current_topic}@{self.current_target} >>> "), + ("class:content", resp)]) return show_text case 'set_config': show_text = FormattedText([("class:error", f"set config args error,/set_config $config_item! ")]) From 763360b305330fc657c00a2f6da768eb80bdc7b4 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sun, 12 Nov 2023 21:59:04 -0800 Subject: [PATCH 15/79] Support Text summary based Knowledge System, Update Agent Workspace. --- rootfs/agents/Thinker/agent.toml | 2 +- rootfs/agents/Tracy/agent.toml | 3 + src/aios_kernel/__init__.py | 1 + src/aios_kernel/agent.py | 204 ++++++---- src/aios_kernel/chatsession.py | 8 +- src/aios_kernel/contact.py | 3 +- src/aios_kernel/simple_kb_db.py | 225 +++++++++++ src/aios_kernel/tg_tunnel.py | 1 + src/aios_kernel/workflow_env.py | 3 - src/aios_kernel/workspace_env.py | 566 ++++++++++++++++++--------- src/requirements.txt | 2 + src/service/aios_shell/aios_shell.py | 4 +- 12 files changed, 765 insertions(+), 257 deletions(-) create mode 100644 src/aios_kernel/simple_kb_db.py diff --git a/rootfs/agents/Thinker/agent.toml b/rootfs/agents/Thinker/agent.toml index 05332f6..b79cc9c 100644 --- a/rootfs/agents/Thinker/agent.toml +++ b/rootfs/agents/Thinker/agent.toml @@ -16,5 +16,5 @@ You mainly use the following methods to generate summary: 4. Try to understand the attitude of different people on different topics or events 5. For the key information or TODO in the information, such as the time, place, amount and other information of the certainty, it must be stored in the summary. -You have a summary of simplicity and profound nonsense, and you don't need to have any polite words to me.Just give me a summary. +Just give me a summary without any other word. """ diff --git a/rootfs/agents/Tracy/agent.toml b/rootfs/agents/Tracy/agent.toml index 8d44476..6338d06 100644 --- a/rootfs/agents/Tracy/agent.toml +++ b/rootfs/agents/Tracy/agent.toml @@ -1,5 +1,8 @@ instance_id = "Tracy" fullname = "Tracy" +llm_model_name = "Llama-2-13b-chat" +max_token_size = 2000 + [[prompt]] role = "system" content = """ diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index 2b1b8ef..a9a5302 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -24,5 +24,6 @@ from .stability_node import Stability_ComputeNode from .local_st_compute_node import LocalSentenceTransformer_Text_ComputeNode,LocalSentenceTransformer_Image_ComputeNode from .compute_node_config import ComputeNodeConfig from .ai_function import SimpleAIFunction +from .workspace_env import WorkspaceEnvironment AIOS_Version = "0.5.2, build 2023-11-1" diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 9e2ca15..13339cb 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -485,12 +485,12 @@ class AIAgent: summary = self.llm_select_session_summary(msg,chatsession) prompt.append(AgentPrompt(summary)) - known_info_str = "# 已知信息\n" + known_info_str = "# Known information\n" have_known_info = False todos_str,todo_count = await workspace.get_todo_tree() if todo_count > 0: have_known_info = True - known_info_str += f"## 已有todo\n{todos_str}\n" + known_info_str += f"## todo\n{todos_str}\n" inner_functions,function_token_len = self._get_inner_functions() system_prompt_len = prompt.get_prompt_token_len() input_len = len(msg.body) @@ -879,40 +879,63 @@ class AIAgent: # 尝试自我学习,会主动获取、读取资料并进行整理 # LLM的本质能力是处理海量知识,应该让LLM能基于知识把自己的工作处理的更好 - def do_self_learn(self) -> None: + async def do_self_learn(self) -> None: # 不同的workspace是否应该有不同的学习方法? - learn_power = self.get_learn_power() - kb = self.get_knowledge_base() - for item in kb.un_learn_items(): - if learn_power <= 0: + workspace = self.get_workspace_by_msg(None) + hash_list = workspace.kb_db.get_knowledge_without_llm_title() + for hash in hash_list: + if self.agent_energy <= 0: break - match item.type(): - case "book": - self.llm_read_book(kb,item) - learn_power -= 1 - case "article": - # 可以用vdb 对不同目录的名字进行选择后,先进行一次快速的插入。有时间再慢慢用LLM整理 - self.llm_read_article(kb,item) - learn_power -= 1 - case "video": - self.llm_watch_video(kb,item) - learn_power -= 1 - case "audio": - self.llm_listen_audio(kb,item) - learn_power -= 1 - case "code_project": - self.llm_read_code_project(kb,item) - learn_power -= 1 - case "image": - self.llm_view_image(kb,item) - learn_power -= 1 - case "other": - self.llm_read_other(kb,item) - learn_power -= 1 - case _: - self.llm_learn_any(kb,item) - pass - + + knowledge = workspace.kb_db.get_knowledge_by_hash(hash) + if knowledge is None: + continue + + if os.path.exists(knowledge.path) is False: + logger.warning(f"do_self_learn: knowledge {knowledge.path} is not exists!") + continue + + #TODO 可以用v-db 对不同目录的名字进行选择后,先进行一次快速的插入。有时间再慢慢用LLM整理 + llm_result = await self._llm_read_article(knowledge) + + #根据结果更新knowledge + if llm_result is not None: + workspace.kb_db.update_knowledge_by_hash(hash,llm_result) + # 在知识库中创建软链接 + + + + self.agent_energy -= 1 + + # match item.type(): + # case "book": + # self.llm_read_book(kb,item) + # learn_power -= 1 + # case "article": + # + # self.llm_read_article(kb,item) + # learn_power -= 1 + # case "video": + # self.llm_watch_video(kb,item) + # learn_power -= 1 + # case "audio": + # self.llm_listen_audio(kb,item) + # learn_power -= 1 + # case "code_project": + # self.llm_read_code_project(kb,item) + # learn_power -= 1 + # case "image": + # self.llm_view_image(kb,item) + # learn_power -= 1 + # case "other": + # self.llm_read_other(kb,item) + # learn_power -= 1 + # case _: + # self.llm_learn_any(kb,item) + # pass + + + async def do_blance_knowledge_base(selft): # 整理自己的知识库(让分类更平衡,更由于自己以后的工作),并尝试更新学习目标 current_path = "/" current_list = kb.get_list(current_path) @@ -933,48 +956,86 @@ class AIAgent: def parser_learn_llm_result(self,llm_result:LLMResult): pass - async def _llm_read_article(self,item:KnowledgeObject) -> ComputeTaskResult: - full_content = item.get_article_full_content() - full_content_len = ComputeKernel.llm_num_tokens_from_text(full_content,self.get_llm_model_name()) + async def gen_known_info_for_knowledge_prompt(self,knowledge_item:dict,need_catalogs = False) -> AgentPrompt: + #已知信息: + # 组织的工作总结(如有)待完成 + # 现在知识库的结构(注意大小控制)gen_kb_tree_prompt (当为空的时候应该让LLM生成一个合适的初始目录结构) + # 原始路径,现在标题,摘要,目录 + workspace =self.get_workspace_by_msg(None) + kb_tree = await workspace.get_knowledege_catalog() + known_obj = {} + title = knowledge_item.get("title") + if title: + known_obj["title"] = title + summary = knowledge_item.get("summary") + if summary: + known_obj["summary"] = summary + tags = knowledge_item.get("tags") + if tags: + known_obj["tags"] = tags + if need_catalogs: + catalogs = knowledge_item.get("catalogs") + if catalogs: + known_obj["catalogs"] = catalogs + + org_path = knowledge_item.get("path") + known_obj["orginal_path"] = org_path + know_info_str = f"# Known information\n{json.dumps(known_obj)}\n" + return AgentPrompt(know_info_str) + + + + + + + async def _llm_read_article(self,knowledge_item:dict) -> ComputeTaskResult: + #目标: + # 得到更好的标题,摘要,目录 (如有必要),tags + # 应放的合适的位置 (结合组织的目标) + #已知信息: + # 整理是为什么目标服务的 learn_prompt + # 组织的工作总结(如有) + # 现在知识库的结构(注意大小控制)gen_kb_tree_prompt (当为空的时候应该让LLM生成一个合适的初始目录结构) + # 原始路径,现在标题,摘要,目录 + + + # 整理长文件(通用技巧) + # 告诉输入的是部分内容,让LLM为任务产生中间结果 + # 依次输入内容,在最后一个内容块输入时,LLM得到结果 + + #full_content = item.get_article_full_content() + workspace = self.get_workspace_by_msg(None) + full_content = await workspace.load_knowledge_content(knowledge_item["hash"]) + if full_content is None: + return + full_content_len = self.token_len(full_content) if full_content_len < self.get_llm_learn_token_limit(): # 短文章不用总结catelog #path_list,summary = llm_get_summary(summary,full_content) - prompt = self.get_agent_role_prompt() - learn_prompt = self.get_learn_prompt() - cotent_prompt = AgentPrompt(full_content) - prompt.append(learn_prompt) - prompt.append(cotent_prompt) - - env_functions = self._get_inner_functions() - + #prompt = self.get_agent_role_prompt() + prompt = self.get_learn_prompt() + known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item) + prompt.append(known_info_prompt) + content_prompt = AgentPrompt(full_content) + prompt.append(content_prompt) + + env_functions = workspace.get_knowledge_base_ai_functions() task_result:ComputeTaskResult = await self._do_llm_complection(prompt,env_functions) if task_result.result_code != ComputeTaskResultCode.OK: - return task_result - llm_result = LLMResult.from_str(task_result.result_str) - path_list,summary = self.parser_learn_llm_result(llm_result) + result_obj = {} + result_obj["error_str"] = task_result.error_str + return result_obj + + result_obj = json.loads(task_result.result_str) + return result_obj else: - # 用传统方法对文章进行一些处理,目的是尽可能减少LLM调用的次数 - catelog = item.get_articl_catelog() - chunk_content = full_content.read(self.get_llm_learn_token_limit()) - summary = kb.try_get_summary(catelog,full_content) - - while chunk_content is not None: - #path_list,summarycatelog = llm_get_summary(summary,chunk_content) - #learn_prompt = self.get_learn_prompt_with_summary() - - prompt = AgentPrompt("summary") - learn_prompt.append(prompt) - prompt = AgentPrompt(chunk_content) - learn_prompt.append(prompt) - - #llm_result = self.do_llm_competion(learn_prompt) - #path_list,summary,catelog = parser_learn_llm_result(llm_result) - - #chunk_content = full_content.read(self.get_llm_learn_token_limit()) + logger.warning(f"llm_read_article: article {knowledge_item['path']} is too long,just read summary!") + result_obj = {} + result_obj["error_str"] = f"llm_read_article: article {knowledge_item['path']} is too long,just read summary!" + return result_obj - kb.insert_item(path_list,item,catelog,summary) async def do_self_think(self): session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) @@ -1043,7 +1104,7 @@ class AIAgent: known_info = "" if chatsession.summary is not None: if len(chatsession.summary) > 1: - known_info += f"## 最近交流的总结 \n {chatsession.summary}\n" + known_info += f"## Recent conversation summary \n {chatsession.summary}\n" result_token_len -= len(chatsession.summary) have_known_info = True @@ -1062,7 +1123,7 @@ class AIAgent: logger.warning(f"_get_prompt_from_session reach limit of token,just read {read_history_msg} history message.") break - known_info += f"## 最近的沟通记录 \n {histroy_str}\n" + known_info += f"## Recent conversation history \n {histroy_str}\n" if have_known_info: return known_info,result_token_len @@ -1103,6 +1164,8 @@ class AIAgent: return False def need_self_learn(self) -> bool: + if self.learn_prompt is not None: + return True return False def wake_up(self) -> None: @@ -1145,6 +1208,9 @@ class AIAgent: logger.error(f"agent {self.agent_id} on timer error:{e},{tb_str}") continue + def token_len(self,text:str) -> int: + return ComputeKernel.llm_num_tokens_from_text(text,self.get_llm_model_name()) + diff --git a/src/aios_kernel/chatsession.py b/src/aios_kernel/chatsession.py index 9d704c8..0302e12 100644 --- a/src/aios_kernel/chatsession.py +++ b/src/aios_kernel/chatsession.py @@ -12,15 +12,15 @@ from .agent_base import AgentMsgType, AgentMsg, AgentMsgStatus class ChatSessionDB: def __init__(self, db_file): """ initialize db connection """ - self.local = threading.local() self.db_file = db_file self._get_conn() def _get_conn(self): """ get db connection """ - if not hasattr(self.local, 'conn'): - self.local.conn = self._create_connection(self.db_file) - return self.local.conn + local = threading.local() + if not hasattr(local, 'conn'): + local.conn = self._create_connection(self.db_file) + return local.conn def _create_connection(self, db_file): """ create a database connection to a SQLite database """ diff --git a/src/aios_kernel/contact.py b/src/aios_kernel/contact.py index ba80df5..361c699 100644 --- a/src/aios_kernel/contact.py +++ b/src/aios_kernel/contact.py @@ -43,7 +43,8 @@ class Contact: self.active_tunnels[msg.sender] = tunnel await tunnel.post_message(msg) return None - + + logger.warn(f"contact {self.name} cann't get tunnel,post message failed!") def get_active_tunnel(self,agent_id) -> AgentTunnel: diff --git a/src/aios_kernel/simple_kb_db.py b/src/aios_kernel/simple_kb_db.py new file mode 100644 index 0000000..29480bd --- /dev/null +++ b/src/aios_kernel/simple_kb_db.py @@ -0,0 +1,225 @@ + +import sqlite3 +import json +import threading +import logging +from datetime import datetime + +from typing import Optional, List + +logger = logging.getLogger(__name__) + +class SimpleKnowledgeDB: + def __init__(self,db_path:str): + self.db_path = db_path + self._get_conn() + + def _get_conn(self): + """ get db connection """ + local = threading.local() + if not hasattr(local, 'conn'): + local.conn = self._create_connection(self.db_path) + return local.conn + + + def _create_connection(self, db_file): + """ create a database connection to a SQLite database """ + conn = None + try: + conn = sqlite3.connect(db_file) + except Exception as e: + logger.error("Error occurred while connecting to database: %s", e) + return None + + if conn: + self._create_tables(conn) + + return conn + + def _create_tables(self,conn): + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS documents ( + doc_path TEXT PRIMARY KEY, + length INTEGER, + last_modify TEXT, + doc_hash TEXT, + create_time TEXT + ) + ''') + cursor.execute(''' + CREATE TABLE IF NOT EXISTS knowledge ( + doc_hash TEXT PRIMARY KEY, + title TEXT, + summary TEXT, + content TEXT, + catalogs TEXT, + tags TEXT, + llm_title TEXT, + llm_summary TEXT, + create_time TEXT + ) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_documents_doc_hash + ON documents (doc_hash) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_knowledge_tags + ON knowledge (tags) + ''') + + conn.commit() + + def add_doc(self, doc_path: str, length: int, last_modify: str, doc_hash: Optional[str] = None): + conn = self._get_conn() + cursor = conn.cursor() + create_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cursor.execute(''' + INSERT INTO documents (doc_path, length, last_modify, doc_hash,create_time) + VALUES (?, ?, ?, ?,?) + ''', (doc_path, length, last_modify, doc_hash,create_time)) + conn.commit() + + def is_doc_exist(self, doc_path: str) -> bool: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT doc_path + FROM documents + WHERE doc_path = ? + ''', (doc_path,)) + return len(cursor.fetchall()) > 0 + + def set_doc_hash(self, doc_path: str, doc_hash: str): + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + UPDATE documents + SET doc_hash = ? + WHERE doc_path = ? + ''', (doc_hash, doc_path)) + conn.commit() + + def get_docs_without_hash(self,limit:int=1024) -> List[str]: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT doc_path + FROM documents + WHERE doc_hash IS NULL OR doc_hash = '' + ORDER BY create_time DESC + LIMIT ? + ''',(limit,)) + return [row[0] for row in cursor.fetchall()] + + #metadata["summary"] + #metadata["catelogs"] + #metadata["tags"] + def add_knowledge(self, doc_hash: str, title: str, metadata: dict,content:str = None,): + conn = self._get_conn() + cursor = conn.cursor() + + create_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + summary = metadata.get("summary", "") + catalogs = metadata.get("catalogs","") + tags = ','.join(metadata.get("tags", [])) + + cursor.execute(''' + INSERT INTO knowledge (doc_hash, title , summary , catalogs , tags,create_time) + VALUES (?, ?, ?, ?, ?,?) + ''', (doc_hash, title, summary, catalogs, tags,create_time)) + conn.commit() + + #llm_result["summary"] + #llm_result["tags"] + #llm_result["catelog"] + def set_knowledge_llm_result(self, doc_hash: str, llm_result: dict): + conn = self._get_conn() + cursor = conn.cursor() + + title = llm_result.get("title", "") + summary = llm_result.get("summary", "") + catalogs = json.dumps(llm_result.get("catalogs", [])) + tags = ','.join(llm_result.get("tags", [])) + + cursor.execute(''' + UPDATE knowledge + SET llm_title = ?,llm_summary = ?, catalogs = ?, tags = ? + WHERE doc_hash = ? + ''', (title,summary, catalogs, tags, doc_hash)) + conn.commit() + + def get_hash_by_doc_path(self, doc_path: str) -> Optional[str]: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT doc_hash + FROM documents + WHERE doc_path = ? + ''', (doc_path,)) + row = cursor.fetchone() + if row is None: + return None + return row[0] + + def get_knowledge(self, doc_hash: str) -> Optional[dict]: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT title, summary, catalogs, tags, llm_title, llm_summary + FROM knowledge + WHERE doc_hash = ? + ''', (doc_hash,)) + row = cursor.fetchone() + if row is None: + return None + + # get doc path + cursor.execute(''' + SELECT doc_path + FROM documents + WHERE doc_hash = ? + ''', (doc_hash,)) + row2 = cursor.fetchone() + if row2 is None: + return None + doc_path = row2[0] + + return { + "full_path": doc_path, + "title": row[0], + "summary": row[1], + "catalogs": json.loads(row[2]), + "tags": row[3].split(","), + } + + def get_knowledge_without_llm_title(self,limit:int=16) -> List[str]: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT doc_hash + FROM knowledge + WHERE llm_title IS NULL OR llm_title = '' + ORDER BY create_time DESC + LIMIT ? + ''',(limit,)) + return [row[0] for row in cursor.fetchall()] + + def query_docs_by_tag(self, tag: str) -> List[str]: + conn = self._get_conn() + cursor = conn.cursor() + tag_json = json.dumps(tag) # 将标签转换为 JSON 字符串 + cursor.execute(''' + SELECT documents.doc_path + FROM documents + JOIN knowledge ON documents.doc_hash = knowledge.doc_hash + WHERE json_extract(knowledge.tags, '$') LIKE ? + ''', (tag)) + return [row[0] for row in cursor.fetchall()] + + def query(self,sql:str): + pass + #cursor = self.conn.cursor() diff --git a/src/aios_kernel/tg_tunnel.py b/src/aios_kernel/tg_tunnel.py index f454d41..eefc5fe 100644 --- a/src/aios_kernel/tg_tunnel.py +++ b/src/aios_kernel/tg_tunnel.py @@ -164,6 +164,7 @@ class TelegramTunnel(AgentTunnel): agent_msg.mentions = [] else: agent_msg.msg_type = AgentMsgType.TYPE_MSG + agent_msg.mentions = [] if message.entities: for entity in message.entities: diff --git a/src/aios_kernel/workflow_env.py b/src/aios_kernel/workflow_env.py index 9e0e85c..94cac90 100644 --- a/src/aios_kernel/workflow_env.py +++ b/src/aios_kernel/workflow_env.py @@ -266,9 +266,6 @@ class CalenderEnvironment(Environment): return f"Execute set_contact OK , contact {name} updated!" - - - async def start(self) -> None: if self.is_run: return diff --git a/src/aios_kernel/workspace_env.py b/src/aios_kernel/workspace_env.py index 7aedfb5..3912d37 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios_kernel/workspace_env.py @@ -1,5 +1,6 @@ # this env is designed for workflow owner filesystem, support file/directory operations +import hashlib import json import subprocess import logging @@ -17,10 +18,13 @@ from typing import Any,List import os import chardet +from markdown import Markdown +import PyPDF2 from .agent_base import AgentMsg,AgentTodo,AgentPrompt,AgentTodoResult from .environment import Environment,EnvironmentEvent from .ai_function import AIFunction,SimpleAIFunction from .storage import AIStorage,ResourceLocation +from .simple_kb_db import SimpleKnowledgeDB logger = logging.getLogger(__name__) @@ -33,6 +37,10 @@ class WorkspaceEnvironment(Environment): os.makedirs(self.root_path+"/todos") self.known_todo = {} + self.kb_db = SimpleKnowledgeDB(f"{self.root_path}/kb.db") + self.doc_dirs = {} + self._scan_thread = None + self._scan_dirthread = None def set_root_path(self,path:str): @@ -44,9 +52,10 @@ class WorkspaceEnvironment(Environment): def get_role_prompt(self,role_id:str) -> AgentPrompt: return None - def get_knowledge_base(self) -> str: + def get_knowledge_base(self,root_dir=None,indent=0) -> str: pass + def get_do_prompt(self,todo:AgentTodo=None)->AgentPrompt: return None @@ -94,7 +103,9 @@ class WorkspaceEnvironment(Environment): return result_str,have_error - + + # file system operation: list,read,write,delete,move,stat + # inner_function async def list(self,path:str,only_dir:bool=False) -> str: directory_path = self.root_path + path items = [] @@ -108,7 +119,8 @@ class WorkspaceEnvironment(Environment): items.append({"name": entry.name, "type": item_type}) return json.dumps(items) - + + # inner_function async def read(self,path:str) -> str: file_path = self.root_path + path cur_encode = "utf-8" @@ -119,10 +131,8 @@ class WorkspaceEnvironment(Environment): content = await f.read(2048) return content - # use diff to update large file content - async def write_diff(self,path:str,diff): - pass + # operation or inner_function (MOST IMPORTANT FUNCTION) async def write(self,path:str,content:str,is_append:bool=False) -> str: file_path = self.root_path + path try: @@ -130,24 +140,24 @@ class WorkspaceEnvironment(Environment): async with aiofiles.open(file_path, mode='a', encoding="utf-8") as f: await f.write(content) else: - async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: - await f.write(content) + if content is None: + # create dir + dir_path = self.root_path + path + os.makedirs(dir_path) + return True + else: + file_path = self.root_path + path + os.makedirs(os.path.dirname(file_path),exist_ok=True) + async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: + await f.write(content) + return True + except Exception as e: return str(e) return None - - async def create(self,path:str,content:str=None) -> bool: - if content is None: - # create dir - dir_path = self.root_path + path - os.makedirs(dir_path) - return True - else: - file_path = self.root_path + path - async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: - await f.write(content) - return True + + # operation or inner_function async def delete(self,path:str) -> str: try: file_path = self.root_path + path @@ -157,21 +167,107 @@ class WorkspaceEnvironment(Environment): return None - async def mkdir(self,path:str) -> bool: - dir_path = self.root_path + path - os.makedirs(dir_path) - return True - - async def rename(self,path:str,new_name:str) -> str: + # operation or inner_function + async def move(self,path:str,new_path:str) -> str: try: file_path = self.root_path + path - new_path = self.root_path + new_name + new_path = self.root_path + new_path os.rename(file_path,new_path) except Exception as e: return str(e) return None + + # inner_function + async def stat(self,path:str) -> str: + try: + file_path = self.root_path + path + stat = os.stat(file_path) + return json.dumps(stat) + except Exception as e: + return str(e) + # operation or inner_function + async def symlink(self,path:str,target:str) -> str: + try: + file_path = self.root_path + path + target_path = self.root_path + target + os.symlink(file_path,target_path) + except Exception as e: + return str(e) + + return None + + # TODO use diff to update large file content + async def update_by_diff(self,path:str,diff): + + pass + + # doc system (read_only,agent cann't modify doc) + + # inner_function + async def list_db(self) -> str: + pass + # inner_function + async def get_db_desc(self,db_name:str) -> str: + pass + # inner_function + async def query(self,db_name:str,sql:str) -> str: + pass + + # search (web) + # inner_function + async def google_search(self,keyword:str,opt=None) -> str: + pass + + # inner_function + async def local_search(self,keyword:str,root_path=None ,opt=None) -> str: + pass + + # inner_function, might be return a image is better + async def web_get(self,url:str) -> str: + pass + + # inner_function + async def blockchain_get(self,chainid:str,query:dict) -> str: + pass + + # code interpreter + # inner_function or operation + async def eval_code(self,pycode:str) -> str: + pass + + # operation or inner_function + async def improve_code(self,path:str): + pass + + # operation or inner_function + async def run(self,file_path:str)->str: + pass + + # operation or inner_function + async def pub_service(self,project_path:str): + pass + + # operation or inner_function + async def exec_tx(self,chain_id:str,tx:dict) -> str: + pass + + # social ability + # operation or inner_function + async def post_message(self,target:str,msg:AgentMsg,wait_time) -> AgentMsg: + pass + + # operation or inner_function + async def add_contact(self,name:str,contact_info) -> str: + pass + + # inner_function , include contact realtime info + async def get_contact(self,name_list:List[str],opt:dict) -> List: + pass + + + # Task/todo system , create,update,delete,query async def get_todo_tree(self,path:str = None,deep:int = 4): if path: directory_path = self.root_path + "/todos/" + path @@ -334,164 +430,244 @@ class WorkspaceEnvironment(Environment): json_obj["logs"] = logs await f.write(json.dumps(json_obj)) -class CodeInterpreter: - def __init__(self, language, debug_mode): - self.language = language - self.proc = None - self.active_line = None - self.debug_mode = debug_mode + async def set_wakeup_timer(self,todo_id:str,timestamp:int) -> str: + pass - def start_process(self): - start_cmd = sys.executable + " -i -q -u" - self.proc = subprocess.Popen(start_cmd.split(), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=0) + # knowledge base system + def get_knowledge_base_ai_functions(self): + func_result = {} - # Start watching ^ its `stdout` and `stderr` streams - threading.Thread(target=self.save_and_display_stream, - args=(self.proc.stdout, False), # Passes False to is_error_stream - daemon=True).start() - threading.Thread(target=self.save_and_display_stream, - args=(self.proc.stderr, True), # Passes True to is_error_stream - daemon=True).start() + func_result["get_knowledge_catalog"] = SimpleAIFunction("get_knowledge_catalog","get knowledge catalog in tree format", + self.get_knowledege_catalog, + {"path":f"catalog path,none is /","depth":"max depth of catalog tree,default is 4"}) + func_result["get_knowledge"] = SimpleAIFunction("get_knowledge","get knowledge metadata", + self.get_knowledge, + {"path":f"knowledge path"}) + func_result["load_knowledge_content"] = SimpleAIFunction("load_knowledge_content","load knowledge content", + self.load_knowledge_content, + {"path":f"knowledge path","pos":"start position of content","length":"length of content"}) + return func_result - def warp_code(self,pycode:str)->str: - # Add import traceback - code = "import traceback\n" + pycode - # Parse the input code into an AST - parsed_code = ast.parse(code) - # Wrap the entire code's AST in a single try-except block - try_except = ast.Try( - body=parsed_code.body, - handlers=[ - ast.ExceptHandler( - type=ast.Name(id="Exception", ctx=ast.Load()), - name=None, - body=[ - ast.Expr( - value=ast.Call( - func=ast.Attribute(value=ast.Name(id="traceback", ctx=ast.Load()), attr="print_exc", ctx=ast.Load()), - args=[], - keywords=[] - ) - ), - ] - ) - ], - orelse=[], - finalbody=[] - ) - - parsed_code.body = [try_except] - return ast.unparse(parsed_code) - - def run(self,py_code:str): - """ - Executes code. - """ - # Get code to execute - self.code = py_code - - # Start the subprocess if it hasn't been started - if not self.proc: - try: - self.start_process() - except Exception as e: - # Sometimes start_process will fail! - # Like if they don't have `node` installed or something. - - traceback_string = traceback.format_exc() - self.output = traceback_string - # Before you return, wait for the display to catch up? - # (I'm not sure why this works) - time.sleep(0.1) - - return self.output - - self.output = "" - - self.print_cmd = 'print("{}")' - code = self.warp_code(py_code) - - if self.debug_mode: - print("Running code:") - print(code) - print("---") - - self.done = threading.Event() - self.done.clear() - - # Write code to stdin of the process - try: - self.proc.stdin.write(code + "\n") - self.proc.stdin.flush() - except BrokenPipeError: - return - self.done.wait() - time.sleep(0.1) - return self.output - - def save_and_display_stream(self, stream, is_error_stream): - - for line in iter(stream.readline, ''): - if self.debug_mode: - print("Recieved output line:") - print(line) - print("---") - - line = line.strip() - if is_error_stream and "KeyboardInterrupt" in line: - raise KeyboardInterrupt - elif "END_OF_EXECUTION" in line: - self.done.set() - self.active_line = None - else: - self.output += "\n" + line - self.output = self.output.strip() - - - -class ShellEnvironment(Environment): - def __init__(self, env_id: str) -> None: - super().__init__(env_id) - - operator_param = { - "command": "command will execute", - } - self.add_ai_function(SimpleAIFunction("shell_exec", - "execute shell command in linux bash", - self.shell_exec,operator_param)) - - #run_code_param = { - # "pycode": "python code will execute", - #} - #self.add_ai_function(SimpleAIFunction("run_code", - # "execute python code", - # self.run_code,run_code_param)) - - - async def shell_exec(self,command:str) -> str: - import asyncio.subprocess - process = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await process.communicate() - returncode = process.returncode - if returncode == 0: - return f"Execute success! stdout is:\n{stdout}\n" + async def get_knowledege_catalog(self,path:str=None,only_dir =True,max_depth:int=5)->str: + if path: + full_path = f"{self.root_path}/knowledge/{path}" else: - return f"Execute failed! stderr is:\n{stderr}\n" + full_path = f"{self.root_path}/knowledge" + + catlogs,file_count = await self.get_directory_structure(full_path,max_depth,only_dir) + return catlogs + + async def get_directory_structure(self,root_dir, max_depth:int=4, only_dir=True, indent=1): + file_count = 0 + structure_str = '' + if os.path.isdir(root_dir): + sub_files = [] + with os.scandir(root_dir) as it: + for entry in it: + if entry.is_dir(): + sub_structure, sub_count = await self.get_directory_structure(entry.path, max_depth, only_dir, indent + 1) + if sub_structure: + structure_str += sub_structure + file_count += sub_count + else: + file_count += 1 + sub_files.append(entry.name) - async def run_code(self,pycode:str) -> str: - interpreter = CodeInterpreter("python",True) - return interpreter.run(pycode) + if only_dir is False: + for file_name in sub_files: + structure_str = structure_str + ' ' * (indent+1) + file_name + '\n' + + dir_name = os.path.basename(root_dir) + dir_info = f"{dir_name} " + + + structure_str = ' ' * indent + dir_info + '\n' + structure_str + + if indent - 1 >= max_depth: + return None, file_count + else: + return structure_str, file_count + + # inner_function + async def get_knowledge(self,path:str) -> str: + full_path = f"{self.root_path}/knowledge/{path}" + if os.islink(full_path): + org_path = os.readlink(full_path) + hash = self.kb_db.get_hash_by_doc_path(org_path) + if hash: + return self.kb_db.get_knowledge(org_path) + + return "not found" + + async def load_knowledge_content(self,path:str,pos:int=0,length:int=0) -> str: + full_path = f"{self.root_path}/knowledge/{path}" + if os.islink(full_path): + org_path = os.readlink(full_path) + if full_path.endswith("pdf"): + logger.info("load_knowledge_content:pdf") + return "pdf is not support now!" + else: + async with aiofiles.open(full_path,'rb') as f: + cur_encode = chardet.detect(f.read(1024))['encoding'] + + async with aiofiles.open(full_path, mode='r', encoding=cur_encode) as f: + f.seek(pos) + content = await f.read(length) + return content + + return "load content failed." + + def _add_document_dir(self,path:str): + self.doc_dirs[path] = 0 + + def _start_scan_document(self): + if self._scan_thread is None: + self._scan_thread = threading.Thread(target=self._scan_document) + self._scan_thread.start() + if self._scan_dirthread is None: + self._scan_dirthread = threading.Thread(target=self._scan_dir) + self._scan_dirthread.start() + + def _parse_pdf_bookmarks(self,bookmarks, parent:list): + + for item in bookmarks: + if isinstance(item,list): + self._parse_pdf_bookmarks(item,parent) + else: + if item.title: + new_item = {} + new_item["page"] = item.page.idnum + new_item["title"] = item.title + my_childs = [] + if item.childs: + if len(item.childs) > 0: + self._parse_pdf_bookmarks(item.childs, my_childs) + new_item["childs"] = my_childs + parent.append(new_item) + else: + logger.warning("parse pdf bookmarks failed: item.title is None!") + + return + + def _parse_pdf(self,doc_path:str): + + metadata = {} + with open(doc_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + doc_info = reader.metadata + if doc_info: + if doc_info.title: + metadata["title"] = doc_info.title + if doc_info.author: + metadata["authors"] = doc_info.author + + bookmarks = reader.outline + if bookmarks: + catalogs = [] + self._parse_pdf_bookmarks(bookmarks,catalogs) + metadata["catalogs"] = json.dumps(catalogs) + + return metadata + + def _parse_txt(self,doc_path:str): + return {} + + def _parse_md(self,doc_path:str): + metadata = {} + cur_encode = "utf-8" + with open(doc_path,'rb') as f: + cur_encode = chardet.detect(f.read(1024))['encoding'] + + with open(doc_path, mode='r', encoding=cur_encode) as f: + content = f.read() + match = re.search(r'^# (.*)', content, re.MULTILINE) + if match: + metadata['title'] = match.group(1).strip() + md = Markdown(extensions=['toc']) + html_str = md.convert(content) + toc = md.toc + if toc: + metadata['catalogs'] = toc + + return metadata + + def _parse_document(self,doc_path:str): + hash_result = None + title = os.path.basename(doc_path) + meta_data = {} + + with open(doc_path, "rb") as f: + hash_md5 = hashlib.md5() + for chunk in iter(lambda: f.read(1024*1024), b""): + hash_md5.update(chunk) + hash_result = hash_md5.hexdigest() + try: + if doc_path.endswith(".md"): + meta_data = self._parse_md(doc_path) + elif doc_path.endswith(".pdf"): + meta_data = self._parse_pdf(doc_path) + except Exception as e: + logger.error("parse document %s failed:%s",doc_path,e) + traceback.print_exc() + + if meta_data.get("title"): + title = meta_data["title"] + + return hash_result,title,meta_data + + + def _support_file(self,file_name:str) -> bool: + if file_name.endswith(".pdf"): + return True + if file_name.endswith(".md"): + return True + if file_name.endswith(".txt"): + return True + return False + + def _scan_dir(self): + while True: + time.sleep(10) + for directory in self.doc_dirs.keys(): + now = time.time() + if now - self.doc_dirs[directory] > 60*15: + self.doc_dirs[directory] = time.time() + else: + continue + + for root, dirs, files in os.walk(directory): + for file in files: + if self._support_file(file): + full_path = os.path.join(root, file) + full_path = os.path.normpath(full_path) + if self.kb_db.is_doc_exist(full_path): + continue + + file_stat = os.stat(full_path) + if file_stat.st_size < 1: + continue + + if file_stat.st_size < 1024*1024*8: + #parse and insert + hash,title,meta_data = self._parse_document(full_path) + self.kb_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime,hash) + self.kb_db.add_knowledge(hash,title,meta_data) + + else: + self.kb_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime) + + def _scan_document(self): + while True: + time.sleep(10) + parse_queue = self.kb_db.get_docs_without_hash() + for doc_path in parse_queue: + hash,title,meta_data = self._parse_document(doc_path) + self.kb_db.set_doc_hash(doc_path,hash) + self.kb_db.add_knowledge(hash,title,meta_data) + + # merge to standard workspace env, **ABANDON this!** class KnowledgeBaseFileSystemEnvironment(Environment): def __init__(self, env_id: str) -> None: @@ -537,3 +713,37 @@ class KnowledgeBaseFileSystemEnvironment(Environment): content = await f.read(2048) return content + +class ShellEnvironment(Environment): + def __init__(self, env_id: str) -> None: + super().__init__(env_id) + + operator_param = { + "command": "command will execute", + } + self.add_ai_function(SimpleAIFunction("shell_exec", + "execute shell command in linux bash", + self.shell_exec,operator_param)) + + #run_code_param = { + # "pycode": "python code will execute", + #} + #self.add_ai_function(SimpleAIFunction("run_code", + # "execute python code", + # self.run_code,run_code_param)) + + + async def shell_exec(self,command:str) -> str: + import asyncio.subprocess + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + returncode = process.returncode + if returncode == 0: + return f"Execute success! stdout is:\n{stdout}\n" + else: + return f"Execute failed! stderr is:\n{stderr}\n" + diff --git a/src/requirements.txt b/src/requirements.txt index 0c452e5..20eebbf 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -138,3 +138,5 @@ pydub stability_sdk sentence-transformers==2.2.2 tiktoken +markdown +PyPDF2 \ No newline at end of file diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 696b8c6..11c5036 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -217,7 +217,9 @@ class AIOS_Shell: return "0.5.1" async def send_msg(self,msg:str,target_id:str,topic:str,sender:str = None) -> str: - #AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) + if sender == self.username: + AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) + agent_msg = AgentMsg() agent_msg.set(sender,target_id,msg) agent_msg.topic = topic From 5fe3073cb69bc38801cb63b5aac6ef9f6146bb15 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Mon, 13 Nov 2023 16:07:33 -0800 Subject: [PATCH 16/79] 1) Use UserConfig to change system default LLM model name 2) Support GPT4-Turbo JSON resp format --- src/aios_kernel/agent.py | 21 ++++++----- src/aios_kernel/agent_base.py | 3 ++ src/aios_kernel/compute_kernel.py | 8 ++--- src/aios_kernel/compute_task.py | 12 ++++--- src/aios_kernel/open_ai_node.py | 58 +++++++++++++++++++++++++------ src/aios_kernel/storage.py | 11 ++++++ 6 files changed, 86 insertions(+), 27 deletions(-) diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 13339cb..64e381f 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -518,15 +518,17 @@ class AIAgent: final_result = task_result.result_str if final_result is not None: - if final_result[0] == "{": - llm_result = LLMResult.from_json_str(final_result) - else: - llm_result : LLMResult = LLMResult.from_str(final_result) + llm_result : LLMResult = LLMResult.from_str(final_result) else: llm_result = LLMResult() llm_result.state = "ignore" - final_result = llm_result.resp + if llm_result.resp is None: + if llm_result.raw_resp: + final_result = json.dumps(llm_result.raw_resp) + else: + final_result = llm_result.resp + await workspace.exec_op_list(llm_result.op_list,self.agent_id) @@ -866,7 +868,7 @@ class AIAgent: prompt.append(todo.detail) prompt.append(todo.result) - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,workspace.get_inner_functions()) + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,workspace.get_inner_functions(),None,True) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_llm_check_todo compute error:{task_result.error_str}") @@ -1129,10 +1131,13 @@ class AIAgent: return known_info,result_token_len return None,0 - async def _do_llm_complection(self,prompt:AgentPrompt,inner_functions:dict=None,org_msg:AgentMsg=None) -> ComputeTaskResult: + async def _do_llm_complection(self,prompt:AgentPrompt,inner_functions:dict=None,org_msg:AgentMsg=None,is_json_resp = False) -> ComputeTaskResult: from .compute_kernel import ComputeKernel #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) + if is_json_resp: + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,"json",self.llm_model_name,self.max_token_size,inner_functions) + else: + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,"text",self.llm_model_name,self.max_token_size,inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_do_llm_complection llm compute error:{task_result.error_str}") #error_resp = msg.create_error_resp(task_result.error_str) diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index 43575d2..9ecc604 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -237,7 +237,9 @@ class LLMResult: def __init__(self) -> None: self.state : str = "ignore" self.resp : str = "" + self.raw_resp = None self.paragraphs : dict[str,FunctionItem] = [] + self.post_msgs : List[AgentMsg] = [] self.send_msgs : List[AgentMsg] = [] @@ -257,6 +259,7 @@ class LLMResult: llm_json = json.loads(llm_json_str) r.state = llm_json.get("state") r.resp = llm_json.get("resp") + r.raw_resp = llm_json post_msgs = llm_json.get("post_msg") r.post_msgs = [] diff --git a/src/aios_kernel/compute_kernel.py b/src/aios_kernel/compute_kernel.py index eb09574..0a9e3a3 100644 --- a/src/aios_kernel/compute_kernel.py +++ b/src/aios_kernel/compute_kernel.py @@ -119,11 +119,11 @@ class ComputeKernel: # friendly interface for use: - def llm_completion(self, prompt: AgentPrompt, mode_name: Optional[str] = None, max_token: int = 0,inner_functions = None): + def llm_completion(self, prompt: AgentPrompt, resp_mode:str="text",mode_name: Optional[str] = None, max_token: int = 0,inner_functions = None): # craete a llm_work_task ,push on queue's end # then task_schedule would run this task.(might schedule some work_task to another host) task_req = ComputeTask() - task_req.set_llm_params(prompt, mode_name, max_token,inner_functions) + task_req.set_llm_params(prompt,resp_mode,mode_name, max_token,inner_functions) self.run(task_req) return task_req @@ -155,8 +155,8 @@ class ComputeKernel: return time_out_result - async def do_llm_completion(self, prompt: AgentPrompt, mode_name: Optional[str] = None, max_token: int = 0, inner_functions = None) -> str: - task_req = self.llm_completion(prompt, mode_name, max_token,inner_functions) + async def do_llm_completion(self, prompt: AgentPrompt,resp_mode:str="text", mode_name: Optional[str] = None, max_token: int = 0, inner_functions = None) -> str: + task_req = self.llm_completion(prompt, resp_mode,mode_name, max_token,inner_functions) return await self._wait_task(task_req) diff --git a/src/aios_kernel/compute_task.py b/src/aios_kernel/compute_task.py index ea7c431..6b9d74c 100644 --- a/src/aios_kernel/compute_task.py +++ b/src/aios_kernel/compute_task.py @@ -4,6 +4,7 @@ import uuid import time from typing import Union from knowledge import ObjectID +from .storage import AIStorage class ComputeTaskResultCode(Enum): OK = 0 @@ -45,16 +46,16 @@ class ComputeTask: self.result = None self.error_str = None - def set_llm_params(self, prompts, model_name, max_token_size, inner_functions = None, callchain_id=None): + def set_llm_params(self, prompts, resp_mode,model_name, max_token_size, inner_functions = None, callchain_id=None): self.task_type = ComputeTaskType.LLM_COMPLETION self.create_time = time.time() self.task_id = uuid.uuid4().hex self.callchain_id = callchain_id self.params["prompts"] = prompts.to_message_list() - if model_name is not None: - self.params["model_name"] = model_name - else: - self.params["model_name"] = "gpt-4-0613" + self.params["resp_mode"] = resp_mode + if model_name is None: + model_name = AIStorage.get_instance().get_user_config().get_value("llm_model_name") + self.params["model_name"] = model_name if max_token_size is None: self.params["max_token_size"] = 4000 else: @@ -115,6 +116,7 @@ class ComputeTaskResult: self.result_refers: dict = {} self.pading_data: bytearray = None + def set_from_task(self, task: ComputeTask): self.task_id = task.task_id diff --git a/src/aios_kernel/open_ai_node.py b/src/aios_kernel/open_ai_node.py index 206d223..1b686d0 100644 --- a/src/aios_kernel/open_ai_node.py +++ b/src/aios_kernel/open_ai_node.py @@ -1,4 +1,5 @@ import openai +from openai import AsyncOpenAI import os import asyncio from asyncio import Queue @@ -59,6 +60,35 @@ class OpenAI_ComputeNode(ComputeNode): async def remove_task(self, task_id: str): pass + def message_to_dict(self, message)->dict: + result = message.dict() + # result_msg = {} + # #message.json() + # if message.content: + # result_msg["content"] = message.content + # result_msg["role"] = message.role + # if message.function_call: + # function_call = {} + # function_call["arguments"] = message.function_call.arguments + # function_call["name"] = message.function_call.name + # result_msg["function_call"] = function_call + + # if message.tool_calls: + # tool_calls = [] + # for tool_call in message.tool_calls: + # tool_call_dict = {} + # tool_call_dict["id"] = tool_call.id + # tool_call_dict["type"] = tool_call.type + # func_call_dict = {} + # func_call_dict["name"] = tool_call.function.name + # func_call_dict["arguments"] = tool_call.function.arguments + # tool_call_dict["function"] = func_call_dict + + # tool_calls.append(tool_call_dict) + # result_msg["tool_calls"] = message.tool_calls + + # result["message"] = result_msg + return result async def _run_task(self, task: ComputeTask): task.state = ComputeTaskState.RUNNING @@ -107,27 +137,34 @@ class OpenAI_ComputeNode(ComputeNode): case ComputeTaskType.LLM_COMPLETION: mode_name = task.params["model_name"] prompts = task.params["prompts"] + resp_mode = task.params["resp_mode"] + if resp_mode == "json": + response_format = { "type": "json_object" } + else: + response_format = None max_token_size = task.params.get("max_token_size") llm_inner_functions = task.params.get("inner_functions") if max_token_size is None: max_token_size = 4000 result_token = max_token_size - + client = AsyncOpenAI() try: if llm_inner_functions is None: logger.info(f"call openai {mode_name} prompts: {prompts}") - resp = await openai.ChatCompletion.acreate(model=mode_name, + resp = await client.chat.completions.create(model=mode_name, messages=prompts, + response_format = response_format, #max_tokens=result_token, - temperature=0.7) + ) else: logger.info(f"call openai {mode_name} prompts: \n\t {prompts} \nfunctions: \n\t{json.dumps(llm_inner_functions)}") - resp = await openai.ChatCompletion.acreate(model=mode_name, + resp = await client.chat.completions.create(model=mode_name, messages=prompts, + response_format = response_format, functions=llm_inner_functions, #max_tokens=result_token, - temperature=0.7) # TODO: add temperature to task params? + ) # TODO: add temperature to task params? except Exception as e: logger.error(f"openai run LLM_COMPLETION task error: {e}") task.state = ComputeTaskState.ERROR @@ -135,10 +172,10 @@ class OpenAI_ComputeNode(ComputeNode): result.error_str = str(e) return result - logger.info(f"openai response: {json.dumps(resp, indent=4)}") + logger.info(f"openai response: {resp}") + status_code = resp.choices[0].finish_reason + token_usage = resp.usage - status_code = resp["choices"][0]["finish_reason"] - token_usage = resp.get("usage") match status_code: case "function_call": task.state = ComputeTaskState.DONE @@ -153,8 +190,9 @@ class OpenAI_ComputeNode(ComputeNode): result.result_code = ComputeTaskResultCode.OK result.worker_id = self.node_id - result.result_str = resp["choices"][0]["message"]["content"] - result.result["message"] = resp["choices"][0]["message"] + result.result_str = resp.choices[0].message.content + + result.result["message"] = self.message_to_dict(resp.choices[0].message) if token_usage: result.result_refers["token_usage"] = token_usage diff --git a/src/aios_kernel/storage.py b/src/aios_kernel/storage.py index 2d83af6..ca3bb56 100644 --- a/src/aios_kernel/storage.py +++ b/src/aios_kernel/storage.py @@ -40,6 +40,17 @@ class UserConfig: self.config_table = {} self.user_config_path:str = None + self._init_default_value("llm_model_name","gpt-4-1106-preview") + + def _init_default_value(self,key:str,value:Any) -> None: + if self.config_table.get(key) is not None: + logger.warning("user config key %s already exist, will be overrided",key) + + new_config_item = UserConfigItem() + new_config_item.default_value = value + new_config_item.is_optional = True + self.config_table[key] = new_config_item + def add_user_config(self,key:str,desc:str,is_optional:bool,default_value:Any=None,item_type="str") -> None: if self.config_table.get(key) is not None: From 87fdba97149396589892c041846c16dc07280629 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Tue, 14 Nov 2023 16:49:36 -0800 Subject: [PATCH 17/79] fix text summary build bug --- rootfs/agents/Jarvis/agent.toml | 9 +- src/aios_kernel/agent.py | 166 ++++++++++++++++++++++--------- src/aios_kernel/simple_kb_db.py | 9 +- src/aios_kernel/workspace_env.py | 115 ++++++++++++++------- 4 files changed, 206 insertions(+), 93 deletions(-) diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index a69e6fb..fd4ab09 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -1,7 +1,6 @@ instance_id = "Jarvis" fullname = "Jarvis" -llm_model_name = "gpt-3.5-turbo-16k-0613" -max_token_size = 16000 +max_token_size = 128000 #enable_kb = "true" enable_timestamp = "true" owner_prompt = "I am your master{name}" @@ -23,9 +22,9 @@ Upon receiving a message, handle it according to the following rules: 1. If you believe someone in the team is better suited to address the message, forward the message to them using the method below: ##/send_msg "MemberName" Message content -2.You can access the master's Calendar to view his schedule. If you need to modify the master's schedule while processing a message, please adjust it using the appropriate method. -3.Be mindful of the identity of the person you are chatting with and provide services accordingly based on their status. -4.For messages that don't follow the above rules, do your best to handle them. +2. You can access the master's Calendar to view his schedule. If you need to modify the master's schedule while processing a message, please adjust it using the appropriate method. +3. Be mindful of the identity of the person you are chatting with and provide services accordingly based on their status. +4. For messages that don't follow the above rules, do your best to handle them. """ diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 64e381f..0247cd4 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -21,6 +21,7 @@ from .contact_manager import ContactManager,Contact,FamilyMember from .compute_kernel import ComputeKernel from .bus import AIBus from .workspace_env import WorkspaceEnvironment +from .storage import AIStorage from knowledge import * @@ -53,11 +54,36 @@ DEFAULT_AGENT_GOAL_TO_TODO_PROMPT = """ """ DEFAULT_AGENT_LEARN_PROMPT = """ -你拥有非常优秀的资料整理技能。我给你一段内容,你会尝试对其进行摘要,并在已有的资料库中找到合适的位置存放该文章。 -1. 结合你的角色和组织的工作目标构建摘要,尽量精简,长度不要超过256个字 -2. 资料库以文件系统的形式组织,浏览知识库是成本高昂的操作,应尝试从根目录往子目录深入来找到最合适的信息。必要的情况下,你可以在合适的位置创建新的目录。为了方便浏览,每一层目录的文件夹数不超过32个,名称长度不超过16个字符,目录深度不超过6层 -3. 你可以从不同的角度给出最多3个合适的位置 -4. 返回一个json来保存摘要和建议保存位置信息 +我是一名软件工程师,拥有非常优秀的资料学习能力。下面是我学习和整理资料的方法 +1. 结合我的角色为资料产生长度不超过256个Token的摘要;尝试产生不超过16个tag; +2. 现有资料库以文件系统的形式组织,我未来借助资料的摘要来浏览知识库 +3. 我将学习过的资料另存在资料库的合适位置(以/开始的完整路径)。保存位置的目录深度不超过5层,文件夹名称长度不超过16个字符。 +4. 当存在已知信息时,需参考已知信息的内容来思考结果。 +5. 由于LLM的Token限制,我学习的可能只是资料的部分内容,此时我应能产生合适的中间结果,中间结果保存在metadata中。当我决定构建中间结果时,我只构建中间结果。 +6. 当我收到最后一部分内容时,我能结合已知的中间结果产生最终结果。 +7. 总是以json格式返回思考结果,json格式如下 +{ + think:"$think_result", + metadata:{...} , # temp result for long content + tags:["tag1","tag2"...], + path:["/graphic/opengl","/database/mysql"], # list of directories to save to. + title:"$article_title", + summary:"$summary", + catalogs: [{ # optional,catalogs is a tree + title:"$catalog_name1", + pos:"$pos:$length" + children:[ + { + title:"$catalog_name 1.1", + pos:"$pos:$length" + } + ]}, + { + title:"$catalog_name2", + pos:"$pos:$length" + } + ] +} """ DEFAULT_AGENT_LEARN_LONG_CONENT_PROMPT = """ @@ -125,7 +151,7 @@ class AIAgent: self.goal_to_todo_prompt = None - self.learn_token_limit = 500 + self.learn_token_limit = 4000 self.learn_prompt = AgentPrompt(DEFAULT_AGENT_LEARN_PROMPT) self.chat_db = None @@ -217,6 +243,9 @@ class AIAgent: return self.template_id def get_llm_model_name(self) -> str: + if self.llm_model_name is None: + return AIStorage.get_instance().get_user_config().get_value("llm_model_name") + return self.llm_model_name def get_max_token_size(self) -> int: @@ -889,22 +918,32 @@ class AIAgent: if self.agent_energy <= 0: break - knowledge = workspace.kb_db.get_knowledge_by_hash(hash) + knowledge = workspace.kb_db.get_knowledge(hash) if knowledge is None: continue - if os.path.exists(knowledge.path) is False: - logger.warning(f"do_self_learn: knowledge {knowledge.path} is not exists!") + full_path = knowledge.get("full_path") + if full_path is None: + continue + + if os.path.exists(full_path) is False: + logger.warning(f"do_self_learn: knowledge {full_path} is not exists!") continue #TODO 可以用v-db 对不同目录的名字进行选择后,先进行一次快速的插入。有时间再慢慢用LLM整理 - llm_result = await self._llm_read_article(knowledge) + result_obj = await self._llm_read_article(knowledge,full_path) #根据结果更新knowledge - if llm_result is not None: - workspace.kb_db.update_knowledge_by_hash(hash,llm_result) + if result_obj is not None: + workspace.kb_db.set_knowledge_llm_result(hash,result_obj) # 在知识库中创建软链接 - + path_list = result_obj.get("path") + new_title = result_obj.get("title") + if path_list: + for new_path in path_list: + full_new_path = f"/knowledge{new_path}/{new_title}" + await workspace.symlink(full_path,full_new_path) + logger.info(f"create soft link {full_path} -> {full_new_path}") self.agent_energy -= 1 @@ -958,13 +997,11 @@ class AIAgent: def parser_learn_llm_result(self,llm_result:LLMResult): pass - async def gen_known_info_for_knowledge_prompt(self,knowledge_item:dict,need_catalogs = False) -> AgentPrompt: - #已知信息: - # 组织的工作总结(如有)待完成 - # 现在知识库的结构(注意大小控制)gen_kb_tree_prompt (当为空的时候应该让LLM生成一个合适的初始目录结构) - # 原始路径,现在标题,摘要,目录 + async def gen_known_info_for_knowledge_prompt(self,knowledge_item:dict,temp_meta = None,need_catalogs = False) -> AgentPrompt: workspace =self.get_workspace_by_msg(None) kb_tree = await workspace.get_knowledege_catalog() + + known_obj = {} title = knowledge_item.get("title") if title: @@ -979,37 +1016,41 @@ class AIAgent: catalogs = knowledge_item.get("catalogs") if catalogs: known_obj["catalogs"] = catalogs - - org_path = knowledge_item.get("path") + + if temp_meta: + for key in temp_meta.keys(): + known_obj[key] = temp_meta[key] + + org_path = knowledge_item.get("full_path") known_obj["orginal_path"] = org_path - know_info_str = f"# Known information\n{json.dumps(known_obj)}\n" + know_info_str = f"# Known information:\n## Current directory structure:\n{kb_tree}\n## Knowlege Metadata:\n{json.dumps(known_obj)}\n" return AgentPrompt(know_info_str) - + async def _llm_read_article(self,knowledge_item:dict,full_path:str) -> ComputeTaskResult: + # Objectives: + # Obtain better titles, abstracts, table of contents (if necessary), tags + # Determine the appropriate place to put it (in line with the organization's goals) + # Known information: + # The reason why the target service's learn_prompt is being sorted + # Summary of the organization's work (if any) + # The current structure of the knowledge base (note the size control) gen_kb_tree_prompt (when empty, LLM should generate an appropriate initial directory structure) + # Original path, current title, abstract, table of contents + # Sorting long files (general tricks) + # Indicate that the input is part of the content, let LLM generate intermediate results for the task + # Enter the content in sequence, when the last content block is input, LLM gets the result - - - async def _llm_read_article(self,knowledge_item:dict) -> ComputeTaskResult: - #目标: - # 得到更好的标题,摘要,目录 (如有必要),tags - # 应放的合适的位置 (结合组织的目标) - #已知信息: - # 整理是为什么目标服务的 learn_prompt - # 组织的工作总结(如有) - # 现在知识库的结构(注意大小控制)gen_kb_tree_prompt (当为空的时候应该让LLM生成一个合适的初始目录结构) - # 原始路径,现在标题,摘要,目录 - - - # 整理长文件(通用技巧) - # 告诉输入的是部分内容,让LLM为任务产生中间结果 - # 依次输入内容,在最后一个内容块输入时,LLM得到结果 #full_content = item.get_article_full_content() workspace = self.get_workspace_by_msg(None) - full_content = await workspace.load_knowledge_content(knowledge_item["hash"]) + full_content = await workspace.load_knowledge_content(full_path) if full_content is None: - return + return None + + if len(full_content) < 16: + logger.warning(f"llm_read_article: article {knowledge_item['path']} is too short,just read summary!") + return None + full_content_len = self.token_len(full_content) if full_content_len < self.get_llm_learn_token_limit(): @@ -1021,9 +1062,9 @@ class AIAgent: prompt.append(known_info_prompt) content_prompt = AgentPrompt(full_content) prompt.append(content_prompt) - - env_functions = workspace.get_knowledge_base_ai_functions() - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,env_functions) + env_functions = None + #env_functions,function_len = workspace.get_knowledge_base_ai_functions() + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,env_functions,None,True) if task_result.result_code != ComputeTaskResultCode.OK: result_obj = {} result_obj["error_str"] = task_result.error_str @@ -1033,10 +1074,41 @@ class AIAgent: return result_obj else: - logger.warning(f"llm_read_article: article {knowledge_item['path']} is too long,just read summary!") - result_obj = {} - result_obj["error_str"] = f"llm_read_article: article {knowledge_item['path']} is too long,just read summary!" - return result_obj + logger.warning(f"llm_read_article: article {full_path} use LLM loop learn!") + pos = 0 + read_len = int(self.get_llm_learn_token_limit() * 1.5) + + temp_meta_data = {} + is_final = False + while pos < full_content_len: + _content = full_content[pos:pos+read_len] + if len(_content) < read_len: + # last chunk + is_final = True + part_content = f"<>\n{_content}" + else: + part_content = f"<>\n{_content}" + pos = pos + read_len + + prompt = self.get_learn_prompt() + known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item,temp_meta_data) + prompt.append(known_info_prompt) + content_prompt = AgentPrompt(part_content) + prompt.append(content_prompt) + env_functions = None + #env_functions,function_len = workspace.get_knowledge_base_ai_functions() + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,env_functions,None,True) + if task_result.result_code != ComputeTaskResultCode.OK: + result_obj = {} + result_obj["error_str"] = task_result.error_str + return result_obj + + result_obj = json.loads(task_result.result_str) + temp_meta_data = result_obj.get("metadata") + if is_final: + return result_obj + + return None async def do_self_think(self): diff --git a/src/aios_kernel/simple_kb_db.py b/src/aios_kernel/simple_kb_db.py index 29480bd..67ef124 100644 --- a/src/aios_kernel/simple_kb_db.py +++ b/src/aios_kernel/simple_kb_db.py @@ -142,7 +142,7 @@ class SimpleKnowledgeDB: title = llm_result.get("title", "") summary = llm_result.get("summary", "") - catalogs = json.dumps(llm_result.get("catalogs", [])) + catalogs = json.dumps(llm_result.get("catalogs", {})) tags = ','.join(llm_result.get("tags", [])) cursor.execute(''' @@ -187,13 +187,16 @@ class SimpleKnowledgeDB: if row2 is None: return None doc_path = row2[0] + return { "full_path": doc_path, "title": row[0], "summary": row[1], - "catalogs": json.loads(row[2]), - "tags": row[3].split(","), + "catalogs": row[2], + "tags": row[3], + "llm_title" : row[4], + "llm_summary" : row[5], } def get_knowledge_without_llm_title(self,limit:int=16) -> List[str]: diff --git a/src/aios_kernel/workspace_env.py b/src/aios_kernel/workspace_env.py index 3912d37..4ece0ce 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios_kernel/workspace_env.py @@ -190,10 +190,13 @@ class WorkspaceEnvironment(Environment): # operation or inner_function async def symlink(self,path:str,target:str) -> str: try: - file_path = self.root_path + path + #file_path = self.root_path + path target_path = self.root_path + target - os.symlink(file_path,target_path) + dir_path = os.path.dirname(target_path) + os.makedirs(dir_path,exist_ok=True) + os.symlink(path,target_path) except Exception as e: + logger.error("symlink failed:%s",e) return str(e) return None @@ -435,18 +438,30 @@ class WorkspaceEnvironment(Environment): # knowledge base system def get_knowledge_base_ai_functions(self): - func_result = {} + all_inner_function = [] - func_result["get_knowledge_catalog"] = SimpleAIFunction("get_knowledge_catalog","get knowledge catalog in tree format", + all_inner_function.append(SimpleAIFunction("get_knowledge_catalog","get knowledge catalog in tree format", self.get_knowledege_catalog, - {"path":f"catalog path,none is /","depth":"max depth of catalog tree,default is 4"}) - func_result["get_knowledge"] = SimpleAIFunction("get_knowledge","get knowledge metadata", + {"path":f"catalog path,none is /","depth":"max depth of catalog tree,default is 4"})) + all_inner_function.append(SimpleAIFunction("get_knowledge","get knowledge metadata", self.get_knowledge, - {"path":f"knowledge path"}) - func_result["load_knowledge_content"] = SimpleAIFunction("load_knowledge_content","load knowledge content", + {"path":f"knowledge path"})) + all_inner_function.append(SimpleAIFunction("load_knowledge_content","load knowledge content", self.load_knowledge_content, - {"path":f"knowledge path","pos":"start position of content","length":"length of content"}) - return func_result + {"path":f"knowledge path","pos":"start position of content","length":"length of content"})) + result_func = [] + result_len = 0 + for inner_func in all_inner_function: + func_name = inner_func.get_name() + + this_func = {} + this_func["name"] = func_name + this_func["description"] = inner_func.get_description() + this_func["parameters"] = inner_func.get_parameters() + result_len += len(json.dumps(this_func)) / 4 + result_func.append(this_func) + + return result_func,result_len async def get_knowledege_catalog(self,path:str=None,only_dir =True,max_depth:int=5)->str: if path: @@ -499,21 +514,26 @@ class WorkspaceEnvironment(Environment): return "not found" - async def load_knowledge_content(self,path:str,pos:int=0,length:int=0) -> str: - full_path = f"{self.root_path}/knowledge/{path}" - if os.islink(full_path): - org_path = os.readlink(full_path) - if full_path.endswith("pdf"): - logger.info("load_knowledge_content:pdf") - return "pdf is not support now!" - else: - async with aiofiles.open(full_path,'rb') as f: - cur_encode = chardet.detect(f.read(1024))['encoding'] + async def load_knowledge_content(self,path:str,pos:int=0,length:int=None) -> str: + if path.endswith("pdf"): + logger.info("load_knowledge_content:pdf") + dir_path = os.path.dirname(path) + base_name = os.path.basename(path) + text_content_path = f"{dir_path}/.{base_name}.txt" + if os.path.exists(text_content_path) is False: + return None + async with aiofiles.open(path, mode='r', encoding=cur_encode) as f: + await f.seek(pos) + content = await f.read(length) + return content + else: + async with aiofiles.open(path,'rb') as f: + cur_encode = chardet.detect(await f.read(1024))['encoding'] - async with aiofiles.open(full_path, mode='r', encoding=cur_encode) as f: - f.seek(pos) - content = await f.read(length) - return content + async with aiofiles.open(path, mode='r', encoding=cur_encode) as f: + await f.seek(pos) + content = await f.read(length) + return content return "load content failed." @@ -550,22 +570,38 @@ class WorkspaceEnvironment(Environment): return def _parse_pdf(self,doc_path:str): - metadata = {} with open(doc_path, 'rb') as file: reader = PyPDF2.PdfReader(file) - doc_info = reader.metadata - if doc_info: - if doc_info.title: - metadata["title"] = doc_info.title - if doc_info.author: - metadata["authors"] = doc_info.author - - bookmarks = reader.outline - if bookmarks: - catalogs = [] - self._parse_pdf_bookmarks(bookmarks,catalogs) - metadata["catalogs"] = json.dumps(catalogs) + try: + doc_info = reader.metadata + if doc_info: + if doc_info.title: + metadata["title"] = doc_info.title + if doc_info.author: + metadata["authors"] = doc_info.author + except Exception as e: + logger.warn("parse pdf metadata failed:%s",e) + + dir_path = os.path.dirname(doc_path) + base_name = os.path.basename(doc_path) + text_content_path = f"{dir_path}/.{base_name}.txt" + full_text = "" + + for page in reader.pages: + text = page.extract_text() + full_text += text + with open(text_content_path, 'w', encoding='utf-8') as f: + f.write(full_text) + + try: + bookmarks = reader.outline + if bookmarks: + catalogs = [] + self._parse_pdf_bookmarks(bookmarks,catalogs) + metadata["catalogs"] = json.dumps(catalogs) + except Exception as e: + logger.warn("parse pdf bookmarks failed:%s",e) return metadata @@ -612,11 +648,14 @@ class WorkspaceEnvironment(Environment): if meta_data.get("title"): title = meta_data["title"] - + logger.info("parse document %s!",doc_path) return hash_result,title,meta_data def _support_file(self,file_name:str) -> bool: + if file_name.startswith("."): + return False + if file_name.endswith(".pdf"): return True if file_name.endswith(".md"): From bddeebdad07ce52dff2a66e825a0dafaa8c1fc1e Mon Sep 17 00:00:00 2001 From: wugren Date: Wed, 15 Nov 2023 18:43:27 +0800 Subject: [PATCH 18/79] Support custom agent --- src/aios_kernel/agent.py | 223 ++++++++++--------- src/aios_kernel/agent_base.py | 129 +++++++---- src/component/agent_manager/agent_manager.py | 57 +++-- 3 files changed, 232 insertions(+), 177 deletions(-) diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 0247cd4..bf2cb84 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -12,7 +12,8 @@ import datetime import copy import sys -from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType,FunctionItem,LLMResult,AgentPrompt,AgentReport,AgentTodo,AgentTodoResult,AgentWorkLog +from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType, FunctionItem, LLMResult, AgentPrompt, AgentReport, \ + AgentTodo, AgentTodoResult, AgentWorkLog, BaseAIAgent from .chatsession import AIChatSession from .compute_task import ComputeTaskResult,ComputeTaskResultCode from .ai_function import AIFunction @@ -116,7 +117,7 @@ class AIAgentTemplete: return True -class AIAgent: +class AIAgent(BaseAIAgent): def __init__(self) -> None: self.role_prompt:AgentPrompt = None self.agent_prompt:AgentPrompt = None @@ -128,7 +129,7 @@ class AIAgent: self.last_recover_time = time.time() self.enable_thread = False self.can_do_unassigned_task = True - + self.agent_id:str = None self.template_id:str = None @@ -137,7 +138,7 @@ class AIAgent: self.enable = True self.enable_kb = False self.enable_timestamp = False - self.guest_prompt_str = None + self.guest_prompt_str = None self.owner_promp_str = None self.contact_prompt_str = None self.history_len = 10 @@ -159,7 +160,7 @@ class AIAgent: self.owner_env : Environment = None self.owenr_bus = None self.enable_function_list = None - + @classmethod def create_from_templete(cls,templete:AIAgentTemplete, fullname:str): @@ -192,7 +193,7 @@ class AIAgent: if config.get("prompt") is not None: self.agent_prompt = AgentPrompt() self.agent_prompt.load_from_config(config["prompt"]) - + if config.get("think_prompt") is not None: self.agent_think_prompt = AgentPrompt() self.agent_think_prompt.load_from_config(config["think_prompt"]) @@ -207,13 +208,13 @@ class AIAgent: if config.get("owner_prompt") is not None: self.owner_promp_str = config["owner_prompt"] - + if config.get("contact_prompt") is not None: self.contact_prompt_str = config["contact_prompt"] if config.get("owner_env") is not None: self.owner_env = config.get("owner_env") - + if config.get("powerby") is not None: self.powerby = config["powerby"] @@ -232,7 +233,7 @@ class AIAgent: if config.get("history_len"): self.history_len = int(config.get("history_len")) return True - + def get_id(self) -> str: return self.agent_id @@ -245,22 +246,22 @@ class AIAgent: def get_llm_model_name(self) -> str: if self.llm_model_name is None: return AIStorage.get_instance().get_user_config().get_value("llm_model_name") - + return self.llm_model_name def get_max_token_size(self) -> int: return self.max_token_size - + def get_llm_learn_token_limit(self) -> int: return self.learn_token_limit - + def get_learn_prompt(self) -> AgentPrompt: return self.learn_prompt - + def get_agent_role_prompt(self) -> AgentPrompt: return self.role_prompt - + def _get_remote_user_prompt(self,remote_user:str) -> AgentPrompt: cm = ContactManager.get_instance() contact = cm.find_contact_by_name(remote_user) @@ -284,7 +285,7 @@ class AIAgent: prompt = AgentPrompt() prompt.system_message = {"role":"system","content":real_str} return prompt - + return None def _get_inner_functions(self) -> dict: @@ -334,13 +335,13 @@ class AIAgent: logger.info("llm execute inner func result:" + result_str) - + prompt.messages.append({"role":"function","content":result_str,"name":func_name}) task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_execute_func llm compute error:{task_result.error_str}") return task_result - + ineternal_call_record.result_str = task_result.result_str ineternal_call_record.done_time = time.time() if org_msg: @@ -356,10 +357,10 @@ class AIAgent: return await self._execute_func(inner_func_call_node,prompt,org_msg,stack_limit-1) else: return task_result - + def get_agent_prompt(self) -> AgentPrompt: return self.agent_prompt - + async def _get_agent_think_prompt(self) -> AgentPrompt: return self.agent_think_prompt @@ -374,11 +375,11 @@ class AIAgent: async def _handle_event(self,event): if event.type == "AgentThink": return await self.do_self_think() - - # async def _process_group_chat_msg(self,msg:AgentMsg) -> AgentMsg: + + # async def _process_group_chat_msg(self,msg:AgentMsg) -> AgentMsg: # session_topic = msg.target + "#" + msg.topic # chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) # workspace = self.get_current_workspace() @@ -410,7 +411,7 @@ class AIAgent: # self._format_msg_by_env_value(prompt) # inner_functions,function_token_len = self._get_inner_functions() - + # system_prompt_len = prompt.get_prompt_token_len() # input_len = len(msg.body) @@ -423,7 +424,7 @@ class AIAgent: # if task_result.result_code != ComputeTaskResultCode.OK: # error_resp = msg.create_error_resp(task_result.error_str) # return error_resp - + # final_result = task_result.result_str # llm_result : LLMResult = LLMResult.from_str(final_result) # is_ignore = False @@ -456,12 +457,12 @@ class AIAgent: # return None def get_workspace_by_msg(self,msg:AgentMsg) -> WorkspaceEnvironment: return self.agent_workspace - + def need_session_summmary(self,msg:AgentMsg,session:AIChatSession) -> bool: return False - + async def _create_openai_thread(self) -> str: - return None + return None async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: msg_prompt = AgentPrompt() @@ -475,7 +476,7 @@ class AIAgent: if self.agent_id in msg.mentions: need_process = True logger.info(f"agent {self.agent_id} recv a group chat message from {msg.sender},but is not mentioned,ignore!") - + if need_process is not True: chatsession.append(msg) resp_msg = msg.create_group_resp_msg(self.agent_id,"") @@ -491,7 +492,7 @@ class AIAgent: need_create_thread = True else: need_create_thread = True - + if need_create_thread: openai_thread_id = await self._create_openai_thread() if openai_thread_id is not None: @@ -508,12 +509,12 @@ class AIAgent: prompt.append(self.get_agent_prompt()) prompt.append(self._get_remote_user_prompt(msg.sender)) self._format_msg_by_env_value(prompt) - + if self.need_session_summmary(msg,chatsession): # get relate session(todos) summary summary = self.llm_select_session_summary(msg,chatsession) prompt.append(AgentPrompt(summary)) - + known_info_str = "# Known information\n" have_known_info = False todos_str,todo_count = await workspace.get_todo_tree() @@ -526,25 +527,25 @@ class AIAgent: if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: history_str,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) else: - history_str,history_token_len = await self.get_prompt_from_session(chatsession,system_prompt_len + function_token_len,input_len) + history_str,history_token_len = await self.get_prompt_from_session(chatsession,system_prompt_len + function_token_len,input_len) if history_str: have_known_info = True known_info_str += history_str - + if have_known_info: known_info_prompt = AgentPrompt(known_info_str) prompt.append(known_info_prompt) # chat context prompt.append(msg_prompt) - + logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") #task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) task_result = await self._do_llm_complection(prompt,inner_functions,msg) if task_result.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(task_result.error_str) return error_resp - + final_result = task_result.result_str if final_result is not None: llm_result : LLMResult = LLMResult.from_str(final_result) @@ -594,11 +595,11 @@ class AIAgent: return None - + async def _get_history_prompt_for_think(self,chatsession:AIChatSession,summary:str,system_token_len:int,pos:int)->(AgentPrompt,int): history_len = (self.max_token_size * 0.7) - system_token_len - + messages = chatsession.read_history(self.history_len,pos,"natural") # read result_token_len = 0 result_prompt = AgentPrompt() @@ -606,7 +607,7 @@ class AIAgent: if summary is not None: if len(summary) > 1: have_summary = True - + if have_summary: result_prompt.messages.append({"role":"user","content":summary}) result_token_len -= len(summary) @@ -628,10 +629,10 @@ class AIAgent: if history_len < 0: logger.warning(f"_get_prompt_from_session reach limit of token,just read {read_history_msg} history message.") break - + result_prompt.messages.append({"role":"user","content":history_str}) return result_prompt,pos+read_history_msg - + async def _get_prompt_from_session_for_groupchat(self,chatsession:AIChatSession,system_token_len,input_token_len,is_groupchat=False): history_len = (self.max_token_size * 0.7) - system_token_len - input_token_len messages = chatsession.read_history(self.history_len) # read @@ -648,7 +649,7 @@ class AIAgent: result_prompt.messages.append({"role":"assistant","content":f"(create on {formatted_time}) {msg.body} "}) else: result_prompt.messages.append({"role":"assistant","content":msg.body}) - + else: if self.enable_timestamp: result_prompt.messages.append({"role":"user","content":f"(create on {formatted_time}) {msg.body} "}) @@ -666,11 +667,11 @@ class AIAgent: async def _llm_summary_work(self,workspace:WorkspaceEnvironment): - # read report ,and update work summary of + # read report ,and update work summary of # build todo list from work summary and goals - # + # report_list = self.get_unread_reports() - + for report in report_list: if self.agent_energy <= 0: break @@ -691,7 +692,7 @@ class AIAgent: async def _llm_review_unassigned_todos(self,workspace:WorkspaceEnvironment): pass - + async def _llm_read_report(self,report:AgentReport,worksapce:WorkspaceEnvironment): work_summary = worksapce.get_work_summary(self.agent_id) prompt : AgentPrompt = AgentPrompt() @@ -710,7 +711,7 @@ class AIAgent: worksapce.set_work_summary(self.agent_id,task_result.result_str) - + # 尝试完成自己的TOOD (不依赖任何其他Agnet) async def do_my_work(self) -> None: workspace : WorkspaceEnvironment = self.get_workspace_by_msg(None) @@ -723,7 +724,7 @@ class AIAgent: todo_list = await workspace.get_todo_list(self.agent_id) check_count = 0 do_count = 0 - + for todo in todo_list: if self.agent_energy <= 0: break @@ -740,7 +741,7 @@ class AIAgent: case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: continue case AgentTodoResult.TODO_RESULT_CODE_OK: - await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_DONE) + await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_DONE) case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: await workspace.update_todo(todo.todo_id,AgentTodo.TDDO_STATE_CHECKFAILED) @@ -748,7 +749,7 @@ class AIAgent: self.agent_energy -= 1 check_count += 1 elif await self.can_do(todo,workspace): - do_result : AgentTodoResult = await self._llm_do(todo,workspace) + do_result : AgentTodoResult = await self._llm_do(todo,workspace) todo.last_do_time = datetime.datetime.now().timestamp() todo.retry_count += 1 @@ -756,14 +757,14 @@ class AIAgent: case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: continue case AgentTodoResult.TODO_RESULT_CODE_OK: - await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_WAITING_CHECK) + await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_WAITING_CHECK) case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_EXEC_FAILED) - await workspace.append_worklog(todo,do_result) + await workspace.append_worklog(todo,do_result) self.agent_energy -= 2 do_count += 1 - + logger.info(f"agent {self.agent_id} ,check:{check_count} todo,do:{do_count} todo.") def get_review_todo_prompt(self,todo:AgentTodo) -> AgentPrompt: @@ -784,50 +785,50 @@ class AIAgent: if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_llm_review_todos compute error:{task_result.error_str}") return - - return - + + return + def get_do_prompt(self,todo:AgentTodo) -> AgentPrompt: return self.do_prompt - + def get_prompt_from_todo(self,todo:AgentTodo) -> AgentPrompt: json_str = json.dumps(todo.raw_obj) return AgentPrompt(json_str) - + async def need_review_todo(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: return False async def can_check(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: if self.get_check_prompt(todo) is None: return False - + if todo.can_check() is False: return False - + if todo.checker is not None: if todo.checker != self.agent_id: return False else: if self.can_do_unassigned_task is False: return False - else: + else: todo.checker = self.agent_id - + return True async def can_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: if todo.can_do() is False: return False - + if todo.worker is not None: if todo.worker != self.agent_id: return False else: if self.can_do_unassigned_task is False: return False - else: - todo.worker = self.agent_id - + else: + todo.worker = self.agent_id + return True async def _llm_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> AgentTodoResult: @@ -835,7 +836,7 @@ class AIAgent: prompt : AgentPrompt = AgentPrompt() #prompt.append(self.agent_prompt) prompt.append(workspace.get_role_prompt(self.agent_id)) - + do_prompt = workspace.get_do_prompt(todo) if do_prompt is None: do_prompt = self.get_do_prompt(todo) @@ -854,7 +855,7 @@ class AIAgent: result.result_code = AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR result.error_str = task_result.error_str return result - + llm_result = LLMResult.from_str(task_result.result_str) # result_str is the explain of how to do this todo result.result_str = llm_result.resp @@ -874,19 +875,19 @@ class AIAgent: result.result_code = AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR #result.error_str = error_str return result - + return result - + async def append_toddo_result(self,todo,worksapce,llm_result,result_str): pass - + def get_check_prompt(self,todo:AgentTodo) -> AgentPrompt: return self.check_prompt async def _llm_check_todo(self, todo:AgentTodo,workspace:WorkspaceEnvironment) : if self.get_check_prompt(todo) is None: return None - + prompt : AgentPrompt = AgentPrompt() prompt.append(self.agent_prompt) prompt.append(workspace.get_role_prompt(self.agent_id)) @@ -907,11 +908,11 @@ class AIAgent: return True todo.last_check_result = task_result.result_str return False - + # 尝试自我学习,会主动获取、读取资料并进行整理 # LLM的本质能力是处理海量知识,应该让LLM能基于知识把自己的工作处理的更好 async def do_self_learn(self) -> None: - # 不同的workspace是否应该有不同的学习方法? + # 不同的workspace是否应该有不同的学习方法? workspace = self.get_workspace_by_msg(None) hash_list = workspace.kb_db.get_knowledge_without_llm_title() for hash in hash_list: @@ -953,7 +954,7 @@ class AIAgent: # self.llm_read_book(kb,item) # learn_power -= 1 # case "article": - # + # # self.llm_read_article(kb,item) # learn_power -= 1 # case "video": @@ -982,8 +983,8 @@ class AIAgent: current_list = kb.get_list(current_path) self_assessment_with_goal = self.get_self_assessment_with_goal() learn_goal = {} - - + + llm_blance_knowledge_base(current_path,current_list,self_assessment_with_goal,learn_goal,learn_power) # 主动学习 @@ -992,7 +993,7 @@ class AIAgent: self.llm_learn_with_search_engine(kb,goal,learn_power) if learn_power <= 0: break - + def parser_learn_llm_result(self,llm_result:LLMResult): pass @@ -1011,13 +1012,13 @@ class AIAgent: known_obj["summary"] = summary tags = knowledge_item.get("tags") if tags: - known_obj["tags"] = tags + known_obj["tags"] = tags if need_catalogs: catalogs = knowledge_item.get("catalogs") if catalogs: known_obj["catalogs"] = catalogs - if temp_meta: + if temp_meta: for key in temp_meta.keys(): known_obj[key] = temp_meta[key] @@ -1030,7 +1031,7 @@ class AIAgent: # Objectives: # Obtain better titles, abstracts, table of contents (if necessary), tags # Determine the appropriate place to put it (in line with the organization's goals) - # Known information: + # Known information: # The reason why the target service's learn_prompt is being sorted # Summary of the organization's work (if any) # The current structure of the knowledge base (note the size control) gen_kb_tree_prompt (when empty, LLM should generate an appropriate initial directory structure) @@ -1040,20 +1041,20 @@ class AIAgent: # Indicate that the input is part of the content, let LLM generate intermediate results for the task # Enter the content in sequence, when the last content block is input, LLM gets the result - + #full_content = item.get_article_full_content() workspace = self.get_workspace_by_msg(None) full_content = await workspace.load_knowledge_content(full_path) if full_content is None: return None - + if len(full_content) < 16: logger.warning(f"llm_read_article: article {knowledge_item['path']} is too short,just read summary!") - return None - + return None + full_content_len = self.token_len(full_content) if full_content_len < self.get_llm_learn_token_limit(): - + # 短文章不用总结catelog #path_list,summary = llm_get_summary(summary,full_content) #prompt = self.get_agent_role_prompt() @@ -1069,7 +1070,7 @@ class AIAgent: result_obj = {} result_obj["error_str"] = task_result.error_str return result_obj - + result_obj = json.loads(task_result.result_str) return result_obj @@ -1102,14 +1103,14 @@ class AIAgent: result_obj = {} result_obj["error_str"] = task_result.error_str return result_obj - + result_obj = json.loads(task_result.result_str) temp_meta_data = result_obj.get("metadata") if is_final: return result_obj return None - + async def do_self_think(self): session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) @@ -1126,8 +1127,8 @@ class AIAgent: used_energy = await self.think_todo_log(todo_log) self.agent_energy -= used_energy - return - + return + async def think_todo_log(self,todo_log:AgentWorkLog): pass @@ -1160,24 +1161,24 @@ class AIAgent: else: new_summary= task_result.result_str logger.info(f"agent {self.agent_id} think session {session_id} from {cur_pos} to {next_pos} summary:{new_summary}") - chatsession.update_think_progress(next_pos,new_summary) - return - + chatsession.update_think_progress(next_pos,new_summary) + return + async def get_prompt_from_session(self,chatsession:AIChatSession,system_token_len,input_token_len) -> AgentPrompt: # TODO: get prompt from group chat is different from single chat if self.enable_thread: return None - + history_len = (self.max_token_size * 0.7) - system_token_len - input_token_len messages = chatsession.read_history(self.history_len) # read result_token_len = 0 - + read_history_msg = 0 have_known_info = False - + known_info = "" if chatsession.summary is not None: - if len(chatsession.summary) > 1: + if len(chatsession.summary) > 1: known_info += f"## Recent conversation summary \n {chatsession.summary}\n" result_token_len -= len(chatsession.summary) have_known_info = True @@ -1196,13 +1197,13 @@ class AIAgent: if history_len < 0: logger.warning(f"_get_prompt_from_session reach limit of token,just read {read_history_msg} history message.") break - + known_info += f"## Recent conversation history \n {histroy_str}\n" - + if have_known_info: return known_info,result_token_len return None,0 - + async def _do_llm_complection(self,prompt:AgentPrompt,inner_functions:dict=None,org_msg:AgentMsg=None,is_json_resp = False) -> ComputeTaskResult: from .compute_kernel import ComputeKernel #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") @@ -1223,28 +1224,28 @@ class AIAgent: if inner_func_call_node: call_prompt : AgentPrompt = copy.deepcopy(prompt) task_result = await self._execute_func(inner_func_call_node,call_prompt,inner_functions,org_msg) - + return task_result - + def need_work(self) -> bool: if self.do_prompt is not None: return True if self.check_prompt is not None: return True - + if self.agent_energy > 2: return True - + return False - + def need_self_think(self) -> bool: return False - + def need_self_learn(self) -> bool: if self.learn_prompt is not None: return True - return False - + return False + def wake_up(self) -> None: if self.agent_task is None: self.agent_task = asyncio.create_task(self._on_timer()) @@ -1288,7 +1289,7 @@ class AIAgent: def token_len(self,text:str) -> int: return ComputeKernel.llm_num_tokens_from_text(text,self.get_llm_model_name()) - - - - + + + + diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index 9ecc604..95b5e9e 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -1,13 +1,16 @@ +import abc import copy +from abc import abstractmethod from datetime import datetime, timedelta import logging from enum import Enum import uuid -import time +import time import re import shlex import json from typing import List + from .ai_function import FunctionItem from .compute_task import ComputeTaskResult @@ -47,9 +50,9 @@ class AgentMsg: def __init__(self,msg_type=AgentMsgType.TYPE_MSG) -> None: self.msg_id = "msg#" + uuid.uuid4().hex self.msg_type:AgentMsgType = msg_type - + self.prev_msg_id:str = None - self.quote_msg_id:str = None + self.quote_msg_id:str = None self.rely_msg_id:str = None # if not none means this is a respone msg self.session_id:str = None @@ -68,7 +71,7 @@ class AgentMsg: self.body_mime:str = None #//default is "text/plain",encode is utf8 #type is call / action - self.func_name = None + self.func_name = None self.args = None self.result_str = None @@ -95,7 +98,7 @@ class AgentMsg: msg.prev_msg_id = prev_msg_id msg.sender = caller return msg - + def create_action_msg(self,action_name:str,args:dict,caller:str): msg = AgentMsg(AgentMsgType.TYPE_ACTION) msg.create_time = time.time() @@ -105,11 +108,11 @@ class AgentMsg: msg.topic = self.topic msg.sender = caller return msg - + def create_error_resp(self,error_msg:str): resp_msg = AgentMsg(AgentMsgType.TYPE_SYSTEM) resp_msg.create_time = time.time() - + resp_msg.rely_msg_id = self.msg_id resp_msg.body = error_msg resp_msg.topic = self.topic @@ -129,7 +132,7 @@ class AgentMsg: resp_msg.topic = self.topic return resp_msg - + def create_group_resp_msg(self,sender_id,resp_body): resp_msg = AgentMsg(AgentMsgType.TYPE_GROUPMSG) resp_msg.create_time = time.time() @@ -158,13 +161,13 @@ class AgentMsg: def get_target(self) -> str: return self.target - + def get_prev_msg_id(self) -> str: return self.prev_msg_id - + def get_quote_msg_id(self) -> str: return self.quote_msg_id - + @classmethod def parse_function_call(cls,func_string:str): str_list = shlex.split(func_string) @@ -237,9 +240,9 @@ class LLMResult: def __init__(self) -> None: self.state : str = "ignore" self.resp : str = "" - self.raw_resp = None + self.raw_resp = None self.paragraphs : dict[str,FunctionItem] = [] - + self.post_msgs : List[AgentMsg] = [] self.send_msgs : List[AgentMsg] = [] @@ -281,14 +284,14 @@ class LLMResult: @classmethod def from_str(self,llm_result_str:str,valid_func:List[str]=None) -> 'LLMResult': r = LLMResult() - + if llm_result_str is None: r.state = "ignore" return r if llm_result_str == "ignore": r.state = "ignore" return r - + if llm_result_str[0] == "{": return LLMResult.from_json_str(llm_result_str) @@ -300,7 +303,7 @@ class LLMResult: case "send_msg":# /send_msg $target_id if len(func_args) != 1: return False - + new_msg = AgentMsg() target_id = func_item.args[0] msg_content = func_item.body @@ -313,7 +316,7 @@ class LLMResult: case "post_msg":# /post_msg $target_id if len(func_args) != 1: return False - + new_msg = AgentMsg() target_id = func_item.args[0] msg_content = func_item.body @@ -333,9 +336,9 @@ class LLMResult: if func_name in valid_func: r.paragraphs[func_name] = func_item return True - + return False - + current_func : FunctionItem = None for line in lines: @@ -361,11 +364,11 @@ class LLMResult: else: r.state = "reponsed" - return r + return r class AgentReport: def __init__(self): - pass + pass class AgentTodoResult: TODO_RESULT_CODE_OK = 0, @@ -386,7 +389,7 @@ class AgentTodoResult: result["error_str"] = self.error_str result["op_list"] = self.op_list return result - + class AgentTodo: TODO_STATE_WAIT_ASSIGN = "wait_assign" @@ -400,7 +403,7 @@ class AgentTodo: TODO_STATE_CASNCEL = "cancel" TODO_STATE_DONE = "done" TODO_STATE_EXPIRED = "expired" - + def __init__(self): self.todo_id = "todo#" + uuid.uuid4().hex self.title = None @@ -409,9 +412,9 @@ class AgentTodo: #self.parent = None self.create_time = time.time() - self.state = "wait_assign" - self.worker = None - self.checker = None + self.state = "wait_assign" + self.worker = None + self.checker = None self.createor = None self.need_check = True @@ -433,7 +436,7 @@ class AgentTodo: todo = AgentTodo() if json_obj.get("id") is not None: todo.todo_id = json_obj.get("id") - + todo.title = json_obj.get("title") todo.state = json_obj.get("state") create_time = json_obj.get("create_time") @@ -448,7 +451,7 @@ class AgentTodo: last_do_time = json_obj.get("last_do_time") if last_do_time: todo.last_do_time = datetime.fromisoformat(last_do_time).timestamp() - last_check_time = json_obj.get("last_check_time") + last_check_time = json_obj.get("last_check_time") if last_check_time: todo.last_check_time = datetime.fromisoformat(last_check_time).timestamp() last_review_time = json_obj.get("last_review_time") @@ -492,21 +495,21 @@ class AgentTodo: result["createor"] = self.createor result["retry_count"] = self.retry_count - return result - + return result + def can_check(self)->bool: if self.state != AgentTodo.TODO_STATE_WAITING_CHECK: return False - + now = datetime.now().timestamp() if self.last_check_time: time_diff = now - self.last_check_time if time_diff < 60*15: logger.info(f"todo {self.title} is already checked, ignore") - return False - + return False + return True - + def can_do(self) -> bool: match self.state: case AgentTodo.TODO_STATE_DONE: @@ -522,32 +525,70 @@ class AgentTodo: if self.retry_count > 3: logger.info(f"todo {self.title} retry count ({self.retry_count}) is too many, ignore") return False - + now = datetime.now().timestamp() - time_diff = self.due_date - now + time_diff = self.due_date - now if time_diff < 0: logger.info(f"todo {self.title} is expired, ignore") self.state = AgentTodo.TODO_STATE_EXPIRED return False - + if time_diff > 7*24*3600: logger.info(f"todo {self.title} is far before due date, ignore") return False - + if self.last_do_time: time_diff = now - self.last_do_time if time_diff < 60*15: logger.info(f"todo {self.title} is already do ignore") - return False - + return False + logger.info(f"todo {self.title} can do.") return True - + class AgentWorkLog: def __init__(self) -> None: pass -class BaseAIAgent: - def __init__(self) -> None: - pass \ No newline at end of file + +class BaseAIAgent(abc.ABC): + @abstractmethod + def get_id(self) -> str: + pass + + @abstractmethod + def get_llm_model_name(self) -> str: + pass + + @abstractmethod + def get_max_token_size(self) -> int: + pass + + @abstractmethod + def get_llm_learn_token_limit(self) -> int: + pass + + @abstractmethod + async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: + pass + + +class CustomAIAgent(BaseAIAgent): + def __init__(self, agent_id: str, llm_model_name: str, max_token_size: int, llm_learn_token_limit: int) -> None: + self.agent_id = agent_id + self.llm_model_name = llm_model_name + self.max_token_size = max_token_size + self.llm_learn_token_limit = llm_learn_token_limit + + def get_id(self) -> str: + return self.agent_id + + def get_llm_model_name(self) -> str: + return self.llm_model_name + + def get_max_token_size(self) -> int: + return self.max_token_size + + def get_llm_learn_token_limit(self) -> int: + return self.llm_learn_token_limit diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index 9b838fb..8f39f71 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -1,11 +1,13 @@ - +import importlib import logging import toml import os +import sys import runpy from typing import Any, Callable, Dict, List, Optional, Union from aios_kernel import AIAgent,AIAgentTemplete,AIStorage,Environment +from aios_kernel.agent_base import BaseAIAgent from package_manager import PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask logger = logging.getLogger(__name__) @@ -17,19 +19,19 @@ cache = "./.agents" class AgentManager: _instance = None - + @classmethod def get_instance(cls)->'AgentManager': if cls._instance is None: cls._instance = AgentManager() return cls._instance - + def __init__(self) -> None: self.agent_templete_env : PackageEnv = None self.agent_env : PackageEnv = None - self.db_path : str = None - self.loaded_agent_instance : Dict[str,AIAgent] = None - + self.db_path : str = None + self.loaded_agent_instance : Dict[str,BaseAIAgent] = None + async def initial(self) -> None: system_app_dir = AIStorage.get_instance().get_system_app_dir() user_data_dir = AIStorage.get_instance().get_myai_dir() @@ -43,13 +45,13 @@ class AgentManager: self.db_path = f"{user_data_dir}/messages.db" self.loaded_agent_instance = {} - + return True - + async def scan_all_agent(self)->None: pass - - + + async def is_exist(self,agent_id:str) -> bool: the_aget = await self.get(agent_id) if the_aget: @@ -60,17 +62,17 @@ class AgentManager: the_agent = self.loaded_agent_instance.get(agent_id) if the_agent: return the_agent - + # try load from disk agent_media_info = self.agent_env.load(agent_id) if agent_media_info is None: return None - + the_agent : AIAgent = await self._load_agent_from_media(agent_media_info) if the_agent is None: logger.warn(f"load agent {agent_id} from media failed!") return None - + the_agent.chat_db = self.db_path return the_agent @@ -86,19 +88,19 @@ class AgentManager: def install(self,templete_id) -> PackageInstallTask: installer = self.agent_templete_env.get_installer() return installer.install(templete_id) - + def uninstall(self,templete_id) -> int: - pass - + pass + async def _load_templete_from_media(self,templete_media:PackageMediaInfo) -> AIAgentTemplete: pass - async def _load_agent_from_media(self,agent_media:PackageMediaInfo) -> AIAgent: + async def _load_agent_from_media(self,agent_media:PackageMediaInfo) -> BaseAIAgent: reader = self.agent_env._create_media_loader(agent_media) if reader is None: logger.error(f"create media loader for {agent_media} failed!") return None - + try: config_file = await reader.read("agent.toml","r") if config_file is None: @@ -125,11 +127,22 @@ class AgentManager: return None return result_agent except Exception as e: - logger.error(f"read agent.toml cfg from {agent_media} failed! unexpected error occurred: {str(e)}") - return None - + custom_agent = os.path.join(agent_media.full_path,"agent.py") + if not os.path.exists(custom_agent): + logger.error(f"read agent.toml cfg from {agent_media} failed! unexpected error occurred: {str(e)}") + return None + + agent_name = os.path.split(agent_media.full_path)[1] + spec = importlib.util.spec_from_file_location(agent_name, custom_agent) + the_api = importlib.util.module_from_spec(spec) + spec.loader.exec_module(the_api) + if not hasattr(the_api,"Agent"): + logger.error(f"read agent.toml cfg from {agent_media} failed! unexpected error occurred: {str(e)}") + return None + return the_api.Agent() + + - def create(self,template,agent_name,agent_last_name,agent_introduce) -> AIAgent: pass From 2d85981dff2f24fe06c69f3df5d1a80e801450bf Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Wed, 15 Nov 2023 20:18:41 -0800 Subject: [PATCH 19/79] support long artic learn --- src/aios_kernel/agent.py | 44 +++++++++++++++++++------------- src/aios_kernel/workspace_env.py | 2 +- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 0247cd4..133759e 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -55,13 +55,12 @@ DEFAULT_AGENT_GOAL_TO_TODO_PROMPT = """ DEFAULT_AGENT_LEARN_PROMPT = """ 我是一名软件工程师,拥有非常优秀的资料学习能力。下面是我学习和整理资料的方法 -1. 结合我的角色为资料产生长度不超过256个Token的摘要;尝试产生不超过16个tag; -2. 现有资料库以文件系统的形式组织,我未来借助资料的摘要来浏览知识库 -3. 我将学习过的资料另存在资料库的合适位置(以/开始的完整路径)。保存位置的目录深度不超过5层,文件夹名称长度不超过16个字符。 -4. 当存在已知信息时,需参考已知信息的内容来思考结果。 -5. 由于LLM的Token限制,我学习的可能只是资料的部分内容,此时我应能产生合适的中间结果,中间结果保存在metadata中。当我决定构建中间结果时,我只构建中间结果。 -6. 当我收到最后一部分内容时,我能结合已知的中间结果产生最终结果。 -7. 总是以json格式返回思考结果,json格式如下 +1. 由于LLM的Token限制,我学习的可能只是资料的部分内容,此时我应能产生合适的学习中间结果,中间结果保存在metadata中。我要么产生中间结果,要么产生最终结果。 +2. 当存在已知信息时,需参考已知信息的内容来思考结果。 +3. 当我收到最后一部分内容时,我能结合已知的中间结果产生最终结果。 +4. 现有资料库以文件系统的形式组织,我未来借助资料的摘要来浏览知识库 +5. 我将学习过的资料另存在资料库的合适位置(以/开始的完整路径)。保存位置的目录深度不超过5层,文件夹名称长度不超过16个字符。 +6. 总是以json格式返回思考结果,json格式如下 { think:"$think_result", metadata:{...} , # temp result for long content @@ -122,7 +121,7 @@ class AIAgent: self.agent_prompt:AgentPrompt = None self.agent_think_prompt:AgentPrompt = None self.llm_model_name:str = None - self.max_token_size:int = 3600 + self.max_token_size:int = 128000 self.agent_energy = 15 self.agent_task = None self.last_recover_time = time.time() @@ -1043,21 +1042,28 @@ class AIAgent: #full_content = item.get_article_full_content() workspace = self.get_workspace_by_msg(None) - full_content = await workspace.load_knowledge_content(full_path) - if full_content is None: - return None + try: + full_content = await workspace.load_knowledge_content(full_path) + if full_content is None: + return None + except Exception as e: + logger.error(f"llm_read_article: load knowledge {full_path} error:{e}") + return None if len(full_content) < 16: logger.warning(f"llm_read_article: article {knowledge_item['path']} is too short,just read summary!") return None + str_len = len(full_content) full_content_len = self.token_len(full_content) + if full_content_len < self.get_llm_learn_token_limit(): # 短文章不用总结catelog #path_list,summary = llm_get_summary(summary,full_content) #prompt = self.get_agent_role_prompt() - prompt = self.get_learn_prompt() + prompt = AgentPrompt() + prompt.append(self.get_learn_prompt()) known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item) prompt.append(known_info_prompt) content_prompt = AgentPrompt(full_content) @@ -1076,21 +1082,23 @@ class AIAgent: else: logger.warning(f"llm_read_article: article {full_path} use LLM loop learn!") pos = 0 - read_len = int(self.get_llm_learn_token_limit() * 1.5) + read_len = int(self.get_llm_learn_token_limit() * 1.2) temp_meta_data = {} is_final = False - while pos < full_content_len: + while pos < str_len: _content = full_content[pos:pos+read_len] - if len(_content) < read_len: + part_cotent_len = len(_content) + if part_cotent_len < read_len: # last chunk is_final = True part_content = f"<>\n{_content}" else: part_content = f"<>\n{_content}" - pos = pos + read_len - prompt = self.get_learn_prompt() + pos = pos + read_len + prompt = AgentPrompt() + prompt.append(self.get_learn_prompt()) known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item,temp_meta_data) prompt.append(known_info_prompt) content_prompt = AgentPrompt(part_content) @@ -1104,7 +1112,7 @@ class AIAgent: return result_obj result_obj = json.loads(task_result.result_str) - temp_meta_data = result_obj.get("metadata") + temp_meta_data = result_obj if is_final: return result_obj diff --git a/src/aios_kernel/workspace_env.py b/src/aios_kernel/workspace_env.py index 4ece0ce..bde7114 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios_kernel/workspace_env.py @@ -528,7 +528,7 @@ class WorkspaceEnvironment(Environment): return content else: async with aiofiles.open(path,'rb') as f: - cur_encode = chardet.detect(await f.read(1024))['encoding'] + cur_encode = chardet.detect(await f.read())['encoding'] async with aiofiles.open(path, mode='r', encoding=cur_encode) as f: await f.seek(pos) From 1dbb218140d548f2c7a50a6ec612a3414d8ad85d Mon Sep 17 00:00:00 2001 From: alexsunxl Date: Fri, 17 Nov 2023 12:01:16 +0800 Subject: [PATCH 20/79] Add the gpt4 vision feature in AIOS --- src/aios_kernel/__init__.py | 1 + src/aios_kernel/compute_kernel.py | 10 +++- src/aios_kernel/compute_task.py | 18 ++++++++ src/aios_kernel/image_2_text_function.py | 46 +++++++++++++++++++ src/aios_kernel/open_ai_node.py | 56 +++++++++++++++++++++++ src/aios_kernel/workflow_env.py | 2 + test/env_test.py | 30 ++++++++++-- test/test.png | Bin 0 -> 706103 bytes 8 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/aios_kernel/image_2_text_function.py create mode 100644 test/test.png diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index a9a5302..9093b07 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -18,6 +18,7 @@ from .email_tunnel import EmailTunnel from .storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem from .contact_manager import ContactManager,Contact,FamilyMember from .text_to_speech_function import TextToSpeechFunction +from .image_2_text_function import Image2TextFunction from .workspace_env import ShellEnvironment from .local_stability_node import Local_Stability_ComputeNode from .stability_node import Stability_ComputeNode diff --git a/src/aios_kernel/compute_kernel.py b/src/aios_kernel/compute_kernel.py index 0a9e3a3..080d455 100644 --- a/src/aios_kernel/compute_kernel.py +++ b/src/aios_kernel/compute_kernel.py @@ -228,5 +228,13 @@ class ComputeKernel: # if task_req.state == ComputeTaskState.DONE: # return None, task_result - # return task_req.error_str, None + def image_2_text(self, image_path: str, prompt:str, model_name:Optional[str] = None, negative_prompt = None): + task = ComputeTask() + task.set_image_2_text_params(image_path,prompt,model_name, negative_prompt) + self.run(task) + return task + async def do_image_2_text(self, image_path: str, prompt:str, model_name:Optional[str] = None, negative_prompt = None) -> ComputeTaskResult: + task = self.image_2_text(image_path,prompt, model_name, negative_prompt) + task = await self._wait_task(task) + return task.result diff --git a/src/aios_kernel/compute_task.py b/src/aios_kernel/compute_task.py index 6b9d74c..3728cdf 100644 --- a/src/aios_kernel/compute_task.py +++ b/src/aios_kernel/compute_task.py @@ -24,6 +24,7 @@ class ComputeTaskType(Enum): NONE = "None" LLM_COMPLETION = "llm_completion" TEXT_2_IMAGE = "text_2_image" + IMAGE_2_TEXT = "image_2_text" IMAGE_2_IMAGE = "image_2_image" VOICE_2_TEXT = "voice_2_text" TEXT_2_VOICE = "text_2_voice" @@ -98,6 +99,23 @@ class ComputeTask: else: self.params["model_name"] = "v1-5-pruned-emaonly" + def set_image_2_text_params(self, image_path: str, prompt: str, model_name, negative_prompt="", callchain_id=None): + self.task_type = ComputeTaskType.IMAGE_2_TEXT + self.create_time = time.time() + self.task_id = uuid.uuid4().hex + self.callchain_id = callchain_id + self.params["image_path"] = image_path + if prompt == '': + self.params["prompt"] = "What's in this image?" + else: + self.params["prompt"] = prompt + self.params["negative_prompt"] = negative_prompt + if model_name is not None: + self.params["model_name"] = model_name + else: + self.params["model_name"] = "gpt-4-vision-preview" + + def display(self) -> str: return f"ComputeTask: {self.task_id} {self.task_type} {self.state}" diff --git a/src/aios_kernel/image_2_text_function.py b/src/aios_kernel/image_2_text_function.py new file mode 100644 index 0000000..da84b04 --- /dev/null +++ b/src/aios_kernel/image_2_text_function.py @@ -0,0 +1,46 @@ +import logging +from typing import Dict + +from aios_kernel import ComputeKernel +from aios_kernel.ai_function import AIFunction + +logger = logging.getLogger(__name__) + +class Image2TextFunction(AIFunction): + def __init__(self): + self.func_id = "image_2_text" + self.description = "According to the input image file address, return the description of the image content" + logger.info(f"init Image2TextFunction") + + def get_name(self) -> str: + return self.func_id + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return { + } + + async def execute(self, **kwargs) -> str: + logger.info(f"execute image_2_text function: {kwargs}") + image_path = kwargs.get("image_path") + data = await ComputeKernel.get_instance().do_image_2_text(image_path, '') + try: + result = data['message']['choices'][0]['message']['content'] + except (KeyError, TypeError, IndexError): + logger.error(f"image_2_text error: {data}") + result = "" + return result + + + def is_local(self) -> bool: + return False + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False + + diff --git a/src/aios_kernel/open_ai_node.py b/src/aios_kernel/open_ai_node.py index 1b686d0..f35fda7 100644 --- a/src/aios_kernel/open_ai_node.py +++ b/src/aios_kernel/open_ai_node.py @@ -6,6 +6,8 @@ from asyncio import Queue import logging import json import aiohttp +import base64 +import requests from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeTaskResultCode from .compute_node import ComputeNode @@ -89,6 +91,50 @@ class OpenAI_ComputeNode(ComputeNode): # result["message"] = result_msg return result + + def _image_2_text(self, task: ComputeTask): + logger.info('openai image_2_text') + # 本地图片处理 + def encode_image(image_path): + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode('utf-8') + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.openai_api_key }" + } + model_name = task.params["model_name"] + base64_image = encode_image(task.params["image_path"]) + payload = { + "model": model_name, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": task.params["prompt"] + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}" + } + } + ] + } + ], + "max_tokens": 300 + } + logger.info('openai send image_2_text request ') + # openai 的库的Vision只支持传图片的url地址。本地图片得用request + response = requests.post("https://api.openai.com/v1/chat/completions", headers=headers, json=payload) + if response.status_code == 200: + logger.info('openai image_2_text success') + return response.json() + else: + logger.error('openai image_2_text error') + logger.error(response.json()) + return None async def _run_task(self, task: ComputeTask): task.state = ComputeTaskState.RUNNING @@ -133,6 +179,12 @@ class OpenAI_ComputeNode(ComputeNode): result.worker_id = self.node_id result.result_str = resp["data"][0]["embedding"] + return result + case ComputeTaskType.IMAGE_2_TEXT: + result.result_code = ComputeTaskResultCode.OK + result.worker_id = self.node_id + # result.result_str = resp["data"][0]["image_2_text"] + result.result["message"] = self._image_2_text(task) return result case ComputeTaskType.LLM_COMPLETION: mode_name = task.params["model_name"] @@ -238,6 +290,10 @@ class OpenAI_ComputeNode(ComputeNode): if model_name.startswith("gpt-"): return True + if task.task_type == ComputeTaskType.IMAGE_2_TEXT: + model_name : str = task.params["model_name"] + if model_name.startswith("gpt-4"): + return True #if task.task_type == ComputeTaskType.TEXT_EMBEDDING: # if task.params["model_name"] == "text-embedding-ada-002": # return True diff --git a/src/aios_kernel/workflow_env.py b/src/aios_kernel/workflow_env.py index 94cac90..9201fba 100644 --- a/src/aios_kernel/workflow_env.py +++ b/src/aios_kernel/workflow_env.py @@ -9,6 +9,7 @@ import logging from typing import Optional from .text_to_speech_function import TextToSpeechFunction +from .image_2_text_function import Image2TextFunction from .compute_kernel import ComputeKernel, ComputeTaskResultCode from .environment import Environment,EnvironmentEvent from .ai_function import SimpleAIFunction @@ -344,6 +345,7 @@ class WorkflowEnvironment(Environment): self.local = threading.local() self.table_name = "WorkflowEnv_" + env_id self.add_ai_function(TextToSpeechFunction()) + self.add_ai_function(Image2TextFunction()) def _get_conn(self): diff --git a/test/env_test.py b/test/env_test.py index 694acbb..5ba2d6b 100644 --- a/test/env_test.py +++ b/test/env_test.py @@ -1,11 +1,9 @@ import asyncio import os import sys - directory = os.path.dirname(__file__) sys.path.append(directory + '/../src') - -from aios_kernel import CalenderEnvironment,WorkflowEnvironment +from aios_kernel import CalenderEnvironment,WorkflowEnvironment,ComputeKernel,OpenAI_ComputeNode,AIStorage async def test_buildin_envs(): @@ -22,6 +20,29 @@ async def test_buildin_envs(): await asyncio.sleep(10) +async def test_image_to_text(): + # init the compute kernel and add the compute node + open_ai_node = OpenAI_ComputeNode.get_instance() + if await open_ai_node.initial() is not True: + print("openai node initial failed!") + return False + ComputeKernel.get_instance().add_compute_node(open_ai_node) + w_env = WorkflowEnvironment("workflow",os.path.abspath(directory + "/../rootfs/workflow_env.db")) + assert w_env.functions['image_2_text'] is not None + await ComputeKernel.get_instance().start() + fn = w_env.get_ai_function('image_2_text') + image_path = os.path.abspath(directory + "/test.png") + arguments = { + 'image_path': image_path + } + + # execute the ai function + result = await fn.execute(**arguments) + assert result is not "" + print(result) + + await asyncio.sleep(10) + if __name__ == "__main__": #test_rstr = "abc is {abc}" @@ -29,4 +50,5 @@ if __name__ == "__main__": #new_str = test_rstr.format_map(values) #print(new_str) - asyncio.run(test_buildin_envs()) \ No newline at end of file + # asyncio.run(test_buildin_envs()) + asyncio.run(test_image_to_text()) \ No newline at end of file diff --git a/test/test.png b/test/test.png new file mode 100644 index 0000000000000000000000000000000000000000..7f6cb7acc161321d3dd8f44f1b0bed7d1d874bed GIT binary patch literal 706103 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

^TJFChjgKhtFLgb4R;E0K4yh_QBT7`j zl!R-NnJ~Jbwg(D&`3BTL6UY69%)Rmdt?|ohC`P{Ij$y z1lbg|Zp;meT`L%1p{8_Zb17U%pjB_tfiQgcUGl8aqz1!1W@{m=LB5hGa0Hv-+g9BO z==^f+oR}-I%pbv!5EV#C)DHMupD?I4cWi(CuD$0okO zNXzj-?zyQ>#i?e&a^3E%;7qnZQ0<0VMSdF6=&7hn7>Uxb&aRwFDR z@}LPg)&hJLIDrJJIMc$r47!kp3|)qW-R`182Rx#{FKBJ+vpKv#L0v`)c^DunDft#u z?>f&f;{B5JXtre#Ry+5$S8siR$P~GkJV#z|r1O&)WTRF?FOg`Qrs*BWe zRp^t~3Oqi+-*DK;1j6yZ)p0w1(oP@JRjJGGWce$&@qy5p+cBMOlwLMW{ALCS{jIOk z>Fpa4SfirLz(8Zc!lQ}MyfYUn;;5ps4)itmlL4Ndi+4{~Z0;2Q2 zV_2A>$EPc8@QOWENL)d!DBzn}JzGuW4>sng7#gw!MBKkD$6Es^Ypi^VW zc*x@3ke%NI^%G67t+VpAFox*o&$-R1Gzz)?7B^YW>8<4e!_R+Q&ddAfWrP6z-ue8A zOIX_sBF-Cp;xgPcS;LTlFPD36#vofq>G^(IwL^?th#0xBtQ8kzyC=E^pIw8+-~ZSx z+$P0rZL?#@xZY|DOF~Ika2Kx?0M~i8R6^i>RHEerbwz{g1Z~Xiwah*&oyK)6bG<8Nxm0~3c z>0d{~cC&d^*(Pr@(YW23_peLRu@QEAaD#P0O*H6&QyDK)R+mG0JIzp5o_fGRnIssG>o*aSy#gU)XxCkj?=M>0O(8sh zMYt*c_6<*lKM1uEu3c5JSVF?Ho1>{Gv|rKDYi4DZreBE{3$srSp?A9$EP@aKMB$Q= zs}s74u%<5~b_OizVm*F!d0MOR1`)tM!%`-V@-Hj1rl8LeT+JUA&b+N0wxbzG^|I9i zExy``>6k`n9?i?-$LS0MqsP0%o-Kg-`M_4*$^eOgJ2}mFIY+rjI_V5A!@m;eCkKa9G;dHXf0Kh|qt4x5)tC_*ZFYY1=;UD@hH;Fep^LJlX_dAKBIHlfAmuQbB8(fi48=?rT+*n7(iKaz@#cMV=PCwA)xL z&l{jmOn%(s%ddt>tNmJilSiC)=19=n?>8$=Dmpq0ZVDJ>N*z$3=I>vtY(6y!h4~L& zhoKZr!XzLBYehZi3tSTP;jup0sMrM-LtHJ*Y!Nc^j%F8@Njp_V@bi7`O6*ILt%=>S!Z(opCVE=6zA=#gSMLVpCs=(vV;t)1&-%#=ZXgc!q+mc6(Q6L6b)A+>C!}>Wn z9y9`Vp9c4PA{<^G-GASxtMVYqZ8pN3b#0l1n92;nd%Bst{(6Q%99oVa-PMd#mqIQU z59YmCw*TZh=7~>6njk8_6w-(aZauYE6@J*+%JhN&O(hWvl1d+p|B>zf z8=Oti?O(@~Fo;nG$SOWikK1E1P86}*WlE|)NT?lVLE5I{MGKJs^WsG{A@5EtyOsyg^GTrtMqc7F2>-R&hkOjz zNJ!wJR}T~e3#uY`?wOvJJ6xIkZaX(i^UWy%U^FSP*FL^tjeMM!Kp$!H1ermkLC6KM z2w)uJj{F6$gFoC^P||c)rpk((q@kg){n@ftA>{VuZd63}9v+_c+A9sItw#39ft<>o*^wAge&)V3C~cNt|0{E{ z+@p_km%5e5JOiz;>2eRws+%%CfweFa|E9T{b@BzpQGNvT7Phs3+EsuEm=Ret`q28f zqW5ryjX@Ni0Mf6F*o}Z*lx_}zQ`-nzwj*7PCuPD*I&W7+4F5Q@!iA9kxL)q`{8*P* zD?P{1X9_HPVn$(oe5wNg3?eRI0P7?5F%$Uvp8wH(Q!vwEgqM)^i0W{4Mpo6UedN3r zd#i4^`665n0Up`-qk7qXLe+7Bgn8&p^AG_5G<~g15zncn%l9DE>VD*}N~RDbl_+R< z%j0vdc?bIwar9uQgN>at>USa=HSznB5|V;1I3}~KHcZ_f)1~{xwc{)+WQ=uFSK+2+oR8yGca>%#H-gamH$OLlDdk~mQ9d5 zM&RrLt>@Ap^x}7mh z*LP;RCL+Ecq|YiJ9pLKocSMixJoIw_jz-P;GcMoR^%%V~ zqb+Gh?05mV&-{Huj!}{3W)F8{)$y~1tH@qSAia_Jl#GvCKVye-+P^{{kk#goc6G|| zKoXrgN&Mp;x=APWT_=cxlQW_wo2R(3N|)bL9RbGX2P}^0zKd))it)ZAWd`3jITsk- zypw-Bmzou-Ut!df29bdCJZk};Q8?{(+L+jwapDiu)tu^AStVlq#DJ)#5tn0_f1fAw zU#!T9FTIZZzi;l%&|9W*7|bD=gUtqIa^dV@3HQm$6LU4uuGW+~EGH&?@2MDC>r)gZ z@82K@l<;Dmd+H;Gj#T^$m|*FJ%YT{N{N1(_C&okIC4ghYXtSP! zTmZJY)AQ%YvWQ@<1;0s@&Ij=Bzb((l#*a0`wd4o*-(Ow^OrWG~8iHtMUi+~zL@AR- z&(b9Hla)0p#BZBQuFm0R=xt)*K~&n$t@BQ+|B~63y#Z1@`1X|IkBg%8_(T89>59ub zRCQ}EWs*Kl5Jf4~=z7wQj22<#{K(#n>pXpd@8}f3K#nI*jgm9ky{&4slN>FWf!aPj znte8wGW_B1bT#t_ekD^Uar!fE1@TYc<4R@%9OExli+q#x91$sYPC0@8B$OBUq>uB- zcl&ESA^Z9M1Y;%RF%yF9M--*LWUq7!#_{y>+0?e!OA=y3BF2a5;elM<$pr>CrOwrqZfGjIH`GM%?s(->1evq%#g z)w-Bz^||!2_Ge8j(L89L)0T-fuT=vhm@QEdz>Q#Gn=875j)MT!GS^j8QGk!5&t&TT z{D$tM~xbwX%*a&cGkB$=+l`$^IGf|HvY zDT1<^giWU9rqmAe-^t@QhfXUmU)4cFA3trm?>|`lzd(SuwH4R0n`+2ly2iLZA&`RW zVoXZ~SDvVz_*2aqmQC+=_f#>dYhk=Rk7y?^hG(Vt4YqDMQ- zd~u$%;-i?H zgET7)QU5YwbP$eTg|;(Bb;u4~2DDTnAHqH^ELIvUk58+r`$J_PNSm1`xQ4N$%C=Ic zAOw=m{JJiu8HaSGo<@$MC%hI{4dpv&k{s!JOs+Ap1;ch>Ro2~zSYW}#8(~A}xoQS{ zC?u8)1Ne>=s#>Hz9B=ZO-5GY|6GBy6FlBX=#|QS;XgroQCl0czcu$L@D8km!;voc( z(_^IhXb1X^Naj>$vM{GxMcoKxq;vU}eu8bCcE)ZqcBW?XW-B?y{$nL6gkX(*td)&@ zS|gO-S{=oIs~@4k^-@zj;5`K-1r9>_G3`geca_5OCcJ2*2&dB{XJ_?FPqeX5M5R

-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�w;?jsqB6z;X(fSG1=^SpAT^rCkRd- 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

S- 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
2?hg)a_su+alWyVZKT)R!4bMf-O5B$0#3` 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%0A_Saqf? zT2{hPwvNQo2!5Yt=* z0|mD6%ZH8am;eHAMA$#!8L!kOc4xFwuQ{5RhCAGvr%TzFm+L98)>TqkntA01n)Ek5 z@+2JPBdxjE}RR~2Oy!Te=>=#Ek%;~n7vA#95XzACawc^5bS+T2_~eTI@d@aIRG z#t)r7*d`YR5Ii9(vbk^cWB(|t;cv{N`iEz{T+ByIl8HqNk;T$16pousp5)87V_Cm8 z;t}qO##S&yZn2qch%xEb0rd2hnf%wEPUso?TQ9er4@)!H`a_z45p*&jdF)!PiHn&T z8*H}=>jmcj@YP+cqVf-mR$OtaiYepm+ND0=@_SYXKH-mdJ!NMBB(qNag&jjJox5WWoJQ>6U;N^>ISs{z{^G^`kzR!wcmiXQZrlLYV3Hk8 zTW4@N-Hcn$i`X6_iJfoKENe`4D#-`}!yZ1JGCSsq+vQ zf9pq%vWAPIl_R0uf4WbM{p~Er0-GUlB+_=!s-{I~o7?-UD_ezaL}wa^V$Rub9(#@3YX;t$!T?S0e`Qw6frd@ zXDF$Dt4fnnn=cgj$Uc^eXyb+FYX08L6g~f4Bn6L*kBh5gd>;;IHBf?Ty+I2N?UGF+ zu_z-Cb=NFQ4gyLMoWTRP&AG>^A3hSreSS|bfMG}Pb(OX6bn4D4QQH_y;UM;Lb=9N3t2k5~OD>%1x zpODI3Uxnt=1`nOKX|75|IzD;mG@;mmU1xAVdL7t4%ZV!Pcr>UnS_R7PkwXKUY1}bL zzHN5tQ)_XIwVfF6zuv*c0EEGum;f<8z7aT4X-@tI)*&`6sDc$Kj+AKan#Ch&Z+dL} zTW&g*wb1zEmd%Wx$7<^OF<`mp4>~da9gp@r3%?GI1uX~Y=p%+WVgkWJezFYskB&bQ zCLQ^4n>*o9dd_Lw?fCQ6VYK5cIp-=|8yaWYbwuEh4idX$Z+kDkNG2zL`^eEZDxYL) z>MHe(cn^r95pINaqe|PO9`ttsxDEQ3OqF`A%cuGxV@1)m#B#ZCZw#_ympJs9&)$Yb zoo*L&K%X8-&$fzC!bvVz^?UmBV7;5PI``Utsh&LhC#kTn<(I9nnG?J8ui9IwDqqR@ z`O~Fk4~5EdZGV2ceA|rF`d@Jw|1UYyMCFXn4E&{*+cD(mV28)J*fdyy`tj;oO;;7j zi64L_#5-8(@+a(2iU)qe>SUnNlWJFoFDXfx?Q%0Auw2UAIDmER`ODE&V=g#wNKdPL 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%0AvYhb& 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 Date: Thu, 2 Nov 2023 10:40:37 +0800 Subject: [PATCH 21/79] email parser document --- doc/tob email.md | 62 +++++++ .../knowledge_pipelines/Bucky.Mail/input.py | 156 ++++++++++++++++++ .../knowledge_manager/input/email.py | 7 +- src/component/knowledge_manager/pipeline.py | 4 +- src/knowledge/pipeline.py | 5 + 5 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 doc/tob email.md create mode 100644 rootfs/knowledge_pipelines/Bucky.Mail/input.py diff --git a/doc/tob email.md b/doc/tob email.md new file mode 100644 index 0000000..f59124e --- /dev/null +++ b/doc/tob email.md @@ -0,0 +1,62 @@ +# issue tree +最核心的机制是树状的issue管理,一个issue应当包含以下属性: ++ 谁提出来的 ++ 分配给谁的,如果有的话 ++ 起始日期 ++ deadline,如果有的话 ++ 在哪个邮件里面提出的,引用某个email的原始链接 ++ 这个issue的summary,有几种情况, + + 一个新的任务,要达成什么目标 + + 提出了一个问题,需求答案 + + 解决了某个issue,完成了task或者解答了一个问题 ++ 推断出来的 issue的状态,进行中,关闭,超时,完成了 ++ parent issue + +knowledge维护一个issue tree,从一个root issue出发(root可以是抽象的,比如一个组织的存在,并不是具体的);knowledge env 提供对这个issue tree的维护接口: ++ 新增issue ++ 更新issue + +# parse email +假定从从某个起始日期开始,以每天为单位,扫描当天新增的email,对每封email: +1. 输入email 和 从knowledge base获取 issue tree +2. llm提示词应当包括:issue tree, email正文, knowledge env, llm完成如下推理: ++ email正文提出了一个新的issue,在knowledge env新增issue ++ email正文改变了一个issue的状态 + + 通报完成了一个task + + 回答了一个问题 + + 明确改变一个issue的状态:认为完成,要延期,认为要取消 ++ 根据推理结果正确产生knowledge env 的调用,更新issue tree的状态 + +## 推理部分可能的out of token: +1. 裁剪掉已经关闭,超时的 issue +2. 根据标题特征,是不是对某个email的回复,定位到某个issue, 裁剪出 sub tree +2. 很长的邮件正文: + 1. 第一种方法:先llm推理email的summary,再把summary当正文输入推理issue + 2. 第二种方法(我觉得更好):分片迭代输入email正文,单次llm推理的提示词就变成:issue tree, 当前email summary, 当段email正文,knowledge env: + + env里面新增一个method,更新当前email summary + + +# build issue tree +## 第一种结构:基于knowledge pipeline +1. pipeline input: 判定当前时间晚于 起始时间并且早于下一个自然天,开始爬正确范围内的邮件输入 +2. pipeline parser:包含准备user prompt 的计算部分,和几个agent ++ 计算部分: 裁剪issue tree,[可选的:调用llm推理生成summary] ++ agent 部分: + + agent提示词:从输入的结构化issue tree, 和邮件正文,回复对issue tree knowledge env的调用 + + 输入提示词: email 正文或者summary,裁剪后的issue tree ++ parser的流程: + 对每一个输入的email,查询(裁剪)当前issue tree,把email 和 issue tree 当作user prompt发送给agent,等待agent返回 + + +## 第二种结构:基于agent workspace(待定) +1. schedule task:在每一天产生一个build issue tree task +2. build issue tree agent: 响应build issue tree task(可不可以以计算为入口,还是只能agent入口) ++ agent调用email env,读出一封邮件 ++ agent调用knowledge env,返回issue tree ++ agent从邮件内容和issue tree推理,回复对issue tree knowledge env 的调用 + +# query issue tree +主动的或者被动的根据当前issue tree的状态,推理出一些汇总的结论: ++ 是不是有超期的事项 ++ 事情是不是有在推进 ++ 有哪些事情完成了 \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/Bucky.Mail/input.py b/rootfs/knowledge_pipelines/Bucky.Mail/input.py new file mode 100644 index 0000000..23ae40a --- /dev/null +++ b/rootfs/knowledge_pipelines/Bucky.Mail/input.py @@ -0,0 +1,156 @@ +import os +import aiofiles +import chardet +import logging +import string +from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal +from aios_kernel.storage import AIStorage + +class KnowledgeEmailSource: + def __init__(self, env: KnowledgePipelineEnvironment, config:dict): + self.config = config + + # @classmethod + # def user_config_items(cls): + # return [("address", "email address"), + # ("password", "email password"), + # ("imap_server", "imap server"), + # ("imap_port", "imap port") + # ] + + async def run_once(self): + # read config from toml file + # and read from config config.local.toml if exists (config.local.toml is ignored by git) + logging.debug(f"knowledge email source {self.id()} run once") + filter = "ALL" + self.client = self.email_client() + await self.read_emails(imap_keyword=filter) + + def email_client(self) -> imaplib.IMAP4_SSL: + logging.info(f"read email config from {self.config.get('imap_server')}") + client = imaplib.IMAP4_SSL( + host=self.config.get('imap_server'), + port=self.config.get('imap_port') + ) + client.login(self.config.get('address'), self.config.get('password')) + return client + + async def read_emails(self, folder: str = 'INBOX', imap_keyword: str = "UNSEEN"): + journal_client = KnowledgeJournalClient() + latest_journal = journal_client.latest_journal(self.id()) + latest_uid = 0 if latest_journal is None else int(latest_journal.item_id) + self.client.select(folder) + _, data = self.client.uid('search', None, imap_keyword) + + # get email uid list + email_list = data[0].split() + logging.info(f"got {len(email_list)} emails") + journal_client = KnowledgeJournalClient() + for uid in email_list: + _uid = int.from_bytes(uid) + if _uid > latest_uid: + email_dir = self.check_email_saved(uid) + if email_dir is not None: + logging.info(f"email uid {uid} already saved") + else: + email_dir = self.read_and_save_email(uid) + logging.info(f"email uid {uid} saved") + email_object = EmailObjectBuilder({}, email_dir).build() + await KnowledgeBase().insert_object(email_object) + journal_client.insert(KnowledgeJournal("email", self.id(), str(int.from_bytes(uid)), str(email_object.calculate_id()))) + + + def read_and_save_email(self, uid: str) -> str: + message_parts = "(BODY.PEEK[])" + _, email_data = self.client.uid('fetch', uid, message_parts) + mail = mailparser.parse_from_bytes(email_data[0][1]) + logging.info(f"got email subject [{mail.subject}]") + self.save_email(mail) + return self.get_local_dir_name(mail) + + def get_local_dir_name(self, mail: mailparser.MailParser) -> str: + dir = f"{self.local_root()}/{self.config.get('address')}" + name = f"{mail.subject}__{mail.date}" + name = hashlib.md5(name.encode('utf-8')).hexdigest() + return f"{dir}/{name}" + + def check_email_saved(self, uid: str) -> str: + message_parts = "(BODY[HEADER])" + _, email_data = self.client.uid('fetch', uid, message_parts) + mail = mailparser.parse_from_bytes(email_data[0][1]) + logging.info(f"[{uid}]check email subject [{mail.subject}]") + dir = self.get_local_dir_name(mail) + logging.info(f"check email saved {dir}") + file = f"{dir}/email.txt" + if os.path.exists(file): + return dir + return None + + # save email attachment(images) + def save_email_attachment(self, mail: mailparser.MailParser, email_dir: str): + for attachment in mail.attachments: + if attachment['mail_content_type'] in ['image/png', 'image/jpeg', 'image/gif']: + print('current mail have image attachment') + img_dir = f"{email_dir}/image" + if not os.path.exists(img_dir): + os.makedirs(img_dir) + filename = attachment['filename'] + filefullname = f"{img_dir}/{filename}" + image_data = attachment['payload'] + try: + image_data = base64.b64decode(image_data) + except base64.binascii.Error: + image_data = image_data.encode() + with open(filefullname, 'wb') as f: + f.write(image_data) + logging.info(f"save email image {filename} success") + + # save email body images(html content) + def save_body_images(self, html_content: str, email_dir: str): + # get all image urls + soup = BeautifulSoup(html_content, 'html.parser') + img_tags = soup.find_all('img') + img_urls = [img['src'] for img in img_tags if 'src' in img.attrs] + logging.info(f'Found {len(img_urls)} images in email body') + + name_count = 0 + + if not os.path.exists(email_dir): + os.makedirs(email_dir) + + for img_url in img_urls: + # keep the original image filename(last of url) + ext = img_url.split('/')[-1].split('.')[-1] + img_filename = os.path.join(email_dir, f"{name_count}.{ext}") + name_count += 1 + # download image + response = requests.get(img_url, stream=True) + if response.status_code == 200: + with open(img_filename, 'wb') as img_file: + for chunk in response.iter_content(1024): + img_file.write(chunk) + logging.info(f'Downloaded {img_url} to {img_filename}') + else: + logging.info(f'Failed to download {img_url}') + + # save email content to local dir + def save_email(self, mail: mailparser.MailParser): + dir = f"{self.local_root()}/{self.config.get('address')}" + if not os.path.exists(dir): + os.makedirs(dir) + email_dir = self.get_local_dir_name(mail) + logging.info(f"save email to {email_dir}") + if not os.path.exists(email_dir): + os.makedirs(email_dir) + with open(f"{email_dir}/email.txt", "w", encoding='utf-8') as f: + # soup = BeautifulSoup(mail.body, 'html.parser') + f.write(mail.body) + with open(f"{email_dir}/meta.json", "w", encoding='utf-8') as f: + mail_dict = json.loads(mail.mail_json) + if 'body' in mail_dict: + del mail_dict['body'] + json.dump(mail_dict, f, ensure_ascii=False, indent=4) + logging.info(f"save email meta info {f.name}") + + self.save_email_attachment(mail, email_dir) + self.save_body_images(mail.body, f"{email_dir}/body_image") \ No newline at end of file diff --git a/src/component/knowledge_manager/input/email.py b/src/component/knowledge_manager/input/email.py index a8eb353..3bcc6b3 100644 --- a/src/component/knowledge_manager/input/email.py +++ b/src/component/knowledge_manager/input/email.py @@ -1,6 +1,6 @@ class KnowledgeEmailSource: - def __init__(self, config:dict): + def __init__(self, env: KnowledgePipelineEnvironment, config:dict): self.config = config self.config["type"] = "email" @@ -15,11 +15,6 @@ class KnowledgeEmailSource: ("imap_port", "imap port") ] - @classmethod - def local_root(cls): - user_data_dir = AIStorage.get_instance().get_myai_dir() - return os.path.abspath(f"{user_data_dir}/knowledge/email") - async def run_once(self): # read config from toml file # and read from config config.local.toml if exists (config.local.toml is ignored by git) diff --git a/src/component/knowledge_manager/pipeline.py b/src/component/knowledge_manager/pipeline.py index 55a5105..1701f4f 100644 --- a/src/component/knowledge_manager/pipeline.py +++ b/src/component/knowledge_manager/pipeline.py @@ -46,7 +46,7 @@ class KnowledgePipelineManager: input_init = runpy.run_path(input_module)["init"] else: input_init = self.input_modules.get(input_module) - input_params = config["input"]["params"] + input_params = config["input"].get("params") parser_module = config["parser"]["module"] _, ext = os.path.splitext(parser_module) @@ -55,7 +55,7 @@ class KnowledgePipelineManager: parser_init = runpy.run_path(parser_module)["init"] else: parser_init = self.parser_modules.get(parser_module) - parser_params = config["parser"]["params"] + parser_params = config["parser"].get("params") data_path = os.path.join(self.root_dir, name) diff --git a/src/knowledge/pipeline.py b/src/knowledge/pipeline.py index af3b665..62f246a 100644 --- a/src/knowledge/pipeline.py +++ b/src/knowledge/pipeline.py @@ -1,6 +1,7 @@ import datetime import sqlite3 import os +import logging from . import ObjectID, KnowledgeStore from enum import Enum @@ -66,12 +67,16 @@ class KnowledgePipelineEnvironment: os.makedirs(pipeline_path) self.pipeline_path = pipeline_path self.journal = KnowledgePipelineJournalClient(pipeline_path) + self.logger = logging.getLogger() def get_journal(self) -> KnowledgePipelineJournalClient: return self.journal def get_knowledge_store(self) -> KnowledgeStore: return self.knowledge_store + + def get_logger(self) -> logging.Logger: + return self.logger class KnowledgePipelineState(Enum): INIT = 0 From 9c00187041136561c943b5ded1a4011994bf8635 Mon Sep 17 00:00:00 2001 From: tsukasa Date: Tue, 14 Nov 2023 18:12:26 +0800 Subject: [PATCH 22/79] a issue parser of email --- rootfs/agents/FindPhoto/agent.toml | 21 -- rootfs/agents/FindPhoto/enviroment.py | 0 .../knowledge_pipelines/Bucky.Mail/input.py | 156 ----------- .../knowledge_pipelines/Mail/Issue/local.py | 9 + .../knowledge_pipelines/Mail/Issue/parser.py | 10 + .../Mail/Issue/pipeline.toml | 9 + .../knowledge_pipelines/Mail/Spider/input.py | 10 + .../Mail/Spider/pipeline.toml | 4 + rootfs/knowledge_pipelines/pipelines.toml | 2 +- src/aios_kernel/__init__.py | 2 +- src/aios_kernel/agent_base.py | 117 +++++++- src/aios_kernel/environment.py | 4 +- .../knowledge_manager/input/email.py | 153 ---------- .../knowledge_manager/input/local_dir.py | 68 ----- .../knowledge_manager/parser/embedding.py | 102 ------- src/component/knowledge_manager/pipeline.py | 6 +- src/component/mail_environment/1/mail.json | 6 + src/component/mail_environment/1/mail.txt | 51 ++++ src/component/mail_environment/__init__.py | 3 + src/component/mail_environment/issue.py | 200 +++++++++++++ src/component/mail_environment/local.py | 34 +++ src/component/mail_environment/mail.py | 265 ++++++++++++++++++ src/component/mail_environment/spider.py | 53 ++++ src/knowledge/object/object.py | 18 +- src/knowledge/object/object_id.py | 11 + src/knowledge/pipeline.py | 3 + src/service/aios_shell/aios_shell.py | 1 - 27 files changed, 797 insertions(+), 521 deletions(-) delete mode 100644 rootfs/agents/FindPhoto/agent.toml delete mode 100644 rootfs/agents/FindPhoto/enviroment.py delete mode 100644 rootfs/knowledge_pipelines/Bucky.Mail/input.py create mode 100644 rootfs/knowledge_pipelines/Mail/Issue/local.py create mode 100644 rootfs/knowledge_pipelines/Mail/Issue/parser.py create mode 100644 rootfs/knowledge_pipelines/Mail/Issue/pipeline.toml create mode 100644 rootfs/knowledge_pipelines/Mail/Spider/input.py create mode 100644 rootfs/knowledge_pipelines/Mail/Spider/pipeline.toml delete mode 100644 src/component/knowledge_manager/input/email.py delete mode 100644 src/component/knowledge_manager/input/local_dir.py delete mode 100644 src/component/knowledge_manager/parser/embedding.py create mode 100644 src/component/mail_environment/1/mail.json create mode 100644 src/component/mail_environment/1/mail.txt create mode 100644 src/component/mail_environment/__init__.py create mode 100644 src/component/mail_environment/issue.py create mode 100644 src/component/mail_environment/local.py create mode 100644 src/component/mail_environment/mail.py create mode 100644 src/component/mail_environment/spider.py diff --git a/rootfs/agents/FindPhoto/agent.toml b/rootfs/agents/FindPhoto/agent.toml deleted file mode 100644 index ddb3f3f..0000000 --- a/rootfs/agents/FindPhoto/agent.toml +++ /dev/null @@ -1,21 +0,0 @@ -instance_id = "FindPhoto" -fullname = "FindPhoto" -llm_model_name = "gpt-4" -max_token_size = 16000 -enable_timestamp = "false" -owner_prompt = "我是你的主人{name}" -contact_prompt = "我是你的朋友{name}" -owner_env = "environment.py" - -[[prompt]] -role = "system" -content = """ -你是FindPhoto,你可以访问我的照片目录。 - -*** -你在收到我的信息后,按如下规则处理 -1. 在第一次接受到一条信息时,优先尝试用合适的关键字查询去查询知识库。 -2. 如果信息中包含一段知识库的查询结果,尝试用查询结果处理,如果还是不能处理,尝试递增index继续查询。 -3. 如果要返回知识库结果条目,在消息开头附上他的json字符串。 -""" - diff --git a/rootfs/agents/FindPhoto/enviroment.py b/rootfs/agents/FindPhoto/enviroment.py deleted file mode 100644 index e69de29..0000000 diff --git a/rootfs/knowledge_pipelines/Bucky.Mail/input.py b/rootfs/knowledge_pipelines/Bucky.Mail/input.py deleted file mode 100644 index 23ae40a..0000000 --- a/rootfs/knowledge_pipelines/Bucky.Mail/input.py +++ /dev/null @@ -1,156 +0,0 @@ -import os -import aiofiles -import chardet -import logging -import string -from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal -from aios_kernel.storage import AIStorage - -class KnowledgeEmailSource: - def __init__(self, env: KnowledgePipelineEnvironment, config:dict): - self.config = config - - # @classmethod - # def user_config_items(cls): - # return [("address", "email address"), - # ("password", "email password"), - # ("imap_server", "imap server"), - # ("imap_port", "imap port") - # ] - - async def run_once(self): - # read config from toml file - # and read from config config.local.toml if exists (config.local.toml is ignored by git) - logging.debug(f"knowledge email source {self.id()} run once") - filter = "ALL" - self.client = self.email_client() - await self.read_emails(imap_keyword=filter) - - def email_client(self) -> imaplib.IMAP4_SSL: - logging.info(f"read email config from {self.config.get('imap_server')}") - client = imaplib.IMAP4_SSL( - host=self.config.get('imap_server'), - port=self.config.get('imap_port') - ) - client.login(self.config.get('address'), self.config.get('password')) - return client - - async def read_emails(self, folder: str = 'INBOX', imap_keyword: str = "UNSEEN"): - journal_client = KnowledgeJournalClient() - latest_journal = journal_client.latest_journal(self.id()) - latest_uid = 0 if latest_journal is None else int(latest_journal.item_id) - self.client.select(folder) - _, data = self.client.uid('search', None, imap_keyword) - - # get email uid list - email_list = data[0].split() - logging.info(f"got {len(email_list)} emails") - journal_client = KnowledgeJournalClient() - for uid in email_list: - _uid = int.from_bytes(uid) - if _uid > latest_uid: - email_dir = self.check_email_saved(uid) - if email_dir is not None: - logging.info(f"email uid {uid} already saved") - else: - email_dir = self.read_and_save_email(uid) - logging.info(f"email uid {uid} saved") - email_object = EmailObjectBuilder({}, email_dir).build() - await KnowledgeBase().insert_object(email_object) - journal_client.insert(KnowledgeJournal("email", self.id(), str(int.from_bytes(uid)), str(email_object.calculate_id()))) - - - def read_and_save_email(self, uid: str) -> str: - message_parts = "(BODY.PEEK[])" - _, email_data = self.client.uid('fetch', uid, message_parts) - mail = mailparser.parse_from_bytes(email_data[0][1]) - logging.info(f"got email subject [{mail.subject}]") - self.save_email(mail) - return self.get_local_dir_name(mail) - - def get_local_dir_name(self, mail: mailparser.MailParser) -> str: - dir = f"{self.local_root()}/{self.config.get('address')}" - name = f"{mail.subject}__{mail.date}" - name = hashlib.md5(name.encode('utf-8')).hexdigest() - return f"{dir}/{name}" - - def check_email_saved(self, uid: str) -> str: - message_parts = "(BODY[HEADER])" - _, email_data = self.client.uid('fetch', uid, message_parts) - mail = mailparser.parse_from_bytes(email_data[0][1]) - logging.info(f"[{uid}]check email subject [{mail.subject}]") - dir = self.get_local_dir_name(mail) - logging.info(f"check email saved {dir}") - file = f"{dir}/email.txt" - if os.path.exists(file): - return dir - return None - - # save email attachment(images) - def save_email_attachment(self, mail: mailparser.MailParser, email_dir: str): - for attachment in mail.attachments: - if attachment['mail_content_type'] in ['image/png', 'image/jpeg', 'image/gif']: - print('current mail have image attachment') - img_dir = f"{email_dir}/image" - if not os.path.exists(img_dir): - os.makedirs(img_dir) - filename = attachment['filename'] - filefullname = f"{img_dir}/{filename}" - image_data = attachment['payload'] - try: - image_data = base64.b64decode(image_data) - except base64.binascii.Error: - image_data = image_data.encode() - with open(filefullname, 'wb') as f: - f.write(image_data) - logging.info(f"save email image {filename} success") - - # save email body images(html content) - def save_body_images(self, html_content: str, email_dir: str): - # get all image urls - soup = BeautifulSoup(html_content, 'html.parser') - img_tags = soup.find_all('img') - img_urls = [img['src'] for img in img_tags if 'src' in img.attrs] - logging.info(f'Found {len(img_urls)} images in email body') - - name_count = 0 - - if not os.path.exists(email_dir): - os.makedirs(email_dir) - - for img_url in img_urls: - # keep the original image filename(last of url) - ext = img_url.split('/')[-1].split('.')[-1] - img_filename = os.path.join(email_dir, f"{name_count}.{ext}") - name_count += 1 - # download image - response = requests.get(img_url, stream=True) - if response.status_code == 200: - with open(img_filename, 'wb') as img_file: - for chunk in response.iter_content(1024): - img_file.write(chunk) - logging.info(f'Downloaded {img_url} to {img_filename}') - else: - logging.info(f'Failed to download {img_url}') - - # save email content to local dir - def save_email(self, mail: mailparser.MailParser): - dir = f"{self.local_root()}/{self.config.get('address')}" - if not os.path.exists(dir): - os.makedirs(dir) - email_dir = self.get_local_dir_name(mail) - logging.info(f"save email to {email_dir}") - if not os.path.exists(email_dir): - os.makedirs(email_dir) - with open(f"{email_dir}/email.txt", "w", encoding='utf-8') as f: - # soup = BeautifulSoup(mail.body, 'html.parser') - f.write(mail.body) - with open(f"{email_dir}/meta.json", "w", encoding='utf-8') as f: - mail_dict = json.loads(mail.mail_json) - if 'body' in mail_dict: - del mail_dict['body'] - json.dump(mail_dict, f, ensure_ascii=False, indent=4) - logging.info(f"save email meta info {f.name}") - - self.save_email_attachment(mail, email_dir) - self.save_body_images(mail.body, f"{email_dir}/body_image") \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/Mail/Issue/local.py b/rootfs/knowledge_pipelines/Mail/Issue/local.py new file mode 100644 index 0000000..1edfff8 --- /dev/null +++ b/rootfs/knowledge_pipelines/Mail/Issue/local.py @@ -0,0 +1,9 @@ +import sys +import os +from knowledge import KnowledgePipelineEnvironment +directory = os.path.dirname(__file__) +sys.path.append(directory + '/../../../../src/component/') + +from mail_environment import LocalEmail +def init(env: KnowledgePipelineEnvironment, params: dict): + return LocalEmail(env, params) \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/Mail/Issue/parser.py b/rootfs/knowledge_pipelines/Mail/Issue/parser.py new file mode 100644 index 0000000..a5f819b --- /dev/null +++ b/rootfs/knowledge_pipelines/Mail/Issue/parser.py @@ -0,0 +1,10 @@ +import sys +import os +from knowledge import * +directory = os.path.dirname(__file__) + +sys.path.append(directory + '/../../../../src/component/') +from mail_environment import IssueParser + +def init(env: KnowledgePipelineEnvironment, params: dict): + return IssueParser(env, params) \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/Mail/Issue/pipeline.toml b/rootfs/knowledge_pipelines/Mail/Issue/pipeline.toml new file mode 100644 index 0000000..d909ba9 --- /dev/null +++ b/rootfs/knowledge_pipelines/Mail/Issue/pipeline.toml @@ -0,0 +1,9 @@ +name = "Mail.Issue" +input.module = "local.py" +input.params.path = "${myai_dir}/mail" +input.params.watch = true +parser.module = "parser.py" +parser.params.mail_path = "${myai_dir}/mail" +parser.params.issue_path = "${myai_dir}/mail/issue.json" +parser.params.root_issue = "巴克云公司推进中的项目" + diff --git a/rootfs/knowledge_pipelines/Mail/Spider/input.py b/rootfs/knowledge_pipelines/Mail/Spider/input.py new file mode 100644 index 0000000..d5e6958 --- /dev/null +++ b/rootfs/knowledge_pipelines/Mail/Spider/input.py @@ -0,0 +1,10 @@ +import sys +import os +from knowledge import KnowledgePipelineEnvironment +directory = os.path.dirname(__file__) +sys.path.append(directory + '/../../../../src/component/') + +from mail_environment import EmailSpider + +def init(env: KnowledgePipelineEnvironment, params: dict): + return EmailSpider(env, params) \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/Mail/Spider/pipeline.toml b/rootfs/knowledge_pipelines/Mail/Spider/pipeline.toml new file mode 100644 index 0000000..df9f306 --- /dev/null +++ b/rootfs/knowledge_pipelines/Mail/Spider/pipeline.toml @@ -0,0 +1,4 @@ +name = "Mail.Issue" +input.module = "input.py" +input.params.path = "${myai_dir}/data" + diff --git a/rootfs/knowledge_pipelines/pipelines.toml b/rootfs/knowledge_pipelines/pipelines.toml index 3c78084..7e69436 100644 --- a/rootfs/knowledge_pipelines/pipelines.toml +++ b/rootfs/knowledge_pipelines/pipelines.toml @@ -1,3 +1,3 @@ pipelines = [ - "Mia" + "Mail/Issue" ] \ No newline at end of file diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index 9093b07..a1ab85f 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -1,7 +1,7 @@ from .environment import Environment,EnvironmentEvent from .agent_base import AgentMsg,AgentMsgStatus,AgentMsgType,AgentPrompt from .chatsession import AIChatSession -from .agent import AIAgent,AIAgentTemplete +from .agent import AIAgent,AIAgentTemplete, BaseAIAgent from .compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType from .compute_node import ComputeNode,LocalComputeNode from .open_ai_node import OpenAI_ComputeNode diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index 95b5e9e..60c5fe6 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -11,8 +11,10 @@ import shlex import json from typing import List -from .ai_function import FunctionItem -from .compute_task import ComputeTaskResult +from .ai_function import FunctionItem, AIFunction +from .compute_task import ComputeTaskResult,ComputeTaskResultCode +from .environment import Environment + logger = logging.getLogger(__name__) @@ -592,3 +594,114 @@ class CustomAIAgent(BaseAIAgent): def get_llm_learn_token_limit(self) -> int: return self.llm_learn_token_limit + +class BaseAIAgent: + def __init__(self) -> None: + pass + + @classmethod + def _get_inner_functions(cls, env:Environment) -> (dict,int): + if env is None: + return None,0 + + all_inner_function = env.get_all_ai_functions() + if all_inner_function is None: + return None,0 + + result_func = [] + result_len = 0 + for inner_func in all_inner_function: + func_name = inner_func.get_name() + this_func = {} + this_func["name"] = func_name + this_func["description"] = inner_func.get_description() + this_func["parameters"] = inner_func.get_parameters() + result_len += len(json.dumps(this_func)) / 4 + result_func.append(this_func) + + return result_func,result_len + + @classmethod + async def do_llm_complection( + cls, + env:Environment, + prompt:AgentPrompt, + org_msg:AgentMsg, + llm_model_name:str, + max_token_size:int + ) -> ComputeTaskResult: + from .compute_kernel import ComputeKernel + #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") + inner_functions,inner_functions_len = cls._get_inner_functions(env) + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,llm_model_name,max_token_size,inner_functions) + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"llm compute error:{task_result.error_str}") + #error_resp = msg.create_error_resp(task_result.error_str) + return task_result + + result_message = task_result.result.get("message") + inner_func_call_node = None + if result_message: + inner_func_call_node = result_message.get("function_call") + + if inner_func_call_node: + call_prompt : AgentPrompt = copy.deepcopy(prompt) + task_result = await cls._execute_func(env,inner_func_call_node,call_prompt,inner_functions,org_msg,llm_model_name,max_token_size) + + return task_result + + @classmethod + async def _execute_func( + cls, + env: Environment, + inner_func_call_node: dict, + prompt: AgentPrompt, + inner_functions: dict, + org_msg:AgentMsg, + llm_model_name:str, + max_token_size:int, + stack_limit = 5 + ) -> ComputeTaskResult: + from .compute_kernel import ComputeKernel + func_name = inner_func_call_node.get("name") + arguments = json.loads(inner_func_call_node.get("arguments")) + logger.info(f"llm execute inner func:{func_name} ({json.dumps(arguments)})") + + func_node : AIFunction = env.get_ai_function(func_name) + if func_node is None: + result_str = f"execute {func_name} error,function not found" + else: + if org_msg: + ineternal_call_record = AgentMsg.create_internal_call_msg(func_name,arguments,org_msg.get_msg_id(),org_msg.target) + + try: + result_str:str = await func_node.execute(**arguments) + except Exception as e: + result_str = f"execute {func_name} error:{str(e)}" + logger.error(f"llm execute inner func:{func_name} error:{e}") + + + logger.info("llm execute inner func result:" + result_str) + + prompt.messages.append({"role":"function","content":result_str,"name":func_name}) + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,llm_model_name,max_token_size,inner_functions) + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"llm compute error:{task_result.error_str}") + return task_result + + ineternal_call_record.result_str = task_result.result_str + ineternal_call_record.done_time = time.time() + if org_msg: + org_msg.inner_call_chain.append(ineternal_call_record) + + inner_func_call_node = None + if stack_limit > 0: + result_message : dict = task_result.result.get("message") + if result_message: + inner_func_call_node = result_message.get("function_call") + + if inner_func_call_node: + return await cls._execute_func(env,inner_func_call_node,prompt,inner_functions,org_msg,llm_model_name,max_token_size,stack_limit-1) + else: + return task_result +>>>>>>> 2f9cee9 (a issue parser of email) diff --git a/src/aios_kernel/environment.py b/src/aios_kernel/environment.py index cf5e9b3..ab384cd 100644 --- a/src/aios_kernel/environment.py +++ b/src/aios_kernel/environment.py @@ -45,8 +45,8 @@ class Environment: #@abstractmethod #TODO: how to use env? different env has different prompt - #def get_env_prompt(self) -> str: - # pass + def get_env_prompt(self) -> str: + pass def add_ai_function(self,func:AIFunction) -> None: if self.functions.get(func.get_name()) is not None: diff --git a/src/component/knowledge_manager/input/email.py b/src/component/knowledge_manager/input/email.py deleted file mode 100644 index 3bcc6b3..0000000 --- a/src/component/knowledge_manager/input/email.py +++ /dev/null @@ -1,153 +0,0 @@ - -class KnowledgeEmailSource: - def __init__(self, env: KnowledgePipelineEnvironment, config:dict): - self.config = config - self.config["type"] = "email" - - def id(self): - return self.config["address"] - - @classmethod - def user_config_items(cls): - return [("address", "email address"), - ("password", "email password"), - ("imap_server", "imap server"), - ("imap_port", "imap port") - ] - - async def run_once(self): - # read config from toml file - # and read from config config.local.toml if exists (config.local.toml is ignored by git) - logging.debug(f"knowledge email source {self.id()} run once") - filter = "ALL" - self.client = self.email_client() - await self.read_emails(imap_keyword=filter) - - def email_client(self) -> imaplib.IMAP4_SSL: - logging.info(f"read email config from {self.config.get('imap_server')}") - client = imaplib.IMAP4_SSL( - host=self.config.get('imap_server'), - port=self.config.get('imap_port') - ) - client.login(self.config.get('address'), self.config.get('password')) - return client - - async def read_emails(self, folder: str = 'INBOX', imap_keyword: str = "UNSEEN"): - journal_client = KnowledgeJournalClient() - latest_journal = journal_client.latest_journal(self.id()) - latest_uid = 0 if latest_journal is None else int(latest_journal.item_id) - self.client.select(folder) - _, data = self.client.uid('search', None, imap_keyword) - - # get email uid list - email_list = data[0].split() - logging.info(f"got {len(email_list)} emails") - journal_client = KnowledgeJournalClient() - for uid in email_list: - _uid = int.from_bytes(uid) - if _uid > latest_uid: - email_dir = self.check_email_saved(uid) - if email_dir is not None: - logging.info(f"email uid {uid} already saved") - else: - email_dir = self.read_and_save_email(uid) - logging.info(f"email uid {uid} saved") - email_object = EmailObjectBuilder({}, email_dir).build() - await KnowledgeBase().insert_object(email_object) - journal_client.insert(KnowledgeJournal("email", self.id(), str(int.from_bytes(uid)), str(email_object.calculate_id()))) - - - def read_and_save_email(self, uid: str) -> str: - message_parts = "(BODY.PEEK[])" - _, email_data = self.client.uid('fetch', uid, message_parts) - mail = mailparser.parse_from_bytes(email_data[0][1]) - logging.info(f"got email subject [{mail.subject}]") - self.save_email(mail) - return self.get_local_dir_name(mail) - - def get_local_dir_name(self, mail: mailparser.MailParser) -> str: - dir = f"{self.local_root()}/{self.config.get('address')}" - name = f"{mail.subject}__{mail.date}" - name = hashlib.md5(name.encode('utf-8')).hexdigest() - return f"{dir}/{name}" - - def check_email_saved(self, uid: str) -> str: - message_parts = "(BODY[HEADER])" - _, email_data = self.client.uid('fetch', uid, message_parts) - mail = mailparser.parse_from_bytes(email_data[0][1]) - logging.info(f"[{uid}]check email subject [{mail.subject}]") - dir = self.get_local_dir_name(mail) - logging.info(f"check email saved {dir}") - file = f"{dir}/email.txt" - if os.path.exists(file): - return dir - return None - - # save email attachment(images) - def save_email_attachment(self, mail: mailparser.MailParser, email_dir: str): - for attachment in mail.attachments: - if attachment['mail_content_type'] in ['image/png', 'image/jpeg', 'image/gif']: - print('current mail have image attachment') - img_dir = f"{email_dir}/image" - if not os.path.exists(img_dir): - os.makedirs(img_dir) - filename = attachment['filename'] - filefullname = f"{img_dir}/{filename}" - image_data = attachment['payload'] - try: - image_data = base64.b64decode(image_data) - except base64.binascii.Error: - image_data = image_data.encode() - with open(filefullname, 'wb') as f: - f.write(image_data) - logging.info(f"save email image {filename} success") - - # save email body images(html content) - def save_body_images(self, html_content: str, email_dir: str): - # get all image urls - soup = BeautifulSoup(html_content, 'html.parser') - img_tags = soup.find_all('img') - img_urls = [img['src'] for img in img_tags if 'src' in img.attrs] - logging.info(f'Found {len(img_urls)} images in email body') - - name_count = 0 - - if not os.path.exists(email_dir): - os.makedirs(email_dir) - - for img_url in img_urls: - # keep the original image filename(last of url) - ext = img_url.split('/')[-1].split('.')[-1] - img_filename = os.path.join(email_dir, f"{name_count}.{ext}") - name_count += 1 - # download image - response = requests.get(img_url, stream=True) - if response.status_code == 200: - with open(img_filename, 'wb') as img_file: - for chunk in response.iter_content(1024): - img_file.write(chunk) - logging.info(f'Downloaded {img_url} to {img_filename}') - else: - logging.info(f'Failed to download {img_url}') - - # save email content to local dir - def save_email(self, mail: mailparser.MailParser): - dir = f"{self.local_root()}/{self.config.get('address')}" - if not os.path.exists(dir): - os.makedirs(dir) - email_dir = self.get_local_dir_name(mail) - logging.info(f"save email to {email_dir}") - if not os.path.exists(email_dir): - os.makedirs(email_dir) - with open(f"{email_dir}/email.txt", "w", encoding='utf-8') as f: - # soup = BeautifulSoup(mail.body, 'html.parser') - f.write(mail.body) - with open(f"{email_dir}/meta.json", "w", encoding='utf-8') as f: - mail_dict = json.loads(mail.mail_json) - if 'body' in mail_dict: - del mail_dict['body'] - json.dump(mail_dict, f, ensure_ascii=False, indent=4) - logging.info(f"save email meta info {f.name}") - - self.save_email_attachment(mail, email_dir) - self.save_body_images(mail.body, f"{email_dir}/body_image") \ No newline at end of file diff --git a/src/component/knowledge_manager/input/local_dir.py b/src/component/knowledge_manager/input/local_dir.py deleted file mode 100644 index e9a54f4..0000000 --- a/src/component/knowledge_manager/input/local_dir.py +++ /dev/null @@ -1,68 +0,0 @@ -import os -import aiofiles -import chardet -import logging -import string -from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal -from aios_kernel.storage import AIStorage - -class KnowledgeDirSource: - def __init__(self, env: KnowledgePipelineEnvironment, config): - self.env = env - path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) - config["path"] = path - self.config = config - - # @classmethod - # def user_config_items(cls): - # return [("path", "local dir path")] - - def path(self): - return self.config["path"] - - @staticmethod - async def read_txt_file(file_path:str)->str: - cur_encode = "utf-8" - async with aiofiles.open(file_path,'rb') as f: - cur_encode = chardet.detect(await f.read())['encoding'] - - async with aiofiles.open(file_path,'r',encoding=cur_encode) as f: - return await f.read() - - async def next(self): - while True: - journals = self.env.journal.latest_journals(1) - from_time = 0 - if len(journals) == 1: - latest_journal = journals[0] - if latest_journal.is_finish(): - yield None - continue - from_time = os.path.getctime(latest_journal.get_input()) - if os.path.getmtime(self.path()) <= from_time: - yield (None, None) - continue - - file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) - for rel_path in file_pathes: - file_path = os.path.join(self.path(), rel_path) - timestamp = os.path.getctime(file_path) - if timestamp <= from_time: - continue - ext = os.path.splitext(file_path)[1].lower() - if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']: - logging.info(f"knowledge dir source found image file {file_path}") - image = ImageObjectBuilder({}, {}, file_path).build(self.env.get_knowledge_store()) - await self.env.get_knowledge_store().insert_object(image) - yield (image.calculate_id(), file_path) - if ext in ['.txt']: - logging.info(f"knowledge dir source found text file {file_path}") - text = await self.read_txt_file(file_path) - document = DocumentObjectBuilder({}, {}, text).build(self.env.get_knowledge_store()) - await self.env.get_knowledge_store().insert_object(document) - yield (document.calculate_id(), file_path) - yield (None, None) - - -def init(env: KnowledgePipelineEnvironment, params: dict) -> KnowledgeDirSource: - return KnowledgeDirSource(env, params) \ No newline at end of file diff --git a/src/component/knowledge_manager/parser/embedding.py b/src/component/knowledge_manager/parser/embedding.py deleted file mode 100644 index cb8b5f8..0000000 --- a/src/component/knowledge_manager/parser/embedding.py +++ /dev/null @@ -1,102 +0,0 @@ -# define a knowledge base class -import json -import string -from aios_kernel import ComputeKernel, AIStorage -from knowledge import * - - -class EmbeddingParser: - def __init__(self, env: KnowledgePipelineEnvironment, config: dict): - self._default_text_model = "all-MiniLM-L6-v2" - self._default_image_model = "clip-ViT-B-32" - - path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) - if not os.path.exists(path): - os.makedirs(path) - config["path"] = path - - self.env = env - self.config = config - - def get_path(self) -> str: - return self.config["path"] - - def __get_vector_store(self, model_name: str) -> ChromaVectorStore: - return ChromaVectorStore(self.get_path(), model_name) - - async def __embedding_document(self, document: DocumentObject): - for chunk_id in document.get_chunk_list(): - chunk = self.env.get_knowledge_store().get_chunk_reader().get_chunk(chunk_id) - if chunk is None: - raise ValueError(f"text chunk not found: {chunk_id}") - - text = chunk.read().decode("utf-8") - vector = await ComputeKernel.get_instance().do_text_embedding(text, self._default_text_model) - if vector: - await self.__get_vector_store(self._default_text_model).insert(vector, chunk_id) - - async def __embedding_image(self, image: ImageObject): - # desc = {} - # if not not image.get_meta(): - # desc["meta"] = image.get_meta() - # if not not image.get_exif(): - # desc["exif"] = image.get_exif() - # if not not image.get_tags(): - # desc["tags"] = image.get_tags() - # vector = await self.compute_kernel.do_text_embedding(json.dumps(desc), self._default_text_model) - vector = await ComputeKernel.get_instance().do_image_embedding(image.calculate_id(), self._default_image_model) - if vector: - await self.__get_vector_store(self._default_image_model).insert(vector, image.calculate_id()) - - async def __embedding_video(self, vedio: VideoObject): - desc = {} - if not not vedio.get_meta(): - desc["meta"] = vedio.get_meta() - if not not vedio.get_info(): - desc["info"] = vedio.get_info() - if not not vedio.get_tags(): - desc["tags"] = vedio.get_tags() - vector = await ComputeKernel.get_instance().do_text_embedding(json.dumps(desc), self._default_text_model) - await self.__get_vector_store(self._default_text_model).insert(vector, vedio.calculate_id()) - - async def __embedding_rich_text(self, rich_text: RichTextObject): - for document_id in rich_text.get_documents().values(): - document = DocumentObject.decode(self.env.get_knowledge_store().get_object_store().get_object(document_id)) - await self.__embedding_document(document) - for image_id in rich_text.get_images().values(): - image = ImageObject.decode(self.env.get_knowledge_store().get_object_store().get_object(image_id)) - await self.__embedding_image(image) - for video_id in rich_text.get_videos().values(): - video = VideoObject.decode(self.env.get_knowledge_store().get_object_store().get_object(video_id)) - await self.__embedding_video(video) - for rich_text_id in rich_text.get_rich_texts().values(): - rich_text = RichTextObject.decode(self.env.get_knowledge_store().get_object_store().get_object(rich_text_id)) - await self.__embedding_rich_text(rich_text) - - async def __embedding_email(self, email: EmailObject): - vector = await ComputeKernel.get_instance().do_text_embedding(json.dumps(email.get_desc()), self._default_text_model) - await self.__get_vector_store(self._default_text_model).insert(vector, email.calculate_id()) - await self.__embedding_rich_text(email.get_rich_text()) - - - async def __do_embedding(self, object: KnowledgeObject): - if object.get_object_type() == ObjectType.Document: - await self.__embedding_document(object) - if object.get_object_type() == ObjectType.Image: - await self.__embedding_image(object) - if object.get_object_type() == ObjectType.Video: - await self.__embedding_video(object) - if object.get_object_type() == ObjectType.RichText: - await self.__embedding_rich_text(object) - if object.get_object_type() == ObjectType.Email: - await self.__embedding_email(object) - else: - pass - - async def parse(self, object: ObjectID) -> str: - obj = self.env.get_knowledge_store().load_object(object) - await self.__do_embedding(obj) - return "insert into vector store" - -def init(env: KnowledgePipelineEnvironment, params: dict) -> EmbeddingParser: - return EmbeddingParser(env, params) \ No newline at end of file diff --git a/src/component/knowledge_manager/pipeline.py b/src/component/knowledge_manager/pipeline.py index 1701f4f..efe0936 100644 --- a/src/component/knowledge_manager/pipeline.py +++ b/src/component/knowledge_manager/pipeline.py @@ -23,10 +23,6 @@ class KnowledgePipelineManager: "names": {}, "running": [] } - from .input import local_dir - self.register_input("local_dir", local_dir.init) - from .parser import embedding - self.register_parser("embedding", embedding.init) def register_input(self, name: str, init_method): self.input_modules[name] = init_method @@ -84,6 +80,6 @@ class KnowledgePipelineManager: config = toml.load(f) for path in config["pipelines"]: pipeline_path = os.path.join(root, path) - with open(os.path.join(pipeline_path, "pipeline.toml")) as f: + with open(os.path.join(pipeline_path, "pipeline.toml"), 'r', encoding='utf-8') as f: pipeline_config = toml.load(f) self.add_pipeline(pipeline_config, pipeline_path) diff --git a/src/component/mail_environment/1/mail.json b/src/component/mail_environment/1/mail.json new file mode 100644 index 0000000..8a30f00 --- /dev/null +++ b/src/component/mail_environment/1/mail.json @@ -0,0 +1,6 @@ +{ + "subject": "开发dmc开源客户端", + "from_addr": "sichangjun@buckyos.com", + "to_addr": ["liuzhicong@buckyos.com"], + "date": "2023-4-10 21:00" +} \ No newline at end of file diff --git a/src/component/mail_environment/1/mail.txt b/src/component/mail_environment/1/mail.txt new file mode 100644 index 0000000..1abf50b --- /dev/null +++ b/src/component/mail_environment/1/mail.txt @@ -0,0 +1,51 @@ +最小功能集:4.15准备好oktc合约和客户端工具;4.21之前完成一些矿场节点部署; +Oktc合约和rust接口 (秋总) +实现以DMC结算的bill 和 order (done) +实现链上挑战—— merkle联通证明;用户提交低深度半路径;矿工提供叶子原文和高深度半路径;(doing) +实现按照价格和质押率索引的spv节点 —— 支持用户按参数匹配下单;(doing) +实现对eth发起的http请求 auth 头认证 —— 支持https实现 链下的 write/restore/challenge 身份认证;(doing) +实现oktc client event listener的block number本地持久化;(doing) +用户端工具:面向普通存储用户,一键备份和恢复; +源数据管理服务: +注册本地路径,生成链下挑战密码本 ——随机偏移和长度QA(done) + 生成链上挑战密码本 —— merkle根和半深度茎节点(doing); +账号服务: +添加本地路径,选择bill id发起order(done) +按参数匹配order ——依赖按参数索引的spv 服务(doing) +生成order提交到交付服务;(done) +交付服务 +注册order和关联的源数据(done) +提交merkle根(done) +监听链事件,同步order状态,向miner写入源数据;(done) +使用密码本持续发起链下挑战(done) +实现链上挑战(doing) +恢复数据到本地目录(done) +关键日志服务 +关键状态改变日志写入(doing) +关键状态日志事件监听接口(doing) +轻量命令行客户端 +服务部署脚本(doing) +传入本地文件 和 order参数一键完成备份 (doing) +一键恢复(doing) +链下挑战失效时,自动发起链上挑战 ——依赖关键日志服务(doing) +mysql实现移植到sqlite(看时间和问题多不多;undo) +矿场端工具:本版本不是发布重点;保证能在自由的节点上稳定运行即可;结构上保证了一定的伸缩性; +存储扇区管理服务:包括gateway 和 node;保证简单部署和扩展,可靠性暂时不需实现; +扇区gateway服务:注册node,分发扇区读写请求到node;(doing) +扇区node服务:注册本地目录到node,注册为可用扇区;(测试中单node可以直接接入,done) +账号服务: +添加扇区和挂单参数,生成bill(done); +监听链事件,响应新的order转入交付服务;(done) +监听链事件,响应链上挑战转入交付服务;(doing) +交付服务: +注册order和关联的扇区;(done) +对user提供交付相关http接口: +查询miner端order状态(done) +写入源数据(done) +完成写入后准备生成merkle root准备证明(done) +恢复源数据(done) +接收链下挑战返回证明(done) +自动提交链上挑战(doing) +关键日志服务 +关键状态改变日志写入(可推迟实现;undo) +自有节点部署 \ No newline at end of file diff --git a/src/component/mail_environment/__init__.py b/src/component/mail_environment/__init__.py new file mode 100644 index 0000000..402cf90 --- /dev/null +++ b/src/component/mail_environment/__init__.py @@ -0,0 +1,3 @@ +from .issue import IssueParser +from .local import LocalEmail +from .spider import EmailSpider \ No newline at end of file diff --git a/src/component/mail_environment/issue.py b/src/component/mail_environment/issue.py new file mode 100644 index 0000000..53ed3c6 --- /dev/null +++ b/src/component/mail_environment/issue.py @@ -0,0 +1,200 @@ +# define a knowledge base class +import json +import string +from aios_kernel import ComputeKernel, AIStorage, Environment, SimpleAIFunction, BaseAIAgent, AgentPrompt, AgentMsg +from knowledge import * +from .mail import MailStorage, Mail + +class IssueState(Enum): + Open = 1 + InProgress = 2 + Closed = 3 + +class IssueUpdateHistory: + def __init__(self, source: str, changes: dict) -> None: + self.source = source + self.changes = changes + + +class Issue: + def __init__(self) -> None: + self.id = None + self.summary = "" + self.state = IssueState.Open + self.source: str = None + self.create_time: datetime = None + self.deadline: datetime = None + self.update_history = [] + self.children = [] + self.parent: ObjectID = None + + @classmethod + def object_type(cls) -> ObjectType: + return ObjectType.from_user_def_type_code(0) + + def to_prompt(self) -> str: + prompt = { + "id": self.id, + "summary": self.summary, + "state": self.state.name, + "deadline": self.deadline + } + return json.dumps(prompt) + + @classmethod + def prompt_desc(cls) -> str: + return '''a issue contains following fileds: { + id: a guid string to identify a issue + summary: summary of this issue + state: state of this issue, will be one of [Open, InProgress, Closed], + deadline: if issue is not closed, deadline is the time to close this issue + } + ''' + + def calculate_id(self) -> str: + desc = { + "summary": self.summary, + "source": self.source, + "create_time": self.create_time, + "deadline": self.deadline, + "parent": self.parent, + } + id = str(KnowledgeObject(Issue.object_type(), desc).calculate_id()) + self.id = id + return id + + +class IssueStorage: + def __init__(self, path: str, root: Issue=None) -> None: + self.path = path + if not os.path.exists(path): + self.root = root + else: + self.root = json.load(open(path, "r")) + + def __flush(self): + json.dump(self.root, open(self.path, "w")) + + def get_issue_by_id(self, id: str) -> Issue: + self.root() + + def __get_issue_by_mail_in_subtree(self, root_issue: Issue, mail_id: str): + if root_issue.source == mail_id: + return root_issue + if root_issue.children is None or len(root_issue.children) == 0: + return None + for child_issue in root_issue.children: + this_issue = self.__get_issue_by_mail_in_subtree(child_issue, mail_id) + if this_issue is not None: + return this_issue + return None + + def get_issue_by_mail(self, mail_storage: MailStorage, mail: Mail) -> Issue: + if mail.reply_to is None: + return self.root + this_mail = mail_storage.get_mail_by_id(mail.reply_to) + while True: + issue = self.__get_issue_by_mail_in_subtree(self.root, this_mail.id) + if issue is not None: + return issue + if this_mail.replay_to is None: + return self.root + this_mail = mail_storage.get_mail_by_id(this_mail.reply_to) + + + def add_issue(self, source_id: str, issue: dict): + parent_id = issue.get("parent") + parent_issue = self.get_issue(parent_id) + issue: Issue = issue + issue["source"] = source_id + issue.calculate_id() + parent_issue.children.append(issue) + self.__flush() + + def update_issue(self, source_id: str, update: dict): + issue = self.get_issue(update["id"]) + source = update["source"] + changes = {} + for key, value in update.items(): + if key != "id" and key is not "source": + changes[key] = { + "old": issue[key], + "new": value, + } + issue[key] = value + issue.update_history.append(IssueUpdateHistory(source, changes)) + + self.__flush() + + +class IssueParserEnvironment(Environment): + def __init__(self, env_id: str, storage: IssueStorage) -> None: + super().__init__(env_id) + self.storage = storage + + update_description = '''update issue with email object''' + + update_param = { + "source_id": "update issue with which email object id", + "update_content": '''issue fileds to update, json format; + if id field exists, update the issue with the id; + if id filed not exists, create a new issue with the content; + other fileds in update_content will be updated to the issue; + ''', + } + self.add_ai_function(SimpleAIFunction("update_issue", + update_description, + self._update, + update_param)) + + async def _update(self, source_id: str, update_content: str): + update_issue = json.loads(update_content) + issue_id = update_issue.get("id") + if issue_id: + self.storage.update_issue(source_id, update_issue) + else: + self.storage.add_issue(source_id, update_issue) + + + +class IssueParser: + def __init__(self, env: KnowledgePipelineEnvironment, config: dict): + mail_path = string.Template(config["mail_path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + issue_path = string.Template(config["issue_path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + config["path"] = issue_path + + self.env = env + self.config = config + self.mail_storage = MailStorage(mail_path) + + root_issue = None + if "root_issue" in config: + root_issue = Issue() + root_issue.summary = config["root_issue"] + self.issue_storage = IssueStorage(issue_path, root_issue) + self.llm_env = IssueParserEnvironment("issue_parser", self.issue_storage) + + def get_path(self) -> str: + return self.config["path"] + + async def parse(self, mail_id: ObjectID) -> str: + mail_id = str(mail_id) + mail = self.mail_storage.get_mail_by_id(mail_id) + issue = self.issue_storage.get_issue_by_mail(self.mail_storage, mail) + mail_str = mail.to_prompt() + issue_str = issue.to_prompt() + + mail_desc = Mail.prompt_desc() + issue_desc = Issue.prompt_desc() + prompt = f'''I'll give a mail in json format, {mail_desc}; + and a issue in json format, {issue_desc}; + you should read this mail {mail_str}, see if this mail associated with this issue {issue_str}; + if this mail is about a new child issue of this issue, create a new issue with this mail, fill param update_content's summary field will mail content, set parent field with id of this issue; + if this mail will update this issue, set id filed to this issue, fill update_content param with new summary and new state with this mail content; + then you should call update_issue function with source_id set to this mail id, and update_content in json format; + if this mail is not associated with issue, you should ignore this mail without an function call; + ''' + + llm_result = await BaseAIAgent.do_llm_complection(self.llm_env, AgentPrompt(prompt), AgentMsg(), "gpt-4", 16000) + return "update issue" + diff --git a/src/component/mail_environment/local.py b/src/component/mail_environment/local.py new file mode 100644 index 0000000..07b038d --- /dev/null +++ b/src/component/mail_environment/local.py @@ -0,0 +1,34 @@ +import os +import logging +import json +import string +from knowledge import * +from aios_kernel.storage import AIStorage +from .mail import Mail, MailStorage + + +class LocalEmail: + def __init__(self, env: KnowledgePipelineEnvironment, config:dict): + self.config = config + self.env = env + path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + self.mail_storage = MailStorage(path, config.get("watch")) + + async def next(self): + while True: + parsed = None + journals = self.env.journal.latest_journals(1) + if len(journals) == 1: + latest_journal = journals[0] + if latest_journal.is_finish(): + yield None + continue + parsed = str(latest_journal.get_object_id()) + + mail_id = self.mail_storage.next_mail_id(parsed) + if mail_id is None: + yield (None, None) + else: + yield (mail_id, str(mail_id)) + + \ No newline at end of file diff --git a/src/component/mail_environment/mail.py b/src/component/mail_environment/mail.py new file mode 100644 index 0000000..ff5ef15 --- /dev/null +++ b/src/component/mail_environment/mail.py @@ -0,0 +1,265 @@ +import asyncio +import json +import mailparser +import base64 +import requests +import datetime +from bs4 import BeautifulSoup +import sqlite3 +import html2text +from knowledge import * + +class Mail: + def __init__(self, **kwargs) -> None: + self.from_addr = kwargs.get("From") + self.to_addr = kwargs.get("To") + self.subject = kwargs.get("Subject") + self.date = kwargs.get("Date") + self.bcc = kwargs.get("BCC") + self.cc = kwargs.get("CC") + self.reply_to = None + self.id: str = None + self.content: str = None + + def to_prompt(self) -> str: + prompt = { + "id": self.id, + "subject": self.subject, + "from": self.from_addr, + "date": self.date, + "content": self.content + } + return json.dumps(prompt) + + @classmethod + def prompt_desc(cls) -> dict: + return '''a mail contains following fileds: { + id: a guid string to identify a mail + subject: subject of this mail + from: sender address of this mail + date: date of this mail + content: content of this mail + } + ''' + + def get_date(self) -> datetime.datetime: + datetime.datetime.strptime(self.date, "%Y-%m-%d %H:%M") + + def calculate_id(self) -> str: + desc = { + "from_addr": self.from_addr, + "to_addr": self.to_addr, + "subject": self.subject, + "date": self.date, + "content": self.content, + "reply_to": self.reply_to + } + id = str(KnowledgeObject(ObjectType.Email, desc).calculate_id()) + self.id = id + return id + +class MailStorage: + def __init__(self, root, watch=False): + self.root = root + if not os.path.exists(root): + os.makedirs(root) + db_file = os.path.join(root, "mail.db") + + self.conn = sqlite3.connect(db_file) + cursor = self.conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS mails ( + uid INTEGER PRIMARY KEY, + object_id TEXT, + date DATETIME, + from_addr TEXT + ) + """ + ) + + if watch: + asyncio.create_task(self.watch_root()) + + def object_id_to_uid(self, object_id): + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT uid FROM mails WHERE object_id = ? + """, + (object_id,), + ) + row = cursor.fetchone() + if row: + return row[0] + return None + + def uid_to_object_id(self, uid): + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT object_id FROM mails WHERE uid = ? + """, + (uid,), + ) + row = cursor.fetchone() + if row: + return row[0] + return None + + def lastest_uid(self): + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT uid FROM mails ORDER BY uid DESC LIMIT 1 + """ + ) + row = cursor.fetchone() + if row: + return row[0] + return None + + def lastest_mail_id(self): + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT object_id FROM mails ORDER BY uid DESC LIMIT 1 + """ + ) + row = cursor.fetchone() + if row: + return row[0] + return None + + def next_mail_id(self, id): + uid = 0 if id is None else self.object_id_to_uid(id) + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT object_id FROM mails WHERE uid > ? ORDER BY uid ASC LIMIT 1 + """, + (uid,), + ) + row = cursor.fetchone() + if row: + return row[0] + return None + + + + def get_mail_by_id(self, id): + uid = self.object_id_to_uid(id) + mail = Mail() + mail.id = id + mail_dir = self.mail_dir(uid) + mail_json = json.load(open(f"{mail_dir}/mail.json", "r", encoding='utf-8')) + mail.__dict__.update(mail_json) + with open(f"{mail_dir}/mail.txt", "r", encoding='utf-8') as f: + mail_content = f.read() + mail.content = mail_content + return mail + + def mail_dir(self, uid): + return os.path.join(self.root, str(uid)) + + # for debug + async def watch_root(self): + while True: + latest_uid = self.lastest_uid() + for uid in os.listdir(self.root): + mail_dir = os.path.join(self.root, uid) + if uid.isdigit() and os.path.isdir(mail_dir): + uid = int(uid) + if uid <= latest_uid: + continue + mail = Mail() + mail_json = json.load(open(f"{mail_dir}/mail.json", "r", encoding='utf-8')) + + mail.__dict__.update(mail_json) + # mail content + with open(f"{mail_dir}/mail.txt", "r", encoding='utf-8') as f: + mail_content = f.read() + mail.content = mail_content + mail.calculate_id() + cursor = self.conn.cursor() + cursor.execute( + """ + INSERT INTO mails (uid, object_id, date, from_addr) + VALUES (?, ?, ?, ?) + """, + (uid, mail.id, mail.get_date(), mail.from_addr), + ) + self.conn.commit() + await asyncio.sleep(10) + + def download(self, uid, mail: mailparser.MailParser): + mail_dir = self.mail_dir(uid) + os.makedirs(dir) + + meta = json.loads(mail.mail_json) + mail = Mail(**meta) + reply_to = meta.get("In-Reply-To") + if reply_to: + mail.reply_to = self.uid_to_object_id(reply_to) + + h = html2text.HTML2Text() + h.ignore_links = True + h.ignore_images = True + mail_content = h.handle(mail.body) + mail.content = mail_content + + mail.calculate_id() + del mail.content + json.dump(mail.__dict__, open(f"{mail_dir}/mail.json", "w", encoding='utf-8')) + + # save mail content + with open(f"{mail_dir}/mail.txt", "w", encoding='utf-8') as f: + f.write(mail_content) + + for attachment in mail.attachments: + if attachment['mail_content_type'] in ['image/png', 'image/jpeg', 'image/gif']: + filename = attachment['filename'] + filefullname = f"{mail_dir}/{filename}" + image_data = attachment['payload'] + try: + image_data = base64.b64decode(image_data) + except base64.binascii.Error: + image_data = image_data.encode() + with open(filefullname, 'wb') as f: + f.write(image_data) + logging.info(f"save email image {filename} success") + + # get all image urls + soup = BeautifulSoup(mail.body, 'html.parser') + img_tags = soup.find_all('img') + img_urls = [img['src'] for img in img_tags if 'src' in img.attrs] + logging.info(f'Found {len(img_urls)} images in email body') + + name_count = 0 + + for img_url in img_urls: + # keep the original image filename(last of url) + ext = img_url.split('/')[-1].split('.')[-1] + img_filename = os.path.join(mail_dir, f"{name_count}.{ext}") + name_count += 1 + # download image + response = requests.get(img_url, stream=True) + if response.status_code == 200: + with open(img_filename, 'wb') as img_file: + for chunk in response.iter_content(1024): + img_file.write(chunk) + logging.info(f'Downloaded {img_url} to {img_filename}') + else: + logging.info(f'Failed to download {img_url}') + + cursor = self.conn.cursor() + cursor.execute( + """ + INSERT INTO mails (uid, object_id, date, from_addr) + VALUES (?, ?, ?, ?) + """, + (uid, mail.id, mail.date, mail.from_addr), + ) + + + \ No newline at end of file diff --git a/src/component/mail_environment/spider.py b/src/component/mail_environment/spider.py new file mode 100644 index 0000000..2502468 --- /dev/null +++ b/src/component/mail_environment/spider.py @@ -0,0 +1,53 @@ +import os +import logging +import json +import imaplib +import mailparser +from knowledge import * +from aios_kernel.storage import AIStorage + + +class EmailSpider: + def __init__(self, env: KnowledgePipelineEnvironment, config:dict): + self.config = config + self.env = env + self.env.get_logger().info(f"read email config from {self.config.get('imap_server')}") + self.client = imaplib.IMAP4_SSL( + host=self.config.get('imap_server'), + port=self.config.get('imap_port') + ) + self.client.login(self.config.get('address'), self.config.get('password')) + self.mail_local_root = os.path.join(self.env.pipeline_path, self.config.get("address")) + os.makedirs(self.mail_local_root) + + async def next(self): + while True: + _, data = self.client.uid('search', None, "ALL") + uid_list = data[0].split() + if uid_list.len() == 0: + yield (None, None) + continue + + journals = self.env.journal.latest_journals(1) + from_uid = 0 + if len(journals) == 1: + latest_journal = journals[0] + if latest_journal.is_finish(): + yield None + continue + from_uid = int(latest_journal.get_input()) + if int.from_bytes(uid_list[-1]) <= from_uid: + yield (None, None) + continue + + for uid in uid_list: + _uid = int.from_bytes(uid) + if _uid > from_uid: + message_parts = "(BODY.PEEK[])" + _, email_data = self.client.uid('fetch', uid, message_parts) + mail = mailparser.parse_from_bytes(email_data[0][1]) + self.save_email(_uid, mail) + + yield (None, None) + + \ No newline at end of file diff --git a/src/knowledge/object/object.py b/src/knowledge/object/object.py index 75852c3..f34da2d 100644 --- a/src/knowledge/object/object.py +++ b/src/knowledge/object/object.py @@ -51,13 +51,13 @@ class KnowledgeObject(ABC): def get_summary(self) -> str: return self.desc.get("summary") - def get_articl_catelog(self) -> str: - assert self.object_type == ObjectType.Document - return self.desc.get("catelog") + # def get_articl_catelog(self) -> str: + # assert self.object_type == ObjectType.Document + # return self.desc.get("catelog") - def get_article_full_content(self) -> str: - assert self.object_type == ObjectType.Document - return self.body + # def get_article_full_content(self) -> str: + # assert self.object_type == ObjectType.Document + # return self.body def calculate_id(self): # Convert the object_type and desc to string and compute the SHA256 hash @@ -73,6 +73,6 @@ class KnowledgeObject(ABC): def encode(self) -> bytes: return pickle.dumps(self) - @staticmethod - def decode(data: bytes) -> "ImageObject": - return pickle.loads(data) + # @staticmethod + # def decode(data: bytes) -> "ImageObject": + # return pickle.loads(data) diff --git a/src/knowledge/object/object_id.py b/src/knowledge/object/object_id.py index 46955f4..3a07b2b 100644 --- a/src/knowledge/object/object_id.py +++ b/src/knowledge/object/object_id.py @@ -13,6 +13,17 @@ class ObjectType(IntEnum): Document = 103 RichText = 104 Email = 105 + UserDef = 200 + + def is_user_def(self) -> bool: + return self.value >= 200 + + def get_user_def_type_code(self): + return (self.value - 200) if self.is_user_def() else None + + @classmethod + def from_user_def_type_code(value): + return value + 200 # define a object ID class to identify a object diff --git a/src/knowledge/pipeline.py b/src/knowledge/pipeline.py index 62f246a..b902225 100644 --- a/src/knowledge/pipeline.py +++ b/src/knowledge/pipeline.py @@ -14,6 +14,9 @@ class KnowledgePipelineJournal: def is_finish(self) -> bool: return self.object_id is None + + def get_object_id(self) -> ObjectID: + return self.object_id def get_input(self) -> str: return self.input diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 11c5036..ccbce5a 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -45,7 +45,6 @@ shell_style = Style.from_dict({ 'error': '#8F0000 bold' }) - class AIOS_Shell: def __init__(self,username:str) -> None: self.username = username From a63e9b6745227b1eab0f6996be7b5bd22cdb4683 Mon Sep 17 00:00:00 2001 From: tsukasa Date: Mon, 20 Nov 2023 22:01:18 +0800 Subject: [PATCH 23/79] read mail with issue tree pipeline works --- .../Mail/Issue/pipeline.toml | 6 +- src/aios_kernel/agent.py | 20 +- src/aios_kernel/agent_base.py | 22 +- src/aios_kernel/compute_kernel.py | 10 +- src/aios_kernel/open_ai_node.py | 6 +- src/component/mail_environment/1/mail.json | 6 - src/component/mail_environment/1/mail.txt | 51 ---- src/component/mail_environment/issue.py | 222 +++++++++++++----- src/component/mail_environment/local.py | 5 +- src/component/mail_environment/mail.py | 1 - src/knowledge/object/object_id.py | 2 +- src/service/aios_shell/aios_shell.py | 4 +- 12 files changed, 215 insertions(+), 140 deletions(-) delete mode 100644 src/component/mail_environment/1/mail.json delete mode 100644 src/component/mail_environment/1/mail.txt diff --git a/rootfs/knowledge_pipelines/Mail/Issue/pipeline.toml b/rootfs/knowledge_pipelines/Mail/Issue/pipeline.toml index d909ba9..4cb377d 100644 --- a/rootfs/knowledge_pipelines/Mail/Issue/pipeline.toml +++ b/rootfs/knowledge_pipelines/Mail/Issue/pipeline.toml @@ -5,5 +5,9 @@ input.params.watch = true parser.module = "parser.py" parser.params.mail_path = "${myai_dir}/mail" parser.params.issue_path = "${myai_dir}/mail/issue.json" -parser.params.root_issue = "巴克云公司推进中的项目" +[parser.params.root_issue] +summary = "巴克云公司推进中的项目" +[[parser.params.root_issue.children]] +summary = "去中心存储项目DMC" + diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 82c9eb0..095410d 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -14,6 +14,7 @@ import sys from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType, FunctionItem, LLMResult, AgentPrompt, AgentReport, \ AgentTodo, AgentTodoResult, AgentWorkLog, BaseAIAgent + from .chatsession import AIChatSession from .compute_task import ComputeTaskResult,ComputeTaskResultCode from .ai_function import AIFunction @@ -287,6 +288,7 @@ class AIAgent(BaseAIAgent): return None + def _get_inner_functions(self) -> dict: if self.owner_env is None: return None,0 @@ -357,6 +359,7 @@ class AIAgent(BaseAIAgent): else: return task_result + def get_agent_prompt(self) -> AgentPrompt: return self.agent_prompt @@ -520,7 +523,7 @@ class AIAgent(BaseAIAgent): if todo_count > 0: have_known_info = True known_info_str += f"## todo\n{todos_str}\n" - inner_functions,function_token_len = self._get_inner_functions() + inner_functions,function_token_len = BaseAIAgent.get_inner_functions(self.owner_env) system_prompt_len = prompt.get_prompt_token_len() input_len = len(msg.body) if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: @@ -540,7 +543,7 @@ class AIAgent(BaseAIAgent): logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") #task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) - task_result = await self._do_llm_complection(prompt,inner_functions,msg) + task_result = await self._do_llm_complection(prompt,msg,inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(task_result.error_str) return error_resp @@ -778,9 +781,9 @@ class AIAgent(BaseAIAgent): todo_tree = workspace.get_todo_tree("/") prompt.append(AgentPrompt(todo_tree)) - inner_functions,function_token_len = self._get_inner_functions() + inner_functions,_ = BaseAIAgent.get_inner_functions(self.owner_env) - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,inner_functions) + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_llm_review_todos compute error:{task_result.error_str}") return @@ -897,7 +900,8 @@ class AIAgent(BaseAIAgent): prompt.append(todo.detail) prompt.append(todo.result) - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,workspace.get_inner_functions(),None,True) + inner_functions,_ = BaseAIAgent.get_inner_functions(workspace) + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,inner_functions=inner_functions,is_json_resp=True) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_llm_check_todo compute error:{task_result.error_str}") @@ -1058,7 +1062,7 @@ class AIAgent(BaseAIAgent): prompt.append(content_prompt) env_functions = None #env_functions,function_len = workspace.get_knowledge_base_ai_functions() - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,env_functions,None,True) + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,is_json_resp=True) if task_result.result_code != ComputeTaskResultCode.OK: result_obj = {} result_obj["error_str"] = task_result.error_str @@ -1091,9 +1095,8 @@ class AIAgent(BaseAIAgent): prompt.append(known_info_prompt) content_prompt = AgentPrompt(part_content) prompt.append(content_prompt) - env_functions = None #env_functions,function_len = workspace.get_knowledge_base_ai_functions() - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,env_functions,None,True) + task_result:ComputeTaskResult = await self._do_llm_complection(prompt,is_json_resp=True) if task_result.result_code != ComputeTaskResultCode.OK: result_obj = {} result_obj["error_str"] = task_result.error_str @@ -1222,6 +1225,7 @@ class AIAgent(BaseAIAgent): return task_result + def need_work(self) -> bool: if self.do_prompt is not None: return True diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index 60c5fe6..75d06b2 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -600,7 +600,7 @@ class BaseAIAgent: pass @classmethod - def _get_inner_functions(cls, env:Environment) -> (dict,int): + def get_inner_functions(cls, env:Environment) -> (dict,int): if env is None: return None,0 @@ -624,18 +624,24 @@ class BaseAIAgent: @classmethod async def do_llm_complection( cls, - env:Environment, prompt:AgentPrompt, - org_msg:AgentMsg, llm_model_name:str, - max_token_size:int + max_token_size:int, + org_msg:AgentMsg=None, + env:Environment=None, + inner_functions=None, + is_json_resp=False, ) -> ComputeTaskResult: from .compute_kernel import ComputeKernel #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - inner_functions,inner_functions_len = cls._get_inner_functions(env) - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,llm_model_name,max_token_size,inner_functions) + if inner_functions is None and env is not None: + inner_functions,_ = cls.get_inner_functions(env) + if is_json_resp: + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,"json",llm_model_name,max_token_size,inner_functions,timeout=None) + else: + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,"text",llm_model_name,max_token_size,inner_functions,timeout=None) if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"llm compute error:{task_result.error_str}") + logger.error(f"_do_llm_complection llm compute error:{task_result.error_str}") #error_resp = msg.create_error_resp(task_result.error_str) return task_result @@ -649,7 +655,7 @@ class BaseAIAgent: task_result = await cls._execute_func(env,inner_func_call_node,call_prompt,inner_functions,org_msg,llm_model_name,max_token_size) return task_result - + @classmethod async def _execute_func( cls, diff --git a/src/aios_kernel/compute_kernel.py b/src/aios_kernel/compute_kernel.py index 080d455..6174fa8 100644 --- a/src/aios_kernel/compute_kernel.py +++ b/src/aios_kernel/compute_kernel.py @@ -127,7 +127,7 @@ class ComputeKernel: self.run(task_req) return task_req - async def _wait_task(self,task_req:ComputeTask)->ComputeTaskResult: + async def _wait_task(self,task_req:ComputeTask, timeout=60)->ComputeTaskResult: async def check_timer(): check_times = 0 while True: @@ -136,8 +136,8 @@ class ComputeKernel: if task_req.state == ComputeTaskState.ERROR: break - - if check_times >= 120: + + if timeout is not None and check_times >= timeout*2: task_req.state = ComputeTaskState.ERROR break @@ -155,9 +155,9 @@ class ComputeKernel: return time_out_result - async def do_llm_completion(self, prompt: AgentPrompt,resp_mode:str="text", mode_name: Optional[str] = None, max_token: int = 0, inner_functions = None) -> str: + async def do_llm_completion(self, prompt: AgentPrompt,resp_mode:str="text", mode_name: Optional[str]=None, max_token:int=0, inner_functions=None, timeout=60) -> str: task_req = self.llm_completion(prompt, resp_mode,mode_name, max_token,inner_functions) - return await self._wait_task(task_req) + return await self._wait_task(task_req, timeout) def text_embedding(self,input:str,model_name:Optional[str] = None): diff --git a/src/aios_kernel/open_ai_node.py b/src/aios_kernel/open_ai_node.py index f35fda7..2999a27 100644 --- a/src/aios_kernel/open_ai_node.py +++ b/src/aios_kernel/open_ai_node.py @@ -200,7 +200,7 @@ class OpenAI_ComputeNode(ComputeNode): max_token_size = 4000 result_token = max_token_size - client = AsyncOpenAI() + client = AsyncOpenAI(api_key=self.openai_api_key) try: if llm_inner_functions is None: logger.info(f"call openai {mode_name} prompts: {prompts}") @@ -215,7 +215,7 @@ class OpenAI_ComputeNode(ComputeNode): messages=prompts, response_format = response_format, functions=llm_inner_functions, - #max_tokens=result_token, + max_tokens=result_token, ) # TODO: add temperature to task params? except Exception as e: logger.error(f"openai run LLM_COMPLETION task error: {e}") @@ -267,8 +267,8 @@ class OpenAI_ComputeNode(ComputeNode): logger.info(f"openai_node get task: {task.display()}") result = await self._run_task(task) if result is not None: - task.state = ComputeTaskState.DONE task.result = result + task.state = ComputeTaskState.DONE asyncio.create_task(_run_task_loop()) diff --git a/src/component/mail_environment/1/mail.json b/src/component/mail_environment/1/mail.json deleted file mode 100644 index 8a30f00..0000000 --- a/src/component/mail_environment/1/mail.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "subject": "开发dmc开源客户端", - "from_addr": "sichangjun@buckyos.com", - "to_addr": ["liuzhicong@buckyos.com"], - "date": "2023-4-10 21:00" -} \ No newline at end of file diff --git a/src/component/mail_environment/1/mail.txt b/src/component/mail_environment/1/mail.txt deleted file mode 100644 index 1abf50b..0000000 --- a/src/component/mail_environment/1/mail.txt +++ /dev/null @@ -1,51 +0,0 @@ -最小功能集:4.15准备好oktc合约和客户端工具;4.21之前完成一些矿场节点部署; -Oktc合约和rust接口 (秋总) -实现以DMC结算的bill 和 order (done) -实现链上挑战—— merkle联通证明;用户提交低深度半路径;矿工提供叶子原文和高深度半路径;(doing) -实现按照价格和质押率索引的spv节点 —— 支持用户按参数匹配下单;(doing) -实现对eth发起的http请求 auth 头认证 —— 支持https实现 链下的 write/restore/challenge 身份认证;(doing) -实现oktc client event listener的block number本地持久化;(doing) -用户端工具:面向普通存储用户,一键备份和恢复; -源数据管理服务: -注册本地路径,生成链下挑战密码本 ——随机偏移和长度QA(done) - 生成链上挑战密码本 —— merkle根和半深度茎节点(doing); -账号服务: -添加本地路径,选择bill id发起order(done) -按参数匹配order ——依赖按参数索引的spv 服务(doing) -生成order提交到交付服务;(done) -交付服务 -注册order和关联的源数据(done) -提交merkle根(done) -监听链事件,同步order状态,向miner写入源数据;(done) -使用密码本持续发起链下挑战(done) -实现链上挑战(doing) -恢复数据到本地目录(done) -关键日志服务 -关键状态改变日志写入(doing) -关键状态日志事件监听接口(doing) -轻量命令行客户端 -服务部署脚本(doing) -传入本地文件 和 order参数一键完成备份 (doing) -一键恢复(doing) -链下挑战失效时,自动发起链上挑战 ——依赖关键日志服务(doing) -mysql实现移植到sqlite(看时间和问题多不多;undo) -矿场端工具:本版本不是发布重点;保证能在自由的节点上稳定运行即可;结构上保证了一定的伸缩性; -存储扇区管理服务:包括gateway 和 node;保证简单部署和扩展,可靠性暂时不需实现; -扇区gateway服务:注册node,分发扇区读写请求到node;(doing) -扇区node服务:注册本地目录到node,注册为可用扇区;(测试中单node可以直接接入,done) -账号服务: -添加扇区和挂单参数,生成bill(done); -监听链事件,响应新的order转入交付服务;(done) -监听链事件,响应链上挑战转入交付服务;(doing) -交付服务: -注册order和关联的扇区;(done) -对user提供交付相关http接口: -查询miner端order状态(done) -写入源数据(done) -完成写入后准备生成merkle root准备证明(done) -恢复源数据(done) -接收链下挑战返回证明(done) -自动提交链上挑战(doing) -关键日志服务 -关键状态改变日志写入(可推迟实现;undo) -自有节点部署 \ No newline at end of file diff --git a/src/component/mail_environment/issue.py b/src/component/mail_environment/issue.py index 53ed3c6..bc505ca 100644 --- a/src/component/mail_environment/issue.py +++ b/src/component/mail_environment/issue.py @@ -14,7 +14,16 @@ class IssueUpdateHistory: def __init__(self, source: str, changes: dict) -> None: self.source = source self.changes = changes - + + def to_json_dict(self) -> dict: + return { + "source": self.source, + "changes": self.changes, + } + + @classmethod + def from_json_dict(cls, json_dict: dict) -> "IssueUpdateHistory": + return IssueUpdateHistory(json_dict["source"], json_dict["changes"]) class Issue: def __init__(self) -> None: @@ -26,20 +35,80 @@ class Issue: self.deadline: datetime = None self.update_history = [] self.children = [] - self.parent: ObjectID = None + self.parent: str = None + + def to_json_dict(self) -> dict: + json_dict = { + "id": self.id, + "summary": self.summary, + "state": self.state.name, + "create_time": self.create_time, + "deadline": self.deadline, + "source": self.source, + "parent": self.parent, + } + if self.children is not None and len(self.children) > 0: + json_dict["children"] = [] + for child in self.children: + json_dict["children"].append(child.to_json_dict()) + if self.update_history is not None and len(self.update_history) > 0: + json_dict["update_history"] = [] + for history in self.update_history: + json_dict["update_history"].append(history.to_json_dict()) + + return json_dict + + @classmethod + def from_json_dict(cls, json_dict: dict) -> "Issue": + issue = Issue() + issue.id = json_dict["id"] + issue.summary = json_dict["summary"] + issue.state = IssueState[json_dict["state"]] + issue.create_time = json_dict["create_time"] + issue.deadline = json_dict["deadline"] + issue.source = json_dict["source"] + issue.parent = json_dict["parent"] + if "children" in json_dict: + issue.children = [] + for child_json_dict in json_dict["children"]: + child = Issue.from_json_dict(child_json_dict) + issue.children.append(child) + if "update_history" in json_dict: + issue.update_history = [] + for history_json_dict in json_dict["update_history"]: + history = IssueUpdateHistory.from_json_dict(history_json_dict) + issue.update_history.append(history) + return issue + @classmethod def object_type(cls) -> ObjectType: return ObjectType.from_user_def_type_code(0) - - def to_prompt(self) -> str: - prompt = { + + def __to_desc(self, desc_list:[], recursion=None): + desc = { "id": self.id, "summary": self.summary, "state": self.state.name, - "deadline": self.deadline + "deadline": self.deadline, } - return json.dumps(prompt) + desc_list.append(desc) + if not recursion or not self.parent: + return + else: + parent = recursion.get_issue_by_id(self.parent) + parent.__to_desc(desc_list, recursion) + + def to_prompt(self, recursion=None) -> str: + desc_list = [] + self.__to_desc(desc_list, recursion) + root = desc_list.pop() + while len(desc_list) > 0: + child = desc_list.pop() + root["child"] = child + root = child + return json.dumps(root) + @classmethod def prompt_desc(cls) -> str: @@ -47,7 +116,8 @@ class Issue: id: a guid string to identify a issue summary: summary of this issue state: state of this issue, will be one of [Open, InProgress, Closed], - deadline: if issue is not closed, deadline is the time to close this issue + deadline: if issue is not closed, deadline is the time to close this issue, + children: child issues of this issue } ''' @@ -69,14 +139,27 @@ class IssueStorage: self.path = path if not os.path.exists(path): self.root = root + self.__flush() else: - self.root = json.load(open(path, "r")) + root_dict = json.load(open(path, "r", encoding="utf-8")) + self.root = Issue.from_json_dict(root_dict) def __flush(self): - json.dump(self.root, open(self.path, "w")) + json.dump(self.root.to_json_dict(), open(self.path, "w", encoding="utf-8"), ensure_ascii=False, indent=4) + + def __get_issue_by_id_in_subtree(self, root_issue: Issue, id: str): + if root_issue.id == id: + return root_issue + if root_issue.children is None or len(root_issue.children) == 0: + return None + for child_issue in root_issue.children: + this_issue = self.__get_issue_by_id_in_subtree(child_issue, id) + if this_issue is not None: + return this_issue + return None def get_issue_by_id(self, id: str) -> Issue: - self.root() + return self.__get_issue_by_id_in_subtree(self.root, id) def __get_issue_by_mail_in_subtree(self, root_issue: Issue, mail_id: str): if root_issue.source == mail_id: @@ -102,29 +185,30 @@ class IssueStorage: this_mail = mail_storage.get_mail_by_id(this_mail.reply_to) - def add_issue(self, source_id: str, issue: dict): - parent_id = issue.get("parent") - parent_issue = self.get_issue(parent_id) - issue: Issue = issue - issue["source"] = source_id + def add_issue(self, source_id: str, parent_id: str, summary: str): + parent_issue = self.get_issue_by_id(parent_id) + issue = Issue() + issue.summary = summary + issue.source = source_id + issue.parent = parent_id issue.calculate_id() parent_issue.children.append(issue) self.__flush() + return issue - def update_issue(self, source_id: str, update: dict): - issue = self.get_issue(update["id"]) - source = update["source"] + def update_issue(self, source_id: str, issue_id: str, update: dict): + issue = self.get_issue_by_id(issue_id) changes = {} for key, value in update.items(): - if key != "id" and key is not "source": - changes[key] = { - "old": issue[key], - "new": value, - } - issue[key] = value - issue.update_history.append(IssueUpdateHistory(source, changes)) + changes[key] = { + "old": issue[key], + "new": value, + } + issue.__dict__[key] = value + issue.update_history.append(IssueUpdateHistory(source_id, changes)) self.__flush() + return issue class IssueParserEnvironment(Environment): @@ -132,29 +216,37 @@ class IssueParserEnvironment(Environment): super().__init__(env_id) self.storage = storage - update_description = '''update issue with email object''' - + create_description = '''create a new issue''' + create_param = { + "mail_id": "new issue with which email object id", + "issue_id": '''new issue's parent issue id''', + "summary": '''new issue's summary''', + } + self.add_ai_function(SimpleAIFunction("create_issue", + create_description, + self._create, + create_param)) + + update_description = '''update an existing issue''' update_param = { - "source_id": "update issue with which email object id", - "update_content": '''issue fileds to update, json format; - if id field exists, update the issue with the id; - if id filed not exists, create a new issue with the content; - other fileds in update_content will be updated to the issue; - ''', + "mail_id": "update issue with which email object id", + "issue_id": '''update issue's id''', + "summary": '''issue's new summary''', } self.add_ai_function(SimpleAIFunction("update_issue", update_description, self._update, update_param)) - async def _update(self, source_id: str, update_content: str): - update_issue = json.loads(update_content) - issue_id = update_issue.get("id") - if issue_id: - self.storage.update_issue(source_id, update_issue) - else: - self.storage.add_issue(source_id, update_issue) - + async def _create(self, mail_id: str, issue_id: str, summary: str): + issue = self.storage.add_issue(mail_id, issue_id, summary) + return issue.id + + async def _update(self, mail_id: str, issue_id: str, summary: str): + update = {} + update["summary"] = summary + issue = self.storage.update_issue(mail_id, issue_id, update) + return issue.id class IssueParser: @@ -169,10 +261,30 @@ class IssueParser: root_issue = None if "root_issue" in config: - root_issue = Issue() - root_issue.summary = config["root_issue"] + root_config = config["root_issue"] + root_issue = IssueParser.__load_issue_config(root_config) + IssueParser.__calac_issue_id(root_issue) + self.issue_storage = IssueStorage(issue_path, root_issue) self.llm_env = IssueParserEnvironment("issue_parser", self.issue_storage) + + @classmethod + def __load_issue_config(cls, issue_config: dict) -> Issue: + issue = Issue() + issue.summary = issue_config["summary"] + if "children" in issue_config: + for child_config in issue_config["children"]: + child_issue = cls.__load_issue_config(child_config) + issue.children.append(child_issue) + return issue + + @classmethod + def __calac_issue_id(cls, issue: Issue): + issue_id = issue.calculate_id() + for child in issue.children: + child.parent = issue_id + cls.__calac_issue_id(child) + def get_path(self) -> str: return self.config["path"] @@ -182,19 +294,21 @@ class IssueParser: mail = self.mail_storage.get_mail_by_id(mail_id) issue = self.issue_storage.get_issue_by_mail(self.mail_storage, mail) mail_str = mail.to_prompt() - issue_str = issue.to_prompt() + issue_str = issue.to_prompt(recursion=self.issue_storage) mail_desc = Mail.prompt_desc() issue_desc = Issue.prompt_desc() - prompt = f'''I'll give a mail in json format, {mail_desc}; - and a issue in json format, {issue_desc}; - you should read this mail {mail_str}, see if this mail associated with this issue {issue_str}; - if this mail is about a new child issue of this issue, create a new issue with this mail, fill param update_content's summary field will mail content, set parent field with id of this issue; - if this mail will update this issue, set id filed to this issue, fill update_content param with new summary and new state with this mail content; - then you should call update_issue function with source_id set to this mail id, and update_content in json format; - if this mail is not associated with issue, you should ignore this mail without an function call; - ''' + prompt = AgentPrompt() + prompt.system_message = {"role": "system", "content": f''' + I'm a CEO of a company named 巴克云; You'ar my assistant, and you should help me to manage my issues. Issues is a concept in software development of this company, but I use it to manage my work. + I'll give you a mail in json format, {mail_desc}; + and a issue in json format, {issue_desc}. Read mail's fileds and issue's fileds, and decide if you should update the issue or create a new issue with this mail. + Then call the function create_issue or update_issue. + if this mail is not associated with issue, you should ignore this mail.'''} + + prompt.append(AgentPrompt(f'''Mail is {mail_str}, issue is {issue_str}. Answer me the function's return value or None if igonred. + ''')) - llm_result = await BaseAIAgent.do_llm_complection(self.llm_env, AgentPrompt(prompt), AgentMsg(), "gpt-4", 16000) + llm_result = await BaseAIAgent.do_llm_complection(prompt, "gpt-4-1106-preview", 4000, env=self.llm_env) return "update issue" diff --git a/src/component/mail_environment/local.py b/src/component/mail_environment/local.py index 07b038d..6ddbfba 100644 --- a/src/component/mail_environment/local.py +++ b/src/component/mail_environment/local.py @@ -31,4 +31,7 @@ class LocalEmail: else: yield (mail_id, str(mail_id)) - \ No newline at end of file + +class LocalEmailWithFilter: + def __init__(self, env: KnowledgePipelineEnvironment, config:dict): + pass \ No newline at end of file diff --git a/src/component/mail_environment/mail.py b/src/component/mail_environment/mail.py index ff5ef15..9f76c0b 100644 --- a/src/component/mail_environment/mail.py +++ b/src/component/mail_environment/mail.py @@ -261,5 +261,4 @@ class MailStorage: (uid, mail.id, mail.date, mail.from_addr), ) - \ No newline at end of file diff --git a/src/knowledge/object/object_id.py b/src/knowledge/object/object_id.py index 3a07b2b..6cef161 100644 --- a/src/knowledge/object/object_id.py +++ b/src/knowledge/object/object_id.py @@ -22,7 +22,7 @@ class ObjectType(IntEnum): return (self.value - 200) if self.is_user_def() else None @classmethod - def from_user_def_type_code(value): + def from_user_def_type_code(cls, value): return value + 200 diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index ccbce5a..6ecf52e 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -23,7 +23,9 @@ from prompt_toolkit.styles import Style directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') - +# import os +# os.environ['HTTP_PROXY'] = '127.0.0.1:10809' +# os.environ['HTTPS_PROXY'] = '127.0.0.1:10809' import proxy from aios_kernel import * From d7cdf2576e265d342c2e6ed3da536815b8fe3b7b Mon Sep 17 00:00:00 2001 From: tsukasa Date: Mon, 20 Nov 2023 23:36:28 +0800 Subject: [PATCH 24/79] read mail with issue tree pipeline works --- src/aios_kernel/__init__.py | 2 +- src/aios_kernel/agent.py | 84 +++---------------------- src/aios_kernel/agent_base.py | 83 +++++++++--------------- src/component/mail_environment/issue.py | 4 +- 4 files changed, 40 insertions(+), 133 deletions(-) diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index a1ab85f..187ee0b 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -1,5 +1,5 @@ from .environment import Environment,EnvironmentEvent -from .agent_base import AgentMsg,AgentMsgStatus,AgentMsgType,AgentPrompt +from .agent_base import AgentMsg,AgentMsgStatus,AgentMsgType,AgentPrompt,CustomAIAgent from .chatsession import AIChatSession from .agent import AIAgent,AIAgentTemplete, BaseAIAgent from .compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 095410d..478449f 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -316,50 +316,6 @@ class AIAgent(BaseAIAgent): return result_func,result_len - async def _execute_func(self,inner_func_call_node:dict,prompt:AgentPrompt,inner_functions,org_msg:AgentMsg=None,stack_limit = 5) -> ComputeTaskResult: - func_name = inner_func_call_node.get("name") - arguments = json.loads(inner_func_call_node.get("arguments")) - logger.info(f"llm execute inner func:{func_name} ({json.dumps(arguments)})") - - func_node : AIFunction = self.owner_env.get_ai_function(func_name) - if func_node is None: - result_str = f"execute {func_name} error,function not found" - else: - if org_msg: - ineternal_call_record = AgentMsg.create_internal_call_msg(func_name,arguments,org_msg.get_msg_id(),org_msg.target) - - try: - result_str:str = await func_node.execute(**arguments) - except Exception as e: - result_str = f"execute {func_name} error:{str(e)}" - logger.error(f"llm execute inner func:{func_name} error:{e}") - - - logger.info("llm execute inner func result:" + result_str) - - prompt.messages.append({"role":"function","content":result_str,"name":func_name}) - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"_execute_func llm compute error:{task_result.error_str}") - return task_result - - ineternal_call_record.result_str = task_result.result_str - ineternal_call_record.done_time = time.time() - if org_msg: - org_msg.inner_call_chain.append(ineternal_call_record) - - inner_func_call_node = None - if stack_limit > 0: - result_message : dict = task_result.result.get("message") - if result_message: - inner_func_call_node = result_message.get("function_call") - - if inner_func_call_node: - return await self._execute_func(inner_func_call_node,prompt,org_msg,stack_limit-1) - else: - return task_result - - def get_agent_prompt(self) -> AgentPrompt: return self.agent_prompt @@ -542,8 +498,7 @@ class AIAgent(BaseAIAgent): logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - #task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,inner_functions) - task_result = await self._do_llm_complection(prompt,msg,inner_functions=inner_functions) + task_result = await self.do_llm_complection(prompt,msg,inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(task_result.error_str) return error_resp @@ -705,7 +660,7 @@ class AIAgent(BaseAIAgent): prompt.append(AgentPrompt(work_summary)) prompt.append(AgentPrompt(report.content)) - task_result:ComputeTaskResult = await self._do_llm_complection(prompt) + task_result:ComputeTaskResult = await self.do_llm_complection(prompt) if task_result.error_str is not None: logger.error(f"_llm_read_report compute error:{task_result.error_str}") @@ -783,7 +738,7 @@ class AIAgent(BaseAIAgent): prompt.append(AgentPrompt(todo_tree)) inner_functions,_ = BaseAIAgent.get_inner_functions(self.owner_env) - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,inner_functions=inner_functions) + task_result:ComputeTaskResult = await self.do_llm_complection(prompt,inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_llm_review_todos compute error:{task_result.error_str}") return @@ -851,7 +806,7 @@ class AIAgent(BaseAIAgent): #prompt.append(work_log_prompt) prompt.append(self.get_prompt_from_todo(todo)) - task_result:ComputeTaskResult = await self._do_llm_complection(prompt) + task_result:ComputeTaskResult = await self.do_llm_complection(prompt) if task_result.error_str is not None: logger.error(f"_llm_do compute error:{task_result.error_str}") result.result_code = AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR @@ -901,7 +856,7 @@ class AIAgent(BaseAIAgent): prompt.append(todo.result) inner_functions,_ = BaseAIAgent.get_inner_functions(workspace) - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,inner_functions=inner_functions,is_json_resp=True) + task_result:ComputeTaskResult = await self.do_llm_complection(prompt,inner_functions=inner_functions,is_json_resp=True) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_llm_check_todo compute error:{task_result.error_str}") @@ -1062,7 +1017,7 @@ class AIAgent(BaseAIAgent): prompt.append(content_prompt) env_functions = None #env_functions,function_len = workspace.get_knowledge_base_ai_functions() - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,is_json_resp=True) + task_result:ComputeTaskResult = await self.do_llm_complection(prompt,is_json_resp=True) if task_result.result_code != ComputeTaskResultCode.OK: result_obj = {} result_obj["error_str"] = task_result.error_str @@ -1096,7 +1051,7 @@ class AIAgent(BaseAIAgent): content_prompt = AgentPrompt(part_content) prompt.append(content_prompt) #env_functions,function_len = workspace.get_knowledge_base_ai_functions() - task_result:ComputeTaskResult = await self._do_llm_complection(prompt,is_json_resp=True) + task_result:ComputeTaskResult = await self.do_llm_complection(prompt,is_json_resp=True) if task_result.result_code != ComputeTaskResultCode.OK: result_obj = {} result_obj["error_str"] = task_result.error_str @@ -1152,7 +1107,7 @@ class AIAgent(BaseAIAgent): logger.info(f"agent {self.agent_id} think session {session_id} is finished!,no more history") break #3) llm summarize chat history - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,self.llm_model_name,self.max_token_size,None) + task_result:ComputeTaskResult = await self.do_llm_complection(prompt) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"think_chatsession llm compute error:{task_result.error_str}") break @@ -1202,29 +1157,6 @@ class AIAgent(BaseAIAgent): return known_info,result_token_len return None,0 - async def _do_llm_complection(self,prompt:AgentPrompt,inner_functions:dict=None,org_msg:AgentMsg=None,is_json_resp = False) -> ComputeTaskResult: - from .compute_kernel import ComputeKernel - #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - if is_json_resp: - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,"json",self.llm_model_name,self.max_token_size,inner_functions) - else: - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,"text",self.llm_model_name,self.max_token_size,inner_functions) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"_do_llm_complection llm compute error:{task_result.error_str}") - #error_resp = msg.create_error_resp(task_result.error_str) - return task_result - - result_message = task_result.result.get("message") - inner_func_call_node = None - if result_message: - inner_func_call_node = result_message.get("function_call") - - if inner_func_call_node: - call_prompt : AgentPrompt = copy.deepcopy(prompt) - task_result = await self._execute_func(inner_func_call_node,call_prompt,inner_functions,org_msg) - - return task_result - def need_work(self) -> bool: if self.do_prompt is not None: diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index 75d06b2..f75132c 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -567,38 +567,6 @@ class BaseAIAgent(abc.ABC): def get_max_token_size(self) -> int: pass - @abstractmethod - def get_llm_learn_token_limit(self) -> int: - pass - - @abstractmethod - async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: - pass - - -class CustomAIAgent(BaseAIAgent): - def __init__(self, agent_id: str, llm_model_name: str, max_token_size: int, llm_learn_token_limit: int) -> None: - self.agent_id = agent_id - self.llm_model_name = llm_model_name - self.max_token_size = max_token_size - self.llm_learn_token_limit = llm_learn_token_limit - - def get_id(self) -> str: - return self.agent_id - - def get_llm_model_name(self) -> str: - return self.llm_model_name - - def get_max_token_size(self) -> int: - return self.max_token_size - - def get_llm_learn_token_limit(self) -> int: - return self.llm_learn_token_limit - -class BaseAIAgent: - def __init__(self) -> None: - pass - @classmethod def get_inner_functions(cls, env:Environment) -> (dict,int): if env is None: @@ -621,12 +589,9 @@ class BaseAIAgent: return result_func,result_len - @classmethod async def do_llm_complection( - cls, + self, prompt:AgentPrompt, - llm_model_name:str, - max_token_size:int, org_msg:AgentMsg=None, env:Environment=None, inner_functions=None, @@ -635,11 +600,11 @@ class BaseAIAgent: from .compute_kernel import ComputeKernel #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") if inner_functions is None and env is not None: - inner_functions,_ = cls.get_inner_functions(env) + inner_functions,_ = BaseAIAgent.get_inner_functions(env) if is_json_resp: - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,"json",llm_model_name,max_token_size,inner_functions,timeout=None) + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,resp_mode="json",mode_name=self.get_llm_model_name(),max_token=self.get_max_token_size(),inner_functions=inner_functions,timeout=None) else: - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,"text",llm_model_name,max_token_size,inner_functions,timeout=None) + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,resp_mode="text",mode_name=self.get_llm_model_name(),max_token=self.get_max_token_size(),inner_functions=inner_functions,timeout=None) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_do_llm_complection llm compute error:{task_result.error_str}") #error_resp = msg.create_error_resp(task_result.error_str) @@ -652,20 +617,17 @@ class BaseAIAgent: if inner_func_call_node: call_prompt : AgentPrompt = copy.deepcopy(prompt) - task_result = await cls._execute_func(env,inner_func_call_node,call_prompt,inner_functions,org_msg,llm_model_name,max_token_size) + task_result = await self._execute_func(env,inner_func_call_node,call_prompt,inner_functions,org_msg) return task_result - @classmethod async def _execute_func( - cls, + self, env: Environment, inner_func_call_node: dict, prompt: AgentPrompt, inner_functions: dict, org_msg:AgentMsg, - llm_model_name:str, - max_token_size:int, stack_limit = 5 ) -> ComputeTaskResult: from .compute_kernel import ComputeKernel @@ -677,9 +639,6 @@ class BaseAIAgent: if func_node is None: result_str = f"execute {func_name} error,function not found" else: - if org_msg: - ineternal_call_record = AgentMsg.create_internal_call_msg(func_name,arguments,org_msg.get_msg_id(),org_msg.target) - try: result_str:str = await func_node.execute(**arguments) except Exception as e: @@ -690,15 +649,16 @@ class BaseAIAgent: logger.info("llm execute inner func result:" + result_str) prompt.messages.append({"role":"function","content":result_str,"name":func_name}) - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,llm_model_name,max_token_size,inner_functions) + task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,mode_name=self.get_llm_model_name(),max_token=self.get_max_token_size(),inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"llm compute error:{task_result.error_str}") return task_result - - ineternal_call_record.result_str = task_result.result_str - ineternal_call_record.done_time = time.time() + if org_msg: - org_msg.inner_call_chain.append(ineternal_call_record) + internal_call_record = AgentMsg.create_internal_call_msg(func_name,arguments,org_msg.get_msg_id(),org_msg.target) + internal_call_record.result_str = task_result.result_str + internal_call_record.done_time = time.time() + org_msg.inner_call_chain.append(internal_call_record) inner_func_call_node = None if stack_limit > 0: @@ -707,7 +667,22 @@ class BaseAIAgent: inner_func_call_node = result_message.get("function_call") if inner_func_call_node: - return await cls._execute_func(env,inner_func_call_node,prompt,inner_functions,org_msg,llm_model_name,max_token_size,stack_limit-1) + return await self._execute_func(env,inner_func_call_node,prompt,inner_functions,org_msg,stack_limit-1) else: return task_result ->>>>>>> 2f9cee9 (a issue parser of email) + + +class CustomAIAgent(BaseAIAgent): + def __init__(self, agent_id: str, llm_model_name: str, max_token_size: int) -> None: + self.agent_id = agent_id + self.llm_model_name = llm_model_name + self.max_token_size = max_token_size + + def get_id(self) -> str: + return self.agent_id + + def get_llm_model_name(self) -> str: + return self.llm_model_name + + def get_max_token_size(self) -> int: + return self.max_token_size \ No newline at end of file diff --git a/src/component/mail_environment/issue.py b/src/component/mail_environment/issue.py index bc505ca..246171e 100644 --- a/src/component/mail_environment/issue.py +++ b/src/component/mail_environment/issue.py @@ -1,7 +1,7 @@ # define a knowledge base class import json import string -from aios_kernel import ComputeKernel, AIStorage, Environment, SimpleAIFunction, BaseAIAgent, AgentPrompt, AgentMsg +from aios_kernel import AIStorage, Environment, SimpleAIFunction, CustomAIAgent, AgentPrompt, AgentMsg from knowledge import * from .mail import MailStorage, Mail @@ -309,6 +309,6 @@ class IssueParser: prompt.append(AgentPrompt(f'''Mail is {mail_str}, issue is {issue_str}. Answer me the function's return value or None if igonred. ''')) - llm_result = await BaseAIAgent.do_llm_complection(prompt, "gpt-4-1106-preview", 4000, env=self.llm_env) + llm_result = await CustomAIAgent("issue parser", "gpt-4-1106-preview", 4000).do_llm_complection(prompt, env=self.llm_env) return "update issue" From cc3faee716e874b0bf59bdad5c8684b7cfbc32b0 Mon Sep 17 00:00:00 2001 From: wugren Date: Tue, 21 Nov 2023 16:18:11 +0800 Subject: [PATCH 25/79] AgentAssistant --- rootfs/agents/AgentAssistant/agent.toml | 32 +++++++++++ rootfs/agents/AgentAssistant/environment.py | 59 +++++++++++++++++++++ src/aios_kernel/agent.py | 2 +- src/aios_kernel/open_ai_node.py | 14 ++--- 4 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 rootfs/agents/AgentAssistant/agent.toml create mode 100644 rootfs/agents/AgentAssistant/environment.py diff --git a/rootfs/agents/AgentAssistant/agent.toml b/rootfs/agents/AgentAssistant/agent.toml new file mode 100644 index 0000000..770bc82 --- /dev/null +++ b/rootfs/agents/AgentAssistant/agent.toml @@ -0,0 +1,32 @@ +instance_id = "AgentAssistant" +fullname = "AgentAssistant" +llm_model_name = "gpt-4-1106-preview" +owner_env = "environment.py" + +[[prompt]] +role = "system" +content = ''' +You are an AI Agent template generation assistant that can help users better define AI Agent templates. +AI Agent can use AI to help users complete relevant tasks based on prompt words. + +Please help users define AI Agent templates according to the following rules: +New generation: +1. Generate prompt words for users based on user questions. Prompt word suggestions can be given directly based on user input until the user confirms the prompt words. +2. Generate instance_id and fullname based on the final prompt word content. +3. Generate the following toml format data and return it to the user. +``` +instance_id = "agent id" +fullname = "agent full name" +[[prompt]] +role = "system" +content = """Prompt words""" +``` +4. After the configuration is generated, the user is supported to modify the instance_id, fullname and prompt words. After the user has modified it, toml format data needs to be regenerated. +5. The save_agent function is called only when the user confirms the save. Do not call it when there is no input to save. +Modify existing agent: +1. The user enters the name of the agent that needs to be opened, and the system calls the read_agent function to read the agent configuration and return it to the user. +2. Support users to modify the fullname and prompt words, but cannot modify the instance_id. After the user has modified it, toml format data needs to be regenerated. +3. The save_agent function is called only when the user confirms the save. Do not call it when there is no input to save. + +PS: You need to communicate in the same language as the user input. +''' diff --git a/rootfs/agents/AgentAssistant/environment.py b/rootfs/agents/AgentAssistant/environment.py new file mode 100644 index 0000000..35fa35b --- /dev/null +++ b/rootfs/agents/AgentAssistant/environment.py @@ -0,0 +1,59 @@ +import logging +from typing import Optional + +import toml + +from aios_kernel import Environment, SimpleAIFunction +import os + + +local_path = os.path.split(os.path.realpath(__file__))[0] + +logger = logging.getLogger(__name__) + + +class AgentAssistantEnvironment(Environment): + def __init__(self): + super().__init__("agent_assistant_env") + self.add_ai_function(SimpleAIFunction("read_agent", + "Read the agent with the specified name", + self.read_agent, + {"agent_id": "The id of the agent to be read"})) + self.add_ai_function(SimpleAIFunction("save_agent", + "save the agent with the specified name", + self.save_agent, + {"agent_id": "The id of the agent to be saved", + "agent_data": "The toml data of the agent to be saved", + "is_new": "Whether to create a new agent, The value is true if it is created, and it is false if it is modified."})) + + def _do_get_value(self, key: str) -> Optional[str]: + pass + + async def read_agent(self, agent_id: str) -> str: + agent_dir = os.path.join(local_path, "..", agent_id) + if not os.path.exists(agent_dir): + return "exec failed.agent is not exists." + + with open(os.path.join(agent_dir, "agent.toml"), "r", encoding='utf-8') as f: + agent_data = f.read() + + return "exec success. agent data:\n" + agent_data + + async def save_agent(self, agent_id: str, agent_data: str, is_new: str) -> str: + logger.info(f"save_agent: {agent_id} {agent_data}") + + agent_dir = os.path.join(local_path, "..", agent_id) + if os.path.exists(agent_dir) and is_new == "true": + return "exec failed.agent already exists, please change the agent id and try again." + + if not os.path.exists(agent_dir): + os.mkdir(agent_dir) + + with open(os.path.join(agent_dir, "agent.toml"), "w", encoding='utf-8') as f: + f.write(agent_data) + + return "exec success." + + +def init(): + return AgentAssistantEnvironment() diff --git a/src/aios_kernel/agent.py b/src/aios_kernel/agent.py index 478449f..bf03eab 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios_kernel/agent.py @@ -498,7 +498,7 @@ class AIAgent(BaseAIAgent): logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - task_result = await self.do_llm_complection(prompt,msg,inner_functions=inner_functions) + task_result = await self.do_llm_complection(prompt,msg, env=self.owner_env,inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(task_result.error_str) return error_resp diff --git a/src/aios_kernel/open_ai_node.py b/src/aios_kernel/open_ai_node.py index 2999a27..cbe1885 100644 --- a/src/aios_kernel/open_ai_node.py +++ b/src/aios_kernel/open_ai_node.py @@ -74,7 +74,7 @@ class OpenAI_ComputeNode(ComputeNode): # function_call["arguments"] = message.function_call.arguments # function_call["name"] = message.function_call.name # result_msg["function_call"] = function_call - + # if message.tool_calls: # tool_calls = [] # for tool_call in message.tool_calls: @@ -88,10 +88,10 @@ class OpenAI_ComputeNode(ComputeNode): # tool_calls.append(tool_call_dict) # result_msg["tool_calls"] = message.tool_calls - + # result["message"] = result_msg return result - + def _image_2_text(self, task: ComputeTask): logger.info('openai image_2_text') # 本地图片处理 @@ -138,7 +138,7 @@ class OpenAI_ComputeNode(ComputeNode): async def _run_task(self, task: ComputeTask): task.state = ComputeTaskState.RUNNING - + result = ComputeTaskResult() result.result_code = ComputeTaskResultCode.ERROR result.set_from_task(task) @@ -157,7 +157,7 @@ class OpenAI_ComputeNode(ComputeNode): task.error_str = str(e) result.error_str = str(e) return result - + # resp = { # "object": "list", # "data": [ @@ -215,7 +215,7 @@ class OpenAI_ComputeNode(ComputeNode): messages=prompts, response_format = response_format, functions=llm_inner_functions, - max_tokens=result_token, + # max_tokens=result_token, ) # TODO: add temperature to task params? except Exception as e: logger.error(f"openai run LLM_COMPLETION task error: {e}") @@ -289,7 +289,7 @@ class OpenAI_ComputeNode(ComputeNode): model_name : str = task.params["model_name"] if model_name.startswith("gpt-"): return True - + if task.task_type == ComputeTaskType.IMAGE_2_TEXT: model_name : str = task.params["model_name"] if model_name.startswith("gpt-4"): From b305a963baa2607cc843940078793eaa17d4050b Mon Sep 17 00:00:00 2001 From: wugren Date: Wed, 22 Nov 2023 19:00:41 +0800 Subject: [PATCH 26/79] TTS and ASR function implemented based on openai api --- src/aios_kernel/__init__.py | 4 +- src/aios_kernel/asr_function.py | 55 ++++++ src/aios_kernel/compute_kernel.py | 32 +++- src/aios_kernel/google_text_to_speech_node.py | 9 + src/aios_kernel/openai_tts_node.py | 120 ++++++++++++ src/aios_kernel/script_to_speech_function.py | 107 +++++++++++ src/aios_kernel/text_to_speech_function.py | 74 +++----- src/aios_kernel/whisper_node.py | 179 ++++++++++++++---- src/aios_kernel/workflow_env.py | 28 +-- src/requirements.txt | 4 +- src/service/aios_shell/aios_shell.py | 78 ++++---- 11 files changed, 551 insertions(+), 139 deletions(-) create mode 100644 src/aios_kernel/asr_function.py create mode 100644 src/aios_kernel/openai_tts_node.py create mode 100644 src/aios_kernel/script_to_speech_function.py diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index 187ee0b..ea00748 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -6,6 +6,7 @@ from .compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeT from .compute_node import ComputeNode,LocalComputeNode from .open_ai_node import OpenAI_ComputeNode from .role import AIRole,AIRoleGroup +from .storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem from .workflow import Workflow from .bus import AIBus from .workflow_env import WorkflowEnvironment,CalenderEnvironment,CalenderEvent,PaintEnvironment @@ -15,8 +16,7 @@ from .google_text_to_speech_node import GoogleTextToSpeechNode from .tunnel import AgentTunnel from .tg_tunnel import TelegramTunnel from .email_tunnel import EmailTunnel -from .storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem -from .contact_manager import ContactManager,Contact,FamilyMember +from .contact_manager import ContactManager,Contact,FamilyMember from .text_to_speech_function import TextToSpeechFunction from .image_2_text_function import Image2TextFunction from .workspace_env import ShellEnvironment diff --git a/src/aios_kernel/asr_function.py b/src/aios_kernel/asr_function.py new file mode 100644 index 0000000..11acde1 --- /dev/null +++ b/src/aios_kernel/asr_function.py @@ -0,0 +1,55 @@ +import logging +from typing import Dict + +from aios_kernel import ComputeKernel +from aios_kernel.ai_function import AIFunction + +logger = logging.getLogger(__name__) + + +class AsrFunction(AIFunction): + def __init__(self): + self.func_id = "speech_to_text" + self.description = "语音识别,将语音转换为文字" + + def get_name(self) -> str: + return self.func_id + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "audio_file": {"type": "string", "description": "音频文件路径"}, + "model": {"type": "string", "description": "识别模型", "enum": ["openai-whisper"]}, + "prompt": {"type": "string", "description": "提示语句,可以为None"}, + "response_format": {"type": "string", "description": "返回格式", "enum": ["text", "json", "srt", "verbose_json", "vtt"]}, + } + } + + async def execute(self, **kwargs) -> str: + logger.info(f"execute asr function: {kwargs}") + + audio_file = kwargs.get("audio_file") + model = kwargs.get("model") + prompt = kwargs.get("prompt") + response_format = kwargs.get("response_format") + if response_format is None: + response_format = "text" + + result = await ComputeKernel.get_instance().do_speech_to_text(audio_file, model, prompt, response_format) + if result is not None: + return f"exec speech_to_text Ok. {response_format} is\n```\n{result.result_str}\n```" + else: + return "exec speech_to_text failed" + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False diff --git a/src/aios_kernel/compute_kernel.py b/src/aios_kernel/compute_kernel.py index 6174fa8..1c6219a 100644 --- a/src/aios_kernel/compute_kernel.py +++ b/src/aios_kernel/compute_kernel.py @@ -77,7 +77,7 @@ class ComputeKernel: if len(support_nodes) < 1: logger.warning(f"task {task.display()} is not support by any compute node") return None - + # hit a random node with weight hit_pos = random.randint(0, total_weights - 1) for i in range(min(len(support_nodes) - 1, hit_pos), -1, -1): @@ -126,8 +126,8 @@ class ComputeKernel: task_req.set_llm_params(prompt,resp_mode,mode_name, max_token,inner_functions) self.run(task_req) return task_req - - async def _wait_task(self,task_req:ComputeTask, timeout=60)->ComputeTaskResult: + + async def _wait_task(self,task_req:ComputeTask, timeout=60)->ComputeTaskResult: async def check_timer(): check_times = 0 while True: @@ -136,7 +136,7 @@ class ComputeKernel: if task_req.state == ComputeTaskState.ERROR: break - + if timeout is not None and check_times >= timeout*2: task_req.state = ComputeTaskState.ERROR break @@ -181,7 +181,7 @@ class ComputeKernel: task_req.set_image_embedding_params(input,model_name) self.run(task_req) return task_req - + async def do_image_embedding(self,input:ObjectID,model_name:Optional[str] = None) -> [float]: task_req = self.image_embedding(input,model_name) task_result = await self._wait_task(task_req) @@ -197,7 +197,8 @@ class ComputeKernel: gender: Optional[str] = None, age: Optional[str] = None, voice_name: Optional[str] = None, - tone: Optional[str] = None): + tone: Optional[str] = None, + model_name: Optional[str] = None): task_req = ComputeTask() task_req.params["text"] = input task_req.params["language_code"] = language_code @@ -205,6 +206,7 @@ class ComputeKernel: task_req.params["age"] = age task_req.params["voice_name"] = voice_name task_req.params["tone"] = tone + task_req.params["model_name"] = model_name task_req.task_type = ComputeTaskType.TEXT_2_VOICE self.run(task_req) @@ -213,6 +215,24 @@ class ComputeKernel: if task_req.state == ComputeTaskState.DONE: return task_result.result + async def do_speech_to_text(self, + audio: str, + model: str, + prompt: Optional[str], + response_format: Optional[str]): + task_req = ComputeTask() + task_req.params["file"] = audio + task_req.params["model_name"] = model + task_req.params["prompt"] = prompt + task_req.params["response_format"] = response_format + task_req.task_type = ComputeTaskType.VOICE_2_TEXT + + self.run(task_req) + + task_result = await self._wait_task(task_req) + + if task_req.state == ComputeTaskState.DONE: + return task_result def text_2_image(self, prompt:str, model_name:Optional[str] = None, negative_prompt = None): task = ComputeTask() diff --git a/src/aios_kernel/google_text_to_speech_node.py b/src/aios_kernel/google_text_to_speech_node.py index 0fdba47..ccf9034 100644 --- a/src/aios_kernel/google_text_to_speech_node.py +++ b/src/aios_kernel/google_text_to_speech_node.py @@ -117,9 +117,18 @@ class GoogleTextToSpeechNode(ComputeNode): def _run_task(self, task: ComputeTask): task.state = ComputeTaskState.RUNNING language_code = task.params["language_code"] + if language_code is None: + language_code = "en" + text = task.params["text"] voice_name = task.params["voice_name"] + if voice_name is None: + voice_name = "default" + gender = task.params["gender"] + if gender is None: + gender = "female" + age = task.params["age"] if language_code == "zh": diff --git a/src/aios_kernel/openai_tts_node.py b/src/aios_kernel/openai_tts_node.py new file mode 100644 index 0000000..68ee064 --- /dev/null +++ b/src/aios_kernel/openai_tts_node.py @@ -0,0 +1,120 @@ +import asyncio +import io +import logging +import os +from asyncio import Queue + +from openai import AsyncOpenAI + +from aios_kernel import ComputeNode, ComputeTask, ComputeTaskState, ComputeTaskResult, ComputeTaskType, AIStorage + +logger = logging.getLogger(__name__) + + +class OpenAITTSComputeNode(ComputeNode): + _instance = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + super().__init__() + self.is_start = False + self.node_id = "openai_tts_node" + self.task_queue = Queue() + self.voice_list = { + "female": ["nova", "shimmer"], + "man": ["alloy", "echo", "fable", "onyx"] + } + if os.getenv("OPENAI_API_KEY") is not None: + self.openai_api_key = os.getenv("OPENAI_API_KEY") + else: + self.openai_api_key = AIStorage.get_instance().get_user_config().get_value("openai_api_key") + + self.start() + + def start(self): + if self.is_start is True: + logger.warn("OpenAITTSComputeNode is already start") + return + self.is_start = True + + async def _run_task_loop(): + while True: + task = await self.task_queue.get() + try: + result = await self._run_task(task) + if result is not None: + task.state = ComputeTaskState.DONE + task.result = result + except Exception as e: + logger.error(f"openai_tts_node run task error: {e}") + task.state = ComputeTaskState.ERROR + task.result = ComputeTaskResult() + task.result.set_from_task(task) + task.result.worker_id = self.node_id + task.result.result_str = str(e) + + asyncio.create_task(_run_task_loop()) + + async def _run_task(self,task: ComputeTask): + task.state = ComputeTaskState.RUNNING + text = task.params["text"] + voice_name = task.params["voice_name"] + if voice_name is None: + voice_name = "default" + gender = task.params["gender"] + if gender is None: + gender = "female" + + voice_list = self.voice_list[gender] + voice = voice_list[hash(voice_name)%len(voice_list)] + + model_name = task.params['model_name'] + if model_name is None: + model_name = 'tts-1' + + client = AsyncOpenAI(api_key=self.openai_api_key) + + response = await client.audio.speech.create(model=model_name, voice=voice, input=text) + + cache = io.BytesIO() + async for data in await response.aiter_bytes(): + cache.write(data) + + cache.seek(0) + + result = ComputeTaskResult() + result.set_from_task(task) + result.worker_id = self.node_id + result.result = cache.read() + return result + + async def push_task(self, task: ComputeTask, proiority: int = 0): + logger.info(f"openai_tts_node push task: {task.display()}") + self.task_queue.put_nowait(task) + + async def remove_task(self, task_id: str): + pass + + def get_task_state(self, task_id: str): + pass + + def display(self) -> str: + return f"OpenAITTSComputeNode: {self.node_id}" + + def get_capacity(self): + return 0 + + def is_support(self, task: ComputeTask) -> bool: + if task.task_type == ComputeTaskType.TEXT_2_VOICE: + if task.params['model_name'] is None or task.params['model_name'] == 'tts-1' or task.params['model_name'] == 'tts-1-hd': + return True + return False + + + def is_local(self) -> bool: + return False diff --git a/src/aios_kernel/script_to_speech_function.py b/src/aios_kernel/script_to_speech_function.py new file mode 100644 index 0000000..7ccd116 --- /dev/null +++ b/src/aios_kernel/script_to_speech_function.py @@ -0,0 +1,107 @@ +import io +import logging +import os +import random +from pathlib import Path +from typing import Dict + +from aios_kernel import ComputeKernel, AIStorage +from aios_kernel.ai_function import AIFunction + +from pydub import AudioSegment + +logger = logging.getLogger(__name__) + + +class ScriptToSpeechFunction(AIFunction): + def __init__(self): + self.func_id = "script_to_speech" + self.description = "根据输入的剧本生成音频文件,成功时会返回音频文件路径" + self.speech_path = os.path.join(AIStorage.get_instance().get_myai_dir(), "tts") + Path(self.speech_path).mkdir(exist_ok=True) + + def get_name(self) -> str: + return self.func_id + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "language": {"type": "string", "description": "演播语言", "enum": ["zh", "en"]}, + "model": {"type": "string", "description": "演播模型", "enum": ["tts-1", "tts-1-hd"]}, + "roles": {"type": "array", "items": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "角色名字"}, + "gender": {"type": "string", "description": "角色性别", "enum": ["man", "female"]}, + "age": {"type": "string", "description": "年龄", "enum": ["child", "adult"]}, + }}}, + "lines": {"type": "array", "items": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "角色名字"}, + "tone": {"type": "string", "description": "演播情感", + "enum": ["happy", "sad", "angry", "fear", "disgust", "surprise", "neutral"]}, + "text": {"type": "string", "description": "台词"}, + } + }} + } + } + + async def execute(self, **kwargs) -> str: + logger.info(f"execute text_to_speech function: {kwargs}") + + language = kwargs.get("language") + if language is None: + language = "zh" + model = kwargs.get("model") + roles = kwargs.get("roles") + lines = kwargs.get("lines") + + audio = None + for line in lines: + name = line.get("name") + tone = line.get("tone") + text = line.get("text") + gender = None + age = None + for role in roles: + role_name = role.get("name") + if role_name == name: + gender = role.get("gender") + age = role.get("age") + break + i = 0 + while i < 3: + try: + data = await ComputeKernel.get_instance().do_text_to_speech(text, language, gender, age, name, tone, model_name=model) + if audio is None: + audio = AudioSegment.from_mp3(io.BytesIO(data)) + else: + audio = audio + AudioSegment.from_mp3(io.BytesIO(data)) + break + except Exception as e: + logger.error(f"do_text_to_speech failed: {e}") + i += 1 + continue + + if audio is not None: + path = os.path.join(self.speech_path, "{}.mp3".format(''.join(random.sample('zyxwvutsrqponmlkjihgfedcba', 10)))) + audio.export(path, format="mp3") + return "exec text_to_speech OK,speech file store at ```{}```".format(path) + else: + return "exec text_to_speech failed" + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False + + diff --git a/src/aios_kernel/text_to_speech_function.py b/src/aios_kernel/text_to_speech_function.py index 90dfd49..0f90ee1 100644 --- a/src/aios_kernel/text_to_speech_function.py +++ b/src/aios_kernel/text_to_speech_function.py @@ -2,19 +2,23 @@ import io import logging import os import random +from pathlib import Path from typing import Dict -from aios_kernel import ComputeKernel +from aios_kernel import ComputeKernel, AIStorage from aios_kernel.ai_function import AIFunction from pydub import AudioSegment logger = logging.getLogger(__name__) + class TextToSpeechFunction(AIFunction): def __init__(self): self.func_id = "text_to_speech" - self.description = "根据输入的剧本生成音频文件,成功时会返回音频文件路径" + self.description = "根据输入的文本生成音频文件,成功时会返回音频文件路径" + self.speech_path = os.path.join(AIStorage.get_instance().get_myai_dir(), "tts") + Path(self.speech_path).mkdir(exist_ok=True) def get_name(self) -> str: return self.func_id @@ -27,22 +31,8 @@ class TextToSpeechFunction(AIFunction): "type": "object", "properties": { "language": {"type": "string", "description": "演播语言", "enum": ["zh", "en"]}, - "roles": {"type": "array", "items": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "角色名字"}, - "gender": {"type": "string", "description": "角色性别", "enum": ["man", "female"]}, - "age": {"type": "string", "description": "年龄", "enum": ["child", "adult"]}, - }}}, - "lines": {"type": "array", "items": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "角色名字"}, - "tone": {"type": "string", "description": "演播情感", - "enum": ["happy", "sad", "angry", "fear", "disgust", "surprise", "neutral"]}, - "text": {"type": "string", "description": "台词"}, - } - }} + "model": {"type": "string", "description": "演播模型", "enum": ["tts-1", "tts-1-hd"]}, + "text": {"type": "string", "description": "文本内容"} } } @@ -51,41 +41,27 @@ class TextToSpeechFunction(AIFunction): language = kwargs.get("language") if language is None: - language = "zh" - roles = kwargs.get("roles") - lines = kwargs.get("lines") + language = "en" + model = kwargs.get("model") + text = kwargs.get("text") - audio = None - for line in lines: - name = line.get("name") - tone = line.get("tone") - text = line.get("text") - gender = None - age = None - for role in roles: - role_name = role.get("name") - if role_name == name: - gender = role.get("gender") - age = role.get("age") - break - i = 0 - while i < 3: - try: - data = await ComputeKernel.get_instance().do_text_to_speech(text, language, gender, age, name, tone) - if audio is None: - audio = AudioSegment.from_mp3(io.BytesIO(data)) - else: - audio = audio + AudioSegment.from_mp3(io.BytesIO(data)) - break - except Exception as e: - logger.error(f"do_text_to_speech failed: {e}") - i += 1 - continue + i = 0 + while i < 3: + try: + data = await ComputeKernel.get_instance().do_text_to_speech(text, language, None, None, None, None, + model_name=model) + if data is not None: + audio = AudioSegment.from_mp3(io.BytesIO(data)) + break + except Exception as e: + logger.error(f"do_text_to_speech failed: {e}") + i += 1 + continue if audio is not None: - path = os.path.join(os.path.realpath(os.curdir), "{}.mp3".format(''.join(random.sample('zyxwvutsrqponmlkjihgfedcba', 10)))) + path = os.path.join(self.speech_path, "{}.mp3".format(''.join(random.sample('zyxwvutsrqponmlkjihgfedcba', 10)))) audio.export(path, format="mp3") - return "exec text_to_speech OK,speech file store at {}".format(path) + return "exec text_to_speech OK,speech file store at ```{}```".format(path) else: return "exec text_to_speech failed" diff --git a/src/aios_kernel/whisper_node.py b/src/aios_kernel/whisper_node.py index b036ad5..bac0cc4 100644 --- a/src/aios_kernel/whisper_node.py +++ b/src/aios_kernel/whisper_node.py @@ -1,55 +1,77 @@ +import io +import json from asyncio import Queue import asyncio import openai import os import logging +import srt +import webvtt +from openai import AsyncOpenAI +from openai.cli._progress import BufferReader +from pydub import AudioSegment +from datetime import timedelta + +from . import AIStorage from .compute_node import ComputeNode from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType logger = logging.getLogger(__name__) +SECONDS_IN_HOUR = 3600 +SECONDS_IN_MINUTE = 60 +HOURS_IN_DAY = 24 +MICROSECONDS_IN_MILLISECOND = 1000 + + +def timedelta_to_vtt_timestamp(timedelta_timestamp): + hrs, secs_remainder = divmod(timedelta_timestamp.seconds, SECONDS_IN_HOUR) + hrs += timedelta_timestamp.days * HOURS_IN_DAY + mins, secs = divmod(secs_remainder, SECONDS_IN_MINUTE) + msecs = timedelta_timestamp.microseconds // MICROSECONDS_IN_MILLISECOND + return "%02d:%02d:%02d.%03d" % (hrs, mins, secs, msecs) + class WhisperComputeNode(ComputeNode): _instance = None - def __new__(cls): + @classmethod + def get_instance(cls): if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance.is_start = False + cls._instance = cls() return cls._instance def __init__(self) -> None: super().__init__() - if self.is_start is True: - logger.warn("WhisperComputeNode is already start") - return - - self.is_start = True + self.is_start = False self.node_id = "whisper_node" self.enable = True self.task_queue = Queue() - self.open_api_key = None - if self.open_api_key is None and os.getenv("OPENAI_API_KEY") is not None: - self.open_api_key = os.getenv("OPENAI_API_KEY") - - if self.open_api_key is None: - raise Exception("WhisperComputeNode open_api_key is None") + if os.getenv("OPENAI_API_KEY") is not None: + self.openai_api_key = os.getenv("OPENAI_API_KEY") + else: + self.openai_api_key = AIStorage.get_instance().get_user_config().get_value("openai_api_key") self.start() def start(self): + if self.is_start is True: + logger.warn("WhisperComputeNode is already start") + return + self.is_start = True async def _run_task_loop(): while True: task = await self.task_queue.get() try: - result = self._run_task(task) + result = await self._run_task(task) if result is not None: task.state = ComputeTaskState.DONE task.result = result except Exception as e: logger.error(f"whisper_node run task error: {e}") + logger.exception(e) task.state = ComputeTaskState.ERROR task.result = ComputeTaskResult() task.result.set_from_task(task) @@ -58,7 +80,7 @@ class WhisperComputeNode(ComputeNode): asyncio.create_task(_run_task_loop()) - def _run_task(self, task: ComputeTask): + async def _run_task(self, task: ComputeTask): task.state = ComputeTaskState.RUNNING prompt = task.params["prompt"] response_format = None @@ -72,19 +94,111 @@ class WhisperComputeNode(ComputeNode): language = task.params["language"] file = task.params["file"] - resp = openai.Audio.transcribe("whisper-1", - file, - self.open_api_key, - prompt=prompt, - response_format=response_format, - temperature=temperature, - language=language) - result = ComputeTaskResult() - result.set_from_task(task) - result.worker_id = self.node_id - result.result_str = resp["text"] - result.result = resp - return result + client = AsyncOpenAI(api_key=self.openai_api_key) + + if os.path.getsize(file) > 25 * 1024 * 1024: + audio = AudioSegment.from_file(file) + text = "" + results = [] + latest_resp = None + step = 30 * 1000 + for i in range(0, 60 * 1000, step): + chunk = audio[i:i + step] + seg_file = io.BytesIO() + chunk.export(seg_file, format="mp3") + seg_file.seek(0) + + resp = await client.audio.transcriptions.create(model="whisper-1", + file = ("test.mp3", seg_file), + language=language, + temperature=temperature, + prompt=prompt, + response_format=response_format) + if response_format == "json": + if text == "": + text = resp.text + else: + text += "," + resp.text + elif response_format == "text": + if text == "": + text = resp + else: + text += "," + resp + elif response_format == "verbose_json": + if text == "": + text = resp.text + else: + text += "," + resp.text + results.extend(resp.segments) + elif response_format == "srt": + srt_list = list(srt.parse(resp)) + for item in srt_list: + item.start += timedelta(milliseconds=i) + item.end += timedelta(milliseconds=i) + results.append(item) + elif response_format == "vtt": + vtt = webvtt.read_buffer(io.StringIO(resp)) + for caption in vtt.captions: + start = timedelta_to_vtt_timestamp( + srt.srt_timestamp_to_timedelta(caption.start) + timedelta(milliseconds=i)) + end = timedelta_to_vtt_timestamp( + srt.srt_timestamp_to_timedelta(caption.end) + timedelta(milliseconds=i)) + results.append(webvtt.Caption(start, end, caption.text)) + else: + raise Exception(f"not support response_format: {response_format}") + + latest_resp = resp + + result = ComputeTaskResult() + result.set_from_task(task) + result.worker_id = self.node_id + if response_format == "text": + result.result_str = text + result.result = text + elif response_format == "json": + result.result_str = json.dumps({"text": text}) + resp.text = text + result.result = resp + elif response_format == "verbose_json": + result.result_str = json.dumps({"text": text, "segments": results}) + latest_resp.text = text + latest_resp.segments = results + result.result = latest_resp + elif response_format == "srt": + result.result_str = srt.compose(results) + result.result = result.result_str + elif response_format == "vtt": + vtt = webvtt.WebVTT() + vtt.captions.extend(results) + f = io.StringIO() + vtt.write(f) + f.seek(0) + result.result_str = f.read() + result.result = result.result_str + return result + else: + with open(file, "rb") as file_reader: + buffer_reader = BufferReader(file_reader.read(), desc="Upload progress") + + resp = await client.audio.transcriptions.create(model="whisper-1", + file = (file, buffer_reader), + language=language, + temperature=temperature, + prompt=prompt, + response_format=response_format) + result = ComputeTaskResult() + result.set_from_task(task) + result.worker_id = self.node_id + if response_format == "json": + result.result_str = json.dumps({"text": resp.text}) + elif response_format == "verbose_json": + result.result_str = json.dumps({"text": resp.text, "segments": resp.segments}) + elif response_format == "srt" or response_format == "vtt" or response_format == "text": + result.result_str = resp + else: + raise Exception(f"not support response_format: {response_format}") + result.result = resp + return result async def push_task(self, task: ComputeTask, proiority: int = 0): logger.info(f"whisper_node push task: {task.display()}") @@ -102,9 +216,10 @@ class WhisperComputeNode(ComputeNode): def get_capacity(self): return 0 - def is_support(self, task_type: ComputeTaskType) -> bool: - if task_type == ComputeTaskType.VOICE_2_TEXT: - return True + def is_support(self, task: ComputeTask) -> bool: + if task.task_type == ComputeTaskType.VOICE_2_TEXT: + if task.params['model_name'] is None or task.params['model_name'] == 'openai-whisper': + return True return False def is_local(self) -> bool: diff --git a/src/aios_kernel/workflow_env.py b/src/aios_kernel/workflow_env.py index 9201fba..03cd128 100644 --- a/src/aios_kernel/workflow_env.py +++ b/src/aios_kernel/workflow_env.py @@ -8,7 +8,7 @@ import threading import logging from typing import Optional -from .text_to_speech_function import TextToSpeechFunction +from .script_to_speech_function import ScriptToSpeechFunction from .image_2_text_function import Image2TextFunction from .compute_kernel import ComputeKernel, ComputeTaskResultCode from .environment import Environment,EnvironmentEvent @@ -80,7 +80,7 @@ class CalenderEnvironment(Environment): "update event in calender", self._update_event,update_param)) - + #maybe this function should be in other env? paint_param = { "prompt": "A description of the content of the painting", @@ -89,18 +89,18 @@ class CalenderEnvironment(Environment): self.add_ai_function(SimpleAIFunction("paint", "Draw a picture according to the description", self._paint,paint_param)) - + self.add_ai_function(SimpleAIFunction("get_contact", "get contact info", self._get_contact,{"name":"name of contact"})) - + self.add_ai_function(SimpleAIFunction("set_contact", "set contact info", self._set_contact,{"name":"name of contact","contact_info":"A json to descrpit contact"})) - - - - + + + + #self.add_ai_function(SimpleAIFunction("user_confirm", # "user confirm", # self._user_confirm)) @@ -169,10 +169,10 @@ class CalenderEnvironment(Environment): _event["location"] = row[5] _event["details"] = row[6] result[row[0]] = _event - + if not have_result: return "No event." - + return json.dumps(result, indent=4, sort_keys=True) async def _update_event(self,event_id, new_title=None, new_participants=None, new_location=None, new_details=None ,start_time=None, end_time=None): @@ -230,7 +230,7 @@ class CalenderEnvironment(Environment): def _do_get_value(self,key:str) -> Optional[str]: return None - + async def _get_contact(self,name:str) -> str: cm = ContactManager.get_instance() contact : Contact = cm.find_contact_by_name(name) @@ -302,7 +302,7 @@ class CalenderEnvironment(Environment): formatted_time = now.strftime('%Y-%m-%d %H:%M:%S') return formatted_time - + async def _paint(self, prompt, model_name = None) -> str: result = await ComputeKernel.get_instance().do_text_2_image(prompt, model_name) if result.result_code == ComputeTaskResultCode.ERROR: @@ -344,7 +344,7 @@ class WorkflowEnvironment(Environment): self.db_file = db_file self.local = threading.local() self.table_name = "WorkflowEnv_" + env_id - self.add_ai_function(TextToSpeechFunction()) + self.add_ai_function(ScriptToSpeechFunction()) self.add_ai_function(Image2TextFunction()) @@ -418,4 +418,4 @@ class WorkflowEnvironment(Environment): logging.error(f"Error occurred while update env{self.env_id}.{key} ,error:{e}") def get_functions(self): - pass \ No newline at end of file + pass diff --git a/src/requirements.txt b/src/requirements.txt index 20eebbf..151ea86 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -139,4 +139,6 @@ stability_sdk sentence-transformers==2.2.2 tiktoken markdown -PyPDF2 \ No newline at end of file +PyPDF2 +srt==3.5.3 +webvtt-py==0.4.6 diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 6ecf52e..f3610c2 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -20,6 +20,8 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import WordCompleter from prompt_toolkit.styles import Style +from aios_kernel.openai_tts_node import OpenAITTSComputeNode + directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') @@ -74,8 +76,8 @@ class AIOS_Shell: user_config.add_user_config("shell.current","last opened target and topic",True,"default@Jarvis") proxy.declare_user_config() - google_text_to_speech = GoogleTextToSpeechNode.get_instance() - google_text_to_speech.declare_user_config() + # google_text_to_speech = GoogleTextToSpeechNode.get_instance() + # google_text_to_speech.declare_user_config() Local_Stability_ComputeNode.declare_user_config() @@ -93,8 +95,8 @@ class AIOS_Shell: if a_workflow is not None: bus.register_message_handler(target_id,a_workflow._process_msg) return True - - a_contact = await ContactManager.get_instance().get_contact(target_id) + + a_contact = ContactManager.get_instance().find_contact_by_name(target_id) if a_contact is not None: bus.register_message_handler(target_id,a_contact._process_msg) return True @@ -143,6 +145,12 @@ class AIOS_Shell: return False ComputeKernel.get_instance().add_compute_node(open_ai_node) + whisper_node = WhisperComputeNode.get_instance() + ComputeKernel.get_instance().add_compute_node(whisper_node); + + openai_tts_node = OpenAITTSComputeNode.get_instance() + ComputeKernel.get_instance().add_compute_node(openai_tts_node) + llama_nodes = ComputeNodeConfig.get_instance().initial() for llama_node in llama_nodes: llama_node.start() @@ -158,41 +166,41 @@ class AIOS_Shell: await AIStorage.get_instance().set_feature_init_result("llama",False) - if await AIStorage.get_instance().is_feature_enable("aigc"): - try: - google_text_to_speech_node = GoogleTextToSpeechNode.get_instance() - google_text_to_speech_node.init() - ComputeKernel.get_instance().add_compute_node(google_text_to_speech_node) - except Exception as e: - logger.error(f"google text to speech node initial failed! {e}") - await AIStorage.get_instance.set_feature_init_result("aigc",False) + # if await AIStorage.get_instance().is_feature_enable("aigc"): + # try: + # google_text_to_speech_node = GoogleTextToSpeechNode.get_instance() + # google_text_to_speech_node.init() + # ComputeKernel.get_instance().add_compute_node(google_text_to_speech_node) + # except Exception as e: + # logger.error(f"google text to speech node initial failed! {e}") + # await AIStorage.get_instance.set_feature_init_result("aigc",False) # stability_api_node = Stability_ComputeNode() # if await stability_api_node.initial() is not True: # logger.error("stability api node initial failed!") # ComputeKernel.get_instance().add_compute_node(stability_api_node) - - + + local_st_text_compute_node = LocalSentenceTransformer_Text_ComputeNode() if local_st_text_compute_node.initial() is not True: logger.error("local sentence transformer text embedding node initial failed!") else: ComputeKernel.get_instance().add_compute_node(local_st_text_compute_node) - + local_st_image_compute_node = LocalSentenceTransformer_Image_ComputeNode() if local_st_image_compute_node.initial() is not True: logger.error("local sentence transformer image embedding node initial failed!") else: ComputeKernel.get_instance().add_compute_node(local_st_image_compute_node) - + await ComputeKernel.get_instance().start() AIBus().get_default_bus().register_unhandle_message_handler(self._handle_no_target_msg) #AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) - - + + pipelines = KnowledgePipelineManager.initial(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge/pipelines")) pipelines.load_dir(os.path.join(AIStorage().get_instance().get_system_app_dir(), "knowledge_pipelines")) pipelines.load_dir(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge_pipelines")) @@ -220,7 +228,7 @@ class AIOS_Shell: async def send_msg(self,msg:str,target_id:str,topic:str,sender:str = None) -> str: if sender == self.username: AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) - + agent_msg = AgentMsg() agent_msg.set(sender,target_id,msg) agent_msg.topic = topic @@ -235,7 +243,7 @@ class AIOS_Shell: async def _user_process_msg(self,msg:AgentMsg) -> AgentMsg: pass - + async def get_tunnel_config_from_input(self,tunnel_target,tunnel_type): tunnel_config = {} @@ -274,7 +282,7 @@ class AIOS_Shell: async def append_tunnel_config(self,tunnel_config): user_data_dir = AIStorage.get_instance().get_myai_dir() tunnels_config_path = os.path.abspath(f"{user_data_dir}/etc/tunnels.cfg.toml") - all_tunnels = None + all_tunnels = None try: all_tunnels = toml.load(tunnels_config_path) except Exception as e: @@ -327,12 +335,12 @@ class AIOS_Shell: if contact_telegram is None: return None contact.telegram = contact_telegram - + contact_email = await try_get_input(f"Input {contact_name}'s email:") if contact_email is None: return None contact.email = contact_email - + contact_phone = await try_get_input(f"Input {contact_name}'s phone (optional):") if contact_phone is not None: contact.phone = contact_phone @@ -340,13 +348,13 @@ class AIOS_Shell: contact_note = await try_get_input(f"Input {contact_name}'s note (optional):") if contact_note is not None: contact.note = contact_note - + contact.added_by = self.username if is_update: cm.set_contact(contact_name,contact) else: cm.add_contact(contact_name,contact) - + async def handle_knowledge_commands(self, args): show_text = FormattedText([("class:title", "sub command not support!\n" "/knowledge pipelines\n" @@ -393,7 +401,7 @@ class AIOS_Shell: if args[1] == "llama": if len(args) < 4: return show_text - + model_name = args[2] url = args[3] ComputeNodeConfig.get_instance().add_node("llama", url, model_name) @@ -409,7 +417,7 @@ class AIOS_Shell: if args[1] == "llama": if len(args) < 4: return show_text - + model_name = args[2] url = args[3] ComputeNodeConfig.get_instance().remove_node("llama", url, model_name) @@ -495,7 +503,7 @@ class AIOS_Shell: else: show_text = FormattedText([("class:error", "/open Need Target Agent/Workflow ID! like /open Jarvis default")]) return show_text - + if len(args) >= 2: topic = args[1] else: @@ -506,7 +514,7 @@ class AIOS_Shell: target_exist = True if await WorkflowManager.get_instance().is_exist(target_id): target_exist = True - + if target_exist is False: show_text = FormattedText([("class:error", f"Target {target_id} not exist!")]) return show_text @@ -537,11 +545,11 @@ class AIOS_Shell: else: show_text = FormattedText([("class:error", "/disable Need Feature Name! like /disable llama")]) return show_text - + if not await AIStorage.get_instance().is_feature_enable(feature): show_text = FormattedText([("class:title", f"Feature {feature} already disabled!")]) return show_text - + await AIStorage.get_instance().disable_feature(feature) show_text = FormattedText([("class:title", f"Feature {feature} disabled!")]) return show_text @@ -717,8 +725,8 @@ async def main(): logging.basicConfig(handlers=[handler], level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') - + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + is_daemon = False logger.info(f"Check Host OS :{os.name}") if os.name != 'nt': @@ -739,7 +747,7 @@ async def main(): shell.username = AIStorage.get_instance().get_user_config().get_value("username") init_result = await shell.initial() proxy.apply_storage() - + if init_result is False: if is_daemon: logger.error("aios shell initial failed!") @@ -759,7 +767,7 @@ async def main(): '/connect $target', '/contact $name', '/knowledge pipelines', - '/knowledge journal $pipeline [$topn]', + '/knowledge journal $pipeline [$topn]', '/knowledge query $object_id', '/set_config $key', '/enable $feature', From 7564349cb7f66abb5b2d5b84d2ab13c5ddedcc80 Mon Sep 17 00:00:00 2001 From: wugren Date: Wed, 22 Nov 2023 19:05:05 +0800 Subject: [PATCH 27/79] Fix split long audio bug --- src/aios_kernel/whisper_node.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/aios_kernel/whisper_node.py b/src/aios_kernel/whisper_node.py index bac0cc4..f9dcee5 100644 --- a/src/aios_kernel/whisper_node.py +++ b/src/aios_kernel/whisper_node.py @@ -101,9 +101,12 @@ class WhisperComputeNode(ComputeNode): text = "" results = [] latest_resp = None - step = 30 * 1000 - for i in range(0, 60 * 1000, step): - chunk = audio[i:i + step] + step = 10 * 60 * 1000 + for i in range(0, len(audio), step): + if i + step < len(audio): + chunk = audio[i:i + step] + else: + chunk = audio[i:] seg_file = io.BytesIO() chunk.export(seg_file, format="mp3") seg_file.seek(0) From 6e8d6904f0175f17ed4aefbff3143b03a8a403ac Mon Sep 17 00:00:00 2001 From: wugren Date: Sat, 25 Nov 2023 18:42:01 +0800 Subject: [PATCH 28/79] Add code interpreter function --- src/aios_kernel/agent_base.py | 50 ++- src/aios_kernel/code_interpreter.py | 426 +++++++++++++++++++ src/aios_kernel/code_interpreter_function.py | 41 ++ src/requirements.txt | 2 + 4 files changed, 498 insertions(+), 21 deletions(-) create mode 100644 src/aios_kernel/code_interpreter.py create mode 100644 src/aios_kernel/code_interpreter_function.py diff --git a/src/aios_kernel/agent_base.py b/src/aios_kernel/agent_base.py index f75132c..452b42e 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios_kernel/agent_base.py @@ -592,7 +592,7 @@ class BaseAIAgent(abc.ABC): async def do_llm_complection( self, prompt:AgentPrompt, - org_msg:AgentMsg=None, + org_msg:AgentMsg=None, env:Environment=None, inner_functions=None, is_json_resp=False, @@ -617,43 +617,47 @@ class BaseAIAgent(abc.ABC): if inner_func_call_node: call_prompt : AgentPrompt = copy.deepcopy(prompt) + func_msg = copy.deepcopy(result_message) + del func_msg["tool_calls"] + call_prompt.messages.append(func_msg) task_result = await self._execute_func(env,inner_func_call_node,call_prompt,inner_functions,org_msg) - + return task_result - + async def _execute_func( - self, - env: Environment, + self, + env: Environment, inner_func_call_node: dict, - prompt: AgentPrompt, - inner_functions: dict, + prompt: AgentPrompt, + inner_functions: dict, org_msg:AgentMsg, stack_limit = 5 ) -> ComputeTaskResult: from .compute_kernel import ComputeKernel - func_name = inner_func_call_node.get("name") - arguments = json.loads(inner_func_call_node.get("arguments")) - logger.info(f"llm execute inner func:{func_name} ({json.dumps(arguments)})") + arguments = None + try: + func_name = inner_func_call_node.get("name") + arguments = json.loads(inner_func_call_node.get("arguments")) + logger.info(f"llm execute inner func:{func_name} ({json.dumps(arguments)})") - func_node : AIFunction = env.get_ai_function(func_name) - if func_node is None: - result_str = f"execute {func_name} error,function not found" - else: - try: + func_node : AIFunction = env.get_ai_function(func_name) + if func_node is None: + result_str = f"execute {func_name} error,function not found" + else: result_str:str = await func_node.execute(**arguments) - except Exception as e: - result_str = f"execute {func_name} error:{str(e)}" - logger.error(f"llm execute inner func:{func_name} error:{e}") + except Exception as e: + result_str = f"execute {func_name} error:{str(e)}" + logger.error(f"llm execute inner func:{func_name} error:{e}") logger.info("llm execute inner func result:" + result_str) - + prompt.messages.append({"role":"function","content":result_str,"name":func_name}) task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,mode_name=self.get_llm_model_name(),max_token=self.get_max_token_size(),inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"llm compute error:{task_result.error_str}") return task_result - + if org_msg: internal_call_record = AgentMsg.create_internal_call_msg(func_name,arguments,org_msg.get_msg_id(),org_msg.target) internal_call_record.result_str = task_result.result_str @@ -665,6 +669,10 @@ class BaseAIAgent(abc.ABC): result_message : dict = task_result.result.get("message") if result_message: inner_func_call_node = result_message.get("function_call") + if inner_func_call_node: + func_msg = copy.deepcopy(result_message) + del func_msg["tool_calls"] + prompt.messages.append(func_msg) if inner_func_call_node: return await self._execute_func(env,inner_func_call_node,prompt,inner_functions,org_msg,stack_limit-1) @@ -685,4 +693,4 @@ class CustomAIAgent(BaseAIAgent): return self.llm_model_name def get_max_token_size(self) -> int: - return self.max_token_size \ No newline at end of file + return self.max_token_size diff --git a/src/aios_kernel/code_interpreter.py b/src/aios_kernel/code_interpreter.py new file mode 100644 index 0000000..850356a --- /dev/null +++ b/src/aios_kernel/code_interpreter.py @@ -0,0 +1,426 @@ +import logging +import os +import pathlib +import shutil +import subprocess +import sys +import re +import time +import ast +from concurrent.futures import ThreadPoolExecutor +from hashlib import md5 +from typing import Optional, Union, List, Tuple +from generic_escape import GenericEscape + +from aios_kernel import AIStorage + +try: + import docker +except ImportError: + docker = None + +CODE_BLOCK_PATTERN = r"```[ \t]*(\w+)?[ \t]*\r?\n(.*?)\r?\n[ \t]*```" +UNKNOWN = "unknown" +TIMEOUT_MSG = "Timeout" +DEFAULT_TIMEOUT = 600 +WIN32 = sys.platform == "win32" +PATH_SEPARATOR = WIN32 and "\\" or "/" + +logger = logging.getLogger(__name__) + + +BUILT_IN_MODULES = set( + [ + "sys", + "os", + "math", + "random", + "datetime", + "json", + "re", + "subprocess", + "time", + "threading", + "logging", + "collections", + "itertools", + "functools", + "operator", + "pathlib", + "shutil", + "tempfile", + "pickle", + "io", + "argparse", + "typing", + "unittest", + "contextlib", + "abc", + "heapq", + "bisect", + "copy", + "decimal", + "fractions", + "hashlib", + "secrets", + "statistics", + "difflib", + "doctest", + "enum", + "inspect", + "traceback", + "weakref", + "gc", + "mmap", + "msvcrt", + "winreg", + "array", + "audioop", + "binascii", + "cProfile", + "concurrent.futures", + "configparser", + "csv", + "ctypes", + "dateutil", + "dis", + "fnmatch", + "getopt", + "glob", + "gzip", + "pdb", + "pprint", + "profile", + "pstats", + "queue", + "socket", + "sqlite3", + "ssl", + "struct", + "tarfile", + "telnetlib", + "timeit", + "tokenize", + "uuid", + "xml", + "zipfile", + "zlib", + ] +) + + +def get_imports(code: str) -> List[str]: + root = ast.parse(code) + + imports = [] + for node in ast.iter_child_nodes(root): + if isinstance(node, ast.Import): + module_names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + module_names = [node.module] + else: + continue + + for name in module_names: + # Exclude built-in modules + if name not in BUILT_IN_MODULES: + imports.append(name) + + return imports + + +def write_requirements(code: str, requirements_filepath: str): + imports = get_imports(code) + + with open(requirements_filepath, "w") as file: + for module in imports: + file.write(module + "\n") + + +def _cmd(lang): + if lang.startswith("python") or lang in ["bash", "sh", "powershell"]: + return lang + if lang in ["shell"]: + return "sh" + if lang in ["ps1"]: + return "powershell" + raise NotImplementedError(f"{lang} not recognized in code execution") + + +def create_runner(code: str, timeout: int = 30) -> str: + """ + Create a Python script that runs the code and prints the output + """ + code = GenericEscape().escape(code) + # Create a runner script + runner = f""" +import os +import subprocess + +my_env = os.environ.copy() +my_env["PYTHONIOENCODING"] = "utf-8" + +process = subprocess.Popen( + f"python -i -q -u".split(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=0, + universal_newlines=True, + env=my_env +) + +process.stdin.write("{code}" + "\\n") +process.stdin.write("exit()\\n") +process.stdin.flush() + +try: + process.wait({timeout}) +except Exception as e: + process.terminate() + +for line in iter(process.stdout.readline, ""): + print(line) + +for line in iter(process.stderr.readline, ""): + if line.startswith(">>>"): + continue + print(line) +""" + return runner + + +def _run_cmd(cmd: [str], work_dir: str, timeout: int) -> str: + if WIN32: + logger.warning("SIGALRM is not supported on Windows. No timeout will be enforced.") + result = subprocess.run( + cmd, + cwd=work_dir, + capture_output=True, + text=True, + ) + else: + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit( + subprocess.run, + cmd, + cwd=work_dir, + capture_output=True, + text=True, + ) + result = future.result(timeout=timeout) + return result + + +def execute_code( + code: Optional[str] = None, + timeout: Optional[int] = None, + filename: Optional[str] = None, + work_dir: Optional[str] = None, + use_docker: Optional[Union[List[str], str, bool]] = None, + lang: Optional[str] = "python", +) -> Tuple[int, str]: + """Execute code in a docker container. + This function is not tested on MacOS. + + Args: + code (Optional, str): The code to execute. + If None, the code from the file specified by filename will be executed. + Either code or filename must be provided. + timeout (Optional, int): The maximum execution time in seconds. + If None, a default timeout will be used. The default timeout is 600 seconds. On Windows, the timeout is not enforced when use_docker=False. + filename (Optional, str): The file name to save the code or where the code is stored when `code` is None. + If None, a file with a randomly generated name will be created. + The randomly generated file will be deleted after execution. + The file name must be a relative path. Relative paths are relative to the working directory. + work_dir (Optional, str): The working directory for the code execution. + If None, a default working directory will be used. + The default working directory is the "extensions" directory under + "path_to_autogen". + use_docker (Optional, list, str or bool): The docker image to use for code execution. + If a list or a str of image name(s) is provided, the code will be executed in a docker container + with the first image successfully pulled. + If None, False or empty, the code will be executed in the current environment. + Default is None, which will be converted into an empty list when docker package is available. + Expected behaviour: + - If `use_docker` is explicitly set to True and the docker package is available, the code will run in a Docker container. + - If `use_docker` is explicitly set to True but the Docker package is missing, an error will be raised. + - If `use_docker` is not set (i.e., left default to None) and the Docker package is not available, a warning will be displayed, but the code will run natively. + If the code is executed in the current environment, + the code must be trusted. + lang (Optional, str): The language of the code. Default is "python". + + Returns: + int: 0 if the code executes successfully. + str: The error message if the code fails to execute; the stdout otherwise. + """ + if all((code is None, filename is None)): + error_msg = f"Either {code=} or {filename=} must be provided." + logger.error(error_msg) + raise AssertionError(error_msg) + + # Warn if use_docker was unspecified (or None), and cannot be provided (the default). + # In this case the current behavior is to fall back to run natively, but this behavior + # is subject to change. + if use_docker is None: + if docker is None: + use_docker = False + logger.warning( + "execute_code was called without specifying a value for use_docker. Since the python docker package is not available, code will be run natively. Note: this fallback behavior is subject to change" + ) + else: + # Default to true + use_docker = True + + timeout = timeout or DEFAULT_TIMEOUT + original_filename = filename + if WIN32 and lang in ["sh", "shell"] and (not use_docker): + lang = "ps1" + if filename is None: + code_hash = md5(code.encode()).hexdigest() + # create a file with a automatically generated name + filename = f"tmp_code_{code_hash}.{'py' if lang.startswith('python') else lang}" + if work_dir is None: + WORKING_DIR = os.path.join(AIStorage.get_instance().get_myai_dir(), "tmp_code") + pathlib.Path(WORKING_DIR).mkdir(exist_ok=True) + work_dir = os.path.join(WORKING_DIR, code_hash) + pathlib.Path(work_dir).mkdir(exist_ok=True) + filepath = os.path.join(work_dir, filename) + file_dir = os.path.dirname(filepath) + os.makedirs(file_dir, exist_ok=True) + if code is not None: + write_requirements(code, os.path.join(file_dir, "requirements.txt")) + code = create_runner(code, 30) + with open(filepath, "w", encoding="utf-8") as fout: + fout.write(code) + + + # check if already running in a docker container + in_docker_container = os.path.exists("/.dockerenv") + if not use_docker or in_docker_container: + try: + env_cmd = ["python", "-m", "venv", os.path.join(file_dir, "venv")] + _run_cmd(env_cmd, file_dir, timeout) + if WIN32: + venv_path = os.path.join(file_dir, "venv", "Scripts") + else: + venv_path = os.path.join(file_dir, "venv", "bin") + pip_cmd = [os.path.join(venv_path, "python"), "-m", "pip", "install", "-r", "requirements.txt"] + _run_cmd(pip_cmd, file_dir, timeout) + # already running in a docker container + cmd = [ + os.path.join(venv_path, "python"), + f".\\{filename}" if WIN32 else filename, + ] + result = _run_cmd(cmd, file_dir, timeout) + except TimeoutError: + if original_filename is None: + shutil.rmtree(os.path.join(file_dir, "venv")) + os.remove(filepath) + os.remove(os.path.join(file_dir, "requirements.txt")) + try: + os.removedirs(file_dir) + except Exception: + pass + return 1, TIMEOUT_MSG + if original_filename is None: + shutil.rmtree(os.path.join(file_dir, "venv")) + os.remove(filepath) + os.remove(os.path.join(file_dir, "requirements.txt")) + try: + os.removedirs(file_dir) + except Exception: + pass + if result.returncode: + logs = result.stderr + if original_filename is None: + abs_path = str(pathlib.Path(filepath).absolute()) + logs = logs.replace(str(abs_path), "").replace(filename, "") + else: + abs_path = str(pathlib.Path(work_dir).absolute()) + PATH_SEPARATOR + logs = logs.replace(str(abs_path), "") + else: + logs = result.stdout + return result.returncode, logs + + # create a docker client + client = docker.from_env() + image_list = ( + ["python:3-alpine", "python:3", "python:3-windowsservercore"] + if use_docker is True + else [use_docker] + if isinstance(use_docker, str) + else use_docker + ) + for image in image_list: + # check if the image exists + try: + client.images.get(image) + break + except docker.errors.ImageNotFound: + # pull the image + logger.info("Pulling image", image) + try: + client.images.pull(image, stream=True, decode=True) + break + except docker.errors.DockerException as e: + logger.error("Failed to pull image", image) + logger.exception(e) + # get a randomized str based on current time to wrap the exit code + exit_code_str = f"exitcode{time.time()}" + start_str = f'start{time.time()}' + abs_path = pathlib.Path(work_dir).absolute() + cmd = [ + "sh", + "-c", + f"pip install --quiet -r requirements.txt; echo -n {start_str}; {_cmd(lang)} {filename}; exit_code=$?; echo -n {exit_code_str}; echo -n $exit_code; echo {exit_code_str};", + ] + # create a docker container + container = client.containers.run( + image, + command=cmd, + working_dir="/workspace", + detach=True, + # get absolute path to the working directory + volumes={abs_path: {"bind": "/workspace", "mode": "rw"}}, + ) + start_time = time.time() + while container.status != "exited" and time.time() - start_time < timeout: + # Reload the container object + container.reload() + if container.status != "exited": + container.stop() + container.remove() + if original_filename is None: + os.remove(filepath) + return 1, TIMEOUT_MSG, image + # get the container logs + logs: str = container.logs().decode("utf-8").rstrip() + start_pos = logs.find(start_str) + if start_pos != -1: + logs = logs[start_pos + len(start_str):] + # # commit the image + # tag = filename.replace("/", "") + # container.commit(repository="python", tag=tag) + # remove the container + container.remove() + # check if the code executed successfully + exit_code = container.attrs["State"]["ExitCode"] + if exit_code == 0: + # extract the exit code from the logs + pattern = re.compile(f"{exit_code_str}(\\d+){exit_code_str}") + match = pattern.search(logs) + exit_code = 1 if match is None else int(match.group(1)) + # remove the exit code from the logs + logs = logs if match is None else pattern.sub("", logs) + + if original_filename is None: + os.remove(filepath) + os.remove(os.path.join(file_dir, "requirements.txt")) + os.removedirs(file_dir) + if exit_code: + logs = logs.replace(f"/workspace/{filename if original_filename is None else ''}", "") + # return the exit code, logs and image + return exit_code, logs + diff --git a/src/aios_kernel/code_interpreter_function.py b/src/aios_kernel/code_interpreter_function.py new file mode 100644 index 0000000..0bf366c --- /dev/null +++ b/src/aios_kernel/code_interpreter_function.py @@ -0,0 +1,41 @@ +from typing import Dict + +from aios_kernel.ai_function import AIFunction +from aios_kernel.code_interpreter import execute_code + + +class CodeInterpreterFunction(AIFunction): + def __init__(self): + self.func_id = "code_interpreter" + self.description = "execute python code" + + def get_name(self) -> str: + return self.func_id + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "code": {"type": "string", "description": "python code"} + } + } + + async def execute(self, **kwargs) -> str: + code = kwargs.get("code") + ret_code, result = execute_code(code=code) + if ret_code == 0: + return result.strip() + else: + return result.strip() + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False diff --git a/src/requirements.txt b/src/requirements.txt index 151ea86..77bc2dc 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -142,3 +142,5 @@ markdown PyPDF2 srt==3.5.3 webvtt-py==0.4.6 +docker +generic_escape From 624fd46492be3535d9f7c3a544088f84be8e6b9e Mon Sep 17 00:00:00 2001 From: wugren Date: Sun, 26 Nov 2023 22:52:12 +0800 Subject: [PATCH 29/79] Add duckduckgo text search function --- .../duckduckgo_text_search_function.py | 52 +++++++++++++++++++ src/requirements.txt | 1 + 2 files changed, 53 insertions(+) create mode 100644 src/aios_kernel/duckduckgo_text_search_function.py diff --git a/src/aios_kernel/duckduckgo_text_search_function.py b/src/aios_kernel/duckduckgo_text_search_function.py new file mode 100644 index 0000000..154f761 --- /dev/null +++ b/src/aios_kernel/duckduckgo_text_search_function.py @@ -0,0 +1,52 @@ +import json +from typing import Dict + +from aios_kernel.ai_function import AIFunction +from duckduckgo_search import AsyncDDGS + + +class DuckDuckGoTextSearchFunction(AIFunction): + def __init__(self): + self.name = "duckduckgo_text_search" + self.description = "Search text from duckduckgo.com" + self.region = "wt-wt" + self.safesearch = "moderate" + self.time = "y" + self.max_results = 5 + + def get_name(self) -> str: + return self.name + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return {"type": "object", + "properties": { + "query": {"type": "string", "description": "The query to search for."} + } + } + + async def execute(self, **kwargs) -> str: + query = kwargs.get("query") + + async with AsyncDDGS() as ddgs: + results = [r async for r in ddgs.text( + query, + region=self.region, + safesearch=self.safesearch, + timelimit=self.time, + backend="api", + max_results=self.max_results + )] + + return json.dumps(results) + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False diff --git a/src/requirements.txt b/src/requirements.txt index 77bc2dc..bfe9053 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -144,3 +144,4 @@ srt==3.5.3 webvtt-py==0.4.6 docker generic_escape +duckduckgo-search From e00fc6c8e4aecabc97d17667d9b7944b91965679 Mon Sep 17 00:00:00 2001 From: wugren Date: Tue, 28 Nov 2023 17:07:39 +0800 Subject: [PATCH 30/79] Add database query --- rootfs/agents/DBQueryer/agent.toml | 15 + rootfs/agents/DBQueryer/environment.py | 18 + src/aios_kernel/sql_database.py | 493 +++++++++++++++++++++++ src/aios_kernel/sql_database_function.py | 112 +++++ src/requirements.txt | 2 + 5 files changed, 640 insertions(+) create mode 100644 rootfs/agents/DBQueryer/agent.toml create mode 100644 rootfs/agents/DBQueryer/environment.py create mode 100644 src/aios_kernel/sql_database.py create mode 100644 src/aios_kernel/sql_database_function.py diff --git a/rootfs/agents/DBQueryer/agent.toml b/rootfs/agents/DBQueryer/agent.toml new file mode 100644 index 0000000..b3d0deb --- /dev/null +++ b/rootfs/agents/DBQueryer/agent.toml @@ -0,0 +1,15 @@ +instance_id = "DBQueryer" +fullname = "database queryer" +llm_model_name = "gpt-4-1106-preview" +owner_env = "environment.py" + +[[prompt]] +role = "system" +content = """You are a SQL database queryer that can help users query the required data from the specified database. +Please follow the following rules to query data: +1. The user can provide the database URL to be queried. If the user provides the database URL to be queried, the URL must be entered in the database function called. +2. If the user's data description does not contain database table related information, please try to query it from the history record. If it is not in the history record, you can call the get_table_infos function to obtain it. +3. Please generate a query statement based on the relevant information of the database table and call the execute_sql function to execute the query statement. If the generated SQL statement is incorrect, you must requery based on the error correction SQL statement. +4. Only query statements can be executed, and no update statements can be executed. +5. You need to communicate in the same language as the user input. +""" diff --git a/rootfs/agents/DBQueryer/environment.py b/rootfs/agents/DBQueryer/environment.py new file mode 100644 index 0000000..e733c45 --- /dev/null +++ b/rootfs/agents/DBQueryer/environment.py @@ -0,0 +1,18 @@ +from typing import Optional + +from aios_kernel import Environment +from aios_kernel.sql_database_function import GetTableInfosFunction, ExecuteSqlFunction + + +class DBQuerierEnvironment(Environment): + def __init__(self): + super().__init__("fairy") + self.add_ai_function(GetTableInfosFunction()) + self.add_ai_function(ExecuteSqlFunction()) + + def _do_get_value(self, key: str) -> Optional[str]: + pass + + +def init(): + return DBQuerierEnvironment() diff --git a/src/aios_kernel/sql_database.py b/src/aios_kernel/sql_database.py new file mode 100644 index 0000000..efee9b4 --- /dev/null +++ b/src/aios_kernel/sql_database.py @@ -0,0 +1,493 @@ +""" +Taken from: langchain +SQLAlchemy wrapper around a database. +""" +from __future__ import annotations +import os + +import warnings +from typing import Any, Dict, Iterable, List, Literal, Optional, Sequence, Union + +import sqlalchemy +from sqlalchemy import MetaData, Table, create_engine, inspect, select, text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import ProgrammingError, SQLAlchemyError +from sqlalchemy.schema import CreateTable +from sqlalchemy.types import NullType + + +def get_from_env(key: str, env_key: str, default: Optional[str] = None) -> str: + """Get a value from a dictionary or an environment variable.""" + if env_key in os.environ and os.environ[env_key]: + return os.environ[env_key] + elif default is not None: + return default + else: + raise ValueError( + f"Did not find {key}, please add an environment variable" + f" `{env_key}` which contains it, or pass" + f" `{key}` as a named parameter." + ) + + +def _format_index(index: sqlalchemy.engine.interfaces.ReflectedIndex) -> str: + return ( + f'Name: {index["name"]}, Unique: {index["unique"]},' + f' Columns: {str(index["column_names"])}' + ) + + +def truncate_word(content: Any, *, length: int, suffix: str = "...") -> str: + """ + Truncate a string to a certain number of words, based on the max string + length. + """ + + if not isinstance(content, str) or length <= 0: + return content + + if len(content) <= length: + return content + + return content[: length - len(suffix)].rsplit(" ", 1)[0] + suffix + + +class SQLDatabase: + """SQLAlchemy wrapper around a database.""" + + def __init__( + self, + engine: Engine, + schema: Optional[str] = None, + metadata: Optional[MetaData] = None, + ignore_tables: Optional[List[str]] = None, + include_tables: Optional[List[str]] = None, + sample_rows_in_table_info: int = 3, + indexes_in_table_info: bool = False, + custom_table_info: Optional[dict] = None, + view_support: bool = False, + max_string_length: int = 300, + ): + """Create engine from database URI.""" + self._engine = engine + self._schema = schema + if include_tables and ignore_tables: + raise ValueError("Cannot specify both include_tables and ignore_tables") + + self._inspector = inspect(self._engine) + + # including view support by adding the views as well as tables to the all + # tables list if view_support is True + self._all_tables = set( + self._inspector.get_table_names(schema=schema) + + (self._inspector.get_view_names(schema=schema) if view_support else []) + ) + + self._include_tables = set(include_tables) if include_tables else set() + if self._include_tables: + missing_tables = self._include_tables - self._all_tables + if missing_tables: + raise ValueError( + f"include_tables {missing_tables} not found in database" + ) + self._ignore_tables = set(ignore_tables) if ignore_tables else set() + if self._ignore_tables: + missing_tables = self._ignore_tables - self._all_tables + if missing_tables: + raise ValueError( + f"ignore_tables {missing_tables} not found in database" + ) + usable_tables = self.get_usable_table_names() + self._usable_tables = set(usable_tables) if usable_tables else self._all_tables + + if not isinstance(sample_rows_in_table_info, int): + raise TypeError("sample_rows_in_table_info must be an integer") + + self._sample_rows_in_table_info = sample_rows_in_table_info + self._indexes_in_table_info = indexes_in_table_info + + self._custom_table_info = custom_table_info + if self._custom_table_info: + if not isinstance(self._custom_table_info, dict): + raise TypeError( + "table_info must be a dictionary with table names as keys and the " + "desired table info as values" + ) + # only keep the tables that are also present in the database + intersection = set(self._custom_table_info).intersection(self._all_tables) + self._custom_table_info = dict( + (table, self._custom_table_info[table]) + for table in self._custom_table_info + if table in intersection + ) + + self._max_string_length = max_string_length + + self._metadata = metadata or MetaData() + # including view support if view_support = true + self._metadata.reflect( + views=view_support, + bind=self._engine, + only=list(self._usable_tables), + schema=self._schema, + ) + + @classmethod + def from_uri( + cls, database_uri: str, engine_args: Optional[dict] = None, **kwargs: Any + ) -> SQLDatabase: + """Construct a SQLAlchemy engine from URI.""" + _engine_args = engine_args or {} + return cls(create_engine(database_uri, **_engine_args), **kwargs) + + @classmethod + def from_databricks( + cls, + catalog: str, + schema: str, + host: Optional[str] = None, + api_token: Optional[str] = None, + warehouse_id: Optional[str] = None, + cluster_id: Optional[str] = None, + engine_args: Optional[dict] = None, + **kwargs: Any, + ) -> SQLDatabase: + """ + Class method to create an SQLDatabase instance from a Databricks connection. + This method requires the 'databricks-sql-connector' package. If not installed, + it can be added using `pip install databricks-sql-connector`. + + Args: + catalog (str): The catalog name in the Databricks database. + schema (str): The schema name in the catalog. + host (Optional[str]): The Databricks workspace hostname, excluding + 'https://' part. If not provided, it attempts to fetch from the + environment variable 'DATABRICKS_HOST'. If still unavailable and if + running in a Databricks notebook, it defaults to the current workspace + hostname. Defaults to None. + api_token (Optional[str]): The Databricks personal access token for + accessing the Databricks SQL warehouse or the cluster. If not provided, + it attempts to fetch from 'DATABRICKS_TOKEN'. If still unavailable + and running in a Databricks notebook, a temporary token for the current + user is generated. Defaults to None. + warehouse_id (Optional[str]): The warehouse ID in the Databricks SQL. If + provided, the method configures the connection to use this warehouse. + Cannot be used with 'cluster_id'. Defaults to None. + cluster_id (Optional[str]): The cluster ID in the Databricks Runtime. If + provided, the method configures the connection to use this cluster. + Cannot be used with 'warehouse_id'. If running in a Databricks notebook + and both 'warehouse_id' and 'cluster_id' are None, it uses the ID of the + cluster the notebook is attached to. Defaults to None. + engine_args (Optional[dict]): The arguments to be used when connecting + Databricks. Defaults to None. + **kwargs (Any): Additional keyword arguments for the `from_uri` method. + + Returns: + SQLDatabase: An instance of SQLDatabase configured with the provided + Databricks connection details. + + Raises: + ValueError: If 'databricks-sql-connector' is not found, or if both + 'warehouse_id' and 'cluster_id' are provided, or if neither + 'warehouse_id' nor 'cluster_id' are provided and it's not executing + inside a Databricks notebook. + """ + try: + from databricks import sql # noqa: F401 + except ImportError: + raise ValueError( + "databricks-sql-connector package not found, please install with" + " `pip install databricks-sql-connector`" + ) + context = None + try: + from dbruntime.databricks_repl_context import get_context + + context = get_context() + except ImportError: + pass + + default_host = context.browserHostName if context else None + if host is None: + host = get_from_env("host", "DATABRICKS_HOST", default_host) + + default_api_token = context.apiToken if context else None + if api_token is None: + api_token = get_from_env("api_token", "DATABRICKS_TOKEN", default_api_token) + + if warehouse_id is None and cluster_id is None: + if context: + cluster_id = context.clusterId + else: + raise ValueError( + "Need to provide either 'warehouse_id' or 'cluster_id'." + ) + + if warehouse_id and cluster_id: + raise ValueError("Can't have both 'warehouse_id' or 'cluster_id'.") + + if warehouse_id: + http_path = f"/sql/1.0/warehouses/{warehouse_id}" + else: + http_path = f"/sql/protocolv1/o/0/{cluster_id}" + + uri = ( + f"databricks://token:{api_token}@{host}?" + f"http_path={http_path}&catalog={catalog}&schema={schema}" + ) + return cls.from_uri(database_uri=uri, engine_args=engine_args, **kwargs) + + @classmethod + def from_cnosdb( + cls, + url: str = "127.0.0.1:8902", + user: str = "root", + password: str = "", + tenant: str = "cnosdb", + database: str = "public", + ) -> SQLDatabase: + """ + Class method to create an SQLDatabase instance from a CnosDB connection. + This method requires the 'cnos-connector' package. If not installed, it + can be added using `pip install cnos-connector`. + + Args: + url (str): The HTTP connection host name and port number of the CnosDB + service, excluding "http://" or "https://", with a default value + of "127.0.0.1:8902". + user (str): The username used to connect to the CnosDB service, with a + default value of "root". + password (str): The password of the user connecting to the CnosDB service, + with a default value of "". + tenant (str): The name of the tenant used to connect to the CnosDB service, + with a default value of "cnosdb". + database (str): The name of the database in the CnosDB tenant. + + Returns: + SQLDatabase: An instance of SQLDatabase configured with the provided + CnosDB connection details. + """ + try: + from cnosdb_connector import make_cnosdb_langchain_uri + + uri = make_cnosdb_langchain_uri(url, user, password, tenant, database) + return cls.from_uri(database_uri=uri) + except ImportError: + raise ValueError( + "cnos-connector package not found, please install with" + " `pip install cnos-connector`" + ) + + @property + def dialect(self) -> str: + """Return string representation of dialect to use.""" + return self._engine.dialect.name + + def get_usable_table_names(self) -> Iterable[str]: + """Get names of tables available.""" + if self._include_tables: + return sorted(self._include_tables) + return sorted(self._all_tables - self._ignore_tables) + + def get_table_names(self) -> Iterable[str]: + """Get names of tables available.""" + warnings.warn( + "This method is deprecated - please use `get_usable_table_names`." + ) + return self.get_usable_table_names() + + @property + def table_info(self) -> str: + """Information about all tables in the database.""" + return self.get_table_info() + + def get_table_info(self, table_names: Optional[List[str]] = None) -> str: + """Get information about specified tables. + + Follows best practices as specified in: Rajkumar et al, 2022 + (https://arxiv.org/abs/2204.00498) + + If `sample_rows_in_table_info`, the specified number of sample rows will be + appended to each table description. This can increase performance as + demonstrated in the paper. + """ + all_table_names = self.get_usable_table_names() + if table_names is not None: + missing_tables = set(table_names).difference(all_table_names) + if missing_tables: + raise ValueError(f"table_names {missing_tables} not found in database") + all_table_names = table_names + + meta_tables = [ + tbl + for tbl in self._metadata.sorted_tables + if tbl.name in set(all_table_names) + and not (self.dialect == "sqlite" and tbl.name.startswith("sqlite_")) + ] + + tables = [] + for table in meta_tables: + if self._custom_table_info and table.name in self._custom_table_info: + tables.append(self._custom_table_info[table.name]) + continue + + # Ignore JSON datatyped columns + for k, v in table.columns.items(): + if type(v.type) is NullType: + table._columns.remove(v) + + # add create table command + create_table = str(CreateTable(table).compile(self._engine)) + table_info = f"{create_table.rstrip()}" + has_extra_info = ( + self._indexes_in_table_info or self._sample_rows_in_table_info + ) + if has_extra_info: + table_info += "\n\n/*" + if self._indexes_in_table_info: + table_info += f"\n{self._get_table_indexes(table)}\n" + if self._sample_rows_in_table_info: + table_info += f"\n{self._get_sample_rows(table)}\n" + if has_extra_info: + table_info += "*/" + tables.append(table_info) + tables.sort() + final_str = "\n\n".join(tables) + return final_str + + def _get_table_indexes(self, table: Table) -> str: + indexes = self._inspector.get_indexes(table.name) + indexes_formatted = "\n".join(map(_format_index, indexes)) + return f"Table Indexes:\n{indexes_formatted}" + + def _get_sample_rows(self, table: Table) -> str: + # build the select command + command = select(table).limit(self._sample_rows_in_table_info) + + # save the columns in string format + columns_str = "\t".join([col.name for col in table.columns]) + + try: + # get the sample rows + with self._engine.connect() as connection: + sample_rows_result = connection.execute(command) # type: ignore + # shorten values in the sample rows + sample_rows = list( + map(lambda ls: [str(i)[:100] for i in ls], sample_rows_result) + ) + + # save the sample rows in string format + sample_rows_str = "\n".join(["\t".join(row) for row in sample_rows]) + + # in some dialects when there are no rows in the table a + # 'ProgrammingError' is returned + except ProgrammingError: + sample_rows_str = "" + + return ( + f"{self._sample_rows_in_table_info} rows from {table.name} table:\n" + f"{columns_str}\n" + f"{sample_rows_str}" + ) + + def _execute( + self, + command: str, + fetch: Union[Literal["all"], Literal["one"]] = "all", + ) -> Sequence[Dict[str, Any]]: + """ + Executes SQL command through underlying engine. + + If the statement returns no rows, an empty list is returned. + """ + with self._engine.begin() as connection: + if self._schema is not None: + if self.dialect == "snowflake": + connection.exec_driver_sql( + "ALTER SESSION SET search_path = %s", (self._schema,) + ) + elif self.dialect == "bigquery": + connection.exec_driver_sql("SET @@dataset_id=?", (self._schema,)) + elif self.dialect == "mssql": + pass + elif self.dialect == "trino": + connection.exec_driver_sql("USE ?", (self._schema,)) + elif self.dialect == "duckdb": + # Unclear which parameterized argument syntax duckdb supports. + # The docs for the duckdb client say they support multiple, + # but `duckdb_engine` seemed to struggle with all of them: + # https://github.com/Mause/duckdb_engine/issues/796 + connection.exec_driver_sql(f"SET search_path TO {self._schema}") + elif self.dialect == "oracle": + connection.exec_driver_sql( + f"ALTER SESSION SET CURRENT_SCHEMA = {self._schema}" + ) + else: # postgresql and other compatible dialects + connection.exec_driver_sql("SET search_path TO %s", (self._schema,)) + cursor = connection.execute(text(command)) + if cursor.returns_rows: + if fetch == "all": + result = [x._asdict() for x in cursor.fetchall()] + elif fetch == "one": + first_result = cursor.fetchone() + result = [] if first_result is None else [first_result._asdict()] + else: + raise ValueError("Fetch parameter must be either 'one' or 'all'") + return result + return [] + + def run( + self, + command: str, + fetch: Union[Literal["all"], Literal["one"]] = "all", + ) -> str: + """Execute a SQL command and return a string representing the results. + + If the statement returns rows, a string of the results is returned. + If the statement returns no rows, an empty string is returned. + """ + result = self._execute(command, fetch) + # Convert columns values to string to avoid issues with sqlalchemy + # truncating text + res = [ + tuple(truncate_word(c, length=self._max_string_length) for c in r.values()) + for r in result + ] + if not res: + return "" + else: + return str(res) + + def get_table_info_no_throw(self, table_names: Optional[List[str]] = None) -> str: + """Get information about specified tables. + + Follows best practices as specified in: Rajkumar et al, 2022 + (https://arxiv.org/abs/2204.00498) + + If `sample_rows_in_table_info`, the specified number of sample rows will be + appended to each table description. This can increase performance as + demonstrated in the paper. + """ + try: + return self.get_table_info(table_names) + except ValueError as e: + """Format the error message""" + return f"Error: {e}" + + def run_no_throw( + self, + command: str, + fetch: Union[Literal["all"], Literal["one"]] = "all", + ) -> str: + """Execute a SQL command and return a string representing the results. + + If the statement returns rows, a string of the results is returned. + If the statement returns no rows, an empty string is returned. + + If the statement throws an error, the error message is returned. + """ + try: + return self.run(command, fetch) + except SQLAlchemyError as e: + """Format the error message""" + return f"Error: {e}" diff --git a/src/aios_kernel/sql_database_function.py b/src/aios_kernel/sql_database_function.py new file mode 100644 index 0000000..2e208e6 --- /dev/null +++ b/src/aios_kernel/sql_database_function.py @@ -0,0 +1,112 @@ +from datetime import timedelta, datetime +from typing import Dict + +from cachetools import TLRUCache, cached + +from aios_kernel.ai_function import AIFunction +from aios_kernel.sql_database import SQLDatabase, get_from_env + + +def _my_ttu(_key, _value, now): + return now + timedelta(seconds=600) + + +database_cache = TLRUCache(ttu=_my_ttu, maxsize=10000, timer=datetime.now) + + +@cached(cache=database_cache) +def get_database(uri: str) -> SQLDatabase: + return SQLDatabase.from_uri(uri) + + +class GetTableInfosFunction(AIFunction): + def __init__(self): + super().__init__() + self.name = "get_table_infos" + self.description = "Get table informations in the database" + + def get_name(self) -> str: + return self.name + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "database_url": {"type": "string", "description": "Database URL,Can be set to None"}, + } + } + + async def execute(self, **kwargs) -> str: + database_url: str = kwargs.get("database_url") + if (database_url is None + or database_url.strip() == "" + or database_url.strip().lower() == "none" + or database_url.strip().lower() == "null"): + database_url = get_from_env(key="database url", env_key="DATABASE_URL") + if database_url is None: + return "error: database_url is None" + database = get_database(database_url) + tables = database.get_usable_table_names() + table_infos = database.get_table_info(tables) + return table_infos + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False + + +class ExecuteSqlFunction(AIFunction): + def __init__(self): + super().__init__() + self.name = "execute_sql" + self.description = """ + Input to this function is a detailed and correct SQL query, output is a result from the database. + If the query is not correct, an error message will be returned. + If an error is returned, rewrite the query, check the query, and try again. + """ + + def get_name(self) -> str: + return self.name + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "database_url": {"type": "string", "description": "Database URL,Can be set to None"}, + "sql": {"type": "string", "description": "SQL to execute"} + } + } + + async def execute(self, **kwargs) -> str: + database_url = kwargs.get("database_url") + if (database_url is None + or database_url.strip() == "" + or database_url.strip().lower() == "none" + or database_url.strip().lower() == "null"): + database_url = get_from_env(key="database url", env_key="DATABASE_URL") + if database_url is None: + return "error: database_url is None" + sql = kwargs.get("sql") + + database = get_database(database_url) + return database.run_no_throw(sql) + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False diff --git a/src/requirements.txt b/src/requirements.txt index bfe9053..0250a1e 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -145,3 +145,5 @@ webvtt-py==0.4.6 docker generic_escape duckduckgo-search +SQLAlchemy +mysqlclient From a29d1e512d652624b2999de77b83223c2c095ad6 Mon Sep 17 00:00:00 2001 From: wugren Date: Tue, 28 Nov 2023 17:45:52 +0800 Subject: [PATCH 31/79] =?UTF-8?q?Add=20postgresql=E3=80=81oracle=E3=80=81m?= =?UTF-8?q?ssql=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/requirements.txt b/src/requirements.txt index 0250a1e..9599ea7 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -147,3 +147,6 @@ generic_escape duckduckgo-search SQLAlchemy mysqlclient +psycopg2-binary +pyodbc +oracledb From 6a00d097012b6e6890e154657f4b1ce91daff0eb Mon Sep 17 00:00:00 2001 From: streetycat <305190374@qq.com> Date: Wed, 29 Nov 2023 07:46:07 +0000 Subject: [PATCH 32/79] Adapt to the function of llama.cpp --- src/aios_kernel/local_llama_compute_node.py | 25 +++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/aios_kernel/local_llama_compute_node.py b/src/aios_kernel/local_llama_compute_node.py index 7f8ad0a..3b1fcb8 100644 --- a/src/aios_kernel/local_llama_compute_node.py +++ b/src/aios_kernel/local_llama_compute_node.py @@ -113,12 +113,27 @@ class LocalLlama_ComputeNode(Queue_ComputeNode): llm_inner_functions = task.params.get("inner_functions") if max_token_size is None: max_token_size = max_token_size - + body = { "messages": [], + "functions": llm_inner_functions, + "tools": [], + "tool_choices": [], "max_tokens": 4000 } + for fun in llm_inner_functions: + body["tools"].append({ + "type": "function", + "function": fun, + }) + body["tool_choices"].append({ + "type": "function", + "function": { + "name": fun["name"] + } + }) + for prompt in prompts: body["messages"].append({ "role": prompt["role"], @@ -126,6 +141,8 @@ class LocalLlama_ComputeNode(Queue_ComputeNode): }) try: + logger.info(f"will post http request to {self.url}/v1/chat/completions, body: {body}") + response = requests.post(self.url + "/v1/chat/completions", json = body, verify=False, headers={"Content-Type": "application/json"}) response.close() @@ -138,8 +155,12 @@ class LocalLlama_ComputeNode(Queue_ComputeNode): token_usage = resp["usage"] match status_code: - case "function_call": + case "tool_calls": task.state = ComputeTaskState.DONE + # rebuild the function name + fun_name = resp["choices"][0]["message"]["function_call"]["name"] + if len(llm_inner_functions) == 1 and (fun_name is None or fun_name == ""): + resp["choices"][0]["message"]["function_call"]["name"] = llm_inner_functions[0]["name"] case "stop": task.state = ComputeTaskState.DONE case _: From 8688b0bc6b6825795d2088669f4f15f0e21106aa Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sun, 26 Nov 2023 21:53:14 -0800 Subject: [PATCH 33/79] modify webui --- doc/webui.drawio | 250 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 212 insertions(+), 38 deletions(-) diff --git a/doc/webui.drawio b/doc/webui.drawio index 7ae4b10..d9b5240 100644 --- a/doc/webui.drawio +++ b/doc/webui.drawio @@ -1,9 +1,15 @@ - + - + + + + + + + @@ -16,23 +22,17 @@ - - - - - - - + - - + + - + @@ -49,90 +49,125 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -140,11 +175,150 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 4955225ecd4a0a4f5533207b04e8511b0a1257d8 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Wed, 29 Nov 2023 15:25:57 -0800 Subject: [PATCH 34/79] fix merge bugs. --- src/aios_kernel/__init__.py | 4 +++- src/aios_kernel/openai_tts_node.py | 2 -- src/requirements.txt | 1 + src/service/aios_shell/aios_shell.py | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index ea00748..1ea0d7c 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -26,5 +26,7 @@ from .local_st_compute_node import LocalSentenceTransformer_Text_ComputeNode,Loc from .compute_node_config import ComputeNodeConfig from .ai_function import SimpleAIFunction from .workspace_env import WorkspaceEnvironment -AIOS_Version = "0.5.2, build 2023-11-1" +from .openai_tts_node import OpenAITTSComputeNode + +AIOS_Version = "0.5.2, build 2023-11-29" diff --git a/src/aios_kernel/openai_tts_node.py b/src/aios_kernel/openai_tts_node.py index 68ee064..36c80e2 100644 --- a/src/aios_kernel/openai_tts_node.py +++ b/src/aios_kernel/openai_tts_node.py @@ -4,8 +4,6 @@ import logging import os from asyncio import Queue -from openai import AsyncOpenAI - from aios_kernel import ComputeNode, ComputeTask, ComputeTaskState, ComputeTaskResult, ComputeTaskType, AIStorage logger = logging.getLogger(__name__) diff --git a/src/requirements.txt b/src/requirements.txt index 9599ea7..1a41e3d 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -150,3 +150,4 @@ mysqlclient psycopg2-binary pyodbc oracledb +html2text diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index f3610c2..12d79e9 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -20,7 +20,7 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import WordCompleter from prompt_toolkit.styles import Style -from aios_kernel.openai_tts_node import OpenAITTSComputeNode + directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') @@ -34,6 +34,7 @@ from aios_kernel import * from knowledge import * + sys.path.append(directory + '/../../component/') from agent_manager import AgentManager from workflow_manager import WorkflowManager From fb47a3c5adeffd3477875af322e19bea64540c5b Mon Sep 17 00:00:00 2001 From: Song Date: Thu, 30 Nov 2023 18:03:24 +0800 Subject: [PATCH 35/79] Add DallE node for text2imge --- rootfs/agents/David/agent.toml | 20 +++- src/aios_kernel/__init__.py | 1 + src/aios_kernel/dall_e_compute_node.py | 146 +++++++++++++++++++++++++ src/aios_kernel/workflow_env.py | 15 +-- test/test_dall_e_node.py | 47 ++++++++ test/test_local_sd_node.py | 14 ++- 6 files changed, 219 insertions(+), 24 deletions(-) create mode 100644 src/aios_kernel/dall_e_compute_node.py create mode 100644 test/test_dall_e_node.py diff --git a/rootfs/agents/David/agent.toml b/rootfs/agents/David/agent.toml index 47d2ff8..df8c308 100644 --- a/rootfs/agents/David/agent.toml +++ b/rootfs/agents/David/agent.toml @@ -6,9 +6,17 @@ owner_env = "paint" [[prompt]] role = "system" -content = """You are an artist, and you will use the 'paint' function in the llm_inner_functions to create artwork. -You will extract relevant keywords based on user input and requirements, and pass these keywords to the 'prompt' parameter of the 'paint' function. -When a specific model is mentioned, pass the model's name to the 'model_name' parameter of the 'paint' function. -If there are instructions not to depict certain content, summarize this content and pass it as keywords to the 'negative_prompt' parameter. -All parameters must be in English. -When the user mentions creating a children's picture book, use the "realisticVisionV51_v51VAE" model, and append the keyword " " to the 'prompt' parameter.""" +content = """You are an advanced artist AI, and your task is to understand users' textual descriptions and transform these descriptions into nuanced visual images. When a user provides a description, you need to carefully analyze and comprehend the details, emotions, environment, objects, and style within the description. Then, you will use this information to create an image that accurately and creatively reflects the user's description. + +Your creative process is as follows: First, you will receive a description from the user, which could be a depiction of anything, such as a scene, an object, a concept, or an emotional expression. You need to extract key information from these descriptions, such as the subject, atmosphere, color scheme, time period, and artistic style. Then, you will pass these key pieces of information as parameters to the prompt parameter of your "paint" function. + +For example, if a user says, "I want a painting that depicts a sunset on a summer beach with children playing and sailboats in the distance, with the sky showing a gradient of orange and purple." You should extract: +- Time: Summer, sunset moment +- Scene: Beach +- Activity: Children playing +- Objects: Sailboats in the distance +- Colors: The gradient of orange and purple in the sky + +Then you will combine these pieces of information into an accurate prompt to pass to the "paint" function, for example: "During a summer sunset, children play on the beach with sailboats visible in the distance, and the sky displays a gradient of orange and purple." + +Please always ensure that your creations respect the user's intentions and that you infuse your unique artistic style and creativity into your work.""" diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py index 1ea0d7c..40749fa 100644 --- a/src/aios_kernel/__init__.py +++ b/src/aios_kernel/__init__.py @@ -27,6 +27,7 @@ from .compute_node_config import ComputeNodeConfig from .ai_function import SimpleAIFunction from .workspace_env import WorkspaceEnvironment from .openai_tts_node import OpenAITTSComputeNode +from .dall_e_compute_node import DallE_ComputeNode AIOS_Version = "0.5.2, build 2023-11-29" diff --git a/src/aios_kernel/dall_e_compute_node.py b/src/aios_kernel/dall_e_compute_node.py new file mode 100644 index 0000000..8e63e03 --- /dev/null +++ b/src/aios_kernel/dall_e_compute_node.py @@ -0,0 +1,146 @@ +import os +import io +import asyncio +from asyncio import Queue +import logging +from pathlib import Path +from openai import OpenAI +import base64 + +from PIL import Image + +from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType, ComputeTaskResultCode +from .compute_node import ComputeNode +from .storage import AIStorage, UserConfig + +logger = logging.getLogger(__name__) + + +class DallE_ComputeNode(ComputeNode): + _instance = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = DallE_ComputeNode() + return cls._instance + + @classmethod + def declare_user_config(cls): + user_config = AIStorage.get_instance().get_user_config() + + if os.getenv("TEXT2IMG_OUTPUT_DIR") is None: + home_dir = Path.home() + output_dir = Path.joinpath(home_dir, "text2img_output") + Path.mkdir(output_dir, exist_ok=True) + user_config.add_user_config( + "text2img_output_dir", "text2image output dir", True, output_dir) + + def __init__(self): + super().__init__() + + self.is_start = False + self.node_id = "dall_e_node" + self.openai_api_key = "" + self.default_model = "dall-e-3" + + self.task_queue = Queue() + + async def initial(self): + if os.getenv("OPENAI_API_KEY") is not None: + self.openai_api_key = os.getenv("OPENAI_API_KEY") + else: + self.openai_api_key = AIStorage.get_instance().get_user_config().get_value("openai_api_key") + + if self.openai_api_key is None: + logger.error("openai_api_key is None!") + return False + + if os.getenv("TEXT2IMG_OUTPUT_DIR") is not None: + self.output_dir = os.getenv("TEXT2IMG_OUTPUT_DIR") + else: + self.output_dir = AIStorage.get_instance( + ).get_user_config().get_value("text2img_output_dir") + + if self.output_dir is None: + self.output_dir = "./" + self.output_dir = os.path.abspath(self.output_dir) + + self.start() + + return True + + async def push_task(self, task: ComputeTask, proiority: int = 0): + logger.info(f"DallE_node push task: {task.display()}") + self.task_queue.put_nowait(task) + + async def remove_task(self, task_id: str): + pass + + def _run_task(self, task: ComputeTask): + task.state = ComputeTaskState.RUNNING + result = ComputeTaskResult() + result.result_code = ComputeTaskResultCode.ERROR + result.set_from_task(task) + + try: + prompt = task.params["prompt"] + logging.info(f"Call DallE {self.default_model} prompts: {prompt}") + client = OpenAI(api_key=self.openai_api_key) + + response = client.images.generate( + model=self.default_model, + prompt=prompt, + size="1024x1024", + quality="standard", + n=1, + response_format="b64_json", + ) + + binary_data = base64.b64decode(response.data[0].b64_json) + image = Image.open(io.BytesIO(binary_data)) + file_name = os.path.join(self.output_dir, task.task_id + ".png") + image.save(file_name) + + task.state = ComputeTaskState.DONE + result.result_code = ComputeTaskResultCode.OK + result.worker_id = self.node_id + result.result = {"file": file_name} + + return result + + except Exception as e: + logging.error(f"Call DallE failed. err: {e}") + task.error_str = str(e) + result.error_str = str(e) + task.state = ComputeTaskState.ERROR + return result + + async def start(self): + if self.is_start: + return + self.is_start = True + + async def _run_task_loop(): + while True: + logger.info("Dall E node is waiting for task...") + task = await self.task_queue.get() + logger.info(f"Dall E node get task: {task.display()}") + result = self._run_task(task) + + asyncio.create_task(_run_task_loop()) + + def display(self) -> str: + return f"DallE_ComputeNode: {self.node_id}" + + def get_task_state(self, task_id: str): + pass + + def get_capacity(self): + pass + + def is_support(self, task: ComputeTask) -> bool: + return task.task_type == ComputeTaskType.TEXT_2_IMAGE + + def is_local(self) -> bool: + return False diff --git a/src/aios_kernel/workflow_env.py b/src/aios_kernel/workflow_env.py index 03cd128..6aacf12 100644 --- a/src/aios_kernel/workflow_env.py +++ b/src/aios_kernel/workflow_env.py @@ -81,15 +81,6 @@ class CalenderEnvironment(Environment): self._update_event,update_param)) - #maybe this function should be in other env? - paint_param = { - "prompt": "A description of the content of the painting", - "model_name": "Which model to use to draw the picture, can be None" - } - self.add_ai_function(SimpleAIFunction("paint", - "Draw a picture according to the description", - self._paint,paint_param)) - self.add_ai_function(SimpleAIFunction("get_contact", "get contact info", self._get_contact,{"name":"name of contact"})) @@ -317,12 +308,10 @@ class PaintEnvironment(Environment): self.is_run = False paint_param = { - "prompt": "Keywords of the content of the painting", - "model_name": "Which model to use to draw the picture, can be None", - "negative_prompt": "Keywords that describe what is not to be drawn, can be None" + "prompt": "Description of the content of the painting", } self.add_ai_function(SimpleAIFunction("paint", - "Draw a picture according to the keywords", + "Draw a picture according to the description", self._paint,paint_param)) def _do_get_value(self,key:str) -> Optional[str]: diff --git a/test/test_dall_e_node.py b/test/test_dall_e_node.py new file mode 100644 index 0000000..37c1d62 --- /dev/null +++ b/test/test_dall_e_node.py @@ -0,0 +1,47 @@ +import os +import time +import uuid +import io +import asyncio +import sys +import logging +import pytest +directory = os.path.dirname(__file__) +sys.path.append(directory + '/../src') +from aios_kernel.dall_e_compute_node import DallE_ComputeNode +from aios_kernel.compute_task import ComputeTaskType, ComputeTask, ComputeTaskState + +os.environ["TEXT2IMG_OUTPUT_DIR"] = "./" +os.environ["openai_api_key"] = "" + +@pytest.mark.asyncio +async def test_dall_e_node(propmt, model): + node = DallE_ComputeNode.get_instance() + if await node.initial() is not True: + print("node initial failed!") + return + + task = ComputeTask() + task.task_type = ComputeTaskType.TEXT_2_IMAGE + task.create_time = time.time() + task.task_id = uuid.uuid4().hex + task.params['prompt'] = propmt + await node.push_task(task) + + while True: + if task.state == ComputeTaskState.DONE: + local_file = task.result.result + print("local file is: ", local_file) + break + await asyncio.sleep(1) + +if __name__ == "__main__": + arg_len = len(os.sys.argv) + prompt = "a beautiful sunset" + model = "dall-e-3" + if arg_len >= 2: + prompt = os.sys.argv[1] + if arg_len == 3: + model = os.sys.argv[2] + + asyncio.run(test_dall_e_node(prompt, model)) diff --git a/test/test_local_sd_node.py b/test/test_local_sd_node.py index 21f8219..4404670 100644 --- a/test/test_local_sd_node.py +++ b/test/test_local_sd_node.py @@ -14,12 +14,12 @@ from aios_kernel.compute_task import ComputeTaskType, ComputeTask, ComputeTaskSt #to launch a local stability node, please check: #https://github.com/glen0125/stable-diffusion-webui-docker -os.environ["LOCAL_STABILITY_URL"] = "" -os.environ["TEXT2IMG_DEFAULT_MODEL"] = "v1-5-pruned-emaonly" -os.environ["TEXT2IMG_OUTPUT_DIR"] = "./" +#os.environ["LOCAL_STABILITY_URL"] = "http://aigc:7860" +#os.environ["TEXT2IMG_DEFAULT_MODEL"] = "v1-5-pruned-emaonly" +#os.environ["TEXT2IMG_OUTPUT_DIR"] = "./" @pytest.mark.asyncio -async def test_local_sd_node(propmt, model): +async def test_local_sd_node(propmt, model, negative_prompt): node = Local_Stability_ComputeNode.get_instance() if await node.initial() is not True: print("node initial failed!") @@ -31,6 +31,7 @@ async def test_local_sd_node(propmt, model): task.task_id = uuid.uuid4().hex task.params['model_name'] = model task.params['prompt'] = propmt + task.params['negative_prompt'] = negative_prompt await node.push_task(task) while True: @@ -51,9 +52,12 @@ if __name__ == "__main__": arg_len = len(os.sys.argv) prompt = "a beautiful sunset" model = "v1-5-pruned-emaonly" + negative_prompt = None if arg_len >= 2: prompt = os.sys.argv[1] if arg_len == 3: model = os.sys.argv[2] + if arg_len == 4: + negative_prompt = os.sys.argv[3] - asyncio.run(test_local_sd_node(prompt, model)) + asyncio.run(test_local_sd_node(prompt, model, negative_prompt)) From adeca91e0a8e55f966e58b7a7da059da6671e99a Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Thu, 30 Nov 2023 21:04:19 -0800 Subject: [PATCH 36/79] Refactor the code directory structure to better suit the current complexity --- .../knowledge_pipelines/Mail/Issue/local.py | 2 +- .../knowledge_pipelines/Mail/Issue/parser.py | 2 +- src/aios/__init__.py | 32 ++++ src/{aios_kernel => aios/agent}/agent.py | 25 +-- src/{aios_kernel => aios/agent}/agent_base.py | 167 +----------------- .../agent}/ai_function.py | 0 src/aios/agent/behavior.py | 4 + .../agent}/chatsession.py | 2 +- src/{aios_kernel => aios/agent}/role.py | 0 src/{aios_kernel => aios/agent}/workflow.py | 16 +- .../environment}/asr_function.py | 4 +- .../environment}/code_interpreter.py | 2 +- .../environment}/code_interpreter_function.py | 4 +- .../duckduckgo_text_search_function.py | 2 +- .../environment}/environment.py | 2 +- .../environment}/image_2_text_function.py | 4 +- .../environment}/script_to_speech_function.py | 6 +- .../environment}/simple_kb_db.py | 0 .../environment}/sql_database.py | 0 .../environment}/sql_database_function.py | 4 +- .../environment}/text_to_speech_function.py | 6 +- .../environment}/workflow_env.py | 20 +-- .../environment}/workspace_env.py | 11 +- src/{aios_kernel => aios/frame}/bus.py | 5 +- .../frame}/compute_kernel.py | 8 +- .../frame}/compute_node.py | 2 +- src/{aios_kernel => aios/frame}/contact.py | 2 +- .../frame}/contact_manager.py | 6 +- .../frame}/queue_compute_node.py | 3 +- src/{aios_kernel => aios/frame}/tunnel.py | 3 +- src/{ => aios}/knowledge/__init__.py | 0 .../knowledge/core_object/__init__.py | 0 .../knowledge/core_object/document_object.py | 0 .../knowledge/core_object/email_object.py | 0 .../knowledge/core_object/image_object.py | 0 .../knowledge/core_object/rich_text_object.py | 2 +- .../knowledge/core_object/video_object.py | 0 src/{ => aios}/knowledge/data/__init__.py | 0 src/{ => aios}/knowledge/data/chunk.py | 0 src/{ => aios}/knowledge/data/chunk_list.py | 0 src/{ => aios}/knowledge/data/chunk_store.py | 0 src/{ => aios}/knowledge/data/reader.py | 0 src/{ => aios}/knowledge/data/tracker.py | 0 src/{ => aios}/knowledge/data/writer.py | 0 src/{ => aios}/knowledge/object/__init__.py | 0 src/{ => aios}/knowledge/object/blob.py | 0 src/{ => aios}/knowledge/object/hash.py | 0 src/{ => aios}/knowledge/object/object.py | 0 src/{ => aios}/knowledge/object/object_id.py | 0 .../knowledge/object/object_store.py | 0 src/{ => aios}/knowledge/object/relation.py | 0 src/{ => aios}/knowledge/pipeline.py | 0 src/{ => aios}/knowledge/store.py | 11 +- src/{ => aios}/knowledge/vector/__init__.py | 0 .../knowledge/vector/chroma_store.py | 0 .../knowledge/vector/vector_base.py | 0 .../ndn_client => aios/net}/__init__.py | 0 src/{component/ndn_client => aios/net}/cid.py | 0 .../ndn_client => aios/net}/ndn_client.py | 0 .../package_manager/README | 0 .../package_manager/__init__.py | 0 .../package_manager/env.py | 0 .../package_manager/index_db.py | 0 .../package_manager/index_syncer.py | 0 .../package_manager/installer.py | 3 +- .../package_manager/media_reader.py | 0 .../package_manager/pkg.py | 0 src/aios/proto/agent_msg.py | 167 ++++++++++++++++++ .../proto}/compute_task.py | 4 +- src/{aios_kernel => aios/storage}/storage.py | 4 +- src/aios_kernel/__init__.py | 32 ---- src/component/agent_manager/agent_manager.py | 4 +- .../email_tunnel.py | 5 +- src/component/google_node/__init__.py | 1 + .../google_text_to_speech_node.py | 4 +- src/component/knowledge_manager/pipeline.py | 2 +- src/component/llama_node/__init__.py | 1 + .../llama_node}/local_llama_compute_node.py | 28 ++- src/component/mail_environment/issue.py | 3 +- src/component/mail_environment/local.py | 3 +- src/component/mail_environment/mail.py | 2 +- src/component/mail_environment/spider.py | 3 +- src/component/openai_node/__init__.py | 3 + .../openai_node}/open_ai_node.py | 5 +- .../openai_node}/openai_tts_node.py | 2 +- .../openai_node}/whisper_node.py | 5 +- src/component/sd_node/__init__.py | 2 + .../sd_node}/local_stability_node.py | 4 +- .../sd_node}/stability_node.py | 4 +- src/component/st_node/__init__.py | 1 + .../st_node}/local_st_compute_node.py | 5 +- src/{aios_kernel => component}/tg_tunnel.py | 8 +- .../workflow_manager/workflow_manager.py | 3 +- src/node_daemon/__init__.py | 1 + src/readme.txt | 34 ++++ src/service/aios_shell/aios_shell.py | 19 +- .../aios_shell}/compute_node_config.py | 11 +- src/service/aios_shell/proxy.py | 2 +- src/service/app_manager/README | 1 - 99 files changed, 391 insertions(+), 342 deletions(-) create mode 100644 src/aios/__init__.py rename src/{aios_kernel => aios/agent}/agent.py (98%) rename src/{aios_kernel => aios/agent}/agent_base.py (80%) rename src/{aios_kernel => aios/agent}/ai_function.py (100%) create mode 100644 src/aios/agent/behavior.py rename src/{aios_kernel => aios/agent}/chatsession.py (99%) rename src/{aios_kernel => aios/agent}/role.py (100%) rename src/{aios_kernel => aios/agent}/workflow.py (98%) rename src/{aios_kernel => aios/environment}/asr_function.py (95%) rename src/{aios_kernel => aios/environment}/code_interpreter.py (99%) rename src/{aios_kernel => aios/environment}/code_interpreter_function.py (90%) rename src/{aios_kernel => aios/environment}/duckduckgo_text_search_function.py (96%) rename src/{aios_kernel => aios/environment}/environment.py (99%) rename src/{aios_kernel => aios/environment}/image_2_text_function.py (92%) rename src/{aios_kernel => aios/environment}/script_to_speech_function.py (96%) rename src/{aios_kernel => aios/environment}/simple_kb_db.py (100%) rename src/{aios_kernel => aios/environment}/sql_database.py (100%) rename src/{aios_kernel => aios/environment}/sql_database_function.py (96%) rename src/{aios_kernel => aios/environment}/text_to_speech_function.py (94%) rename src/{aios_kernel => aios/environment}/workflow_env.py (98%) rename src/{aios_kernel => aios/environment}/workspace_env.py (99%) rename src/{aios_kernel => aios/frame}/bus.py (98%) rename src/{aios_kernel => aios/frame}/compute_kernel.py (98%) rename src/{aios_kernel => aios/frame}/compute_node.py (92%) rename src/{aios_kernel => aios/frame}/contact.py (98%) rename src/{aios_kernel => aios/frame}/contact_manager.py (98%) rename src/{aios_kernel => aios/frame}/queue_compute_node.py (91%) rename src/{aios_kernel => aios/frame}/tunnel.py (98%) rename src/{ => aios}/knowledge/__init__.py (100%) rename src/{ => aios}/knowledge/core_object/__init__.py (100%) rename src/{ => aios}/knowledge/core_object/document_object.py (100%) rename src/{ => aios}/knowledge/core_object/email_object.py (100%) rename src/{ => aios}/knowledge/core_object/image_object.py (100%) rename src/{ => aios}/knowledge/core_object/rich_text_object.py (97%) rename src/{ => aios}/knowledge/core_object/video_object.py (100%) rename src/{ => aios}/knowledge/data/__init__.py (100%) rename src/{ => aios}/knowledge/data/chunk.py (100%) rename src/{ => aios}/knowledge/data/chunk_list.py (100%) rename src/{ => aios}/knowledge/data/chunk_store.py (100%) rename src/{ => aios}/knowledge/data/reader.py (100%) rename src/{ => aios}/knowledge/data/tracker.py (100%) rename src/{ => aios}/knowledge/data/writer.py (100%) rename src/{ => aios}/knowledge/object/__init__.py (100%) rename src/{ => aios}/knowledge/object/blob.py (100%) rename src/{ => aios}/knowledge/object/hash.py (100%) rename src/{ => aios}/knowledge/object/object.py (100%) rename src/{ => aios}/knowledge/object/object_id.py (100%) rename src/{ => aios}/knowledge/object/object_store.py (100%) rename src/{ => aios}/knowledge/object/relation.py (100%) rename src/{ => aios}/knowledge/pipeline.py (100%) rename src/{ => aios}/knowledge/store.py (95%) rename src/{ => aios}/knowledge/vector/__init__.py (100%) rename src/{ => aios}/knowledge/vector/chroma_store.py (100%) rename src/{ => aios}/knowledge/vector/vector_base.py (100%) rename src/{component/ndn_client => aios/net}/__init__.py (100%) rename src/{component/ndn_client => aios/net}/cid.py (100%) rename src/{component/ndn_client => aios/net}/ndn_client.py (100%) rename src/{component => aios}/package_manager/README (100%) rename src/{component => aios}/package_manager/__init__.py (100%) rename src/{component => aios}/package_manager/env.py (100%) rename src/{component => aios}/package_manager/index_db.py (100%) rename src/{component => aios}/package_manager/index_syncer.py (100%) rename src/{component => aios}/package_manager/installer.py (98%) rename src/{component => aios}/package_manager/media_reader.py (100%) rename src/{component => aios}/package_manager/pkg.py (100%) create mode 100644 src/aios/proto/agent_msg.py rename src/{aios_kernel => aios/proto}/compute_task.py (98%) rename src/{aios_kernel => aios/storage}/storage.py (98%) delete mode 100644 src/aios_kernel/__init__.py rename src/{aios_kernel => component}/email_tunnel.py (98%) create mode 100644 src/component/google_node/__init__.py rename src/{aios_kernel => component/google_node}/google_text_to_speech_node.py (97%) create mode 100644 src/component/llama_node/__init__.py rename src/{aios_kernel => component/llama_node}/local_llama_compute_node.py (92%) create mode 100644 src/component/openai_node/__init__.py rename src/{aios_kernel => component/openai_node}/open_ai_node.py (98%) rename src/{aios_kernel => component/openai_node}/openai_tts_node.py (96%) rename src/{aios_kernel => component/openai_node}/whisper_node.py (98%) create mode 100644 src/component/sd_node/__init__.py rename src/{aios_kernel => component/sd_node}/local_stability_node.py (97%) rename src/{aios_kernel => component/sd_node}/stability_node.py (97%) create mode 100644 src/component/st_node/__init__.py rename src/{aios_kernel => component/st_node}/local_st_compute_node.py (97%) rename src/{aios_kernel => component}/tg_tunnel.py (98%) create mode 100644 src/node_daemon/__init__.py create mode 100644 src/readme.txt rename src/{aios_kernel => service/aios_shell}/compute_node_config.py (92%) delete mode 100644 src/service/app_manager/README diff --git a/rootfs/knowledge_pipelines/Mail/Issue/local.py b/rootfs/knowledge_pipelines/Mail/Issue/local.py index 1edfff8..7c08bfd 100644 --- a/rootfs/knowledge_pipelines/Mail/Issue/local.py +++ b/rootfs/knowledge_pipelines/Mail/Issue/local.py @@ -1,6 +1,6 @@ import sys import os -from knowledge import KnowledgePipelineEnvironment +from aios import KnowledgePipelineEnvironment directory = os.path.dirname(__file__) sys.path.append(directory + '/../../../../src/component/') diff --git a/rootfs/knowledge_pipelines/Mail/Issue/parser.py b/rootfs/knowledge_pipelines/Mail/Issue/parser.py index a5f819b..fd0779e 100644 --- a/rootfs/knowledge_pipelines/Mail/Issue/parser.py +++ b/rootfs/knowledge_pipelines/Mail/Issue/parser.py @@ -1,6 +1,6 @@ import sys import os -from knowledge import * +from aios import * directory = os.path.dirname(__file__) sys.path.append(directory + '/../../../../src/component/') diff --git a/src/aios/__init__.py b/src/aios/__init__.py new file mode 100644 index 0000000..1a0c512 --- /dev/null +++ b/src/aios/__init__.py @@ -0,0 +1,32 @@ + +from .proto.agent_msg import * +from .proto.compute_task import * + +from .agent.agent_base import AgentPrompt,CustomAIAgent +from .agent.chatsession import AIChatSession +from .agent.agent import AIAgent,AIAgentTemplete, BaseAIAgent +from .agent.role import AIRole,AIRoleGroup +from .agent.workflow import Workflow +from .agent.ai_function import SimpleAIFunction + +from .frame.compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType +from .frame.compute_node import ComputeNode,LocalComputeNode +from .frame.bus import AIBus +from .frame.tunnel import AgentTunnel +from .frame.contact_manager import ContactManager,Contact,FamilyMember +from .frame.queue_compute_node import Queue_ComputeNode + +from .environment.environment import Environment,EnvironmentEvent +from .environment.workflow_env import WorkflowEnvironment,CalenderEnvironment,CalenderEvent,PaintEnvironment +from .environment.text_to_speech_function import TextToSpeechFunction +from .environment.image_2_text_function import Image2TextFunction +from .environment.workspace_env import ShellEnvironment,WorkspaceEnvironment + +from .storage.storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem + +from .net import * +from .knowledge import * +from .package_manager import * + + +AIOS_Version = "0.5.2, build 2023-11-30" \ No newline at end of file diff --git a/src/aios_kernel/agent.py b/src/aios/agent/agent.py similarity index 98% rename from src/aios_kernel/agent.py rename to src/aios/agent/agent.py index bf03eab..ad8dcf8 100644 --- a/src/aios_kernel/agent.py +++ b/src/aios/agent/agent.py @@ -12,20 +12,21 @@ import datetime import copy import sys -from .agent_base import AgentMsg, AgentMsgStatus, AgentMsgType, FunctionItem, LLMResult, AgentPrompt, AgentReport, \ - AgentTodo, AgentTodoResult, AgentWorkLog, BaseAIAgent +from ..proto.agent_msg import AgentMsg +from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode -from .chatsession import AIChatSession -from .compute_task import ComputeTaskResult,ComputeTaskResultCode -from .ai_function import AIFunction -from .environment import Environment -from .contact_manager import ContactManager,Contact,FamilyMember -from .compute_kernel import ComputeKernel -from .bus import AIBus -from .workspace_env import WorkspaceEnvironment -from .storage import AIStorage +from .agent_base import * +from .chatsession import * +from .ai_function import * -from knowledge import * +from ..frame.contact_manager import ContactManager,Contact,FamilyMember +from ..frame.compute_kernel import ComputeKernel +from ..frame.bus import AIBus +from ..environment.environment import * +from ..environment.workspace_env import WorkspaceEnvironment +from ..storage.storage import AIStorage + +from ..knowledge import * logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/agent_base.py b/src/aios/agent/agent_base.py similarity index 80% rename from src/aios_kernel/agent_base.py rename to src/aios/agent/agent_base.py index 452b42e..2977bc8 100644 --- a/src/aios_kernel/agent_base.py +++ b/src/aios/agent/agent_base.py @@ -12,170 +12,13 @@ import json from typing import List from .ai_function import FunctionItem, AIFunction -from .compute_task import ComputeTaskResult,ComputeTaskResultCode -from .environment import Environment +from ..proto.agent_msg import AgentMsg, AgentMsgType +from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode +from ..environment.environment import Environment logger = logging.getLogger(__name__) -class AgentMsgType(Enum): - TYPE_MSG = 0 - TYPE_GROUPMSG = 1 - TYPE_INTERNAL_CALL = 10 - TYPE_ACTION = 20 - TYPE_EVENT = 30 - TYPE_SYSTEM = 40 - - -class AgentMsgStatus(Enum): - RESPONSED = 0 - INIT = 1 - SENDING = 2 - PROCESSING = 3 - ERROR = 4 - RECVED = 5 - EXECUTED = 6 - -# msg is a msg / msg resp -# msg body可以有内容类型(MIME标签),text, image, voice, video, file,以及富文本(html) -# msg is a inner function call with result -# msg is a Action with result - -# qutoe Msg -# forword msg -# reply msg - -# 逻辑上的同一个Message在同一个session中看到的msgid相同 -# 在不同的session中看到的msgid不同 - -class AgentMsg: - def __init__(self,msg_type=AgentMsgType.TYPE_MSG) -> None: - self.msg_id = "msg#" + uuid.uuid4().hex - self.msg_type:AgentMsgType = msg_type - - self.prev_msg_id:str = None - self.quote_msg_id:str = None - self.rely_msg_id:str = None # if not none means this is a respone msg - self.session_id:str = None - - #forword info - - - self.create_time = 0 - self.done_time = 0 - self.topic:str = None # topic is use to find session, not store in db - - self.sender:str = None # obj_id.sub_objid@tunnel_id - self.target:str = None - self.mentions:[] = None #use in group chat only - #self.title:str = None - self.body:str = None - self.body_mime:str = None #//default is "text/plain",encode is utf8 - - #type is call / action - self.func_name = None - self.args = None - self.result_str = None - - #type is event - self.event_name = None - self.event_args = None - - self.status = AgentMsgStatus.INIT - self.inner_call_chain = [] - self.resp_msg = None - - @classmethod - def from_json(cls,json_obj:dict) -> 'AgentMsg': - msg = AgentMsg() - - return msg - - @classmethod - def create_internal_call_msg(self,func_name:str,args:dict,prev_msg_id:str,caller:str): - msg = AgentMsg(AgentMsgType.TYPE_INTERNAL_CALL) - msg.create_time = time.time() - msg.func_name = func_name - msg.args = args - msg.prev_msg_id = prev_msg_id - msg.sender = caller - return msg - - def create_action_msg(self,action_name:str,args:dict,caller:str): - msg = AgentMsg(AgentMsgType.TYPE_ACTION) - msg.create_time = time.time() - msg.func_name = action_name - msg.args = args - msg.prev_msg_id = self.msg_id - msg.topic = self.topic - msg.sender = caller - return msg - - def create_error_resp(self,error_msg:str): - resp_msg = AgentMsg(AgentMsgType.TYPE_SYSTEM) - resp_msg.create_time = time.time() - - resp_msg.rely_msg_id = self.msg_id - resp_msg.body = error_msg - resp_msg.topic = self.topic - resp_msg.sender = self.target - resp_msg.target = self.sender - - return resp_msg - - def create_resp_msg(self,resp_body): - resp_msg = AgentMsg() - resp_msg.create_time = time.time() - - resp_msg.rely_msg_id = self.msg_id - resp_msg.sender = self.target - resp_msg.target = self.sender - resp_msg.body = resp_body - resp_msg.topic = self.topic - - return resp_msg - - def create_group_resp_msg(self,sender_id,resp_body): - resp_msg = AgentMsg(AgentMsgType.TYPE_GROUPMSG) - resp_msg.create_time = time.time() - - resp_msg.rely_msg_id = self.msg_id - resp_msg.target = self.target - resp_msg.sender = sender_id - resp_msg.body = resp_body - resp_msg.topic = self.topic - - return resp_msg - - def set(self,sender:str,target:str,body:str,topic:str=None) -> None: - self.sender = sender - self.target = target - self.body = body - self.create_time = time.time() - if topic: - self.topic = topic - - def get_msg_id(self) -> str: - return self.msg_id - - def get_sender(self) -> str: - return self.sender - - def get_target(self) -> str: - return self.target - - def get_prev_msg_id(self) -> str: - return self.prev_msg_id - - def get_quote_msg_id(self) -> str: - return self.quote_msg_id - - @classmethod - def parse_function_call(cls,func_string:str): - str_list = shlex.split(func_string) - func_name = str_list[0] - params = str_list[1:] - return func_name, params class AgentPrompt: def __init__(self,prompt_str = None) -> None: @@ -597,7 +440,8 @@ class BaseAIAgent(abc.ABC): inner_functions=None, is_json_resp=False, ) -> ComputeTaskResult: - from .compute_kernel import ComputeKernel + from ..frame.compute_kernel import ComputeKernel + #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") if inner_functions is None and env is not None: inner_functions,_ = BaseAIAgent.get_inner_functions(env) @@ -633,7 +477,6 @@ class BaseAIAgent(abc.ABC): org_msg:AgentMsg, stack_limit = 5 ) -> ComputeTaskResult: - from .compute_kernel import ComputeKernel arguments = None try: func_name = inner_func_call_node.get("name") diff --git a/src/aios_kernel/ai_function.py b/src/aios/agent/ai_function.py similarity index 100% rename from src/aios_kernel/ai_function.py rename to src/aios/agent/ai_function.py diff --git a/src/aios/agent/behavior.py b/src/aios/agent/behavior.py new file mode 100644 index 0000000..64b8834 --- /dev/null +++ b/src/aios/agent/behavior.py @@ -0,0 +1,4 @@ +# TODO: let agent develolp custmized behavior easily +class AgentBehavior: + def __init__(self) -> None: + pass \ No newline at end of file diff --git a/src/aios_kernel/chatsession.py b/src/aios/agent/chatsession.py similarity index 99% rename from src/aios_kernel/chatsession.py rename to src/aios/agent/chatsession.py index 0302e12..8500faf 100644 --- a/src/aios_kernel/chatsession.py +++ b/src/aios/agent/chatsession.py @@ -7,7 +7,7 @@ import datetime import uuid import json -from .agent_base import AgentMsgType, AgentMsg, AgentMsgStatus +from ..proto.agent_msg import AgentMsgType, AgentMsg, AgentMsgStatus class ChatSessionDB: def __init__(self, db_file): diff --git a/src/aios_kernel/role.py b/src/aios/agent/role.py similarity index 100% rename from src/aios_kernel/role.py rename to src/aios/agent/role.py diff --git a/src/aios_kernel/workflow.py b/src/aios/agent/workflow.py similarity index 98% rename from src/aios_kernel/workflow.py rename to src/aios/agent/workflow.py index a7e562d..7855483 100644 --- a/src/aios_kernel/workflow.py +++ b/src/aios/agent/workflow.py @@ -7,15 +7,19 @@ from asyncio import Queue from typing import Optional,Tuple,List from abc import ABC, abstractmethod -from .environment import Environment,EnvironmentEvent -from .agent_base import AgentMsg,AgentMsgStatus,FunctionItem,LLMResult,AgentPrompt +from ..proto.compute_task import * +from ..proto.agent_msg import * + +from .agent_base import * from .chatsession import AIChatSession from .role import AIRole,AIRoleGroup from .ai_function import AIFunction,FunctionItem -from .compute_kernel import ComputeKernel -from .compute_task import ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskResultCode -from .bus import AIBus -from .workflow_env import WorkflowEnvironment + +from ..frame.compute_kernel import ComputeKernel +from ..frame.bus import AIBus + +from ..environment.environment import Environment,EnvironmentEvent +from ..environment.workflow_env import WorkflowEnvironment logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/asr_function.py b/src/aios/environment/asr_function.py similarity index 95% rename from src/aios_kernel/asr_function.py rename to src/aios/environment/asr_function.py index 11acde1..9b97006 100644 --- a/src/aios_kernel/asr_function.py +++ b/src/aios/environment/asr_function.py @@ -1,8 +1,8 @@ import logging from typing import Dict -from aios_kernel import ComputeKernel -from aios_kernel.ai_function import AIFunction +from ..frame.compute_kernel import ComputeKernel +from ..agent.ai_function import AIFunction logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/code_interpreter.py b/src/aios/environment/code_interpreter.py similarity index 99% rename from src/aios_kernel/code_interpreter.py rename to src/aios/environment/code_interpreter.py index 850356a..68c00d8 100644 --- a/src/aios_kernel/code_interpreter.py +++ b/src/aios/environment/code_interpreter.py @@ -12,7 +12,7 @@ from hashlib import md5 from typing import Optional, Union, List, Tuple from generic_escape import GenericEscape -from aios_kernel import AIStorage +from ..storage.storage import AIStorage try: import docker diff --git a/src/aios_kernel/code_interpreter_function.py b/src/aios/environment/code_interpreter_function.py similarity index 90% rename from src/aios_kernel/code_interpreter_function.py rename to src/aios/environment/code_interpreter_function.py index 0bf366c..734af71 100644 --- a/src/aios_kernel/code_interpreter_function.py +++ b/src/aios/environment/code_interpreter_function.py @@ -1,7 +1,7 @@ from typing import Dict -from aios_kernel.ai_function import AIFunction -from aios_kernel.code_interpreter import execute_code +from ..agent.ai_function import AIFunction +from .code_interpreter import execute_code class CodeInterpreterFunction(AIFunction): diff --git a/src/aios_kernel/duckduckgo_text_search_function.py b/src/aios/environment/duckduckgo_text_search_function.py similarity index 96% rename from src/aios_kernel/duckduckgo_text_search_function.py rename to src/aios/environment/duckduckgo_text_search_function.py index 154f761..5418095 100644 --- a/src/aios_kernel/duckduckgo_text_search_function.py +++ b/src/aios/environment/duckduckgo_text_search_function.py @@ -1,7 +1,7 @@ import json from typing import Dict -from aios_kernel.ai_function import AIFunction +from ..agent.ai_function import AIFunction from duckduckgo_search import AsyncDDGS diff --git a/src/aios_kernel/environment.py b/src/aios/environment/environment.py similarity index 99% rename from src/aios_kernel/environment.py rename to src/aios/environment/environment.py index ab384cd..eafe74f 100644 --- a/src/aios_kernel/environment.py +++ b/src/aios/environment/environment.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from typing import Any, Callable, Optional,Dict,Awaitable,List import logging -from .ai_function import AIFunction +from ..agent.ai_function import AIFunction logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/image_2_text_function.py b/src/aios/environment/image_2_text_function.py similarity index 92% rename from src/aios_kernel/image_2_text_function.py rename to src/aios/environment/image_2_text_function.py index da84b04..8760f56 100644 --- a/src/aios_kernel/image_2_text_function.py +++ b/src/aios/environment/image_2_text_function.py @@ -1,8 +1,8 @@ import logging from typing import Dict -from aios_kernel import ComputeKernel -from aios_kernel.ai_function import AIFunction +from ..frame.compute_kernel import ComputeKernel +from ..agent.ai_function import AIFunction logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/script_to_speech_function.py b/src/aios/environment/script_to_speech_function.py similarity index 96% rename from src/aios_kernel/script_to_speech_function.py rename to src/aios/environment/script_to_speech_function.py index 7ccd116..6c71229 100644 --- a/src/aios_kernel/script_to_speech_function.py +++ b/src/aios/environment/script_to_speech_function.py @@ -5,8 +5,10 @@ import random from pathlib import Path from typing import Dict -from aios_kernel import ComputeKernel, AIStorage -from aios_kernel.ai_function import AIFunction +from ..agent.ai_function import AIFunction +from ..frame.compute_kernel import ComputeKernel +from ..storage.storage import AIStorage + from pydub import AudioSegment diff --git a/src/aios_kernel/simple_kb_db.py b/src/aios/environment/simple_kb_db.py similarity index 100% rename from src/aios_kernel/simple_kb_db.py rename to src/aios/environment/simple_kb_db.py diff --git a/src/aios_kernel/sql_database.py b/src/aios/environment/sql_database.py similarity index 100% rename from src/aios_kernel/sql_database.py rename to src/aios/environment/sql_database.py diff --git a/src/aios_kernel/sql_database_function.py b/src/aios/environment/sql_database_function.py similarity index 96% rename from src/aios_kernel/sql_database_function.py rename to src/aios/environment/sql_database_function.py index 2e208e6..989b82b 100644 --- a/src/aios_kernel/sql_database_function.py +++ b/src/aios/environment/sql_database_function.py @@ -3,8 +3,8 @@ from typing import Dict from cachetools import TLRUCache, cached -from aios_kernel.ai_function import AIFunction -from aios_kernel.sql_database import SQLDatabase, get_from_env +from ..agent.ai_function import AIFunction +from .sql_database import SQLDatabase, get_from_env def _my_ttu(_key, _value, now): diff --git a/src/aios_kernel/text_to_speech_function.py b/src/aios/environment/text_to_speech_function.py similarity index 94% rename from src/aios_kernel/text_to_speech_function.py rename to src/aios/environment/text_to_speech_function.py index 0f90ee1..e93a568 100644 --- a/src/aios_kernel/text_to_speech_function.py +++ b/src/aios/environment/text_to_speech_function.py @@ -5,8 +5,10 @@ import random from pathlib import Path from typing import Dict -from aios_kernel import ComputeKernel, AIStorage -from aios_kernel.ai_function import AIFunction +from ..agent.ai_function import AIFunction +from ..frame.compute_kernel import ComputeKernel +from ..storage.storage import AIStorage + from pydub import AudioSegment diff --git a/src/aios_kernel/workflow_env.py b/src/aios/environment/workflow_env.py similarity index 98% rename from src/aios_kernel/workflow_env.py rename to src/aios/environment/workflow_env.py index 03cd128..c5dcce6 100644 --- a/src/aios_kernel/workflow_env.py +++ b/src/aios/environment/workflow_env.py @@ -7,19 +7,19 @@ from sqlite3 import Error import threading import logging from typing import Optional - -from .script_to_speech_function import ScriptToSpeechFunction -from .image_2_text_function import Image2TextFunction -from .compute_kernel import ComputeKernel, ComputeTaskResultCode -from .environment import Environment,EnvironmentEvent -from .ai_function import SimpleAIFunction -from .storage import AIStorage -from .contact_manager import ContactManager,Contact,FamilyMember - import aiosqlite -logger = logging.getLogger(__name__) +from ..proto.compute_task import * +from ..agent.ai_function import SimpleAIFunction +from ..frame.compute_kernel import ComputeKernel +from ..frame.contact_manager import ContactManager,Contact,FamilyMember +from ..storage.storage import AIStorage +from .environment import Environment,EnvironmentEvent +from .script_to_speech_function import ScriptToSpeechFunction +from .image_2_text_function import Image2TextFunction + +logger = logging.getLogger(__name__) class CalenderEvent(EnvironmentEvent): def __init__(self,data) -> None: diff --git a/src/aios_kernel/workspace_env.py b/src/aios/environment/workspace_env.py similarity index 99% rename from src/aios_kernel/workspace_env.py rename to src/aios/environment/workspace_env.py index bde7114..d54c7b8 100644 --- a/src/aios_kernel/workspace_env.py +++ b/src/aios/environment/workspace_env.py @@ -17,14 +17,15 @@ import aiofiles from typing import Any,List import os import chardet - from markdown import Markdown import PyPDF2 -from .agent_base import AgentMsg,AgentTodo,AgentPrompt,AgentTodoResult -from .environment import Environment,EnvironmentEvent -from .ai_function import AIFunction,SimpleAIFunction -from .storage import AIStorage,ResourceLocation + +from ..proto.agent_msg import * +from ..agent.agent_base import AgentTodo,AgentPrompt,AgentTodoResult +from ..agent.ai_function import AIFunction,SimpleAIFunction +from ..storage.storage import AIStorage,ResourceLocation from .simple_kb_db import SimpleKnowledgeDB +from .environment import Environment,EnvironmentEvent logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/bus.py b/src/aios/frame/bus.py similarity index 98% rename from src/aios_kernel/bus.py rename to src/aios/frame/bus.py index 94f789e..090865c 100644 --- a/src/aios_kernel/bus.py +++ b/src/aios/frame/bus.py @@ -1,10 +1,11 @@ from typing import Coroutine,Dict,Any -from .agent_base import AgentMsg,AgentMsgStatus,AgentMsgType import asyncio from asyncio import Queue - import logging +from ..proto.agent_msg import * +from ..agent.agent_base import * + logger = logging.getLogger(__name__) class AIBusHandler: diff --git a/src/aios_kernel/compute_kernel.py b/src/aios/frame/compute_kernel.py similarity index 98% rename from src/aios_kernel/compute_kernel.py rename to src/aios/frame/compute_kernel.py index 1c6219a..229819a 100644 --- a/src/aios_kernel/compute_kernel.py +++ b/src/aios/frame/compute_kernel.py @@ -4,13 +4,12 @@ from typing import Optional import logging import asyncio import tiktoken - from asyncio import Queue -from knowledge import ObjectID -from .agent_base import AgentPrompt +from ..proto.compute_task import * +from ..knowledge import ObjectID +from ..agent.agent_base import AgentPrompt from .compute_node import ComputeNode -from .compute_task import ComputeTask, ComputeTaskState, ComputeTaskResult, ComputeTaskType,ComputeTaskResultCode logger = logging.getLogger(__name__) @@ -18,7 +17,6 @@ logger = logging.getLogger(__name__) # to suitable computing nodes, achieving a balance of speed, cost, and power consumption, # is the CORE GOAL of the entire computing task schedule system (aios_kernel). - class ComputeKernel: _instance = None @classmethod diff --git a/src/aios_kernel/compute_node.py b/src/aios/frame/compute_node.py similarity index 92% rename from src/aios_kernel/compute_node.py rename to src/aios/frame/compute_node.py index 7ce7292..6b8a43d 100644 --- a/src/aios_kernel/compute_node.py +++ b/src/aios/frame/compute_node.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod -from .compute_task import ComputeTask, ComputeTaskType +from ..proto.compute_task import ComputeTask, ComputeTaskType class ComputeNode(ABC): def __init__(self) -> None: diff --git a/src/aios_kernel/contact.py b/src/aios/frame/contact.py similarity index 98% rename from src/aios_kernel/contact.py rename to src/aios/frame/contact.py index 361c699..f29e45c 100644 --- a/src/aios_kernel/contact.py +++ b/src/aios/frame/contact.py @@ -2,7 +2,7 @@ from typing import List import logging from datetime import datetime -from .agent_base import AgentMsg +from ..proto.agent_msg import AgentMsg from .tunnel import AgentTunnel diff --git a/src/aios_kernel/contact_manager.py b/src/aios/frame/contact_manager.py similarity index 98% rename from src/aios_kernel/contact_manager.py rename to src/aios/frame/contact_manager.py index daeced6..871cfab 100644 --- a/src/aios_kernel/contact_manager.py +++ b/src/aios/frame/contact_manager.py @@ -1,11 +1,13 @@ from typing import List import toml import time +import logging + from datetime import datetime -from .agent_base import AgentMsg +from ..proto.agent_msg import AgentMsg from .tunnel import AgentTunnel from .contact import Contact,FamilyMember -import logging + logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/queue_compute_node.py b/src/aios/frame/queue_compute_node.py similarity index 91% rename from src/aios_kernel/queue_compute_node.py rename to src/aios/frame/queue_compute_node.py index 4eb22f4..be8a48e 100644 --- a/src/aios_kernel/queue_compute_node.py +++ b/src/aios/frame/queue_compute_node.py @@ -4,8 +4,7 @@ from asyncio import Queue import logging from abc import abstractmethod -from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskResultCode, ComputeTaskState, ComputeTaskType -from .compute_node import ComputeNode +from aios import ComputeTask, ComputeNode,ComputeTaskResult, ComputeTaskResultCode, ComputeTaskState, ComputeTaskType logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/tunnel.py b/src/aios/frame/tunnel.py similarity index 98% rename from src/aios_kernel/tunnel.py rename to src/aios/frame/tunnel.py index 2300c2c..0c6d79d 100644 --- a/src/aios_kernel/tunnel.py +++ b/src/aios/frame/tunnel.py @@ -1,7 +1,8 @@ from abc import ABC, abstractmethod import logging from typing import Coroutine -from .agent_base import AgentMsg + +from ..proto.agent_msg import AgentMsg from .bus import AIBus logger = logging.getLogger(__name__) diff --git a/src/knowledge/__init__.py b/src/aios/knowledge/__init__.py similarity index 100% rename from src/knowledge/__init__.py rename to src/aios/knowledge/__init__.py diff --git a/src/knowledge/core_object/__init__.py b/src/aios/knowledge/core_object/__init__.py similarity index 100% rename from src/knowledge/core_object/__init__.py rename to src/aios/knowledge/core_object/__init__.py diff --git a/src/knowledge/core_object/document_object.py b/src/aios/knowledge/core_object/document_object.py similarity index 100% rename from src/knowledge/core_object/document_object.py rename to src/aios/knowledge/core_object/document_object.py diff --git a/src/knowledge/core_object/email_object.py b/src/aios/knowledge/core_object/email_object.py similarity index 100% rename from src/knowledge/core_object/email_object.py rename to src/aios/knowledge/core_object/email_object.py diff --git a/src/knowledge/core_object/image_object.py b/src/aios/knowledge/core_object/image_object.py similarity index 100% rename from src/knowledge/core_object/image_object.py rename to src/aios/knowledge/core_object/image_object.py diff --git a/src/knowledge/core_object/rich_text_object.py b/src/aios/knowledge/core_object/rich_text_object.py similarity index 97% rename from src/knowledge/core_object/rich_text_object.py rename to src/aios/knowledge/core_object/rich_text_object.py index 7791689..8d9437e 100644 --- a/src/knowledge/core_object/rich_text_object.py +++ b/src/aios/knowledge/core_object/rich_text_object.py @@ -1,4 +1,4 @@ -from knowledge.object.object_id import ObjectType +from ..object.object_id import ObjectType from ..object import KnowledgeObject from ..data import ChunkList, ChunkListWriter from ..object import ObjectType diff --git a/src/knowledge/core_object/video_object.py b/src/aios/knowledge/core_object/video_object.py similarity index 100% rename from src/knowledge/core_object/video_object.py rename to src/aios/knowledge/core_object/video_object.py diff --git a/src/knowledge/data/__init__.py b/src/aios/knowledge/data/__init__.py similarity index 100% rename from src/knowledge/data/__init__.py rename to src/aios/knowledge/data/__init__.py diff --git a/src/knowledge/data/chunk.py b/src/aios/knowledge/data/chunk.py similarity index 100% rename from src/knowledge/data/chunk.py rename to src/aios/knowledge/data/chunk.py diff --git a/src/knowledge/data/chunk_list.py b/src/aios/knowledge/data/chunk_list.py similarity index 100% rename from src/knowledge/data/chunk_list.py rename to src/aios/knowledge/data/chunk_list.py diff --git a/src/knowledge/data/chunk_store.py b/src/aios/knowledge/data/chunk_store.py similarity index 100% rename from src/knowledge/data/chunk_store.py rename to src/aios/knowledge/data/chunk_store.py diff --git a/src/knowledge/data/reader.py b/src/aios/knowledge/data/reader.py similarity index 100% rename from src/knowledge/data/reader.py rename to src/aios/knowledge/data/reader.py diff --git a/src/knowledge/data/tracker.py b/src/aios/knowledge/data/tracker.py similarity index 100% rename from src/knowledge/data/tracker.py rename to src/aios/knowledge/data/tracker.py diff --git a/src/knowledge/data/writer.py b/src/aios/knowledge/data/writer.py similarity index 100% rename from src/knowledge/data/writer.py rename to src/aios/knowledge/data/writer.py diff --git a/src/knowledge/object/__init__.py b/src/aios/knowledge/object/__init__.py similarity index 100% rename from src/knowledge/object/__init__.py rename to src/aios/knowledge/object/__init__.py diff --git a/src/knowledge/object/blob.py b/src/aios/knowledge/object/blob.py similarity index 100% rename from src/knowledge/object/blob.py rename to src/aios/knowledge/object/blob.py diff --git a/src/knowledge/object/hash.py b/src/aios/knowledge/object/hash.py similarity index 100% rename from src/knowledge/object/hash.py rename to src/aios/knowledge/object/hash.py diff --git a/src/knowledge/object/object.py b/src/aios/knowledge/object/object.py similarity index 100% rename from src/knowledge/object/object.py rename to src/aios/knowledge/object/object.py diff --git a/src/knowledge/object/object_id.py b/src/aios/knowledge/object/object_id.py similarity index 100% rename from src/knowledge/object/object_id.py rename to src/aios/knowledge/object/object_id.py diff --git a/src/knowledge/object/object_store.py b/src/aios/knowledge/object/object_store.py similarity index 100% rename from src/knowledge/object/object_store.py rename to src/aios/knowledge/object/object_store.py diff --git a/src/knowledge/object/relation.py b/src/aios/knowledge/object/relation.py similarity index 100% rename from src/knowledge/object/relation.py rename to src/aios/knowledge/object/relation.py diff --git a/src/knowledge/pipeline.py b/src/aios/knowledge/pipeline.py similarity index 100% rename from src/knowledge/pipeline.py rename to src/aios/knowledge/pipeline.py diff --git a/src/knowledge/store.py b/src/aios/knowledge/store.py similarity index 95% rename from src/knowledge/store.py rename to src/aios/knowledge/store.py index a2ebd77..5e848d7 100644 --- a/src/knowledge/store.py +++ b/src/aios/knowledge/store.py @@ -1,13 +1,11 @@ import os +import json +import logging from .object import ObjectStore, ObjectRelationStore, ObjectID, ObjectType, KnowledgeObject from .core_object import DocumentObject, ImageObject, VideoObject, RichTextObject, EmailObject from .data import ChunkStore, ChunkTracker, ChunkListWriter, ChunkReader -import json -import logging - - - +from ..storage.storage import AIStorage # KnowledgeStore class, which aggregates ChunkStore, ChunkTracker, and ObjectStore, and is a global singleton that makes it easy to use these three built-in store examples class KnowledgeStore: @@ -17,8 +15,7 @@ class KnowledgeStore: if cls._instance is None: cls._instance = super().__new__(cls) - import aios_kernel - knowledge_dir = aios_kernel.storage.AIStorage().get_myai_dir() / "knowledge" / "objects" + knowledge_dir = AIStorage.get_instance().get_myai_dir() / "knowledge" / "objects" if not os.path.exists(knowledge_dir): os.makedirs(knowledge_dir) diff --git a/src/knowledge/vector/__init__.py b/src/aios/knowledge/vector/__init__.py similarity index 100% rename from src/knowledge/vector/__init__.py rename to src/aios/knowledge/vector/__init__.py diff --git a/src/knowledge/vector/chroma_store.py b/src/aios/knowledge/vector/chroma_store.py similarity index 100% rename from src/knowledge/vector/chroma_store.py rename to src/aios/knowledge/vector/chroma_store.py diff --git a/src/knowledge/vector/vector_base.py b/src/aios/knowledge/vector/vector_base.py similarity index 100% rename from src/knowledge/vector/vector_base.py rename to src/aios/knowledge/vector/vector_base.py diff --git a/src/component/ndn_client/__init__.py b/src/aios/net/__init__.py similarity index 100% rename from src/component/ndn_client/__init__.py rename to src/aios/net/__init__.py diff --git a/src/component/ndn_client/cid.py b/src/aios/net/cid.py similarity index 100% rename from src/component/ndn_client/cid.py rename to src/aios/net/cid.py diff --git a/src/component/ndn_client/ndn_client.py b/src/aios/net/ndn_client.py similarity index 100% rename from src/component/ndn_client/ndn_client.py rename to src/aios/net/ndn_client.py diff --git a/src/component/package_manager/README b/src/aios/package_manager/README similarity index 100% rename from src/component/package_manager/README rename to src/aios/package_manager/README diff --git a/src/component/package_manager/__init__.py b/src/aios/package_manager/__init__.py similarity index 100% rename from src/component/package_manager/__init__.py rename to src/aios/package_manager/__init__.py diff --git a/src/component/package_manager/env.py b/src/aios/package_manager/env.py similarity index 100% rename from src/component/package_manager/env.py rename to src/aios/package_manager/env.py diff --git a/src/component/package_manager/index_db.py b/src/aios/package_manager/index_db.py similarity index 100% rename from src/component/package_manager/index_db.py rename to src/aios/package_manager/index_db.py diff --git a/src/component/package_manager/index_syncer.py b/src/aios/package_manager/index_syncer.py similarity index 100% rename from src/component/package_manager/index_syncer.py rename to src/aios/package_manager/index_syncer.py diff --git a/src/component/package_manager/installer.py b/src/aios/package_manager/installer.py similarity index 98% rename from src/component/package_manager/installer.py rename to src/aios/package_manager/installer.py index 8ab8f5f..98a5f7b 100644 --- a/src/component/package_manager/installer.py +++ b/src/aios/package_manager/installer.py @@ -6,7 +6,8 @@ import aiofiles import os from typing import Tuple -from ndn_client import ContentId,NDN_Client +#from ndn_client import ContentId,NDN_Client +from ..net import ContentId,NDN_Client from .pkg import PackageInfo,PackageMediaInfo from .env import PackageEnv diff --git a/src/component/package_manager/media_reader.py b/src/aios/package_manager/media_reader.py similarity index 100% rename from src/component/package_manager/media_reader.py rename to src/aios/package_manager/media_reader.py diff --git a/src/component/package_manager/pkg.py b/src/aios/package_manager/pkg.py similarity index 100% rename from src/component/package_manager/pkg.py rename to src/aios/package_manager/pkg.py diff --git a/src/aios/proto/agent_msg.py b/src/aios/proto/agent_msg.py new file mode 100644 index 0000000..3c1e2a0 --- /dev/null +++ b/src/aios/proto/agent_msg.py @@ -0,0 +1,167 @@ +import logging +import uuid +from enum import Enum +import time + +logger = logging.getLogger(__name__) + +class AgentMsgType(Enum): + TYPE_MSG = 0 + TYPE_GROUPMSG = 1 + TYPE_INTERNAL_CALL = 10 + TYPE_ACTION = 20 + TYPE_EVENT = 30 + TYPE_SYSTEM = 40 + + +class AgentMsgStatus(Enum): + RESPONSED = 0 + INIT = 1 + SENDING = 2 + PROCESSING = 3 + ERROR = 4 + RECVED = 5 + EXECUTED = 6 + +# msg is a msg / msg resp +# msg body可以有内容类型(MIME标签),text, image, voice, video, file,以及富文本(html) +# msg is a inner function call with result +# msg is a Action with result + +# qutoe Msg +# forword msg +# reply msg + +# 逻辑上的同一个Message在同一个session中看到的msgid相同 +# 在不同的session中看到的msgid不同 + + + +class AgentMsg: + def __init__(self,msg_type=AgentMsgType.TYPE_MSG) -> None: + self.msg_id = "msg#" + uuid.uuid4().hex + self.msg_type:AgentMsgType = msg_type + + self.prev_msg_id:str = None + self.quote_msg_id:str = None + self.rely_msg_id:str = None # if not none means this is a respone msg + self.session_id:str = None + + #forword info + + + self.create_time = 0 + self.done_time = 0 + self.topic:str = None # topic is use to find session, not store in db + + self.sender:str = None # obj_id.sub_objid@tunnel_id + self.target:str = None + self.mentions:[] = None #use in group chat only + #self.title:str = None + self.body:str = None + self.body_mime:str = None #//default is "text/plain",encode is utf8 + + #type is call / action + self.func_name = None + self.args = None + self.result_str = None + + #type is event + self.event_name = None + self.event_args = None + + self.status = AgentMsgStatus.INIT + self.inner_call_chain = [] + self.resp_msg = None + + @classmethod + def from_json(cls,json_obj:dict) -> 'AgentMsg': + msg = AgentMsg() + + return msg + + @classmethod + def create_internal_call_msg(self,func_name:str,args:dict,prev_msg_id:str,caller:str): + msg = AgentMsg(AgentMsgType.TYPE_INTERNAL_CALL) + msg.create_time = time.time() + msg.func_name = func_name + msg.args = args + msg.prev_msg_id = prev_msg_id + msg.sender = caller + return msg + + def create_action_msg(self,action_name:str,args:dict,caller:str): + msg = AgentMsg(AgentMsgType.TYPE_ACTION) + msg.create_time = time.time() + msg.func_name = action_name + msg.args = args + msg.prev_msg_id = self.msg_id + msg.topic = self.topic + msg.sender = caller + return msg + + def create_error_resp(self,error_msg:str): + resp_msg = AgentMsg(AgentMsgType.TYPE_SYSTEM) + resp_msg.create_time = time.time() + + resp_msg.rely_msg_id = self.msg_id + resp_msg.body = error_msg + resp_msg.topic = self.topic + resp_msg.sender = self.target + resp_msg.target = self.sender + + return resp_msg + + def create_resp_msg(self,resp_body): + resp_msg = AgentMsg() + resp_msg.create_time = time.time() + + resp_msg.rely_msg_id = self.msg_id + resp_msg.sender = self.target + resp_msg.target = self.sender + resp_msg.body = resp_body + resp_msg.topic = self.topic + + return resp_msg + + def create_group_resp_msg(self,sender_id,resp_body): + resp_msg = AgentMsg(AgentMsgType.TYPE_GROUPMSG) + resp_msg.create_time = time.time() + + resp_msg.rely_msg_id = self.msg_id + resp_msg.target = self.target + resp_msg.sender = sender_id + resp_msg.body = resp_body + resp_msg.topic = self.topic + + return resp_msg + + def set(self,sender:str,target:str,body:str,topic:str=None) -> None: + self.sender = sender + self.target = target + self.body = body + self.create_time = time.time() + if topic: + self.topic = topic + + def get_msg_id(self) -> str: + return self.msg_id + + def get_sender(self) -> str: + return self.sender + + def get_target(self) -> str: + return self.target + + def get_prev_msg_id(self) -> str: + return self.prev_msg_id + + def get_quote_msg_id(self) -> str: + return self.quote_msg_id + + @classmethod + def parse_function_call(cls,func_string:str): + str_list = shlex.split(func_string) + func_name = str_list[0] + params = str_list[1:] + return func_name, params \ No newline at end of file diff --git a/src/aios_kernel/compute_task.py b/src/aios/proto/compute_task.py similarity index 98% rename from src/aios_kernel/compute_task.py rename to src/aios/proto/compute_task.py index 3728cdf..c226b51 100644 --- a/src/aios_kernel/compute_task.py +++ b/src/aios/proto/compute_task.py @@ -3,8 +3,8 @@ from enum import Enum import uuid import time from typing import Union -from knowledge import ObjectID -from .storage import AIStorage +from ..knowledge import ObjectID +from ..storage.storage import AIStorage class ComputeTaskResultCode(Enum): OK = 0 diff --git a/src/aios_kernel/storage.py b/src/aios/storage/storage.py similarity index 98% rename from src/aios_kernel/storage.py rename to src/aios/storage/storage.py index ca3bb56..fe0b3fd 100644 --- a/src/aios_kernel/storage.py +++ b/src/aios/storage/storage.py @@ -195,7 +195,7 @@ class AIStorage: /opt/aios """ if self.is_dev_mode: - return os.path.abspath(_file_dir + "/../") + return os.path.abspath(_file_dir + "/../../") else: return "/opt/aios/" @@ -206,7 +206,7 @@ class AIStorage: /opt/aios/app """ if self.is_dev_mode: - return os.path.abspath(_file_dir + "/../../rootfs/") + return os.path.abspath(_file_dir + "/../../../rootfs/") else: return "/opt/aios/app/" diff --git a/src/aios_kernel/__init__.py b/src/aios_kernel/__init__.py deleted file mode 100644 index 1ea0d7c..0000000 --- a/src/aios_kernel/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -from .environment import Environment,EnvironmentEvent -from .agent_base import AgentMsg,AgentMsgStatus,AgentMsgType,AgentPrompt,CustomAIAgent -from .chatsession import AIChatSession -from .agent import AIAgent,AIAgentTemplete, BaseAIAgent -from .compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType -from .compute_node import ComputeNode,LocalComputeNode -from .open_ai_node import OpenAI_ComputeNode -from .role import AIRole,AIRoleGroup -from .storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem -from .workflow import Workflow -from .bus import AIBus -from .workflow_env import WorkflowEnvironment,CalenderEnvironment,CalenderEvent,PaintEnvironment -from .local_llama_compute_node import LocalLlama_ComputeNode -from .whisper_node import WhisperComputeNode -from .google_text_to_speech_node import GoogleTextToSpeechNode -from .tunnel import AgentTunnel -from .tg_tunnel import TelegramTunnel -from .email_tunnel import EmailTunnel -from .contact_manager import ContactManager,Contact,FamilyMember -from .text_to_speech_function import TextToSpeechFunction -from .image_2_text_function import Image2TextFunction -from .workspace_env import ShellEnvironment -from .local_stability_node import Local_Stability_ComputeNode -from .stability_node import Stability_ComputeNode -from .local_st_compute_node import LocalSentenceTransformer_Text_ComputeNode,LocalSentenceTransformer_Image_ComputeNode -from .compute_node_config import ComputeNodeConfig -from .ai_function import SimpleAIFunction -from .workspace_env import WorkspaceEnvironment -from .openai_tts_node import OpenAITTSComputeNode - -AIOS_Version = "0.5.2, build 2023-11-29" - diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index 8f39f71..aeff7b4 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -6,9 +6,7 @@ import sys import runpy from typing import Any, Callable, Dict, List, Optional, Union -from aios_kernel import AIAgent,AIAgentTemplete,AIStorage,Environment -from aios_kernel.agent_base import BaseAIAgent -from package_manager import PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask +from aios import AIAgent,AIAgentTemplete,AIStorage,Environment,BaseAIAgent,PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/email_tunnel.py b/src/component/email_tunnel.py similarity index 98% rename from src/aios_kernel/email_tunnel.py rename to src/component/email_tunnel.py index c0be9d2..c5e906a 100644 --- a/src/aios_kernel/email_tunnel.py +++ b/src/component/email_tunnel.py @@ -7,9 +7,8 @@ import mailparser import logging import time import datetime -from .tunnel import AgentTunnel -from .agent_base import AgentMsg -from .contact_manager import ContactManager + +from aios import AgentTunnel,AgentMsg,ContactManager from email.message import EmailMessage diff --git a/src/component/google_node/__init__.py b/src/component/google_node/__init__.py new file mode 100644 index 0000000..22ac89b --- /dev/null +++ b/src/component/google_node/__init__.py @@ -0,0 +1 @@ +from .google_text_to_speech_node import * \ No newline at end of file diff --git a/src/aios_kernel/google_text_to_speech_node.py b/src/component/google_node/google_text_to_speech_node.py similarity index 97% rename from src/aios_kernel/google_text_to_speech_node.py rename to src/component/google_node/google_text_to_speech_node.py index ccf9034..ecdb874 100644 --- a/src/aios_kernel/google_text_to_speech_node.py +++ b/src/component/google_node/google_text_to_speech_node.py @@ -7,9 +7,7 @@ from typing import Optional from google.cloud import texttospeech -from .storage import AIStorage -from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType -from .compute_node import ComputeNode +from aios import AIStorage,ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeNode logger = logging.getLogger(__name__) diff --git a/src/component/knowledge_manager/pipeline.py b/src/component/knowledge_manager/pipeline.py index efe0936..31b105d 100644 --- a/src/component/knowledge_manager/pipeline.py +++ b/src/component/knowledge_manager/pipeline.py @@ -2,7 +2,7 @@ import os import runpy import toml import asyncio -from knowledge import KnowledgePipelineEnvironment, KnowledgePipeline +from aios import KnowledgePipelineEnvironment, KnowledgePipeline class KnowledgePipelineManager: diff --git a/src/component/llama_node/__init__.py b/src/component/llama_node/__init__.py new file mode 100644 index 0000000..e6623f9 --- /dev/null +++ b/src/component/llama_node/__init__.py @@ -0,0 +1 @@ +from .local_llama_compute_node import LocalLlama_ComputeNode \ No newline at end of file diff --git a/src/aios_kernel/local_llama_compute_node.py b/src/component/llama_node/local_llama_compute_node.py similarity index 92% rename from src/aios_kernel/local_llama_compute_node.py rename to src/component/llama_node/local_llama_compute_node.py index 3b1fcb8..0a22369 100644 --- a/src/aios_kernel/local_llama_compute_node.py +++ b/src/component/llama_node/local_llama_compute_node.py @@ -5,9 +5,7 @@ import requests from typing import Optional, List from pydantic import BaseModel -from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskResultCode, ComputeTaskState, ComputeTaskType -from .queue_compute_node import Queue_ComputeNode -from .storage import AIStorage,UserConfig +from aios import ComputeTask,Queue_ComputeNode, ComputeTaskResult, ComputeTaskResultCode, ComputeTaskState, ComputeTaskType,AIStorage,UserConfig logger = logging.getLogger(__name__) @@ -121,18 +119,18 @@ class LocalLlama_ComputeNode(Queue_ComputeNode): "tool_choices": [], "max_tokens": 4000 } - - for fun in llm_inner_functions: - body["tools"].append({ - "type": "function", - "function": fun, - }) - body["tool_choices"].append({ - "type": "function", - "function": { - "name": fun["name"] - } - }) + if llm_inner_functions is not None: + for fun in llm_inner_functions: + body["tools"].append({ + "type": "function", + "function": fun, + }) + body["tool_choices"].append({ + "type": "function", + "function": { + "name": fun["name"] + } + }) for prompt in prompts: body["messages"].append({ diff --git a/src/component/mail_environment/issue.py b/src/component/mail_environment/issue.py index 246171e..0c55e7e 100644 --- a/src/component/mail_environment/issue.py +++ b/src/component/mail_environment/issue.py @@ -1,8 +1,7 @@ # define a knowledge base class import json import string -from aios_kernel import AIStorage, Environment, SimpleAIFunction, CustomAIAgent, AgentPrompt, AgentMsg -from knowledge import * +from aios import * from .mail import MailStorage, Mail class IssueState(Enum): diff --git a/src/component/mail_environment/local.py b/src/component/mail_environment/local.py index 6ddbfba..21f3a94 100644 --- a/src/component/mail_environment/local.py +++ b/src/component/mail_environment/local.py @@ -2,8 +2,7 @@ import os import logging import json import string -from knowledge import * -from aios_kernel.storage import AIStorage +from aios import * from .mail import Mail, MailStorage diff --git a/src/component/mail_environment/mail.py b/src/component/mail_environment/mail.py index 9f76c0b..ad9ba2e 100644 --- a/src/component/mail_environment/mail.py +++ b/src/component/mail_environment/mail.py @@ -7,7 +7,7 @@ import datetime from bs4 import BeautifulSoup import sqlite3 import html2text -from knowledge import * +from aios import * class Mail: def __init__(self, **kwargs) -> None: diff --git a/src/component/mail_environment/spider.py b/src/component/mail_environment/spider.py index 2502468..cc6586b 100644 --- a/src/component/mail_environment/spider.py +++ b/src/component/mail_environment/spider.py @@ -3,8 +3,7 @@ import logging import json import imaplib import mailparser -from knowledge import * -from aios_kernel.storage import AIStorage +from aios import * class EmailSpider: diff --git a/src/component/openai_node/__init__.py b/src/component/openai_node/__init__.py new file mode 100644 index 0000000..6453fbf --- /dev/null +++ b/src/component/openai_node/__init__.py @@ -0,0 +1,3 @@ +from .open_ai_node import * +from .openai_tts_node import * +from .whisper_node import * \ No newline at end of file diff --git a/src/aios_kernel/open_ai_node.py b/src/component/openai_node/open_ai_node.py similarity index 98% rename from src/aios_kernel/open_ai_node.py rename to src/component/openai_node/open_ai_node.py index cbe1885..1e595fd 100644 --- a/src/aios_kernel/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -9,10 +9,7 @@ import aiohttp import base64 import requests -from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeTaskResultCode -from .compute_node import ComputeNode -from .storage import AIStorage,UserConfig - +from aios import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeTaskResultCode,ComputeNode,AIStorage,UserConfig logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/openai_tts_node.py b/src/component/openai_node/openai_tts_node.py similarity index 96% rename from src/aios_kernel/openai_tts_node.py rename to src/component/openai_node/openai_tts_node.py index 36c80e2..f71ee41 100644 --- a/src/aios_kernel/openai_tts_node.py +++ b/src/component/openai_node/openai_tts_node.py @@ -4,7 +4,7 @@ import logging import os from asyncio import Queue -from aios_kernel import ComputeNode, ComputeTask, ComputeTaskState, ComputeTaskResult, ComputeTaskType, AIStorage +from aios import ComputeNode, ComputeTask, ComputeTaskState, ComputeTaskResult, ComputeTaskType, AIStorage logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/whisper_node.py b/src/component/openai_node/whisper_node.py similarity index 98% rename from src/aios_kernel/whisper_node.py rename to src/component/openai_node/whisper_node.py index f9dcee5..9b9cd37 100644 --- a/src/aios_kernel/whisper_node.py +++ b/src/component/openai_node/whisper_node.py @@ -13,9 +13,7 @@ from openai.cli._progress import BufferReader from pydub import AudioSegment from datetime import timedelta -from . import AIStorage -from .compute_node import ComputeNode -from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType +from aios import AIStorage,ComputeNode,ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType logger = logging.getLogger(__name__) @@ -24,7 +22,6 @@ SECONDS_IN_MINUTE = 60 HOURS_IN_DAY = 24 MICROSECONDS_IN_MILLISECOND = 1000 - def timedelta_to_vtt_timestamp(timedelta_timestamp): hrs, secs_remainder = divmod(timedelta_timestamp.seconds, SECONDS_IN_HOUR) hrs += timedelta_timestamp.days * HOURS_IN_DAY diff --git a/src/component/sd_node/__init__.py b/src/component/sd_node/__init__.py new file mode 100644 index 0000000..bd6d2b8 --- /dev/null +++ b/src/component/sd_node/__init__.py @@ -0,0 +1,2 @@ +from .local_stability_node import * +from .stability_node import * \ No newline at end of file diff --git a/src/aios_kernel/local_stability_node.py b/src/component/sd_node/local_stability_node.py similarity index 97% rename from src/aios_kernel/local_stability_node.py rename to src/component/sd_node/local_stability_node.py index d0d7f9d..6341590 100644 --- a/src/aios_kernel/local_stability_node.py +++ b/src/component/sd_node/local_stability_node.py @@ -9,9 +9,7 @@ import requests from typing import Tuple from pathlib import Path -from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType, ComputeTaskResultCode -from .compute_node import ComputeNode -from .storage import AIStorage, UserConfig +from aios import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeTaskResultCode,ComputeNode,AIStorage,UserConfig logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/stability_node.py b/src/component/sd_node/stability_node.py similarity index 97% rename from src/aios_kernel/stability_node.py rename to src/component/sd_node/stability_node.py index 2ea1f44..8c8cc9f 100644 --- a/src/aios_kernel/stability_node.py +++ b/src/component/sd_node/stability_node.py @@ -9,9 +9,7 @@ from PIL import Image from stability_sdk import client import stability_sdk.interfaces.gooseai.generation.generation_pb2 as generation -from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType, ComputeTaskResultCode -from .compute_node import ComputeNode -from .storage import AIStorage, UserConfig +from aios import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeTaskResultCode,ComputeNode,AIStorage,UserConfig logger = logging.getLogger(__name__) diff --git a/src/component/st_node/__init__.py b/src/component/st_node/__init__.py new file mode 100644 index 0000000..a1fd2e0 --- /dev/null +++ b/src/component/st_node/__init__.py @@ -0,0 +1 @@ +from .local_st_compute_node import * \ No newline at end of file diff --git a/src/aios_kernel/local_st_compute_node.py b/src/component/st_node/local_st_compute_node.py similarity index 97% rename from src/aios_kernel/local_st_compute_node.py rename to src/component/st_node/local_st_compute_node.py index 4142d5a..659d123 100644 --- a/src/aios_kernel/local_st_compute_node.py +++ b/src/component/st_node/local_st_compute_node.py @@ -5,9 +5,8 @@ from pydantic import BaseModel from typing import Union from PIL import Image import io -from .compute_task import ComputeTask, ComputeTaskState, ComputeTaskType,ComputeTaskResult,ComputeTaskResultCode -from .queue_compute_node import Queue_ComputeNode -from knowledge import ObjectID + +from aios import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeTaskResultCode,ComputeNode,AIStorage,UserConfig,ObjectID,Queue_ComputeNode logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/tg_tunnel.py b/src/component/tg_tunnel.py similarity index 98% rename from src/aios_kernel/tg_tunnel.py rename to src/component/tg_tunnel.py index eefc5fe..c0941ba 100644 --- a/src/aios_kernel/tg_tunnel.py +++ b/src/component/tg_tunnel.py @@ -10,13 +10,7 @@ from telegram import Bot from telegram.ext import Updater from telegram.error import Forbidden, NetworkError -from knowledge import ObjectType, KnowledgeStore - -from .tunnel import AgentTunnel -from .storage import AIStorage -from .contact_manager import ContactManager,Contact,FamilyMember -from .agent_base import AgentMsg,AgentMsgType - +from aios import ObjectType, KnowledgeStore,AgentTunnel,AIStorage,ContactManager,Contact,FamilyMember,AgentMsg,AgentMsgType logger = logging.getLogger(__name__) diff --git a/src/component/workflow_manager/workflow_manager.py b/src/component/workflow_manager/workflow_manager.py index 28bd523..923aa81 100644 --- a/src/component/workflow_manager/workflow_manager.py +++ b/src/component/workflow_manager/workflow_manager.py @@ -2,8 +2,7 @@ import logging import toml import os -from aios_kernel import Workflow,AIStorage -from package_manager import PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask +from aios import Workflow,AIStorage,PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask from agent_manager import AgentManager logger = logging.getLogger(__name__) diff --git a/src/node_daemon/__init__.py b/src/node_daemon/__init__.py new file mode 100644 index 0000000..66f65c6 --- /dev/null +++ b/src/node_daemon/__init__.py @@ -0,0 +1 @@ +# might implement by Rust in the future \ No newline at end of file diff --git a/src/readme.txt b/src/readme.txt new file mode 100644 index 0000000..a9a6729 --- /dev/null +++ b/src/readme.txt @@ -0,0 +1,34 @@ +#目录说明 +## aios +系统的主要内核部分 +### agent +智能体相关目录(包括agent和workflow),也是aios的关键抽象 + +### frame +aios的核心框架 + +### proto +非系统内部的,承诺长期兼容的协议定义 + +### enviroment +enviroment定义 +其它内置的基础环境实现,包含其它非LLM AI能力的function + +### knowledge +知识库相关实现 + +### storage +传统的存储组件 + +### net +基础网络库(主要是NDN,NON网络)的基础组件 + +## node_daemon +运行在host_os上,通过传统os概念控制aios的各个基础组件的启动。可以看成是aios的bios +aios是一个network os, 因此这个组件里还包含了最为基础的仓库实现,以支持各个组件的在线发布\安装\更新 + + +## component +可以按需加载的build-in组件 +## service +内置的service \ No newline at end of file diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 12d79e9..59220de 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -11,7 +11,6 @@ from logging.handlers import RotatingFileHandler from typing import Any, Optional, TypeVar, Tuple, Sequence import argparse - from prompt_toolkit import HTML, PromptSession, prompt,print_formatted_text from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.selection import SelectionState @@ -20,8 +19,6 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import WordCompleter from prompt_toolkit.styles import Style - - directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') @@ -30,15 +27,23 @@ sys.path.append(directory + '/../../') # os.environ['HTTPS_PROXY'] = '127.0.0.1:10809' import proxy -from aios_kernel import * -from knowledge import * - - +from aios import * sys.path.append(directory + '/../../component/') + +from google_node import * +from llama_node import * +from openai_node import * +from sd_node import * +from st_node import * + from agent_manager import AgentManager from workflow_manager import WorkflowManager from knowledge_manager import KnowledgePipelineManager +from tg_tunnel import TelegramTunnel +from email_tunnel import EmailTunnel + +from compute_node_config import * logger = logging.getLogger(__name__) diff --git a/src/aios_kernel/compute_node_config.py b/src/service/aios_shell/compute_node_config.py similarity index 92% rename from src/aios_kernel/compute_node_config.py rename to src/service/aios_shell/compute_node_config.py index c3b2c96..6528d74 100644 --- a/src/aios_kernel/compute_node_config.py +++ b/src/service/aios_shell/compute_node_config.py @@ -1,4 +1,5 @@ """ +compute node config Configuration for nodes: ``` @@ -14,12 +15,18 @@ Configuration for nodes: """ import logging from typing import List +import sys import os import toml -from .local_llama_compute_node import LocalLlama_ComputeNode -from .storage import AIStorage + +from aios import AIStorage +directory = os.path.dirname(__file__) +sys.path.append(directory + '/../../component/') + +from llama_node import LocalLlama_ComputeNode + # define singleton class knowledge pipline class ComputeNodeConfig: diff --git a/src/service/aios_shell/proxy.py b/src/service/aios_shell/proxy.py index 2ed8a82..208c1a6 100644 --- a/src/service/aios_shell/proxy.py +++ b/src/service/aios_shell/proxy.py @@ -9,7 +9,7 @@ import logging directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') -from aios_kernel import AIStorage +from aios import AIStorage logger = logging.getLogger(__name__) diff --git a/src/service/app_manager/README b/src/service/app_manager/README deleted file mode 100644 index 30404ce..0000000 --- a/src/service/app_manager/README +++ /dev/null @@ -1 +0,0 @@ -TODO \ No newline at end of file From 83149b55908ca7423a3017e269fd23e2d10b48dd Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Thu, 30 Nov 2023 21:14:24 -0800 Subject: [PATCH 37/79] fix merge bugs. --- src/component/openai_node/__init__.py | 3 +- .../openai_node}/dall_e_compute_node.py | 4 +-- test/test_workspace.py | 28 +++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) rename src/{aios/environment => component/openai_node}/dall_e_compute_node.py (95%) create mode 100644 test/test_workspace.py diff --git a/src/component/openai_node/__init__.py b/src/component/openai_node/__init__.py index 6453fbf..40c265a 100644 --- a/src/component/openai_node/__init__.py +++ b/src/component/openai_node/__init__.py @@ -1,3 +1,4 @@ from .open_ai_node import * from .openai_tts_node import * -from .whisper_node import * \ No newline at end of file +from .whisper_node import * +from .dall_e_compute_node import * \ No newline at end of file diff --git a/src/aios/environment/dall_e_compute_node.py b/src/component/openai_node/dall_e_compute_node.py similarity index 95% rename from src/aios/environment/dall_e_compute_node.py rename to src/component/openai_node/dall_e_compute_node.py index 8e63e03..a1ffaa6 100644 --- a/src/aios/environment/dall_e_compute_node.py +++ b/src/component/openai_node/dall_e_compute_node.py @@ -9,9 +9,7 @@ import base64 from PIL import Image -from .compute_task import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType, ComputeTaskResultCode -from .compute_node import ComputeNode -from .storage import AIStorage, UserConfig +from aios import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType, ComputeTaskResultCode,ComputeNode, AIStorage, UserConfig logger = logging.getLogger(__name__) diff --git a/test/test_workspace.py b/test/test_workspace.py new file mode 100644 index 0000000..62929d7 --- /dev/null +++ b/test/test_workspace.py @@ -0,0 +1,28 @@ +import sys +import os +import logging +import asyncio +import time +import unittest + +dir_path = os.path.dirname(os.path.realpath(__file__)) + +sys.path.append("{}/../src/".format(dir_path)) + +from aios_kernel import WorkspaceEnvironment + +async def test_workspace(): + test_env = WorkspaceEnvironment("test") + test_env._add_document_dir(f"{dir_path}/../rootfs/test_doc") + test_env._start_scan_document() + catalogs = await test_env.get_knowledege_catalog() + print(catalogs) + asyncio.sleep(60*60) + + +if __name__ == "__main__": + asyncio.run(test_workspace()) + print("OK!") + time.sleep(60*60) + + From 3dc4fe8e4ccac3f21d1edc06b699206d52c94f6b Mon Sep 17 00:00:00 2001 From: Song Date: Mon, 4 Dec 2023 10:39:56 +0800 Subject: [PATCH 38/79] Fix the dall e invocation process --- rootfs/agents/David/agent.toml | 2 ++ src/aios/agent/agent_base.py | 2 ++ src/aios/environment/workflow_env.py | 6 +++--- src/aios/frame/compute_kernel.py | 4 ++-- src/component/openai_node/dall_e_compute_node.py | 4 ++-- src/service/aios_shell/aios_shell.py | 7 +++++++ test/test_dall_e_node.py | 4 ++-- 7 files changed, 20 insertions(+), 9 deletions(-) diff --git a/rootfs/agents/David/agent.toml b/rootfs/agents/David/agent.toml index df8c308..0e5341f 100644 --- a/rootfs/agents/David/agent.toml +++ b/rootfs/agents/David/agent.toml @@ -19,4 +19,6 @@ For example, if a user says, "I want a painting that depicts a sunset on a summe Then you will combine these pieces of information into an accurate prompt to pass to the "paint" function, for example: "During a summer sunset, children play on the beach with sailboats visible in the distance, and the sky displays a gradient of orange and purple." +After successfully creating the image, please clearly state the file path where the image is saved in your response. For instance, you might say, "The image has been successfully created and saved at the following path: /path/to/the/image.png." + Please always ensure that your creations respect the user's intentions and that you infuse your unique artistic style and creativity into your work.""" diff --git a/src/aios/agent/agent_base.py b/src/aios/agent/agent_base.py index 2977bc8..15c738e 100644 --- a/src/aios/agent/agent_base.py +++ b/src/aios/agent/agent_base.py @@ -477,6 +477,8 @@ class BaseAIAgent(abc.ABC): org_msg:AgentMsg, stack_limit = 5 ) -> ComputeTaskResult: + from ..frame.compute_kernel import ComputeKernel + arguments = None try: func_name = inner_func_call_node.get("name") diff --git a/src/aios/environment/workflow_env.py b/src/aios/environment/workflow_env.py index c8f8448..52b5858 100644 --- a/src/aios/environment/workflow_env.py +++ b/src/aios/environment/workflow_env.py @@ -319,9 +319,9 @@ class PaintEnvironment(Environment): async def _paint(self, prompt, model_name = None, negative_prompt = None) -> str: - err, result = await ComputeKernel.get_instance().do_text_2_image(prompt, model_name, negative_prompt) - if err is not None: - return f"exec paint failed. err:{err}" + result = await ComputeKernel.get_instance().do_text_2_image(prompt, model_name, negative_prompt) + if result.result_code == ComputeTaskResultCode.ERROR: + return f"exec paint failed. err:{result.error_str}" else: return f'exec paint OK, saved as a local file, path is: {result.result["file"]}' diff --git a/src/aios/frame/compute_kernel.py b/src/aios/frame/compute_kernel.py index 229819a..45670cc 100644 --- a/src/aios/frame/compute_kernel.py +++ b/src/aios/frame/compute_kernel.py @@ -240,9 +240,9 @@ class ComputeKernel: async def do_text_2_image(self, prompt:str, model_name:Optional[str] = None, negative_prompt = None) -> ComputeTaskResult: task = self.text_2_image(prompt,model_name, negative_prompt) - task = await self._wait_task(task) + task_result = await self._wait_task(task) - return task.result + return task_result # if task_req.state == ComputeTaskState.DONE: # return None, task_result diff --git a/src/component/openai_node/dall_e_compute_node.py b/src/component/openai_node/dall_e_compute_node.py index a1ffaa6..eee4a0b 100644 --- a/src/component/openai_node/dall_e_compute_node.py +++ b/src/component/openai_node/dall_e_compute_node.py @@ -14,13 +14,13 @@ from aios import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskTy logger = logging.getLogger(__name__) -class DallE_ComputeNode(ComputeNode): +class DallEComputeNode(ComputeNode): _instance = None @classmethod def get_instance(cls): if cls._instance is None: - cls._instance = DallE_ComputeNode() + cls._instance = DallEComputeNode() return cls._instance @classmethod diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 59220de..0ac7a81 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -157,6 +157,13 @@ class AIOS_Shell: openai_tts_node = OpenAITTSComputeNode.get_instance() ComputeKernel.get_instance().add_compute_node(openai_tts_node) + dall_e_node = DallEComputeNode.get_instance() + if await dall_e_node.initial() is not True: + logger.error("dall-e node initial failed!") + else: + await dall_e_node.start() + ComputeKernel.get_instance().add_compute_node(dall_e_node) + llama_nodes = ComputeNodeConfig.get_instance().initial() for llama_node in llama_nodes: llama_node.start() diff --git a/test/test_dall_e_node.py b/test/test_dall_e_node.py index 37c1d62..04c826d 100644 --- a/test/test_dall_e_node.py +++ b/test/test_dall_e_node.py @@ -8,7 +8,7 @@ import logging import pytest directory = os.path.dirname(__file__) sys.path.append(directory + '/../src') -from aios_kernel.dall_e_compute_node import DallE_ComputeNode +from aios_kernel.dall_e_compute_node import DallEComputeNode from aios_kernel.compute_task import ComputeTaskType, ComputeTask, ComputeTaskState os.environ["TEXT2IMG_OUTPUT_DIR"] = "./" @@ -16,7 +16,7 @@ os.environ["openai_api_key"] = "" @pytest.mark.asyncio async def test_dall_e_node(propmt, model): - node = DallE_ComputeNode.get_instance() + node = DallEComputeNode.get_instance() if await node.initial() is not True: print("node initial failed!") return From a00170675226ab93ac13d3b7cf146e7a0f21eade Mon Sep 17 00:00:00 2001 From: streetycat <305190374@qq.com> Date: Fri, 1 Dec 2023 09:26:55 +0000 Subject: [PATCH 39/79] Compute Node Installation Wizard Compute Node Installation Wizardx# Date: Fri Dec 1 09:26:55 2023 +0000 --- src/aios/storage/storage.py | 9 +- src/service/aios_shell/aios_shell.py | 42 +-- .../local_compute_node_builder/__init__.py | 30 +++ .../local_compute_node_builder.py | 39 +++ .../local_llama_node_builder.py | 248 ++++++++++++++++++ 5 files changed, 336 insertions(+), 32 deletions(-) create mode 100644 src/service/aios_shell/local_compute_node_builder/__init__.py create mode 100644 src/service/aios_shell/local_compute_node_builder/local_compute_node_builder.py create mode 100644 src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py diff --git a/src/aios/storage/storage.py b/src/aios/storage/storage.py index fe0b3fd..11edd29 100644 --- a/src/aios/storage/storage.py +++ b/src/aios/storage/storage.py @@ -216,6 +216,13 @@ class AIStorage: ~/myai/ """ return Path.home() / "myai" + + def get_download_dir(self) -> str: + """ + download dir is the dir for user to store the files downloaded with the system. + ~/myai/download + """ + return f"{self.get_myai_dir()}/download" def get_db(self,app_name:str)->ResourceLocation: pass @@ -242,5 +249,3 @@ class AIStorage: except Exception as e: logger.error(f"open or create file {path} failed! {str(e)}") - - diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 0ac7a81..b61b906 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -28,6 +28,7 @@ sys.path.append(directory + '/../../') import proxy from aios import * +import local_compute_node_builder sys.path.append(directory + '/../../component/') @@ -402,41 +403,22 @@ class AIOS_Shell: async def handle_node_commands(self, args): show_text = FormattedText([("class:title", "sub command not support!\n" - "/node add llama $model_name $url\n" - "/node rm llama $model_name $url\n" + "/node add\n" + "/node rm $model_name $url\n" "/node list\n")]) if len(args) < 1: return show_text sub_cmd = args[0] if sub_cmd == "add": - if len(args) < 2: - return show_text - if args[1] == "llama": - if len(args) < 4: - return show_text - - model_name = args[2] - url = args[3] - ComputeNodeConfig.get_instance().add_node("llama", url, model_name) - ComputeNodeConfig.get_instance().save() - node = LocalLlama_ComputeNode(url, model_name) - node.start() - ComputeKernel.get_instance().add_compute_node(node) - else: - return show_text + await local_compute_node_builder.build(session, shell_style) elif sub_cmd == "rm": - if len(args) < 2: - return show_text - if args[1] == "llama": - if len(args) < 4: - return show_text - - model_name = args[2] - url = args[3] - ComputeNodeConfig.get_instance().remove_node("llama", url, model_name) - ComputeNodeConfig.get_instance().save() - else: + if len(args) < 3: return show_text + + model_name = args[1] + url = args[2] + ComputeNodeConfig.get_instance().remove_node("llama", url, model_name) + ComputeNodeConfig.get_instance().save() elif sub_cmd == "list": print_formatted_text(ComputeNodeConfig.get_instance().list()) @@ -785,8 +767,8 @@ async def main(): '/set_config $key', '/enable $feature', '/disable $feature', - '/node add llama $model_name $url', - '/node rm llama $model_name $url', + '/node add', + '/node rm $model_name $url', '/node list', '/show', '/exit', diff --git a/src/service/aios_shell/local_compute_node_builder/__init__.py b/src/service/aios_shell/local_compute_node_builder/__init__.py new file mode 100644 index 0000000..cb84ca9 --- /dev/null +++ b/src/service/aios_shell/local_compute_node_builder/__init__.py @@ -0,0 +1,30 @@ +from prompt_toolkit import PromptSession +from prompt_toolkit.styles import Style +from service.aios_shell.local_compute_node_builder.local_llama_node_builder import LocalLlamaNodeBuilder +from .local_compute_node_builder import BuilderState, LocalComputeNodeBuilder + +async def build(prompt_session: PromptSession, shell_style: Style) -> str or None: + # model_type = await prompt_session.prompt_async(f"Please select the node server type (default: llama.cpp):", style = shell_style) + + model_type = 'llama.cpp' + + state = BuilderState(prompt_session, shell_style) + + match model_type: + case 'llama.cpp': + builder = LocalLlamaNodeBuilder(state) + + while True: + param = builder.next_parameter() + if param is None: + return None + + value = await state.prompt_session.prompt_async(f"{state.last_result_prompt}{param.desc}:", style = state.shell_style) + if value: + value = value.strip() + + state.params[param.name] = value + url = await param.applier.apply(state, param.name, value) + + if url is not None: + return url \ No newline at end of file diff --git a/src/service/aios_shell/local_compute_node_builder/local_compute_node_builder.py b/src/service/aios_shell/local_compute_node_builder/local_compute_node_builder.py new file mode 100644 index 0000000..7d9bc3d --- /dev/null +++ b/src/service/aios_shell/local_compute_node_builder/local_compute_node_builder.py @@ -0,0 +1,39 @@ +from abc import abstractmethod + +from prompt_toolkit import PromptSession +from prompt_toolkit.styles import Style + +class BuilderState: + def __init__(self, prompt_session: PromptSession, shell_style: Style): + self.prompt_session = prompt_session + self.shell_style = shell_style + self.next_step = 0 + self.last_result_prompt = "" + self.params = {} + +# class ApplyResult: +# def __init__(self, next_step: any, url: str or None = None, result_prompt: str or None = None) -> None: +# self.next_step = next_step +# self.url = url +# self.result_prompt = result_prompt + + +class ParameterApplier: + @abstractmethod + async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: + pass + +class BuildParameter: + def __init__(self, name: str, applier: ParameterApplier, desc: str or None = None, default_value: str or None = None): + self.name = name + self.desc = desc + self.default_value = default_value + self.applier = applier + +class LocalComputeNodeBuilder: + def __init__(self, state: BuilderState) -> None: + self.state = state + + @abstractmethod + def next_parameter(self) -> BuildParameter or None: + pass \ No newline at end of file diff --git a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py new file mode 100644 index 0000000..25b7b14 --- /dev/null +++ b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py @@ -0,0 +1,248 @@ +import os +import random +import subprocess +import requests + +from prompt_toolkit import print_formatted_text +from prompt_toolkit.shortcuts import ProgressBar +from prompt_toolkit.formatted_text import FormattedText +from aios_kernel.compute_kernel import ComputeKernel +from aios_kernel.compute_node_config import ComputeNodeConfig +from aios_kernel.local_llama_compute_node import LocalLlama_ComputeNode + +from aios_kernel.storage import AIStorage +from .local_compute_node_builder import BuildParameter, BuilderState, LocalComputeNodeBuilder, ParameterApplier + +class BuildParameterModelPath: + async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: + if value: + if os.path.exists(value): + state.next_step += 2 + else: + print_formatted_text(FormattedText([("class:error", f"Model not exist at {value}")]), style = state.shell_style) + else: + state.next_step += 1 + + +class BuildParameterModelUrl: + async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: + if value is None: + value = "1" + + url = value + recommend = _recommend_model_urls.get(value) + if recommend: + url = recommend["url"] + + save_path = f"{AIStorage.get_instance().get_download_dir()}/{url.split('/').pop()}" + + print_formatted_text(FormattedText([("class:prompt", f"Will save the model to {save_path}:\n")]), style = state.shell_style) + + try: + # get file size + response = requests.head(url) + file_size = int(response.headers.get('content-length', 0)) + + # start download + response = requests.get(url, stream=True) + + if response.status_code == 200: + with open(save_path, 'wb') as f, ProgressBar() as pb: + for data in pb(response.iter_content(1024), total = (file_size + 1023) // 1024): + f.write(data) + + print_formatted_text(FormattedText([("class:prompt", f"Download model success, save at: {save_path}\n")]), style = state.shell_style) + + state.params["model_path"] = save_path + state.next_step += 1 + else: + print_formatted_text(FormattedText([("class:error", f"Download model failed, error: {response.status_code}\nYou can retry it or select another one.")]), style = state.shell_style) + + except Exception as e: + print_formatted_text(FormattedText([("class:error", f"Download model failed: {e}\nYou can retry it or select another one.")]), style = state.shell_style) + +class ParameterNodeNameApplier: + async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: + value = value or os.path.basename(state.params["model_path"]) + state.params["node_name"] = value + state.next_step += 1 + +class ParameterPortApplier: + async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: + if value is None or value == "0": + value = str(random.randint(10000, 60000)) + + state.params["port"] = value + state.next_step += 1 + +class ParameterNGpuLayersApplier: + async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: + value = value or "83" + state.params["n_gpu_layers"] = value + state.next_step += 1 + +class ParameterNCtxApplier: + async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: + value = value or "4096" + state.params["n_ctx"] = value + state.next_step += 1 + +class ParameterChatFormatApplier: + async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: + value = value or "llama-2" + state.params["chat_format"] = value + state.next_step += 1 + +class ParameterExternParamsApplier: + async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: + extern_params = value + docker_image = "" + gpu_options = None + + if state.params["n_gpu_layers"] == "0": + docker_image = "ghcr.io/abetlen/llama-cpp-python:latest" + else: + gpu_options = "--gpus all" + llama_cpp_python_repo_url = "https://github.com/abetlen/llama-cpp-python.git" + download_path = AIStorage.get_instance().get_download_dir() + llama_cpp_python_path = download_path + "/llama-cpp-python" + + # update the `llama-cpp-python` + retry = True + while retry: + retry = False + result = None + if os.path.exists(llama_cpp_python_path): + result = subprocess.run(['git', 'pull'], cwd = llama_cpp_python_path, stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) + else: + result = subprocess.run(['git', 'clone', llama_cpp_python_repo_url, download_path], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) + + if result.stderr: + while True: + sel = await state.prompt_session.prompt_async(f"Update 'llama-cpp-python' failed, you can press 'r' to retry, or 'c' to continue with the current version.", style = state.shell_style) + if sel == 'r': + retry = True + break + elif sel == 'c': + break + else: + pass # Select again + else: + break + + # build the image + docker_image = 'llama-cpp-python-cuda' + retry = True + while retry: + retry = False + result = subprocess.run(['docker', 'rmi', docker_image], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) + result = subprocess.run(['docker', 'build', '-t', docker_image, f"{llama_cpp_python_path}/docker/cuda_simple/"], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) + + if result.stderr: + while True: + sel = await state.prompt_session.prompt_async(f"Build the image failed, you can press 'r' to retry, or 'c' to continue with the current version.", style = state.shell_style) + if sel == 'r': + retry = True + break + elif sel == 'c': + break + else: + pass # Select again + else: + break + + retry = True + while True: + retry = False + run_options = ['docker', 'run', '-d'] + + if gpu_options: + run_options.append(gpu_options) + + run_options.extend([ + '-p', f"{state.params['port']}:8000", + '-v', f"{os.path.dirname(state.params['model_path'])}:/models", '-e', f"MODEL=/models/{os.path.basename(state.params['model_path'])}", + 'llama-cpp-python-cuda', + 'python3', '-m', 'llama_cpp.server', + '--n_gpu_layers', state.params["n_gpu_layers"], + '--n_ctx', state.params["n_ctx"], + '--chat_format', state.params["chat_format"], + ]) + + if extern_params: + run_options.extend(extern_params.split(' ')) + + result = subprocess.run(run_options, stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) + + if result.stderr: + while True: + sel = await state.prompt_session.prompt_async(f"Start the node service failed, you can press 'r' to retry, or 'a' to abort.", style = state.shell_style) + if sel == 'r': + retry = True + break + elif sel == 'a': + break + else: + pass # Select again + else: + local_url = f'http://localhost:{state.params["port"]}' + foreign_url = 'http://{your-host-address}:' + state.params["port"] + model_name = state.params['node_name'] + + ComputeNodeConfig.get_instance().add_node("llama", local_url, model_name) + ComputeNodeConfig.get_instance().save() + node = LocalLlama_ComputeNode(local_url, model_name) + node.start() + ComputeKernel.get_instance().add_compute_node(node) + + print_formatted_text(FormattedText([( + "class:prompt", + f""" + Congratulations! The node ({model_name}) service successed. + You can access it with follow url: + {local_url} + And 'http://{foreign_url}' in other computers. + Now you can refer it in agents as `llm_model_name={model_name}` + """ + )]), style = state.shell_style) + break + +_recommend_model_urls = { + "1": { + "model": "Llama-2-70B-chat", + "url": "https://huggingface.co/TheBloke/Llama-2-70B-chat-GGUF/resolve/main/llama-2-70b-chat.Q4_0.gguf" + }, + "2": { + "model": "Llama-2-13B-chat", + "url": "https://huggingface.co/TheBloke/Llama-2-13B-chat-GGUF/resolve/main/llama-2-13b-chat.Q4_0.gguf" + }, + "3": { + "model": "Llama-2-7B-Chat-GGUF", + "url": "https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/blob/main/llama-2-7b-chat.Q4_K_M.gguf" + }, +} + +_recommend_model_url_table_str = map(lambda id, info: f"\t{id}\t{info['model']}\t{info['url']}\n", _recommend_model_urls) + +_params = [ + BuildParameter("model_path", BuildParameterModelPath(), "Please input the model file path (Press 'Enter' if you need to download it)"), + BuildParameter("model_url", BuildParameterModelUrl(), + f""" + Please input the url to download the model, or you can input the 'ID' in the follow table to select one: + ID\tmodel\turl + {_recommend_model_url_table_str} + Please input (default: Llama-2-70B-chat) + """ + ), + BuildParameter("node_name", ParameterNodeNameApplier(), "Please input name for your node, and you can set it in 'llm_model_name' of 'agent.toml' (default: the name of the model file)"), + BuildParameter("port", ParameterPortApplier(), "Please input the port which the node server will listen on (default: random)"), + BuildParameter("n_gpu_layers", ParameterNGpuLayersApplier(), "Please input layers offload to GPU (<=83 for Llama, 0 for CPU only, default: 83)"), + BuildParameter("n_ctx", ParameterNCtxApplier(), "Please input the content limit (default: 4096)"), + BuildParameter("chat_format", ParameterChatFormatApplier(), "Please input the chat format (default: llama-2)"), + BuildParameter("extern_params", ParameterExternParamsApplier(), "Please input other parameters refer to 'llama-cpp-python'(https://github.com/abetlen/llama-cpp-python), press 'Enter' to ignore it"), +] + +class LocalLlamaNodeBuilder(LocalComputeNodeBuilder): + def next_parameter(self) -> BuildParameter or None: + if self.state.next_step < len(_params): + return _params[self.state.next_step] From 15046f02544267cb1f0fee9e4cbb1f69fa5bb7bc Mon Sep 17 00:00:00 2001 From: streetycat <305190374@qq.com> Date: Mon, 4 Dec 2023 08:15:28 +0000 Subject: [PATCH 40/79] fix bugs for interactive of /node add --- .../llama_node/local_llama_compute_node.py | 3 - .../local_compute_node_builder/__init__.py | 12 +- .../local_compute_node_builder.py | 3 +- .../local_llama_node_builder.py | 138 +++++++++--------- 4 files changed, 84 insertions(+), 72 deletions(-) diff --git a/src/component/llama_node/local_llama_compute_node.py b/src/component/llama_node/local_llama_compute_node.py index 0a22369..efb9712 100644 --- a/src/component/llama_node/local_llama_compute_node.py +++ b/src/component/llama_node/local_llama_compute_node.py @@ -1,9 +1,6 @@ -import json import logging import requests -from typing import Optional, List -from pydantic import BaseModel from aios import ComputeTask,Queue_ComputeNode, ComputeTaskResult, ComputeTaskResultCode, ComputeTaskState, ComputeTaskType,AIStorage,UserConfig diff --git a/src/service/aios_shell/local_compute_node_builder/__init__.py b/src/service/aios_shell/local_compute_node_builder/__init__.py index cb84ca9..4fb1d16 100644 --- a/src/service/aios_shell/local_compute_node_builder/__init__.py +++ b/src/service/aios_shell/local_compute_node_builder/__init__.py @@ -1,13 +1,19 @@ -from prompt_toolkit import PromptSession +import os +from prompt_toolkit import HTML, PromptSession, print_formatted_text from prompt_toolkit.styles import Style from service.aios_shell.local_compute_node_builder.local_llama_node_builder import LocalLlamaNodeBuilder from .local_compute_node_builder import BuilderState, LocalComputeNodeBuilder +from aios_kernel.storage import AIStorage async def build(prompt_session: PromptSession, shell_style: Style) -> str or None: # model_type = await prompt_session.prompt_async(f"Please select the node server type (default: llama.cpp):", style = shell_style) model_type = 'llama.cpp' + download_dir = AIStorage.get_instance().get_download_dir() + if not os.path.exists(download_dir): + os.mkdir(download_dir) + state = BuilderState(prompt_session, shell_style) match model_type: @@ -19,7 +25,9 @@ async def build(prompt_session: PromptSession, shell_style: Style) -> str or Non if param is None: return None - value = await state.prompt_session.prompt_async(f"{state.last_result_prompt}{param.desc}:", style = state.shell_style) + if state.last_result_prompt or param.desc: + print_formatted_text(f"{state.last_result_prompt}{param.desc}", style = state.shell_style) + value = await state.prompt_session.prompt_async(f"{param.prompt}:", style = state.shell_style) if value: value = value.strip() diff --git a/src/service/aios_shell/local_compute_node_builder/local_compute_node_builder.py b/src/service/aios_shell/local_compute_node_builder/local_compute_node_builder.py index 7d9bc3d..33e1e36 100644 --- a/src/service/aios_shell/local_compute_node_builder/local_compute_node_builder.py +++ b/src/service/aios_shell/local_compute_node_builder/local_compute_node_builder.py @@ -24,8 +24,9 @@ class ParameterApplier: pass class BuildParameter: - def __init__(self, name: str, applier: ParameterApplier, desc: str or None = None, default_value: str or None = None): + def __init__(self, name: str, applier: ParameterApplier, prompt: str or None = None, desc: str or None = None, default_value: str or None = None): self.name = name + self.prompt = prompt self.desc = desc self.default_value = default_value self.applier = applier diff --git a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py index 25b7b14..5d818af 100644 --- a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py +++ b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py @@ -97,12 +97,13 @@ class ParameterExternParamsApplier: async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: extern_params = value docker_image = "" - gpu_options = None + gpu_options = [] + state.next_step += 1 if state.params["n_gpu_layers"] == "0": docker_image = "ghcr.io/abetlen/llama-cpp-python:latest" else: - gpu_options = "--gpus all" + gpu_options = ["--gpus", "all"] llama_cpp_python_repo_url = "https://github.com/abetlen/llama-cpp-python.git" download_path = AIStorage.get_instance().get_download_dir() llama_cpp_python_path = download_path + "/llama-cpp-python" @@ -115,9 +116,10 @@ class ParameterExternParamsApplier: if os.path.exists(llama_cpp_python_path): result = subprocess.run(['git', 'pull'], cwd = llama_cpp_python_path, stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) else: - result = subprocess.run(['git', 'clone', llama_cpp_python_repo_url, download_path], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) + result = subprocess.run(['git', 'clone', llama_cpp_python_repo_url, llama_cpp_python_path], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) if result.stderr: + print_formatted_text(FormattedText([("class:warn", result.stderr)]), style = state.shell_style) while True: sel = await state.prompt_session.prompt_async(f"Update 'llama-cpp-python' failed, you can press 'r' to retry, or 'c' to continue with the current version.", style = state.shell_style) if sel == 'r': @@ -139,6 +141,7 @@ class ParameterExternParamsApplier: result = subprocess.run(['docker', 'build', '-t', docker_image, f"{llama_cpp_python_path}/docker/cuda_simple/"], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) if result.stderr: + print_formatted_text(FormattedText([("class:warn", result.stderr)]), style = state.shell_style) while True: sel = await state.prompt_session.prompt_async(f"Build the image failed, you can press 'r' to retry, or 'c' to continue with the current version.", style = state.shell_style) if sel == 'r': @@ -151,89 +154,92 @@ class ParameterExternParamsApplier: else: break - retry = True - while True: - retry = False - run_options = ['docker', 'run', '-d'] + retry = True + while retry: + retry = False + run_options = ['docker', 'run', '-d'] - if gpu_options: - run_options.append(gpu_options) - - run_options.extend([ - '-p', f"{state.params['port']}:8000", - '-v', f"{os.path.dirname(state.params['model_path'])}:/models", '-e', f"MODEL=/models/{os.path.basename(state.params['model_path'])}", - 'llama-cpp-python-cuda', - 'python3', '-m', 'llama_cpp.server', - '--n_gpu_layers', state.params["n_gpu_layers"], - '--n_ctx', state.params["n_ctx"], - '--chat_format', state.params["chat_format"], - ]) - - if extern_params: - run_options.extend(extern_params.split(' ')) + if gpu_options: + run_options.extend(gpu_options) + + run_options.extend([ + '-p', f"{state.params['port']}:8000", + '-v', f"{os.path.dirname(state.params['model_path'])}:/models", '-e', f"MODEL=/models/{os.path.basename(state.params['model_path'])}", + 'llama-cpp-python-cuda', + 'python3', '-m', 'llama_cpp.server', + '--n_gpu_layers', state.params["n_gpu_layers"], + '--n_ctx', state.params["n_ctx"], + '--chat_format', state.params["chat_format"], + ]) + + if extern_params: + run_options.extend(extern_params.split(' ')) - result = subprocess.run(run_options, stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) + print_formatted_text(FormattedText([("class:prompt", f"Will start service with: {' '.join(run_options)}")]), style = state.shell_style) - if result.stderr: - while True: - sel = await state.prompt_session.prompt_async(f"Start the node service failed, you can press 'r' to retry, or 'a' to abort.", style = state.shell_style) - if sel == 'r': - retry = True - break - elif sel == 'a': - break - else: - pass # Select again - else: - local_url = f'http://localhost:{state.params["port"]}' - foreign_url = 'http://{your-host-address}:' + state.params["port"] - model_name = state.params['node_name'] + result = subprocess.run(run_options, stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) - ComputeNodeConfig.get_instance().add_node("llama", local_url, model_name) - ComputeNodeConfig.get_instance().save() - node = LocalLlama_ComputeNode(local_url, model_name) - node.start() - ComputeKernel.get_instance().add_compute_node(node) + if result.stderr: + print_formatted_text(FormattedText([("class:warn", result.stderr)]), style = state.shell_style) + while True: + sel = await state.prompt_session.prompt_async(f"Start the node service failed, you can press 'r' to retry, or 'a' to abort.", style = state.shell_style) + if sel == 'r': + retry = True + break + elif sel == 'a': + break + else: + pass # Select again + else: + local_url = f'http://localhost:{state.params["port"]}' + foreign_url = 'http://{your-host-address}:' + state.params["port"] + model_name = state.params['node_name'] + + ComputeNodeConfig.get_instance().add_node("llama", local_url, model_name) + ComputeNodeConfig.get_instance().save() + node = LocalLlama_ComputeNode(local_url, model_name) + node.start() + ComputeKernel.get_instance().add_compute_node(node) + + print_formatted_text(FormattedText([( + "class:prompt", +f""" +Congratulations! The node ({model_name}) service successed. +You can access it with follow url: +{local_url} +And 'http://{foreign_url}' in other computers. +Now you can refer it in agents as `llm_model_name={model_name}` +""" + )]), style = state.shell_style) + break - print_formatted_text(FormattedText([( - "class:prompt", - f""" - Congratulations! The node ({model_name}) service successed. - You can access it with follow url: - {local_url} - And 'http://{foreign_url}' in other computers. - Now you can refer it in agents as `llm_model_name={model_name}` - """ - )]), style = state.shell_style) - break - _recommend_model_urls = { "1": { - "model": "Llama-2-70B-chat", + "model": "Llama-2-70B-Chat-GGUF", "url": "https://huggingface.co/TheBloke/Llama-2-70B-chat-GGUF/resolve/main/llama-2-70b-chat.Q4_0.gguf" }, "2": { - "model": "Llama-2-13B-chat", + "model": "Llama-2-13B-Chat-GGUF", "url": "https://huggingface.co/TheBloke/Llama-2-13B-chat-GGUF/resolve/main/llama-2-13b-chat.Q4_0.gguf" }, "3": { "model": "Llama-2-7B-Chat-GGUF", - "url": "https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/blob/main/llama-2-7b-chat.Q4_K_M.gguf" + "url": "https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf" }, } -_recommend_model_url_table_str = map(lambda id, info: f"\t{id}\t{info['model']}\t{info['url']}\n", _recommend_model_urls) +_recommend_model_url_table_str = "" +for i in range(1, 999): + id = str(i) + info = _recommend_model_urls.get(id) + if info: + _recommend_model_url_table_str += f"\n\t{id}\t{info['model']}\t{info['url']}" + else: + break _params = [ BuildParameter("model_path", BuildParameterModelPath(), "Please input the model file path (Press 'Enter' if you need to download it)"), - BuildParameter("model_url", BuildParameterModelUrl(), - f""" - Please input the url to download the model, or you can input the 'ID' in the follow table to select one: - ID\tmodel\turl - {_recommend_model_url_table_str} - Please input (default: Llama-2-70B-chat) - """ - ), + BuildParameter("model_url", BuildParameterModelUrl(), "Please input (default: Llama-2-70B-chat)", f"Now you need input the url to download the model, or you can input the 'ID' in the follow table to select one:\n\tID\tmodel\t\turl{_recommend_model_url_table_str}"), BuildParameter("node_name", ParameterNodeNameApplier(), "Please input name for your node, and you can set it in 'llm_model_name' of 'agent.toml' (default: the name of the model file)"), BuildParameter("port", ParameterPortApplier(), "Please input the port which the node server will listen on (default: random)"), BuildParameter("n_gpu_layers", ParameterNGpuLayersApplier(), "Please input layers offload to GPU (<=83 for Llama, 0 for CPU only, default: 83)"), From 6ff7a07d195de1f7874fc1d12f181807a07b0fda Mon Sep 17 00:00:00 2001 From: streetycat <305190374@qq.com> Date: Mon, 4 Dec 2023 09:58:41 +0000 Subject: [PATCH 41/79] Update the refered component to new path --- src/[44 | 0 src/aios_kernel/code_interpreter.py | 426 +++++++++++++++ src/aios_kernel/code_interpreter_function.py | 41 ++ .../duckduckgo_text_search_function.py | 52 ++ src/aios_kernel/sql_database.py | 493 ++++++++++++++++++ src/aios_kernel/sql_database_function.py | 112 ++++ .../local_compute_node_builder/__init__.py | 4 +- .../local_llama_node_builder.py | 8 +- 8 files changed, 1130 insertions(+), 6 deletions(-) create mode 100644 src/[44 create mode 100644 src/aios_kernel/code_interpreter.py create mode 100644 src/aios_kernel/code_interpreter_function.py create mode 100644 src/aios_kernel/duckduckgo_text_search_function.py create mode 100644 src/aios_kernel/sql_database.py create mode 100644 src/aios_kernel/sql_database_function.py diff --git a/src/[44 b/src/[44 new file mode 100644 index 0000000..e69de29 diff --git a/src/aios_kernel/code_interpreter.py b/src/aios_kernel/code_interpreter.py new file mode 100644 index 0000000..850356a --- /dev/null +++ b/src/aios_kernel/code_interpreter.py @@ -0,0 +1,426 @@ +import logging +import os +import pathlib +import shutil +import subprocess +import sys +import re +import time +import ast +from concurrent.futures import ThreadPoolExecutor +from hashlib import md5 +from typing import Optional, Union, List, Tuple +from generic_escape import GenericEscape + +from aios_kernel import AIStorage + +try: + import docker +except ImportError: + docker = None + +CODE_BLOCK_PATTERN = r"```[ \t]*(\w+)?[ \t]*\r?\n(.*?)\r?\n[ \t]*```" +UNKNOWN = "unknown" +TIMEOUT_MSG = "Timeout" +DEFAULT_TIMEOUT = 600 +WIN32 = sys.platform == "win32" +PATH_SEPARATOR = WIN32 and "\\" or "/" + +logger = logging.getLogger(__name__) + + +BUILT_IN_MODULES = set( + [ + "sys", + "os", + "math", + "random", + "datetime", + "json", + "re", + "subprocess", + "time", + "threading", + "logging", + "collections", + "itertools", + "functools", + "operator", + "pathlib", + "shutil", + "tempfile", + "pickle", + "io", + "argparse", + "typing", + "unittest", + "contextlib", + "abc", + "heapq", + "bisect", + "copy", + "decimal", + "fractions", + "hashlib", + "secrets", + "statistics", + "difflib", + "doctest", + "enum", + "inspect", + "traceback", + "weakref", + "gc", + "mmap", + "msvcrt", + "winreg", + "array", + "audioop", + "binascii", + "cProfile", + "concurrent.futures", + "configparser", + "csv", + "ctypes", + "dateutil", + "dis", + "fnmatch", + "getopt", + "glob", + "gzip", + "pdb", + "pprint", + "profile", + "pstats", + "queue", + "socket", + "sqlite3", + "ssl", + "struct", + "tarfile", + "telnetlib", + "timeit", + "tokenize", + "uuid", + "xml", + "zipfile", + "zlib", + ] +) + + +def get_imports(code: str) -> List[str]: + root = ast.parse(code) + + imports = [] + for node in ast.iter_child_nodes(root): + if isinstance(node, ast.Import): + module_names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + module_names = [node.module] + else: + continue + + for name in module_names: + # Exclude built-in modules + if name not in BUILT_IN_MODULES: + imports.append(name) + + return imports + + +def write_requirements(code: str, requirements_filepath: str): + imports = get_imports(code) + + with open(requirements_filepath, "w") as file: + for module in imports: + file.write(module + "\n") + + +def _cmd(lang): + if lang.startswith("python") or lang in ["bash", "sh", "powershell"]: + return lang + if lang in ["shell"]: + return "sh" + if lang in ["ps1"]: + return "powershell" + raise NotImplementedError(f"{lang} not recognized in code execution") + + +def create_runner(code: str, timeout: int = 30) -> str: + """ + Create a Python script that runs the code and prints the output + """ + code = GenericEscape().escape(code) + # Create a runner script + runner = f""" +import os +import subprocess + +my_env = os.environ.copy() +my_env["PYTHONIOENCODING"] = "utf-8" + +process = subprocess.Popen( + f"python -i -q -u".split(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=0, + universal_newlines=True, + env=my_env +) + +process.stdin.write("{code}" + "\\n") +process.stdin.write("exit()\\n") +process.stdin.flush() + +try: + process.wait({timeout}) +except Exception as e: + process.terminate() + +for line in iter(process.stdout.readline, ""): + print(line) + +for line in iter(process.stderr.readline, ""): + if line.startswith(">>>"): + continue + print(line) +""" + return runner + + +def _run_cmd(cmd: [str], work_dir: str, timeout: int) -> str: + if WIN32: + logger.warning("SIGALRM is not supported on Windows. No timeout will be enforced.") + result = subprocess.run( + cmd, + cwd=work_dir, + capture_output=True, + text=True, + ) + else: + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit( + subprocess.run, + cmd, + cwd=work_dir, + capture_output=True, + text=True, + ) + result = future.result(timeout=timeout) + return result + + +def execute_code( + code: Optional[str] = None, + timeout: Optional[int] = None, + filename: Optional[str] = None, + work_dir: Optional[str] = None, + use_docker: Optional[Union[List[str], str, bool]] = None, + lang: Optional[str] = "python", +) -> Tuple[int, str]: + """Execute code in a docker container. + This function is not tested on MacOS. + + Args: + code (Optional, str): The code to execute. + If None, the code from the file specified by filename will be executed. + Either code or filename must be provided. + timeout (Optional, int): The maximum execution time in seconds. + If None, a default timeout will be used. The default timeout is 600 seconds. On Windows, the timeout is not enforced when use_docker=False. + filename (Optional, str): The file name to save the code or where the code is stored when `code` is None. + If None, a file with a randomly generated name will be created. + The randomly generated file will be deleted after execution. + The file name must be a relative path. Relative paths are relative to the working directory. + work_dir (Optional, str): The working directory for the code execution. + If None, a default working directory will be used. + The default working directory is the "extensions" directory under + "path_to_autogen". + use_docker (Optional, list, str or bool): The docker image to use for code execution. + If a list or a str of image name(s) is provided, the code will be executed in a docker container + with the first image successfully pulled. + If None, False or empty, the code will be executed in the current environment. + Default is None, which will be converted into an empty list when docker package is available. + Expected behaviour: + - If `use_docker` is explicitly set to True and the docker package is available, the code will run in a Docker container. + - If `use_docker` is explicitly set to True but the Docker package is missing, an error will be raised. + - If `use_docker` is not set (i.e., left default to None) and the Docker package is not available, a warning will be displayed, but the code will run natively. + If the code is executed in the current environment, + the code must be trusted. + lang (Optional, str): The language of the code. Default is "python". + + Returns: + int: 0 if the code executes successfully. + str: The error message if the code fails to execute; the stdout otherwise. + """ + if all((code is None, filename is None)): + error_msg = f"Either {code=} or {filename=} must be provided." + logger.error(error_msg) + raise AssertionError(error_msg) + + # Warn if use_docker was unspecified (or None), and cannot be provided (the default). + # In this case the current behavior is to fall back to run natively, but this behavior + # is subject to change. + if use_docker is None: + if docker is None: + use_docker = False + logger.warning( + "execute_code was called without specifying a value for use_docker. Since the python docker package is not available, code will be run natively. Note: this fallback behavior is subject to change" + ) + else: + # Default to true + use_docker = True + + timeout = timeout or DEFAULT_TIMEOUT + original_filename = filename + if WIN32 and lang in ["sh", "shell"] and (not use_docker): + lang = "ps1" + if filename is None: + code_hash = md5(code.encode()).hexdigest() + # create a file with a automatically generated name + filename = f"tmp_code_{code_hash}.{'py' if lang.startswith('python') else lang}" + if work_dir is None: + WORKING_DIR = os.path.join(AIStorage.get_instance().get_myai_dir(), "tmp_code") + pathlib.Path(WORKING_DIR).mkdir(exist_ok=True) + work_dir = os.path.join(WORKING_DIR, code_hash) + pathlib.Path(work_dir).mkdir(exist_ok=True) + filepath = os.path.join(work_dir, filename) + file_dir = os.path.dirname(filepath) + os.makedirs(file_dir, exist_ok=True) + if code is not None: + write_requirements(code, os.path.join(file_dir, "requirements.txt")) + code = create_runner(code, 30) + with open(filepath, "w", encoding="utf-8") as fout: + fout.write(code) + + + # check if already running in a docker container + in_docker_container = os.path.exists("/.dockerenv") + if not use_docker or in_docker_container: + try: + env_cmd = ["python", "-m", "venv", os.path.join(file_dir, "venv")] + _run_cmd(env_cmd, file_dir, timeout) + if WIN32: + venv_path = os.path.join(file_dir, "venv", "Scripts") + else: + venv_path = os.path.join(file_dir, "venv", "bin") + pip_cmd = [os.path.join(venv_path, "python"), "-m", "pip", "install", "-r", "requirements.txt"] + _run_cmd(pip_cmd, file_dir, timeout) + # already running in a docker container + cmd = [ + os.path.join(venv_path, "python"), + f".\\{filename}" if WIN32 else filename, + ] + result = _run_cmd(cmd, file_dir, timeout) + except TimeoutError: + if original_filename is None: + shutil.rmtree(os.path.join(file_dir, "venv")) + os.remove(filepath) + os.remove(os.path.join(file_dir, "requirements.txt")) + try: + os.removedirs(file_dir) + except Exception: + pass + return 1, TIMEOUT_MSG + if original_filename is None: + shutil.rmtree(os.path.join(file_dir, "venv")) + os.remove(filepath) + os.remove(os.path.join(file_dir, "requirements.txt")) + try: + os.removedirs(file_dir) + except Exception: + pass + if result.returncode: + logs = result.stderr + if original_filename is None: + abs_path = str(pathlib.Path(filepath).absolute()) + logs = logs.replace(str(abs_path), "").replace(filename, "") + else: + abs_path = str(pathlib.Path(work_dir).absolute()) + PATH_SEPARATOR + logs = logs.replace(str(abs_path), "") + else: + logs = result.stdout + return result.returncode, logs + + # create a docker client + client = docker.from_env() + image_list = ( + ["python:3-alpine", "python:3", "python:3-windowsservercore"] + if use_docker is True + else [use_docker] + if isinstance(use_docker, str) + else use_docker + ) + for image in image_list: + # check if the image exists + try: + client.images.get(image) + break + except docker.errors.ImageNotFound: + # pull the image + logger.info("Pulling image", image) + try: + client.images.pull(image, stream=True, decode=True) + break + except docker.errors.DockerException as e: + logger.error("Failed to pull image", image) + logger.exception(e) + # get a randomized str based on current time to wrap the exit code + exit_code_str = f"exitcode{time.time()}" + start_str = f'start{time.time()}' + abs_path = pathlib.Path(work_dir).absolute() + cmd = [ + "sh", + "-c", + f"pip install --quiet -r requirements.txt; echo -n {start_str}; {_cmd(lang)} {filename}; exit_code=$?; echo -n {exit_code_str}; echo -n $exit_code; echo {exit_code_str};", + ] + # create a docker container + container = client.containers.run( + image, + command=cmd, + working_dir="/workspace", + detach=True, + # get absolute path to the working directory + volumes={abs_path: {"bind": "/workspace", "mode": "rw"}}, + ) + start_time = time.time() + while container.status != "exited" and time.time() - start_time < timeout: + # Reload the container object + container.reload() + if container.status != "exited": + container.stop() + container.remove() + if original_filename is None: + os.remove(filepath) + return 1, TIMEOUT_MSG, image + # get the container logs + logs: str = container.logs().decode("utf-8").rstrip() + start_pos = logs.find(start_str) + if start_pos != -1: + logs = logs[start_pos + len(start_str):] + # # commit the image + # tag = filename.replace("/", "") + # container.commit(repository="python", tag=tag) + # remove the container + container.remove() + # check if the code executed successfully + exit_code = container.attrs["State"]["ExitCode"] + if exit_code == 0: + # extract the exit code from the logs + pattern = re.compile(f"{exit_code_str}(\\d+){exit_code_str}") + match = pattern.search(logs) + exit_code = 1 if match is None else int(match.group(1)) + # remove the exit code from the logs + logs = logs if match is None else pattern.sub("", logs) + + if original_filename is None: + os.remove(filepath) + os.remove(os.path.join(file_dir, "requirements.txt")) + os.removedirs(file_dir) + if exit_code: + logs = logs.replace(f"/workspace/{filename if original_filename is None else ''}", "") + # return the exit code, logs and image + return exit_code, logs + diff --git a/src/aios_kernel/code_interpreter_function.py b/src/aios_kernel/code_interpreter_function.py new file mode 100644 index 0000000..0bf366c --- /dev/null +++ b/src/aios_kernel/code_interpreter_function.py @@ -0,0 +1,41 @@ +from typing import Dict + +from aios_kernel.ai_function import AIFunction +from aios_kernel.code_interpreter import execute_code + + +class CodeInterpreterFunction(AIFunction): + def __init__(self): + self.func_id = "code_interpreter" + self.description = "execute python code" + + def get_name(self) -> str: + return self.func_id + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "code": {"type": "string", "description": "python code"} + } + } + + async def execute(self, **kwargs) -> str: + code = kwargs.get("code") + ret_code, result = execute_code(code=code) + if ret_code == 0: + return result.strip() + else: + return result.strip() + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False diff --git a/src/aios_kernel/duckduckgo_text_search_function.py b/src/aios_kernel/duckduckgo_text_search_function.py new file mode 100644 index 0000000..154f761 --- /dev/null +++ b/src/aios_kernel/duckduckgo_text_search_function.py @@ -0,0 +1,52 @@ +import json +from typing import Dict + +from aios_kernel.ai_function import AIFunction +from duckduckgo_search import AsyncDDGS + + +class DuckDuckGoTextSearchFunction(AIFunction): + def __init__(self): + self.name = "duckduckgo_text_search" + self.description = "Search text from duckduckgo.com" + self.region = "wt-wt" + self.safesearch = "moderate" + self.time = "y" + self.max_results = 5 + + def get_name(self) -> str: + return self.name + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return {"type": "object", + "properties": { + "query": {"type": "string", "description": "The query to search for."} + } + } + + async def execute(self, **kwargs) -> str: + query = kwargs.get("query") + + async with AsyncDDGS() as ddgs: + results = [r async for r in ddgs.text( + query, + region=self.region, + safesearch=self.safesearch, + timelimit=self.time, + backend="api", + max_results=self.max_results + )] + + return json.dumps(results) + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False diff --git a/src/aios_kernel/sql_database.py b/src/aios_kernel/sql_database.py new file mode 100644 index 0000000..efee9b4 --- /dev/null +++ b/src/aios_kernel/sql_database.py @@ -0,0 +1,493 @@ +""" +Taken from: langchain +SQLAlchemy wrapper around a database. +""" +from __future__ import annotations +import os + +import warnings +from typing import Any, Dict, Iterable, List, Literal, Optional, Sequence, Union + +import sqlalchemy +from sqlalchemy import MetaData, Table, create_engine, inspect, select, text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import ProgrammingError, SQLAlchemyError +from sqlalchemy.schema import CreateTable +from sqlalchemy.types import NullType + + +def get_from_env(key: str, env_key: str, default: Optional[str] = None) -> str: + """Get a value from a dictionary or an environment variable.""" + if env_key in os.environ and os.environ[env_key]: + return os.environ[env_key] + elif default is not None: + return default + else: + raise ValueError( + f"Did not find {key}, please add an environment variable" + f" `{env_key}` which contains it, or pass" + f" `{key}` as a named parameter." + ) + + +def _format_index(index: sqlalchemy.engine.interfaces.ReflectedIndex) -> str: + return ( + f'Name: {index["name"]}, Unique: {index["unique"]},' + f' Columns: {str(index["column_names"])}' + ) + + +def truncate_word(content: Any, *, length: int, suffix: str = "...") -> str: + """ + Truncate a string to a certain number of words, based on the max string + length. + """ + + if not isinstance(content, str) or length <= 0: + return content + + if len(content) <= length: + return content + + return content[: length - len(suffix)].rsplit(" ", 1)[0] + suffix + + +class SQLDatabase: + """SQLAlchemy wrapper around a database.""" + + def __init__( + self, + engine: Engine, + schema: Optional[str] = None, + metadata: Optional[MetaData] = None, + ignore_tables: Optional[List[str]] = None, + include_tables: Optional[List[str]] = None, + sample_rows_in_table_info: int = 3, + indexes_in_table_info: bool = False, + custom_table_info: Optional[dict] = None, + view_support: bool = False, + max_string_length: int = 300, + ): + """Create engine from database URI.""" + self._engine = engine + self._schema = schema + if include_tables and ignore_tables: + raise ValueError("Cannot specify both include_tables and ignore_tables") + + self._inspector = inspect(self._engine) + + # including view support by adding the views as well as tables to the all + # tables list if view_support is True + self._all_tables = set( + self._inspector.get_table_names(schema=schema) + + (self._inspector.get_view_names(schema=schema) if view_support else []) + ) + + self._include_tables = set(include_tables) if include_tables else set() + if self._include_tables: + missing_tables = self._include_tables - self._all_tables + if missing_tables: + raise ValueError( + f"include_tables {missing_tables} not found in database" + ) + self._ignore_tables = set(ignore_tables) if ignore_tables else set() + if self._ignore_tables: + missing_tables = self._ignore_tables - self._all_tables + if missing_tables: + raise ValueError( + f"ignore_tables {missing_tables} not found in database" + ) + usable_tables = self.get_usable_table_names() + self._usable_tables = set(usable_tables) if usable_tables else self._all_tables + + if not isinstance(sample_rows_in_table_info, int): + raise TypeError("sample_rows_in_table_info must be an integer") + + self._sample_rows_in_table_info = sample_rows_in_table_info + self._indexes_in_table_info = indexes_in_table_info + + self._custom_table_info = custom_table_info + if self._custom_table_info: + if not isinstance(self._custom_table_info, dict): + raise TypeError( + "table_info must be a dictionary with table names as keys and the " + "desired table info as values" + ) + # only keep the tables that are also present in the database + intersection = set(self._custom_table_info).intersection(self._all_tables) + self._custom_table_info = dict( + (table, self._custom_table_info[table]) + for table in self._custom_table_info + if table in intersection + ) + + self._max_string_length = max_string_length + + self._metadata = metadata or MetaData() + # including view support if view_support = true + self._metadata.reflect( + views=view_support, + bind=self._engine, + only=list(self._usable_tables), + schema=self._schema, + ) + + @classmethod + def from_uri( + cls, database_uri: str, engine_args: Optional[dict] = None, **kwargs: Any + ) -> SQLDatabase: + """Construct a SQLAlchemy engine from URI.""" + _engine_args = engine_args or {} + return cls(create_engine(database_uri, **_engine_args), **kwargs) + + @classmethod + def from_databricks( + cls, + catalog: str, + schema: str, + host: Optional[str] = None, + api_token: Optional[str] = None, + warehouse_id: Optional[str] = None, + cluster_id: Optional[str] = None, + engine_args: Optional[dict] = None, + **kwargs: Any, + ) -> SQLDatabase: + """ + Class method to create an SQLDatabase instance from a Databricks connection. + This method requires the 'databricks-sql-connector' package. If not installed, + it can be added using `pip install databricks-sql-connector`. + + Args: + catalog (str): The catalog name in the Databricks database. + schema (str): The schema name in the catalog. + host (Optional[str]): The Databricks workspace hostname, excluding + 'https://' part. If not provided, it attempts to fetch from the + environment variable 'DATABRICKS_HOST'. If still unavailable and if + running in a Databricks notebook, it defaults to the current workspace + hostname. Defaults to None. + api_token (Optional[str]): The Databricks personal access token for + accessing the Databricks SQL warehouse or the cluster. If not provided, + it attempts to fetch from 'DATABRICKS_TOKEN'. If still unavailable + and running in a Databricks notebook, a temporary token for the current + user is generated. Defaults to None. + warehouse_id (Optional[str]): The warehouse ID in the Databricks SQL. If + provided, the method configures the connection to use this warehouse. + Cannot be used with 'cluster_id'. Defaults to None. + cluster_id (Optional[str]): The cluster ID in the Databricks Runtime. If + provided, the method configures the connection to use this cluster. + Cannot be used with 'warehouse_id'. If running in a Databricks notebook + and both 'warehouse_id' and 'cluster_id' are None, it uses the ID of the + cluster the notebook is attached to. Defaults to None. + engine_args (Optional[dict]): The arguments to be used when connecting + Databricks. Defaults to None. + **kwargs (Any): Additional keyword arguments for the `from_uri` method. + + Returns: + SQLDatabase: An instance of SQLDatabase configured with the provided + Databricks connection details. + + Raises: + ValueError: If 'databricks-sql-connector' is not found, or if both + 'warehouse_id' and 'cluster_id' are provided, or if neither + 'warehouse_id' nor 'cluster_id' are provided and it's not executing + inside a Databricks notebook. + """ + try: + from databricks import sql # noqa: F401 + except ImportError: + raise ValueError( + "databricks-sql-connector package not found, please install with" + " `pip install databricks-sql-connector`" + ) + context = None + try: + from dbruntime.databricks_repl_context import get_context + + context = get_context() + except ImportError: + pass + + default_host = context.browserHostName if context else None + if host is None: + host = get_from_env("host", "DATABRICKS_HOST", default_host) + + default_api_token = context.apiToken if context else None + if api_token is None: + api_token = get_from_env("api_token", "DATABRICKS_TOKEN", default_api_token) + + if warehouse_id is None and cluster_id is None: + if context: + cluster_id = context.clusterId + else: + raise ValueError( + "Need to provide either 'warehouse_id' or 'cluster_id'." + ) + + if warehouse_id and cluster_id: + raise ValueError("Can't have both 'warehouse_id' or 'cluster_id'.") + + if warehouse_id: + http_path = f"/sql/1.0/warehouses/{warehouse_id}" + else: + http_path = f"/sql/protocolv1/o/0/{cluster_id}" + + uri = ( + f"databricks://token:{api_token}@{host}?" + f"http_path={http_path}&catalog={catalog}&schema={schema}" + ) + return cls.from_uri(database_uri=uri, engine_args=engine_args, **kwargs) + + @classmethod + def from_cnosdb( + cls, + url: str = "127.0.0.1:8902", + user: str = "root", + password: str = "", + tenant: str = "cnosdb", + database: str = "public", + ) -> SQLDatabase: + """ + Class method to create an SQLDatabase instance from a CnosDB connection. + This method requires the 'cnos-connector' package. If not installed, it + can be added using `pip install cnos-connector`. + + Args: + url (str): The HTTP connection host name and port number of the CnosDB + service, excluding "http://" or "https://", with a default value + of "127.0.0.1:8902". + user (str): The username used to connect to the CnosDB service, with a + default value of "root". + password (str): The password of the user connecting to the CnosDB service, + with a default value of "". + tenant (str): The name of the tenant used to connect to the CnosDB service, + with a default value of "cnosdb". + database (str): The name of the database in the CnosDB tenant. + + Returns: + SQLDatabase: An instance of SQLDatabase configured with the provided + CnosDB connection details. + """ + try: + from cnosdb_connector import make_cnosdb_langchain_uri + + uri = make_cnosdb_langchain_uri(url, user, password, tenant, database) + return cls.from_uri(database_uri=uri) + except ImportError: + raise ValueError( + "cnos-connector package not found, please install with" + " `pip install cnos-connector`" + ) + + @property + def dialect(self) -> str: + """Return string representation of dialect to use.""" + return self._engine.dialect.name + + def get_usable_table_names(self) -> Iterable[str]: + """Get names of tables available.""" + if self._include_tables: + return sorted(self._include_tables) + return sorted(self._all_tables - self._ignore_tables) + + def get_table_names(self) -> Iterable[str]: + """Get names of tables available.""" + warnings.warn( + "This method is deprecated - please use `get_usable_table_names`." + ) + return self.get_usable_table_names() + + @property + def table_info(self) -> str: + """Information about all tables in the database.""" + return self.get_table_info() + + def get_table_info(self, table_names: Optional[List[str]] = None) -> str: + """Get information about specified tables. + + Follows best practices as specified in: Rajkumar et al, 2022 + (https://arxiv.org/abs/2204.00498) + + If `sample_rows_in_table_info`, the specified number of sample rows will be + appended to each table description. This can increase performance as + demonstrated in the paper. + """ + all_table_names = self.get_usable_table_names() + if table_names is not None: + missing_tables = set(table_names).difference(all_table_names) + if missing_tables: + raise ValueError(f"table_names {missing_tables} not found in database") + all_table_names = table_names + + meta_tables = [ + tbl + for tbl in self._metadata.sorted_tables + if tbl.name in set(all_table_names) + and not (self.dialect == "sqlite" and tbl.name.startswith("sqlite_")) + ] + + tables = [] + for table in meta_tables: + if self._custom_table_info and table.name in self._custom_table_info: + tables.append(self._custom_table_info[table.name]) + continue + + # Ignore JSON datatyped columns + for k, v in table.columns.items(): + if type(v.type) is NullType: + table._columns.remove(v) + + # add create table command + create_table = str(CreateTable(table).compile(self._engine)) + table_info = f"{create_table.rstrip()}" + has_extra_info = ( + self._indexes_in_table_info or self._sample_rows_in_table_info + ) + if has_extra_info: + table_info += "\n\n/*" + if self._indexes_in_table_info: + table_info += f"\n{self._get_table_indexes(table)}\n" + if self._sample_rows_in_table_info: + table_info += f"\n{self._get_sample_rows(table)}\n" + if has_extra_info: + table_info += "*/" + tables.append(table_info) + tables.sort() + final_str = "\n\n".join(tables) + return final_str + + def _get_table_indexes(self, table: Table) -> str: + indexes = self._inspector.get_indexes(table.name) + indexes_formatted = "\n".join(map(_format_index, indexes)) + return f"Table Indexes:\n{indexes_formatted}" + + def _get_sample_rows(self, table: Table) -> str: + # build the select command + command = select(table).limit(self._sample_rows_in_table_info) + + # save the columns in string format + columns_str = "\t".join([col.name for col in table.columns]) + + try: + # get the sample rows + with self._engine.connect() as connection: + sample_rows_result = connection.execute(command) # type: ignore + # shorten values in the sample rows + sample_rows = list( + map(lambda ls: [str(i)[:100] for i in ls], sample_rows_result) + ) + + # save the sample rows in string format + sample_rows_str = "\n".join(["\t".join(row) for row in sample_rows]) + + # in some dialects when there are no rows in the table a + # 'ProgrammingError' is returned + except ProgrammingError: + sample_rows_str = "" + + return ( + f"{self._sample_rows_in_table_info} rows from {table.name} table:\n" + f"{columns_str}\n" + f"{sample_rows_str}" + ) + + def _execute( + self, + command: str, + fetch: Union[Literal["all"], Literal["one"]] = "all", + ) -> Sequence[Dict[str, Any]]: + """ + Executes SQL command through underlying engine. + + If the statement returns no rows, an empty list is returned. + """ + with self._engine.begin() as connection: + if self._schema is not None: + if self.dialect == "snowflake": + connection.exec_driver_sql( + "ALTER SESSION SET search_path = %s", (self._schema,) + ) + elif self.dialect == "bigquery": + connection.exec_driver_sql("SET @@dataset_id=?", (self._schema,)) + elif self.dialect == "mssql": + pass + elif self.dialect == "trino": + connection.exec_driver_sql("USE ?", (self._schema,)) + elif self.dialect == "duckdb": + # Unclear which parameterized argument syntax duckdb supports. + # The docs for the duckdb client say they support multiple, + # but `duckdb_engine` seemed to struggle with all of them: + # https://github.com/Mause/duckdb_engine/issues/796 + connection.exec_driver_sql(f"SET search_path TO {self._schema}") + elif self.dialect == "oracle": + connection.exec_driver_sql( + f"ALTER SESSION SET CURRENT_SCHEMA = {self._schema}" + ) + else: # postgresql and other compatible dialects + connection.exec_driver_sql("SET search_path TO %s", (self._schema,)) + cursor = connection.execute(text(command)) + if cursor.returns_rows: + if fetch == "all": + result = [x._asdict() for x in cursor.fetchall()] + elif fetch == "one": + first_result = cursor.fetchone() + result = [] if first_result is None else [first_result._asdict()] + else: + raise ValueError("Fetch parameter must be either 'one' or 'all'") + return result + return [] + + def run( + self, + command: str, + fetch: Union[Literal["all"], Literal["one"]] = "all", + ) -> str: + """Execute a SQL command and return a string representing the results. + + If the statement returns rows, a string of the results is returned. + If the statement returns no rows, an empty string is returned. + """ + result = self._execute(command, fetch) + # Convert columns values to string to avoid issues with sqlalchemy + # truncating text + res = [ + tuple(truncate_word(c, length=self._max_string_length) for c in r.values()) + for r in result + ] + if not res: + return "" + else: + return str(res) + + def get_table_info_no_throw(self, table_names: Optional[List[str]] = None) -> str: + """Get information about specified tables. + + Follows best practices as specified in: Rajkumar et al, 2022 + (https://arxiv.org/abs/2204.00498) + + If `sample_rows_in_table_info`, the specified number of sample rows will be + appended to each table description. This can increase performance as + demonstrated in the paper. + """ + try: + return self.get_table_info(table_names) + except ValueError as e: + """Format the error message""" + return f"Error: {e}" + + def run_no_throw( + self, + command: str, + fetch: Union[Literal["all"], Literal["one"]] = "all", + ) -> str: + """Execute a SQL command and return a string representing the results. + + If the statement returns rows, a string of the results is returned. + If the statement returns no rows, an empty string is returned. + + If the statement throws an error, the error message is returned. + """ + try: + return self.run(command, fetch) + except SQLAlchemyError as e: + """Format the error message""" + return f"Error: {e}" diff --git a/src/aios_kernel/sql_database_function.py b/src/aios_kernel/sql_database_function.py new file mode 100644 index 0000000..2e208e6 --- /dev/null +++ b/src/aios_kernel/sql_database_function.py @@ -0,0 +1,112 @@ +from datetime import timedelta, datetime +from typing import Dict + +from cachetools import TLRUCache, cached + +from aios_kernel.ai_function import AIFunction +from aios_kernel.sql_database import SQLDatabase, get_from_env + + +def _my_ttu(_key, _value, now): + return now + timedelta(seconds=600) + + +database_cache = TLRUCache(ttu=_my_ttu, maxsize=10000, timer=datetime.now) + + +@cached(cache=database_cache) +def get_database(uri: str) -> SQLDatabase: + return SQLDatabase.from_uri(uri) + + +class GetTableInfosFunction(AIFunction): + def __init__(self): + super().__init__() + self.name = "get_table_infos" + self.description = "Get table informations in the database" + + def get_name(self) -> str: + return self.name + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "database_url": {"type": "string", "description": "Database URL,Can be set to None"}, + } + } + + async def execute(self, **kwargs) -> str: + database_url: str = kwargs.get("database_url") + if (database_url is None + or database_url.strip() == "" + or database_url.strip().lower() == "none" + or database_url.strip().lower() == "null"): + database_url = get_from_env(key="database url", env_key="DATABASE_URL") + if database_url is None: + return "error: database_url is None" + database = get_database(database_url) + tables = database.get_usable_table_names() + table_infos = database.get_table_info(tables) + return table_infos + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False + + +class ExecuteSqlFunction(AIFunction): + def __init__(self): + super().__init__() + self.name = "execute_sql" + self.description = """ + Input to this function is a detailed and correct SQL query, output is a result from the database. + If the query is not correct, an error message will be returned. + If an error is returned, rewrite the query, check the query, and try again. + """ + + def get_name(self) -> str: + return self.name + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "database_url": {"type": "string", "description": "Database URL,Can be set to None"}, + "sql": {"type": "string", "description": "SQL to execute"} + } + } + + async def execute(self, **kwargs) -> str: + database_url = kwargs.get("database_url") + if (database_url is None + or database_url.strip() == "" + or database_url.strip().lower() == "none" + or database_url.strip().lower() == "null"): + database_url = get_from_env(key="database url", env_key="DATABASE_URL") + if database_url is None: + return "error: database_url is None" + sql = kwargs.get("sql") + + database = get_database(database_url) + return database.run_no_throw(sql) + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False diff --git a/src/service/aios_shell/local_compute_node_builder/__init__.py b/src/service/aios_shell/local_compute_node_builder/__init__.py index 4fb1d16..a04f822 100644 --- a/src/service/aios_shell/local_compute_node_builder/__init__.py +++ b/src/service/aios_shell/local_compute_node_builder/__init__.py @@ -1,9 +1,9 @@ import os from prompt_toolkit import HTML, PromptSession, print_formatted_text from prompt_toolkit.styles import Style +from aios.storage.storage import AIStorage from service.aios_shell.local_compute_node_builder.local_llama_node_builder import LocalLlamaNodeBuilder -from .local_compute_node_builder import BuilderState, LocalComputeNodeBuilder -from aios_kernel.storage import AIStorage +from .local_compute_node_builder import BuilderState async def build(prompt_session: PromptSession, shell_style: Style) -> str or None: # model_type = await prompt_session.prompt_async(f"Please select the node server type (default: llama.cpp):", style = shell_style) diff --git a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py index 5d818af..d9bdcde 100644 --- a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py +++ b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py @@ -6,11 +6,11 @@ import requests from prompt_toolkit import print_formatted_text from prompt_toolkit.shortcuts import ProgressBar from prompt_toolkit.formatted_text import FormattedText -from aios_kernel.compute_kernel import ComputeKernel -from aios_kernel.compute_node_config import ComputeNodeConfig -from aios_kernel.local_llama_compute_node import LocalLlama_ComputeNode -from aios_kernel.storage import AIStorage +from aios.storage.storage import AIStorage +from aios import ComputeKernel +from component.llama_node.local_llama_compute_node import LocalLlama_ComputeNode +from service.aios_shell.compute_node_config import ComputeNodeConfig from .local_compute_node_builder import BuildParameter, BuilderState, LocalComputeNodeBuilder, ParameterApplier class BuildParameterModelPath: From 5bb1be1fe36ad0e71c22f6172f7622cfdc3b8c44 Mon Sep 17 00:00:00 2001 From: streetycat <305190374@qq.com> Date: Mon, 4 Dec 2023 10:19:08 +0000 Subject: [PATCH 42/79] Recover the command '/node add' --- src/service/aios_shell/aios_shell.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index b61b906..1a13476 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -29,6 +29,7 @@ sys.path.append(directory + '/../../') import proxy from aios import * import local_compute_node_builder +from component.llama_node.local_llama_compute_node import LocalLlama_ComputeNode sys.path.append(directory + '/../../component/') @@ -403,14 +404,26 @@ class AIOS_Shell: async def handle_node_commands(self, args): show_text = FormattedText([("class:title", "sub command not support!\n" - "/node add\n" + "/node add $model_name $url\n" + "/node create\n" "/node rm $model_name $url\n" "/node list\n")]) if len(args) < 1: return show_text sub_cmd = args[0] - if sub_cmd == "add": + if sub_cmd == "create": await local_compute_node_builder.build(session, shell_style) + elif sub_cmd == "add": + if len(args) < 3: + return show_text + + model_name = args[1] + url = args[2] + ComputeNodeConfig.get_instance().add_node("llama", url, model_name) + ComputeNodeConfig.get_instance().save() + node = LocalLlama_ComputeNode(url, model_name) + node.start() + ComputeKernel.get_instance().add_compute_node(node) elif sub_cmd == "rm": if len(args) < 3: return show_text @@ -767,7 +780,8 @@ async def main(): '/set_config $key', '/enable $feature', '/disable $feature', - '/node add', + '/node add $model_name $url', + '/node create', '/node rm $model_name $url', '/node list', '/show', From 9cf4613d314f807e0998b0989ec32e4f2c24b5d5 Mon Sep 17 00:00:00 2001 From: wugren Date: Fri, 1 Dec 2023 14:22:34 +0800 Subject: [PATCH 43/79] mime --- rootfs/agents/TextSummary/agent.py | 48 ++++++++++++++++++ rootfs/agents/Vision/agent.toml | 7 +++ src/aios/agent/agent.py | 51 +++++++++++++++++++- src/aios/agent/agent_base.py | 29 +++++++++-- src/aios/knowledge/data/writer.py | 38 +++++++-------- src/aios_kernel/image_utils.py | 22 +++++++++ src/aios_kernel/video_utils.py | 18 +++++++ src/component/agent_manager/agent_manager.py | 9 ++-- src/component/openai_node/open_ai_node.py | 15 ++++-- src/requirements.txt | 2 + src/service/aios_shell/aios_shell.py | 4 +- 11 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 rootfs/agents/TextSummary/agent.py create mode 100644 rootfs/agents/Vision/agent.toml create mode 100644 src/aios_kernel/image_utils.py create mode 100644 src/aios_kernel/video_utils.py diff --git a/rootfs/agents/TextSummary/agent.py b/rootfs/agents/TextSummary/agent.py new file mode 100644 index 0000000..ba6f505 --- /dev/null +++ b/rootfs/agents/TextSummary/agent.py @@ -0,0 +1,48 @@ +import copy + +from aios_kernel import CustomAIAgent, AgentMsg, AgentMsgType, AgentPrompt +from aios_kernel.compute_task import ComputeTaskResultCode +from knowledge.data.writer import split_text + + +class TextSummaryAgent(CustomAIAgent): + def __init__(self): + super().__init__("TextSummary", "Text Summary", 128000) + + async def _process_msg(self, msg: AgentMsg, workspace=None) -> AgentMsg: + if msg.msg_type is not AgentMsgType.TYPE_MSG: + return AgentMsg.create_error_resp(msg, "only support msg type") + + if msg.body_mime is not None and msg.body_mime != "text/plain": + return AgentMsg.create_error_resp(msg, "only support text/plain mime type") + + chunks = split_text(msg.body, separators=["\n\n", "\n"], chunk_size=4000, chunk_overlap=200, length_function=len) + + prompt = AgentPrompt() + prompt.system_message = "Your job is to generate a summary based on the input." + if len(chunks) == 1: + prompt.append(AgentPrompt(chunks[0])) + resp = await self.do_llm_complection(prompt) + if resp.result_code != ComputeTaskResultCode.OK: + return msg.create_error_resp(resp.error_str) + return msg.create_resp_msg(resp.result_str) + + segments = [] + for i, chunk in enumerate(chunks): + seg_prompt = copy.deepcopy(prompt) + seg_prompt.append(AgentPrompt(chunk)) + resp = await self.do_llm_complection(seg_prompt) + if resp.result_code != ComputeTaskResultCode.OK: + return msg.create_error_resp(resp.error_str) + segments.append(resp.result_str) + + segments_str = "\n".join(segments) + prompt.append(AgentPrompt(f"以下文本分段之后的各段摘要,请合并生成一个完整摘要:\n{segments_str}")) + resp = await self.do_llm_complection(prompt) + if resp.result_code != ComputeTaskResultCode.OK: + return msg.create_error_resp(resp.error_str) + return msg.create_resp_msg(resp.result_str) + + +def init(): + return TextSummaryAgent() diff --git a/rootfs/agents/Vision/agent.toml b/rootfs/agents/Vision/agent.toml new file mode 100644 index 0000000..f3bf29a --- /dev/null +++ b/rootfs/agents/Vision/agent.toml @@ -0,0 +1,7 @@ +instance_id = "Vision" +fullname = "Vision" +llm_model_name = "gpt-4-vision-preview" + +[[prompt]] +role = "system" +content = """你的工作对用户输入的图片和视频做分析,并根据用户的意图做出回应。""" diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index ad8dcf8..f9e728d 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -27,6 +27,7 @@ from ..environment.workspace_env import WorkspaceEnvironment from ..storage.storage import AIStorage from ..knowledge import * +from . import video_utils, image_utils logger = logging.getLogger(__name__) @@ -423,11 +424,39 @@ class AIAgent(BaseAIAgent): async def _create_openai_thread(self) -> str: return None + def check_and_to_base64(self, image_path: str) -> str: + if image_utils.is_file(image_path): + return image_utils.image_to_base64(image_path) + else: + return image_path + async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: msg_prompt = AgentPrompt() if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: need_process = False - msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] + if msg.is_image_msg(): + image_prompt, images = msg.get_image_body() + if image_prompt is None: + content = [[{"type": "text", "text": f"{msg.sender}'s message"}]] + content.extend([{"type": "image_url", "url": self.check_and_to_base64(image)} for image in images]) + msg_prompt.messages = [{"role": "user", "content": content}] + else: + content = [{"type": "text", "text": f"{msg.sender}:{image_prompt}"}] + content.extend([{"type": "image_url", "url": self.check_and_to_base64(image)} for image in images]) + msg_prompt.messages = [{"role": "user", "content": content}] + elif msg.is_video_msg(): + video_prompt, video = msg.get_video_body() + frames = video_utils.extract_frames(video) + if video_prompt is None: + content = [{"type": "text", "text": f"{msg.sender}'s message"}] + content.extend([{"type": "image_url", "url": frame} for frame in frames]) + msg_prompt.messages = [{"role": "user", "content": content}] + else: + content = [{"type": "text", "text": f"{msg.sender}:{video_prompt}"}] + content.extend([{"type": "image_url", "url": frame} for frame in frames]) + msg_prompt.messages = [{"role": "user", "content": content}] + else: + msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] session_topic = msg.target + "#" + msg.topic chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) @@ -441,7 +470,25 @@ class AIAgent(BaseAIAgent): resp_msg = msg.create_group_resp_msg(self.agent_id,"") return resp_msg else: - msg_prompt.messages = [{"role":"user","content":msg.body}] + if msg.is_image_msg(): + image_prompt, images = msg.get_image_body() + if image_prompt is None: + msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "url": image} for image in images]}] + else: + content = [{"type": "text", "text": image_prompt}] + content.extend([{"type": "image_url", "url": image} for image in images]) + msg_prompt.messages = [{"role": "user", "content": content}] + elif msg.is_video_msg(): + video_prompt, video = msg.get_video_body() + frames = video_utils.extract_frames(video) + if video_prompt is None: + msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "url": frame} for frame in frames]}] + else: + content = [{"type": "text", "text": video_prompt}] + content.extend([{"type": "image_url", "url": frame} for frame in frames]) + msg_prompt.messages = [{"role": "user", "content": content}] + else: + msg_prompt.messages = [{"role":"user","content":msg.body}] session_topic = msg.get_sender() + "#" + msg.topic chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) if self.enable_thread: diff --git a/src/aios/agent/agent_base.py b/src/aios/agent/agent_base.py index 15c738e..78e431d 100644 --- a/src/aios/agent/agent_base.py +++ b/src/aios/agent/agent_base.py @@ -9,7 +9,7 @@ import time import re import shlex import json -from typing import List +from typing import List, Tuple from .ai_function import FunctionItem, AIFunction from ..proto.agent_msg import AgentMsg, AgentMsgType @@ -410,6 +410,10 @@ class BaseAIAgent(abc.ABC): def get_max_token_size(self) -> int: pass + @abstractmethod + async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: + pass + @classmethod def get_inner_functions(cls, env:Environment) -> (dict,int): if env is None: @@ -445,10 +449,29 @@ class BaseAIAgent(abc.ABC): #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") if inner_functions is None and env is not None: inner_functions,_ = BaseAIAgent.get_inner_functions(env) + + model_name = self.get_llm_model_name() + if org_msg.is_video_msg() or org_msg.is_image_msg(): + if model_name.startswith("gpt4"): + model_name = "gpt-4-vision-preview" if is_json_resp: - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,resp_mode="json",mode_name=self.get_llm_model_name(),max_token=self.get_max_token_size(),inner_functions=inner_functions,timeout=None) + task_result: ComputeTaskResult = await (ComputeKernel.get_instance() + .do_llm_completion( + prompt, + resp_mode="json", + mode_name=model_name, + max_token=self.get_max_token_size(), + inner_functions=inner_functions, + timeout=None)) else: - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,resp_mode="text",mode_name=self.get_llm_model_name(),max_token=self.get_max_token_size(),inner_functions=inner_functions,timeout=None) + task_result: ComputeTaskResult = await (ComputeKernel.get_instance() + .do_llm_completion( + prompt, + resp_mode="text", + mode_name=model_name, + max_token=self.get_max_token_size(), + inner_functions=inner_functions, + timeout=None)) if task_result.result_code != ComputeTaskResultCode.OK: logger.error(f"_do_llm_complection llm compute error:{task_result.error_str}") #error_resp = msg.create_error_resp(task_result.error_str) diff --git a/src/aios/knowledge/data/writer.py b/src/aios/knowledge/data/writer.py index 0381bc6..6ebf3ac 100644 --- a/src/aios/knowledge/data/writer.py +++ b/src/aios/knowledge/data/writer.py @@ -19,10 +19,10 @@ def _join_docs(docs: List[str], separator: str) -> Optional[str]: return text def _merge_splits( - splits: Iterable[str], - separator: str, - chunk_size: int, - chunk_overlap: int, + splits: Iterable[str], + separator: str, + chunk_size: int, + chunk_overlap: int, length_function: Callable[[str], int] ) -> List[str]: # We now want to combine these smaller pieces into medium size @@ -86,11 +86,11 @@ def _split_text_with_regex( return [s for s in splits if s != ""] -def _split_text( - text: str, - separators: List[str], - chunk_size: int, - chunk_overlap: int, +def split_text( + text: str, + separators: List[str], + chunk_size: int, + chunk_overlap: int, length_function: Callable[[str], int] ) -> List[str]: @@ -127,7 +127,7 @@ def _split_text( if not new_separators: final_chunks.append(s) else: - other_info = _split_text(s, new_separators, chunk_size, chunk_overlap, length_function) + other_info = split_text(s, new_separators, chunk_size, chunk_overlap, length_function) final_chunks.extend(other_info) if _good_splits: merged_text = _merge_splits(_good_splits, _separator, chunk_size, chunk_overlap, length_function) @@ -153,7 +153,7 @@ class ChunkListWriter: chunk = file.read(chunk_size) if not chunk: break - + chunk_len = len(chunk) chunk_id = ChunkID.hash_data(chunk) chunk_list.append(chunk_id) @@ -176,14 +176,14 @@ class ChunkListWriter: file_hash = HashValue(hash_obj.digest()) # print(f"calc file hash: {file_path}, {file_hash}") - + return ChunkList(chunk_list, file_hash) def create_chunk_list_from_text( - self, - text: str, - chunk_size: int = 4000, - chunk_overlap: int = 200, + self, + text: str, + chunk_size: int = 4000, + chunk_overlap: int = 200, separators: str = ["\n\n", "\n", " ", ""] ) -> ChunkList: enc = tiktoken.encoding_for_model("gpt-3.5-turbo") @@ -196,8 +196,8 @@ class ChunkListWriter: disallowed_special="all", ) ) - - text_list = _split_text(text, separators, chunk_size, chunk_overlap, length_function) + + text_list = split_text(text, separators, chunk_size, chunk_overlap, length_function) chunk_list = [] hash_obj = hashlib.sha256() @@ -211,4 +211,4 @@ class ChunkListWriter: self.chunk_store.put_chunk(chunk_id, chunk_bytes) hash = HashValue(hash_obj.digest()) - return ChunkList(chunk_list, hash) \ No newline at end of file + return ChunkList(chunk_list, hash) diff --git a/src/aios_kernel/image_utils.py b/src/aios_kernel/image_utils.py new file mode 100644 index 0000000..dd706bc --- /dev/null +++ b/src/aios_kernel/image_utils.py @@ -0,0 +1,22 @@ +import base64 +import os.path + + +def to_base64(image_path: str) -> str: + """Convert image to base64.""" + ext = os.path.splitext(image_path)[1] + with open(image_path, "rb") as image_file: + base64_image = base64.b64encode(image_file.read()).decode("utf-8") + return f"data:image/{ext};base64,{base64_image}" + + +def is_file(image_path: str) -> bool: + return os.path.isfile(image_path) + + +def is_base64(image_path: str) -> bool: + return image_path.startswith("data:image/") + + +def is_url(image_path: str) -> bool: + return image_path.startswith("http://") or image_path.startswith("https://") diff --git a/src/aios_kernel/video_utils.py b/src/aios_kernel/video_utils.py new file mode 100644 index 0000000..444fab8 --- /dev/null +++ b/src/aios_kernel/video_utils.py @@ -0,0 +1,18 @@ +import base64 +from typing import List + +import cv2 + + +def extract_frames(video_path: str) -> List[str]: + """Extract frames from video.""" + frames = [] + vidcap = cv2.VideoCapture(video_path) + while vidcap.isOpened(): + success, image = vidcap.read() + if not success: + break + _, buffer = cv2.imencode(".jpg", image) + frames.append(base64.b64encode(buffer).decode("utf-8")) + vidcap.release() + return frames diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index aeff7b4..cb1ed03 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -130,14 +130,11 @@ class AgentManager: logger.error(f"read agent.toml cfg from {agent_media} failed! unexpected error occurred: {str(e)}") return None - agent_name = os.path.split(agent_media.full_path)[1] - spec = importlib.util.spec_from_file_location(agent_name, custom_agent) - the_api = importlib.util.module_from_spec(spec) - spec.loader.exec_module(the_api) - if not hasattr(the_api,"Agent"): + agent = runpy.run_path(custom_agent) + if "init" not in agent: logger.error(f"read agent.toml cfg from {agent_media} failed! unexpected error occurred: {str(e)}") return None - return the_api.Agent() + return agent["init"]() diff --git a/src/component/openai_node/open_ai_node.py b/src/component/openai_node/open_ai_node.py index 1e595fd..1918b2d 100644 --- a/src/component/openai_node/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -11,6 +11,7 @@ import requests from aios import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeTaskResultCode,ComputeNode,AIStorage,UserConfig + logger = logging.getLogger(__name__) @@ -92,15 +93,19 @@ class OpenAI_ComputeNode(ComputeNode): def _image_2_text(self, task: ComputeTask): logger.info('openai image_2_text') # 本地图片处理 - def encode_image(image_path): - with open(image_path, "rb") as image_file: - return base64.b64encode(image_file.read()).decode('utf-8') + headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.openai_api_key }" } model_name = task.params["model_name"] - base64_image = encode_image(task.params["image_path"]) + image_path = task.params["image_path"] + + if image_utils.is_file(image_path): + url = image_utils.to_base64(image_path) + else: + url = image_path + payload = { "model": model_name, "messages": [ @@ -114,7 +119,7 @@ class OpenAI_ComputeNode(ComputeNode): { "type": "image_url", "image_url": { - "url": f"data:image/jpeg;base64,{base64_image}" + "url": url } } ] diff --git a/src/requirements.txt b/src/requirements.txt index 1a41e3d..11f207f 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -151,3 +151,5 @@ psycopg2-binary pyodbc oracledb html2text +docx2txt +opencv-python diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 0ac7a81..16de836 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -238,12 +238,12 @@ class AIOS_Shell: def get_version(self) -> str: return "0.5.1" - async def send_msg(self,msg:str,target_id:str,topic:str,sender:str = None) -> str: + async def send_msg(self,msg:str,target_id:str,topic:str,sender:str = None, msg_mime:str=None) -> str: if sender == self.username: AIBus().get_default_bus().register_message_handler(self.username,self._user_process_msg) agent_msg = AgentMsg() - agent_msg.set(sender,target_id,msg) + agent_msg.set(sender,target_id,msg,body_mime=msg_mime) agent_msg.topic = topic resp = await AIBus.get_default_bus().send_message(agent_msg) if resp is not None: From fde73894436bd95b0ccb7439da2098bdbcbeb7be Mon Sep 17 00:00:00 2001 From: wugren Date: Sat, 2 Dec 2023 22:02:07 +0800 Subject: [PATCH 44/79] AgentMsg support image and video --- rootfs/agents/AgentAssistant/environment.py | 3 +- rootfs/agents/DBQueryer/environment.py | 4 +- rootfs/agents/TextSummary/agent.py | 7 +- rootfs/agents/Vision/agent.toml | 3 +- src/aios/__init__.py | 3 +- src/aios/agent/agent.py | 24 ++-- src/aios/agent/agent_base.py | 3 +- src/aios/proto/agent_msg.py | 74 +++++++++++- src/aios/utils/__init__.py | 2 + src/aios/utils/image_utils.py | 40 +++++++ src/aios/utils/video_utils.py | 122 ++++++++++++++++++++ src/aios_kernel/image_utils.py | 22 ---- src/aios_kernel/video_utils.py | 18 --- src/component/openai_node/open_ai_node.py | 27 ++++- src/service/aios_shell/aios_shell.py | 52 ++++++++- 15 files changed, 331 insertions(+), 73 deletions(-) create mode 100644 src/aios/utils/__init__.py create mode 100644 src/aios/utils/image_utils.py create mode 100644 src/aios/utils/video_utils.py delete mode 100644 src/aios_kernel/image_utils.py delete mode 100644 src/aios_kernel/video_utils.py diff --git a/rootfs/agents/AgentAssistant/environment.py b/rootfs/agents/AgentAssistant/environment.py index 35fa35b..e5ce961 100644 --- a/rootfs/agents/AgentAssistant/environment.py +++ b/rootfs/agents/AgentAssistant/environment.py @@ -3,9 +3,10 @@ from typing import Optional import toml -from aios_kernel import Environment, SimpleAIFunction import os +from aios.agent.ai_function import SimpleAIFunction +from aios.environment.environment import Environment local_path = os.path.split(os.path.realpath(__file__))[0] diff --git a/rootfs/agents/DBQueryer/environment.py b/rootfs/agents/DBQueryer/environment.py index e733c45..8c8ec02 100644 --- a/rootfs/agents/DBQueryer/environment.py +++ b/rootfs/agents/DBQueryer/environment.py @@ -1,7 +1,7 @@ from typing import Optional -from aios_kernel import Environment -from aios_kernel.sql_database_function import GetTableInfosFunction, ExecuteSqlFunction +from aios.environment.environment import Environment +from aios.environment.sql_database_function import GetTableInfosFunction, ExecuteSqlFunction class DBQuerierEnvironment(Environment): diff --git a/rootfs/agents/TextSummary/agent.py b/rootfs/agents/TextSummary/agent.py index ba6f505..537f520 100644 --- a/rootfs/agents/TextSummary/agent.py +++ b/rootfs/agents/TextSummary/agent.py @@ -1,8 +1,9 @@ import copy -from aios_kernel import CustomAIAgent, AgentMsg, AgentMsgType, AgentPrompt -from aios_kernel.compute_task import ComputeTaskResultCode -from knowledge.data.writer import split_text +from aios.agent.agent_base import CustomAIAgent, AgentPrompt +from aios.knowledge.data.writer import split_text +from aios.proto.agent_msg import AgentMsg, AgentMsgType +from aios.proto.compute_task import ComputeTaskResultCode class TextSummaryAgent(CustomAIAgent): diff --git a/rootfs/agents/Vision/agent.toml b/rootfs/agents/Vision/agent.toml index f3bf29a..52b50b6 100644 --- a/rootfs/agents/Vision/agent.toml +++ b/rootfs/agents/Vision/agent.toml @@ -4,4 +4,5 @@ llm_model_name = "gpt-4-vision-preview" [[prompt]] role = "system" -content = """你的工作对用户输入的图片和视频做分析,并根据用户的意图做出回应。""" +content = """你的工作对用户输入的图片和视频做分析,并根据用户的意图做出回应。 +如果用户请求的是视频时,你接受到的是视频的关键帧,请根据关键帧内容回复用户问题。""" diff --git a/src/aios/__init__.py b/src/aios/__init__.py index 1a0c512..ce866cf 100644 --- a/src/aios/__init__.py +++ b/src/aios/__init__.py @@ -27,6 +27,7 @@ from .storage.storage import ResourceLocation,AIStorage,UserConfig,UserConfigIte from .net import * from .knowledge import * from .package_manager import * +from .utils import * -AIOS_Version = "0.5.2, build 2023-11-30" \ No newline at end of file +AIOS_Version = "0.5.2, build 2023-11-30" diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index f9e728d..2b97326 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -27,7 +27,7 @@ from ..environment.workspace_env import WorkspaceEnvironment from ..storage.storage import AIStorage from ..knowledge import * -from . import video_utils, image_utils +from ..utils import video_utils, image_utils logger = logging.getLogger(__name__) @@ -426,7 +426,7 @@ class AIAgent(BaseAIAgent): def check_and_to_base64(self, image_path: str) -> str: if image_utils.is_file(image_path): - return image_utils.image_to_base64(image_path) + return image_utils.to_base64(image_path, (1024, 1024)) else: return image_path @@ -438,22 +438,22 @@ class AIAgent(BaseAIAgent): image_prompt, images = msg.get_image_body() if image_prompt is None: content = [[{"type": "text", "text": f"{msg.sender}'s message"}]] - content.extend([{"type": "image_url", "url": self.check_and_to_base64(image)} for image in images]) + content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) msg_prompt.messages = [{"role": "user", "content": content}] else: content = [{"type": "text", "text": f"{msg.sender}:{image_prompt}"}] - content.extend([{"type": "image_url", "url": self.check_and_to_base64(image)} for image in images]) + content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) msg_prompt.messages = [{"role": "user", "content": content}] elif msg.is_video_msg(): video_prompt, video = msg.get_video_body() - frames = video_utils.extract_frames(video) + frames = video_utils.extract_frames(video, (1024, 1024)) if video_prompt is None: content = [{"type": "text", "text": f"{msg.sender}'s message"}] - content.extend([{"type": "image_url", "url": frame} for frame in frames]) + content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) msg_prompt.messages = [{"role": "user", "content": content}] else: content = [{"type": "text", "text": f"{msg.sender}:{video_prompt}"}] - content.extend([{"type": "image_url", "url": frame} for frame in frames]) + content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) msg_prompt.messages = [{"role": "user", "content": content}] else: msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] @@ -473,19 +473,19 @@ class AIAgent(BaseAIAgent): if msg.is_image_msg(): image_prompt, images = msg.get_image_body() if image_prompt is None: - msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "url": image} for image in images]}] + msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]}] else: content = [{"type": "text", "text": image_prompt}] - content.extend([{"type": "image_url", "url": image} for image in images]) + content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) msg_prompt.messages = [{"role": "user", "content": content}] elif msg.is_video_msg(): video_prompt, video = msg.get_video_body() - frames = video_utils.extract_frames(video) + frames = video_utils.extract_frames(video, (1024, 1024)) if video_prompt is None: - msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "url": frame} for frame in frames]}] + msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": frame}} for frame in frames]}] else: content = [{"type": "text", "text": video_prompt}] - content.extend([{"type": "image_url", "url": frame} for frame in frames]) + content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) msg_prompt.messages = [{"role": "user", "content": content}] else: msg_prompt.messages = [{"role":"user","content":msg.body}] diff --git a/src/aios/agent/agent_base.py b/src/aios/agent/agent_base.py index 78e431d..4094c36 100644 --- a/src/aios/agent/agent_base.py +++ b/src/aios/agent/agent_base.py @@ -452,7 +452,7 @@ class BaseAIAgent(abc.ABC): model_name = self.get_llm_model_name() if org_msg.is_video_msg() or org_msg.is_image_msg(): - if model_name.startswith("gpt4"): + if model_name.startswith("gpt-4"): model_name = "gpt-4-vision-preview" if is_json_resp: task_result: ComputeTaskResult = await (ComputeKernel.get_instance() @@ -501,7 +501,6 @@ class BaseAIAgent(abc.ABC): stack_limit = 5 ) -> ComputeTaskResult: from ..frame.compute_kernel import ComputeKernel - arguments = None try: func_name = inner_func_call_node.get("name") diff --git a/src/aios/proto/agent_msg.py b/src/aios/proto/agent_msg.py index 3c1e2a0..c7209bf 100644 --- a/src/aios/proto/agent_msg.py +++ b/src/aios/proto/agent_msg.py @@ -1,7 +1,10 @@ +import json import logging +import shlex import uuid from enum import Enum import time +from typing import Tuple, List logger = logging.getLogger(__name__) @@ -35,8 +38,6 @@ class AgentMsgStatus(Enum): # 逻辑上的同一个Message在同一个session中看到的msgid相同 # 在不同的session中看到的msgid不同 - - class AgentMsg: def __init__(self,msg_type=AgentMsgType.TYPE_MSG) -> None: self.msg_id = "msg#" + uuid.uuid4().hex @@ -136,14 +137,79 @@ class AgentMsg: return resp_msg - def set(self,sender:str,target:str,body:str,topic:str=None) -> None: + def set(self,sender:str,target:str,body:str,topic:str=None,body_mime:str=None) -> None: self.sender = sender self.target = target self.body = body + self.body_mime = body_mime self.create_time = time.time() if topic: self.topic = topic + @staticmethod + def create_image_body(images: [str], prompt: str = None): + return json.dumps({"images": images, "prompt": prompt}) + + @staticmethod + def parse_image_body(image_body: str) -> Tuple[str, List[str]]: + body = json.loads(image_body) + return body.get("prompt"), body.get("images") + + @staticmethod + def create_video_body(video: str, prompt: str = None): + return json.dumps({"video": video, "prompt": prompt}) + + @staticmethod + def parse_video_body(video_body: str) -> Tuple[str, str]: + body = json.loads(video_body) + return body.get("prompt"), body.get("video") + + def set_image(self, sender: str, target: str, image_format: str, images: [str], prompt: str = None, topic: str = None): + self.sender = sender + self.target = target + self.create_time = time.time() + self.body_mime = f"image/{image_format}" + self.body = self.create_image_body(images, prompt) + if topic: + self.topic = topic + + def is_image_msg(self) -> bool: + if self.body_mime is None: + return False + if self.body_mime.startswith("image/"): + return True + return False + + def get_image_body(self) -> Tuple[str, List[str]]: + if self.body_mime is None: + return None + if self.body_mime.startswith("image/"): + return self.parse_image_body(self.body) + return None + + def set_video(self, sender: str, target: str, video_format: str, video: str, prompt: str = None, topic: str = None): + self.sender = sender + self.target = target + self.create_time = time.time() + self.body_mime = f"video/{video_format}" + self.body = self.create_video_body(video, prompt) + if topic: + self.topic = topic + + def get_video_body(self) -> Tuple[str, str]: + if self.body_mime is None: + return None + if self.body_mime.startswith("video/"): + return self.parse_video_body(self.body) + return None + + def is_video_msg(self) -> bool: + if self.body_mime is None: + return False + if self.body_mime.startswith("video/"): + return True + return False + def get_msg_id(self) -> str: return self.msg_id @@ -164,4 +230,4 @@ class AgentMsg: str_list = shlex.split(func_string) func_name = str_list[0] params = str_list[1:] - return func_name, params \ No newline at end of file + return func_name, params diff --git a/src/aios/utils/__init__.py b/src/aios/utils/__init__.py new file mode 100644 index 0000000..dbaab83 --- /dev/null +++ b/src/aios/utils/__init__.py @@ -0,0 +1,2 @@ +from . import image_utils +from . import video_utils diff --git a/src/aios/utils/image_utils.py b/src/aios/utils/image_utils.py new file mode 100644 index 0000000..3d07b13 --- /dev/null +++ b/src/aios/utils/image_utils.py @@ -0,0 +1,40 @@ +import base64 +import os.path +from typing import Tuple + +import cv2 + + +def to_base64(image_path: str, resize: Tuple[int, int] = None) -> str: + """Convert image to base64.""" + ext = os.path.splitext(image_path)[1][1:] + if resize is None: + with open(image_path, "rb") as image_file: + base64_image = base64.b64encode(image_file.read()).decode("utf-8") + return f"data:image/{ext};base64,{base64_image}" + else: + dest_width, dest_height = resize + img = cv2.imread(image_path) + width, height = img.shape[:2] + if width > dest_width or height > dest_height: + width_rate = dest_width / width + height_rate = dest_height / height + rate = min(width_rate, height_rate) + dest_width = int(width * rate) + dest_height = int(height * rate) + img = cv2.resize(img, (dest_width, dest_height), interpolation=cv2.INTER_AREA) + _, buf = cv2.imencode(f".{ext}", img) + base64_image = base64.b64encode(buf).decode("utf-8") + return f"data:image/{ext};base64,{base64_image}" + + +def is_file(image_path: str) -> bool: + return os.path.isfile(image_path) + + +def is_base64(image_path: str) -> bool: + return image_path.startswith("data:image/") + + +def is_url(image_path: str) -> bool: + return image_path.startswith("http://") or image_path.startswith("https://") diff --git a/src/aios/utils/video_utils.py b/src/aios/utils/video_utils.py new file mode 100644 index 0000000..dd687cf --- /dev/null +++ b/src/aios/utils/video_utils.py @@ -0,0 +1,122 @@ +import base64 +from typing import List, Tuple + +import cv2 +import numpy as np + + +def precess_image(image): + ''' + Graying and GaussianBlur + :param image: The image matrix,np.array + :return: The processed image matrix,np.array + ''' + gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + gray_image = cv2.GaussianBlur(gray_image, (3, 3), 0) + return gray_image + + +def abs_diff(pre_image, curr_image): + ''' + Calculate absolute difference between pre_image and curr_image + :param pre_image:The image in past frame,np.array + :param curr_image:The image in current frame,np.array + :return: + ''' + gray_pre_image = precess_image(pre_image) + gray_curr_image = precess_image(curr_image) + diff = cv2.absdiff(gray_pre_image, gray_curr_image) + res, diff = cv2.threshold(diff, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU) + cnt_diff = np.sum(np.sum(diff)) + return cnt_diff + + +def exponential_smoothing(alpha, s): + ''' + Primary exponential smoothing + :param alpha: Smoothing factor,num + :param s: List of data,list + :return: List of data after smoothing,list + ''' + s_temp = [s[0]] + print(s_temp) + for i in range(1, len(s), 1): + s_temp.append(alpha * s[i - 1] + (1 - alpha) * s_temp[i - 1]) + return s_temp + + +def extract_frames(video_path: str, resize: Tuple[int, int] = None, smooth=False, alpha=0.07, window=25) -> List[str]: + """Extract frames from video.""" + frames = [] + vidcap = cv2.VideoCapture(video_path) + diff = [] + frm = 0 + pre_image = np.array([]) + cur_image = np.array([]) + + while True: + frm = frm + 1 + success, image = vidcap.read() + if not success: + break + + if frm == 1: + pre_image = image + cur_image = image + else: + pre_image = cur_image + cur_image = image + + diff.append(abs_diff(pre_image, cur_image)) + + if smooth: + diff = exponential_smoothing(alpha, diff) + + diff = np.array(diff) + mean = np.mean(diff) + dev = np.std(diff) + diff = (diff - mean) / dev + + idx = [] + for i, d in enumerate(diff): + ub = len(diff) - 1 + lb = 0 + if not i - window // 2 < lb: + lb = i - window // 2 + if not i + window // 2 > ub: + ub = i + window // 2 + + comp_window = diff[lb: ub] + if d >= max(comp_window): + idx.append(i) + + tmp = np.array(idx) + tmp = tmp + 1 + idx = set(tmp.tolist()) + vidcap.release() + + vidcap = cv2.VideoCapture(video_path) + i = 0 + frm = 0 + while vidcap.isOpened() and i < 10: + frm = frm + 1 + success, image = vidcap.read() + if not success: + break + if frm not in idx: + continue + if resize is not None: + dest_width, dest_height = resize + width, height = image.shape[:2] + if width > dest_width or height > dest_height: + width_rate = dest_width / width + height_rate = dest_height / height + rate = min(width_rate, height_rate) + dest_width = int(width * rate) + dest_height = int(height * rate) + image = cv2.resize(image, (dest_width, dest_height), interpolation=cv2.INTER_AREA) + _, buffer = cv2.imencode(".jpg", image) + frames.append(f"data:image/jpg;base64,{base64.b64encode(buffer).decode('utf-8')}") + i += 1 + vidcap.release() + return frames diff --git a/src/aios_kernel/image_utils.py b/src/aios_kernel/image_utils.py deleted file mode 100644 index dd706bc..0000000 --- a/src/aios_kernel/image_utils.py +++ /dev/null @@ -1,22 +0,0 @@ -import base64 -import os.path - - -def to_base64(image_path: str) -> str: - """Convert image to base64.""" - ext = os.path.splitext(image_path)[1] - with open(image_path, "rb") as image_file: - base64_image = base64.b64encode(image_file.read()).decode("utf-8") - return f"data:image/{ext};base64,{base64_image}" - - -def is_file(image_path: str) -> bool: - return os.path.isfile(image_path) - - -def is_base64(image_path: str) -> bool: - return image_path.startswith("data:image/") - - -def is_url(image_path: str) -> bool: - return image_path.startswith("http://") or image_path.startswith("https://") diff --git a/src/aios_kernel/video_utils.py b/src/aios_kernel/video_utils.py deleted file mode 100644 index 444fab8..0000000 --- a/src/aios_kernel/video_utils.py +++ /dev/null @@ -1,18 +0,0 @@ -import base64 -from typing import List - -import cv2 - - -def extract_frames(video_path: str) -> List[str]: - """Extract frames from video.""" - frames = [] - vidcap = cv2.VideoCapture(video_path) - while vidcap.isOpened(): - success, image = vidcap.read() - if not success: - break - _, buffer = cv2.imencode(".jpg", image) - frames.append(base64.b64encode(buffer).decode("utf-8")) - vidcap.release() - return frames diff --git a/src/component/openai_node/open_ai_node.py b/src/component/openai_node/open_ai_node.py index 1918b2d..e45dd95 100644 --- a/src/component/openai_node/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -8,9 +8,10 @@ import json import aiohttp import base64 import requests +from openai._types import NOT_GIVEN from aios import ComputeTask, ComputeTaskResult, ComputeTaskState, ComputeTaskType,ComputeTaskResultCode,ComputeNode,AIStorage,UserConfig - +from aios import image_utils logger = logging.getLogger(__name__) @@ -102,7 +103,7 @@ class OpenAI_ComputeNode(ComputeNode): image_path = task.params["image_path"] if image_utils.is_file(image_path): - url = image_utils.to_base64(image_path) + url = image_utils.to_base64(image_path, (1024, 1024)) else: url = image_path @@ -201,7 +202,16 @@ class OpenAI_ComputeNode(ComputeNode): if max_token_size is None: max_token_size = 4000 - result_token = max_token_size + if mode_name == "gpt-4-vision-preview": + response_format = NOT_GIVEN + llm_inner_functions = None + if max_token_size > 4096: + result_token = 4096 + else: + result_token = max_token_size + else: + result_token = NOT_GIVEN + client = AsyncOpenAI(api_key=self.openai_api_key) try: if llm_inner_functions is None: @@ -209,7 +219,7 @@ class OpenAI_ComputeNode(ComputeNode): resp = await client.chat.completions.create(model=mode_name, messages=prompts, response_format = response_format, - #max_tokens=result_token, + max_tokens=result_token, ) else: logger.info(f"call openai {mode_name} prompts: \n\t {prompts} \nfunctions: \n\t{json.dumps(llm_inner_functions)}") @@ -217,7 +227,7 @@ class OpenAI_ComputeNode(ComputeNode): messages=prompts, response_format = response_format, functions=llm_inner_functions, - # max_tokens=result_token, + max_tokens=result_token, ) # TODO: add temperature to task params? except Exception as e: logger.error(f"openai run LLM_COMPLETION task error: {e}") @@ -227,7 +237,12 @@ class OpenAI_ComputeNode(ComputeNode): return result logger.info(f"openai response: {resp}") - status_code = resp.choices[0].finish_reason + if mode_name == "gpt-4-vision-preview": + status_code = resp.choices[0].finish_reason + if status_code is None: + status_code = resp.choices[0].finish_details['type'] + else: + status_code = resp.choices[0].finish_reason token_usage = resp.usage match status_code: diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 16de836..f299d76 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -31,7 +31,7 @@ from aios import * sys.path.append(directory + '/../../component/') -from google_node import * +from google_node import * from llama_node import * from openai_node import * from sd_node import * @@ -460,6 +460,54 @@ class AIOS_Shell: show_text = FormattedText([("class:title", f"{self.current_topic}@{self.current_target} >>> "), ("class:content", resp)]) return show_text + case 'send_img': + sender = None + if len(args) == 4: + target_id = args[0] + msg_content = args[1] + image_path = args[2] + topic = args[3] + sender = self.username + elif len(args) == 5: + target_id = args[0] + msg_content = args[1] + image_path = args[2] + topic = args[3] + sender = args[4] + + ext = os.path.splitext(image_path)[1][1:] + resp = await self.send_msg(AgentMsg.create_image_body([image_path], msg_content), + target_id, + topic, + sender, + f"image/{ext}") + show_text = FormattedText([("class:title", f"{self.current_topic}@{self.current_target} >>> "), + ("class:content", resp)]) + return show_text + case 'send_video': + sender = None + if len(args) == 4: + target_id = args[0] + msg_content = args[1] + video_path = args[2] + topic = args[3] + sender = self.username + elif len(args) == 5: + target_id = args[0] + msg_content = args[1] + video_path = args[2] + topic = args[3] + sender = args[4] + + ext = os.path.splitext(video_path)[1][1:] + resp = await self.send_msg(AgentMsg.create_video_body(video_path, msg_content), + target_id, + topic, + sender, + f"video/{ext}") + show_text = FormattedText([("class:title", f"{self.current_topic}@{self.current_target} >>> "), + ("class:content", resp)]) + return show_text case 'set_config': show_text = FormattedText([("class:error", f"set config args error,/set_config $config_item! ")]) if len(args) == 1: @@ -775,6 +823,8 @@ async def main(): return await main_daemon_loop(shell) completer = WordCompleter(['/send $target $msg $topic', + '/send_img $target $msg $img_path $topic', + '/send_video $target &msg &video_path $topic', '/open $target $topic', '/history $num $offset', '/connect $target', From 1cdf6f73c0572436109f78adc9501478187b6daf Mon Sep 17 00:00:00 2001 From: wugren Date: Mon, 4 Dec 2023 10:51:45 +0800 Subject: [PATCH 45/79] Implement IssueAgent --- src/component/mail_environment/issue.py | 68 ++++++++++++++----------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/src/component/mail_environment/issue.py b/src/component/mail_environment/issue.py index 0c55e7e..075d12b 100644 --- a/src/component/mail_environment/issue.py +++ b/src/component/mail_environment/issue.py @@ -19,7 +19,7 @@ class IssueUpdateHistory: "source": self.source, "changes": self.changes, } - + @classmethod def from_json_dict(cls, json_dict: dict) -> "IssueUpdateHistory": return IssueUpdateHistory(json_dict["source"], json_dict["changes"]) @@ -40,7 +40,7 @@ class Issue: json_dict = { "id": self.id, "summary": self.summary, - "state": self.state.name, + "state": self.state.name, "create_time": self.create_time, "deadline": self.deadline, "source": self.source, @@ -54,7 +54,7 @@ class Issue: json_dict["update_history"] = [] for history in self.update_history: json_dict["update_history"].append(history.to_json_dict()) - + return json_dict @classmethod @@ -78,26 +78,26 @@ class Issue: history = IssueUpdateHistory.from_json_dict(history_json_dict) issue.update_history.append(history) return issue - + @classmethod def object_type(cls) -> ObjectType: return ObjectType.from_user_def_type_code(0) - + def __to_desc(self, desc_list:[], recursion=None): desc = { "id": self.id, "summary": self.summary, - "state": self.state.name, + "state": self.state.name, "deadline": self.deadline, } desc_list.append(desc) if not recursion or not self.parent: - return + return else: parent = recursion.get_issue_by_id(self.parent) parent.__to_desc(desc_list, recursion) - + def to_prompt(self, recursion=None) -> str: desc_list = [] self.__to_desc(desc_list, recursion) @@ -107,8 +107,8 @@ class Issue: root["child"] = child root = child return json.dumps(root) - - + + @classmethod def prompt_desc(cls) -> str: return '''a issue contains following fileds: { @@ -119,7 +119,7 @@ class Issue: children: child issues of this issue } ''' - + def calculate_id(self) -> str: desc = { "summary": self.summary, @@ -183,7 +183,7 @@ class IssueStorage: return self.root this_mail = mail_storage.get_mail_by_id(this_mail.reply_to) - + def add_issue(self, source_id: str, parent_id: str, summary: str): parent_issue = self.get_issue_by_id(parent_id) issue = Issue() @@ -204,11 +204,19 @@ class IssueStorage: "new": value, } issue.__dict__[key] = value - issue.update_history.append(IssueUpdateHistory(source_id, changes)) + issue.update_history.append(IssueUpdateHistory(source_id, changes)) self.__flush() return issue - + + +class IssueAgent(CustomAIAgent): + async def _process_msg(self, msg: AgentMsg, workspace=None) -> AgentMsg: + pass + + def __init__(self, agent_id: str, llm_model_name: str, max_token_size: int) -> None: + super().__init__(agent_id, llm_model_name, max_token_size) + class IssueParserEnvironment(Environment): def __init__(self, env_id: str, storage: IssueStorage) -> None: @@ -217,30 +225,30 @@ class IssueParserEnvironment(Environment): create_description = '''create a new issue''' create_param = { - "mail_id": "new issue with which email object id", + "mail_id": "new issue with which email object id", "issue_id": '''new issue's parent issue id''', "summary": '''new issue's summary''', } - self.add_ai_function(SimpleAIFunction("create_issue", + self.add_ai_function(SimpleAIFunction("create_issue", create_description, - self._create, + self._create, create_param)) - + update_description = '''update an existing issue''' update_param = { - "mail_id": "update issue with which email object id", + "mail_id": "update issue with which email object id", "issue_id": '''update issue's id''', "summary": '''issue's new summary''', } - self.add_ai_function(SimpleAIFunction("update_issue", + self.add_ai_function(SimpleAIFunction("update_issue", update_description, - self._update, + self._update, update_param)) - + async def _create(self, mail_id: str, issue_id: str, summary: str): issue = self.storage.add_issue(mail_id, issue_id, summary) return issue.id - + async def _update(self, mail_id: str, issue_id: str, summary: str): update = {} update["summary"] = summary @@ -253,7 +261,7 @@ class IssueParser: mail_path = string.Template(config["mail_path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) issue_path = string.Template(config["issue_path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) config["path"] = issue_path - + self.env = env self.config = config self.mail_storage = MailStorage(mail_path) @@ -268,7 +276,7 @@ class IssueParser: self.llm_env = IssueParserEnvironment("issue_parser", self.issue_storage) @classmethod - def __load_issue_config(cls, issue_config: dict) -> Issue: + def __load_issue_config(cls, issue_config: dict) -> Issue: issue = Issue() issue.summary = issue_config["summary"] if "children" in issue_config: @@ -276,15 +284,15 @@ class IssueParser: child_issue = cls.__load_issue_config(child_config) issue.children.append(child_issue) return issue - + @classmethod def __calac_issue_id(cls, issue: Issue): issue_id = issue.calculate_id() for child in issue.children: child.parent = issue_id cls.__calac_issue_id(child) - - + + def get_path(self) -> str: return self.config["path"] @@ -304,8 +312,8 @@ class IssueParser: and a issue in json format, {issue_desc}. Read mail's fileds and issue's fileds, and decide if you should update the issue or create a new issue with this mail. Then call the function create_issue or update_issue. if this mail is not associated with issue, you should ignore this mail.'''} - - prompt.append(AgentPrompt(f'''Mail is {mail_str}, issue is {issue_str}. Answer me the function's return value or None if igonred. + + prompt.append(IssueAgent(f'''Mail is {mail_str}, issue is {issue_str}. Answer me the function's return value or None if igonred. ''')) llm_result = await CustomAIAgent("issue parser", "gpt-4-1106-preview", 4000).do_llm_complection(prompt, env=self.llm_env) From 1e987cc9d344b656b6cb91dafe40603a5835368b Mon Sep 17 00:00:00 2001 From: wugren Date: Mon, 4 Dec 2023 19:05:41 +0800 Subject: [PATCH 46/79] =?UTF-8?q?Supports=20telegram=E2=80=99s=20picture?= =?UTF-8?q?=20and=20video=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rootfs/agents/Vision/agent.toml | 6 ++-- src/component/tg_tunnel.py | 54 +++++++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/rootfs/agents/Vision/agent.toml b/rootfs/agents/Vision/agent.toml index 52b50b6..672c219 100644 --- a/rootfs/agents/Vision/agent.toml +++ b/rootfs/agents/Vision/agent.toml @@ -1,8 +1,8 @@ instance_id = "Vision" fullname = "Vision" -llm_model_name = "gpt-4-vision-preview" +llm_model_name = "gpt-4-1106-preview" [[prompt]] role = "system" -content = """你的工作对用户输入的图片和视频做分析,并根据用户的意图做出回应。 -如果用户请求的是视频时,你接受到的是视频的关键帧,请根据关键帧内容回复用户问题。""" +content = """Your job is to analyze user input images and videos and respond based on user intent. +If the user requests a video and you receive key frames of the video, please reply to the user's question based on the key frame content.""" diff --git a/src/component/tg_tunnel.py b/src/component/tg_tunnel.py index c0941ba..fa0e5e5 100644 --- a/src/component/tg_tunnel.py +++ b/src/component/tg_tunnel.py @@ -1,4 +1,6 @@ +import datetime import logging +import os.path import threading import asyncio import uuid @@ -51,6 +53,9 @@ class TelegramTunnel(AgentTunnel): self.allow_group = "contact" self.in_process_tg_msg = {} self.chatid_record = {} + self.telegram_cache = os.path.join(AIStorage.get_instance().get_myai_dir(), "telegram") + if not os.path.exists(self.telegram_cache): + os.makedirs(self.telegram_cache) async def _do_process_raw_message(self,bot: Bot, update_id: int) -> int: # Request updates after the last update_id @@ -58,7 +63,7 @@ class TelegramTunnel(AgentTunnel): for update in updates: next_update_id = update.update_id + 1 - if update.message and update.message.text: + if update.message and (update.message.text or (update.message.photo and len(update.message.photo) > 0) or update.message.video): await self.on_message(bot,update) return next_update_id @@ -96,9 +101,10 @@ class TelegramTunnel(AgentTunnel): update_id += 1 except Exception as e: logger.error(f"tg_tunnel error:{e}") + logger.exception(e) await asyncio.sleep(1) - + asyncio.create_task(_run_app()) logger.info(f"tunnel {self.tunnel_id} started.") @@ -120,7 +126,7 @@ class TelegramTunnel(AgentTunnel): # if chatid is None: # logger.warning(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target} failed! chatid not found!") # return None - + # if bot is None: # logger.warning(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target} failed! bot not found!") # return None @@ -130,7 +136,7 @@ class TelegramTunnel(AgentTunnel): # await bot.send_message(chat_id=chatid,text=msg.body) # logging.info(f"tg_tunnel send message {msg.msg_id} from agent {msg.sender} to human {msg.target} @ chatid:{chatid}success!") # return None - + # logger.warning(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target} failed! contact not found!") # return None @@ -143,13 +149,37 @@ class TelegramTunnel(AgentTunnel): else: logger.warning(f"tg_tunnel process message {msg.msg_id} from agent {msg.sender} to human {msg.target} failed! chatid not found!") + def get_cache_path(self) -> str: + today = datetime.datetime.today() + path = os.path.join(self.telegram_cache, str(today.year), str(today.month)) + if not os.path.exists(path): + os.makedirs(path) + return path async def conver_tg_msg_to_agent_msg(self,message:Message) -> AgentMsg: agent_msg = AgentMsg() agent_msg.topic = "_telegram" agent_msg.msg_id = "tg_msg#" + str(message.message_id) + "#" + uuid.uuid4().hex agent_msg.target = self.target_id - agent_msg.body = message.text + if message.text is not None: + agent_msg.body = message.text + elif message.photo is not None and len(message.photo) > 0: + photo_files = [] + photo_file = await message.photo[-1].get_file() + ext = photo_file.file_path.rsplit(".")[-1] + file_path = os.path.join(self.get_cache_path(), photo_file.file_id + f".{ext}") + await photo_file.download_to_drive(file_path) + photo_files.append(file_path) + agent_msg.body = agent_msg.create_image_body(photo_files, message.caption) + agent_msg.body_mime = f"image/{ext}" + elif message.video is not None: + video_file = await message.video.get_file() + ext = video_file.file_path.rsplit(".")[-1] + file_path = os.path.join(self.get_cache_path(), video_file.file_id + f".{ext}") + await video_file.download_to_drive(file_path) + agent_msg.body = agent_msg.create_video_body(file_path, message.caption) + agent_msg.body_mime = f"video/{ext}" + agent_msg.create_time = time.time() messag_type = message.chat.type if messag_type == "supergroup" or messag_type == "group": @@ -168,7 +198,7 @@ class TelegramTunnel(AgentTunnel): agent_msg.mentions.append(self.target_id) else: agent_msg.mentions.append(mention) - + if message.caption_entities: for entity in message.caption_entities: if entity.type == 'mention': @@ -203,11 +233,11 @@ class TelegramTunnel(AgentTunnel): if update.effective_user.is_bot: logger.warning(f"ignore message from telegram bot {update.effective_user.id}") return None - + if self.in_process_tg_msg.get(update.message.message_id) is not None: logger.warning(f"ignore message from telegram bot {update.effective_user.id}") return None - + self.in_process_tg_msg[update.message.message_id] = True agent_msg = await self.conver_tg_msg_to_agent_msg(message) @@ -226,7 +256,7 @@ class TelegramTunnel(AgentTunnel): if self.allow_group != "contact" and self.allow_group !="guest": await update.message.reply_text(f"You're not supposed to talk to me! Please contact my father~") return - + else: if self.allow_group != "guest": await update.message.reply_text(f"The current Telegram account is not in the contact list. If you want to receive a reply, you can add the configuration in the contacts.toml file or switch tunnel to guest mode.") @@ -246,7 +276,7 @@ class TelegramTunnel(AgentTunnel): if contact is not None: contact.set_active_tunnel(self.target_id,self) self.chatid_record[reomte_user_name] = update.effective_chat.id - self.ai_bus.register_message_handler(reomte_user_name,contact._process_msg) + self.ai_bus.register_message_handler(reomte_user_name,contact._process_msg) agent_msg.sender = reomte_user_name logger.info(f"process message {agent_msg.msg_id} from {agent_msg.sender} to {agent_msg.target}") @@ -266,11 +296,11 @@ class TelegramTunnel(AgentTunnel): if resp_msg.body_mime is None: if resp_msg.body is None: return - + if len(resp_msg.body) < 1: await update.message.reply_text("") return - + knowledge_object = KnowledgeStore().parse_object_in_message(resp_msg.body) if knowledge_object is not None: if knowledge_object.get_object_type() == ObjectType.Image: From 5f451107c9d3473f2e4df5395c72e6448eaf05b4 Mon Sep 17 00:00:00 2001 From: wugren Date: Tue, 5 Dec 2023 18:08:32 +0800 Subject: [PATCH 47/79] =?UTF-8?q?Add=20pdf=E3=80=81docx=20parser=20and=20i?= =?UTF-8?q?mage=E3=80=81video=20to=20text=20function=20for=20knowledge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rootfs/agents/TextSummary/agent.py | 2 +- rootfs/knowledge_pipelines/Mia/input.py | 162 ++++++++++++++++++++++-- src/aios/knowledge/object/object_id.py | 17 ++- 3 files changed, 164 insertions(+), 17 deletions(-) diff --git a/rootfs/agents/TextSummary/agent.py b/rootfs/agents/TextSummary/agent.py index 537f520..831b786 100644 --- a/rootfs/agents/TextSummary/agent.py +++ b/rootfs/agents/TextSummary/agent.py @@ -20,7 +20,7 @@ class TextSummaryAgent(CustomAIAgent): chunks = split_text(msg.body, separators=["\n\n", "\n"], chunk_size=4000, chunk_overlap=200, length_function=len) prompt = AgentPrompt() - prompt.system_message = "Your job is to generate a summary based on the input." + prompt.system_message = {"role":"system","content":"Your job is to generate a summary based on the input."} if len(chunks) == 1: prompt.append(AgentPrompt(chunks[0])) resp = await self.do_llm_complection(prompt) diff --git a/rootfs/knowledge_pipelines/Mia/input.py b/rootfs/knowledge_pipelines/Mia/input.py index e9a54f4..4166bef 100644 --- a/rootfs/knowledge_pipelines/Mia/input.py +++ b/rootfs/knowledge_pipelines/Mia/input.py @@ -1,17 +1,30 @@ +import copy import os +from typing import List + import aiofiles import chardet import logging import string -from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal -from aios_kernel.storage import AIStorage + +import docx2txt +from PyPDF2 import PdfReader + +from aios import KnowledgePipelineEnvironment, ImageObjectBuilder, DocumentObjectBuilder, KnowledgeStore, RichTextObject +from aios.agent.agent_base import AgentPrompt +from aios.frame.compute_kernel import ComputeKernel +from aios.knowledge.data.writer import split_text +from aios.proto.compute_task import ComputeTaskResult, ComputeTaskResultCode +from aios.storage.storage import AIStorage +from aios.utils import video_utils, image_utils + class KnowledgeDirSource: def __init__(self, env: KnowledgePipelineEnvironment, config): self.env = env path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) config["path"] = path - self.config = config + self.config = config # @classmethod # def user_config_items(cls): @@ -19,16 +32,16 @@ class KnowledgeDirSource: def path(self): return self.config["path"] - + @staticmethod async def read_txt_file(file_path:str)->str: cur_encode = "utf-8" async with aiofiles.open(file_path,'rb') as f: cur_encode = chardet.detect(await f.read())['encoding'] - + async with aiofiles.open(file_path,'r',encoding=cur_encode) as f: return await f.read() - + async def next(self): while True: journals = self.env.journal.latest_journals(1) @@ -42,7 +55,7 @@ class KnowledgeDirSource: if os.path.getmtime(self.path()) <= from_time: yield (None, None) continue - + file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) for rel_path in file_pathes: file_path = os.path.join(self.path(), rel_path) @@ -62,7 +75,138 @@ class KnowledgeDirSource: await self.env.get_knowledge_store().insert_object(document) yield (document.calculate_id(), file_path) yield (None, None) - + def init(env: KnowledgePipelineEnvironment, params: dict) -> KnowledgeDirSource: - return KnowledgeDirSource(env, params) \ No newline at end of file + return KnowledgeDirSource(env, params) + + +async def image_to_text(images: List[str]) -> str: + msg_prompt = AgentPrompt() + image_prompt = "What's in this image?" + content = [{"type": "text", "text": image_prompt}] + content.extend([{"type": "image_url", "image_url": {"url": image_utils.to_base64(image)}} for image in images]) + msg_prompt.messages = [{"role": "user", "content": content}] + + resp: ComputeTaskResult = await (ComputeKernel.get_instance() + .do_llm_completion(prompt=msg_prompt, + resp_mode="text", + mode_name="gpt-4-vision-preview", + max_token=4000, + inner_functions=None, + timeout=None)) + if resp.result_code != ComputeTaskResultCode.OK: + raise Exception(f"image_to_text error: {resp.result_code} msg:{resp.error_str}") + return resp.result_str + + +async def video_to_text(video: str) -> str: + prompt = "These pictures are key frames extracted from the video. Please describe the content of the video based on these key frames." + frames = video_utils.extract_frames(video, (1024, 1024)) + msg_prompt = AgentPrompt() + content = [{"type": "text", "text": prompt}] + content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) + msg_prompt.messages = [{"role": "user", "content": content}] + resp: ComputeTaskResult = await (ComputeKernel.get_instance() + .do_llm_completion(prompt=msg_prompt, + resp_mode="text", + mode_name="gpt-4-vision-preview", + max_token=4000, + inner_functions=None, + timeout=None)) + if resp.result_code != ComputeTaskResultCode.OK: + raise Exception(f"video_to_text error: {resp.result_code} msg:{resp.error_str}") + return resp.result_str + + +async def summary_document(text: str, separators: List[str]=["\n\n", "\n"]) -> str: + chunks = split_text(text, separators=separators, chunk_size=4000, chunk_overlap=200, length_function=len) + + prompt = AgentPrompt() + prompt.system_message = {"role":"system","content":"Your job is to generate a summary based on the input."} + if len(chunks) == 1: + prompt.append(AgentPrompt(chunks[0])) + resp = await (ComputeKernel.get_instance() + .do_llm_completion(prompt=prompt, + resp_mode="text", + mode_name="gpt-4-1106-preview", + max_token=4000, + inner_functions=None, + timeout=None)) + if resp.result_code != ComputeTaskResultCode.OK: + raise Exception(f"summary_document error: {resp.result_code} msg:{resp.error_str}") + return resp.result_str + + segments = [] + for i, chunk in enumerate(chunks): + seg_prompt = copy.deepcopy(prompt) + seg_prompt.append(AgentPrompt(chunk)) + resp = await (ComputeKernel.get_instance() + .do_llm_completion(prompt=seg_prompt, + resp_mode="text", + mode_name="gpt-4-1106-preview", + max_token=4000, + inner_functions=None, + timeout=None)) + if resp.result_code != ComputeTaskResultCode.OK: + raise Exception(f"summary_document error: {resp.result_code} msg:{resp.error_str}") + segments.append(resp.result_str) + + segments_str = "\n".join(segments) + prompt.append(AgentPrompt(f"Please combine the summaries of the following paragraphs into one complete summary:\n{segments_str}")) + resp = await (ComputeKernel.get_instance() + .do_llm_completion(prompt=prompt, + resp_mode="text", + mode_name="gpt-4-1106-preview", + max_token=4000, + inner_functions=None, + timeout=None)) + if resp.result_code != ComputeTaskResultCode.OK: + raise Exception(f"summary_document error: {resp.result_code} msg:{resp.error_str}") + return resp.result_str + + + +def pdf_to_rich_text_object(pdf: str, store: KnowledgeStore) -> RichTextObject: + base_name = os.path.basename(pdf) + cache_path = os.path.join(AIStorage.get_instance().get_myai_dir(), "knowledge", "doc_cache", base_name) + if not os.path.exists(cache_path): + os.makedirs(cache_path) + + reader = PdfReader(pdf) + rich_text = RichTextObject() + page_texts = [] + image_count = 0 + for page in reader.pages: + text = page.extract_text() + page_texts.append(text) + for image in page.images: + image_path = os.path.join(cache_path, f"{image_count}_{image.name}") + with open(image_path, "wb") as f: + f.write(image.data) + image_object = ImageObjectBuilder({}, {}, image_path).build(store) + rich_text.add_image(image_object) + + document = DocumentObjectBuilder({}, {}, "".join(page_texts)).build(store) + rich_text.add_document(document) + + return rich_text + + +def doc_to_rich_text_object(doc: str, store: KnowledgeStore) -> RichTextObject: + base_name = os.path.basename(doc) + cache_path = os.path.join(AIStorage.get_instance().get_myai_dir(), "knowledge", "doc_cache", base_name) + if not os.path.exists(cache_path): + os.makedirs(cache_path) + text = docx2txt.process(doc, cache_path) + + rich_text = RichTextObject() + for image in os.listdir(cache_path): + image_path = os.path.join(cache_path, image) + image_object = ImageObjectBuilder({}, {}, image_path).build(store) + rich_text.add_image(image_object) + + document = DocumentObjectBuilder({}, {}, text).build(store) + rich_text.add_document(document) + + return rich_text diff --git a/src/aios/knowledge/object/object_id.py b/src/aios/knowledge/object/object_id.py index 6cef161..1aca474 100644 --- a/src/aios/knowledge/object/object_id.py +++ b/src/aios/knowledge/object/object_id.py @@ -17,10 +17,10 @@ class ObjectType(IntEnum): def is_user_def(self) -> bool: return self.value >= 200 - + def get_user_def_type_code(self): return (self.value - 200) if self.is_user_def() else None - + @classmethod def from_user_def_type_code(cls, value): return value + 200 @@ -34,7 +34,7 @@ class ObjectID: # pylint: disable=too-few-public-methods def __str__(self): return self.to_base58() - + def to_base58(self): return base58.b58encode(self.value).decode() @@ -57,13 +57,16 @@ class ObjectID: # pylint: disable=too-few-public-methods def new_chunk_id(chunk_hash: HashValue): assert len(chunk_hash.value) == 32, "ObjectID must be 32 bytes long" return ObjectID(bytes([ObjectType.Chunk]) + chunk_hash.value[1:]) - + def get_object_type(self) -> ObjectType: return ObjectType(self.value[0]) - + @staticmethod def hash_data(data: bytes): return ObjectID.new_chunk_id(HashValue.hash_data(data)) - + def __eq__(self, other) -> bool: - return self.value == other.value \ No newline at end of file + return self.value == other.value + + def __hash__(self): + return hash(self.value) From b7b968d5f7d9e8ab88bac1d091dda2fd657bcab9 Mon Sep 17 00:00:00 2001 From: tsukasa Date: Fri, 1 Dec 2023 14:29:10 +0800 Subject: [PATCH 48/79] define bas environment --- rootfs/agents/JarvisPlus/agent.toml | 2 +- .../Mail/Spider/pipeline.toml | 4 - .../Mail/{Spider => Sync}/input.py | 0 .../Mail/Sync/pipeline.toml | 14 + rootfs/knowledge_pipelines/Mia/parser.py | 2 +- rootfs/knowledge_pipelines/pipelines.toml | 2 +- src/aios/agent/agent.py | 350 +++---- src/aios/agent/agent_base.py | 45 +- src/aios/agent/ai_function.py | 67 +- src/aios/environment/environment.py | 206 ++-- src/aios/environment/workspace_env.py | 927 ++++++------------ src/aios/knowledge/pipeline.py | 34 +- .../common_environment/local_document.py | 671 +++++++++++++ .../common_environment/to_learn_parser.py | 214 ++++ src/component/knowledge_manager/pipeline.py | 19 +- src/component/mail_environment/local.py | 2 +- src/component/mail_environment/mail.py | 129 ++- src/component/mail_environment/spider.py | 35 +- 18 files changed, 1700 insertions(+), 1023 deletions(-) delete mode 100644 rootfs/knowledge_pipelines/Mail/Spider/pipeline.toml rename rootfs/knowledge_pipelines/Mail/{Spider => Sync}/input.py (100%) create mode 100644 rootfs/knowledge_pipelines/Mail/Sync/pipeline.toml create mode 100644 src/component/common_environment/local_document.py create mode 100644 src/component/common_environment/to_learn_parser.py diff --git a/rootfs/agents/JarvisPlus/agent.toml b/rootfs/agents/JarvisPlus/agent.toml index dc249a2..b7cdac7 100644 --- a/rootfs/agents/JarvisPlus/agent.toml +++ b/rootfs/agents/JarvisPlus/agent.toml @@ -6,7 +6,7 @@ enable_timestamp = "true" owner_prompt = "I am your master {name} , now is {now}" contact_prompt = "I am your master's friend {name}" -[[do_prompt]] +[work.do] role = "system" content = """ My name is JarvisPlus, I am the master's super personal assistant. I think hard and try my best to complete TODOs. diff --git a/rootfs/knowledge_pipelines/Mail/Spider/pipeline.toml b/rootfs/knowledge_pipelines/Mail/Spider/pipeline.toml deleted file mode 100644 index df9f306..0000000 --- a/rootfs/knowledge_pipelines/Mail/Spider/pipeline.toml +++ /dev/null @@ -1,4 +0,0 @@ -name = "Mail.Issue" -input.module = "input.py" -input.params.path = "${myai_dir}/data" - diff --git a/rootfs/knowledge_pipelines/Mail/Spider/input.py b/rootfs/knowledge_pipelines/Mail/Sync/input.py similarity index 100% rename from rootfs/knowledge_pipelines/Mail/Spider/input.py rename to rootfs/knowledge_pipelines/Mail/Sync/input.py diff --git a/rootfs/knowledge_pipelines/Mail/Sync/pipeline.toml b/rootfs/knowledge_pipelines/Mail/Sync/pipeline.toml new file mode 100644 index 0000000..17fa1e2 --- /dev/null +++ b/rootfs/knowledge_pipelines/Mail/Sync/pipeline.toml @@ -0,0 +1,14 @@ +name = "Mail.Sync" +input.module = "input.py" +[input.params] +path = "${myai_dir}/mail" +imap_server = "imap.qq.com" +imap_port = 993 +address = "115620204@qq.com" +password = "zbbjpbukeonqbjja" +[input.params.fields] +from = "from" +to = "to" +subject = "subject" + + diff --git a/rootfs/knowledge_pipelines/Mia/parser.py b/rootfs/knowledge_pipelines/Mia/parser.py index cb8b5f8..6a3ca22 100644 --- a/rootfs/knowledge_pipelines/Mia/parser.py +++ b/rootfs/knowledge_pipelines/Mia/parser.py @@ -96,7 +96,7 @@ class EmbeddingParser: async def parse(self, object: ObjectID) -> str: obj = self.env.get_knowledge_store().load_object(object) await self.__do_embedding(obj) - return "insert into vector store" + return str(object) def init(env: KnowledgePipelineEnvironment, params: dict) -> EmbeddingParser: return EmbeddingParser(env, params) \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/pipelines.toml b/rootfs/knowledge_pipelines/pipelines.toml index 7e69436..2008066 100644 --- a/rootfs/knowledge_pipelines/pipelines.toml +++ b/rootfs/knowledge_pipelines/pipelines.toml @@ -1,3 +1,3 @@ pipelines = [ - "Mail/Issue" + "Mail/Sync" ] \ No newline at end of file diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 2b97326..4614216 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -18,6 +18,7 @@ from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode from .agent_base import * from .chatsession import * from .ai_function import * +from ..environment.workspace_env import WorkspaceEnvironment, TodoListType from ..frame.contact_manager import ContactManager,Contact,FamilyMember from ..frame.compute_kernel import ComputeKernel @@ -145,17 +146,22 @@ class AIAgent(BaseAIAgent): self.contact_prompt_str = None self.history_len = 10 - self.review_todo_prompt = None - self.read_report_prompt = None - self.do_prompt = None - self.check_prompt = None - - self.goal_to_todo_prompt = None + todo_prompts = {} + todo_prompts[TodoListType.TO_WORK] = { + "do": DEFAULT_AGENT_DO_PROMPT, + "check": DEFAULT_AGENT_SELF_CHECK_PROMPT, + "review": None, + } + todo_prompts[TodoListType.TO_LEARN] = { + "do": DEFAULT_AGENT_LEARN_PROMPT, + "check": None, + "review": None, + } + self.todo_prompts = todo_prompts self.learn_token_limit = 4000 - self.learn_prompt = AgentPrompt(DEFAULT_AGENT_LEARN_PROMPT) self.chat_db = None self.unread_msg = Queue() # msg from other agent @@ -163,19 +169,18 @@ class AIAgent(BaseAIAgent): self.owenr_bus = None self.enable_function_list = None - - @classmethod - def create_from_templete(cls,templete:AIAgentTemplete, fullname:str): - # Agent just inherit from templete on craete,if template changed,agent will not change - result_agent = AIAgent() - result_agent.llm_model_name = templete.llm_model_name - result_agent.max_token_size = templete.max_token_size - result_agent.template_id = templete.template_id - result_agent.agent_id = "agent#" + uuid.uuid4().hex - result_agent.fullname = fullname - result_agent.powerby = templete.author - result_agent.agent_prompt = templete.prompt - return result_agent + # @classmethod + # def create_from_templete(cls,templete:AIAgentTemplete, fullname:str): + # # Agent just inherit from templete on craete,if template changed,agent will not change + # result_agent = AIAgent() + # result_agent.llm_model_name = templete.llm_model_name + # result_agent.max_token_size = templete.max_token_size + # result_agent.template_id = templete.template_id + # result_agent.agent_id = "agent#" + uuid.uuid4().hex + # result_agent.fullname = fullname + # result_agent.powerby = templete.author + # result_agent.agent_prompt = templete.prompt + # return result_agent def load_from_config(self,config:dict) -> bool: if config.get("instance_id") is None: @@ -200,11 +205,25 @@ class AIAgent(BaseAIAgent): self.agent_think_prompt = AgentPrompt() self.agent_think_prompt.load_from_config(config["think_prompt"]) - if config.get("do_prompt") is not None: - self.do_prompt = AgentPrompt() - self.do_prompt.load_from_config(config["do_prompt"]) - self.wake_up() - + def load_todo_config(todo_type:str) -> bool: + todo_config = config.get(todo_type) + if todo_config is not None: + if todo_config.get("do") is not None: + prompt = AgentPrompt() + prompt.load_from_config(todo_config["do"]) + self.todo_prompts[todo_type]["do"] = prompt + if todo_config.get("check") is not None: + prompt = AgentPrompt() + prompt.load_from_config(todo_config["check"]) + self.todo_prompts[todo_type]["check"] = prompt + if todo_config.get("review_prompt") is not None: + prompt = AgentPrompt() + prompt.load_from_config(todo_config["review_prompt"]) + self.todo_prompts[todo_type]["review"] = prompt + + load_todo_config(TodoListType.TO_WORK) + load_todo_config(TodoListType.TO_LEARN) + if config.get("guest_prompt") is not None: self.guest_prompt_str = config["guest_prompt"] @@ -234,6 +253,9 @@ class AIAgent(BaseAIAgent): self.enable_timestamp = bool(config["enable_timestamp"]) if config.get("history_len"): self.history_len = int(config.get("history_len")) + + self.wake_up() + return True def get_id(self) -> str: @@ -372,7 +394,7 @@ class AIAgent(BaseAIAgent): # self._format_msg_by_env_value(prompt) # inner_functions,function_token_len = self._get_inner_functions() - # system_prompt_len = prompt.get_prompt_token_len() + # system_prompt_len = self.token_len(prompt=prompt) # input_len = len(msg.body) # history_prmpt,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) @@ -411,10 +433,10 @@ class AIAgent(BaseAIAgent): # resp_msg = msg.create_group_resp_msg(self.agent_id,final_result) # chatsession.append(msg) # chatsession.append(resp_msg) - # return resp_msg - # return None + + def get_workspace_by_msg(self,msg:AgentMsg) -> WorkspaceEnvironment: return self.agent_workspace @@ -528,7 +550,7 @@ class AIAgent(BaseAIAgent): have_known_info = True known_info_str += f"## todo\n{todos_str}\n" inner_functions,function_token_len = BaseAIAgent.get_inner_functions(self.owner_env) - system_prompt_len = prompt.get_prompt_token_len() + system_prompt_len = self.token_len(prompt=prompt) input_len = len(msg.body) if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: history_str,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) @@ -600,9 +622,7 @@ class AIAgent(BaseAIAgent): return None - async def _get_history_prompt_for_think(self,chatsession:AIChatSession,summary:str,system_token_len:int,pos:int)->(AgentPrompt,int): - history_len = (self.max_token_size * 0.7) - system_token_len messages = chatsession.read_history(self.history_len,pos,"natural") # read @@ -716,9 +736,7 @@ class AIAgent(BaseAIAgent): worksapce.set_work_summary(self.agent_id,task_result.result_str) - - # 尝试完成自己的TOOD (不依赖任何其他Agnet) - async def do_my_work(self) -> None: + async def _llm_run_todo_list(self, todo_list_type: TodoListType): workspace : WorkspaceEnvironment = self.get_workspace_by_msg(None) logger.info(f"agent {self.agent_id} do my work start!") @@ -726,134 +744,154 @@ class AIAgent(BaseAIAgent): #if await self.need_review_todolist(): # await self._llm_review_todolist(workspace) - todo_list = await workspace.get_todo_list(self.agent_id) + todo_list = workspace.todo_list[todo_list_type] + need_todo = todo_list.get_todo_list(self.agent_id) + check_count = 0 do_count = 0 + review_count = 0 - for todo in todo_list: + for todo in need_todo: if self.agent_energy <= 0: break + + do_prompts = self._can_do_todo(todo_list_type, todo) + if do_prompts: + prompt : AgentPrompt = AgentPrompt() + prompt.append(self.agent_prompt) + prompt.append(workspace.get_role_prompt(self.agent_id)) + prompt.append(do_prompts) + prompt.append(todo.to_prompt()) + + do_result : AgentTodoResult = await self._llm_do_todo(todo, prompt, workspace) + todo.last_do_time = datetime.datetime.now().timestamp() + todo.retry_count += 1 + + match do_result.result_code: + case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: + continue + case AgentTodoResult.TODO_RESULT_CODE_OK: + await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_WAITING_CHECK) + case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: + await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_EXEC_FAILED) - if await self.need_review_todo(todo,workspace): - review_result = await self._llm_review_todo(todo,workspace) - todo.last_review_time = datetime.datetime.now().timestamp() + await todo_list.append_worklog(todo,do_result) + self.agent_energy -= 2 + do_count += 1 + + # review_result = await self._llm_review_todo(todo,workspace) + # todo.last_review_time = datetime.datetime.now().timestamp() + continue - elif await self.can_check(todo,workspace): - check_result : AgentTodoResult = await self._llm_check_todo(todo,workspace) + check_prompts = self._can_check_todo(todo_list_type, todo) + if check_prompts: + prompt : AgentPrompt = AgentPrompt() + prompt.append(self.agent_prompt) + prompt.append(workspace.get_role_prompt(self.agent_id)) + prompt.append(check_prompts) + + if todo.last_check_result: + prompt.append(AgentPrompt(todo.last_check_result)) + + prompt.append(todo.detail) + prompt.append(todo.result) + + check_result: AgentTodoResult = await self._llm_check_todo(todo, prompt, workspace) todo.last_check_time = datetime.datetime.now().timestamp() match check_result.result_code: case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: continue case AgentTodoResult.TODO_RESULT_CODE_OK: - await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_DONE) + await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_DONE) case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: - await workspace.update_todo(todo.todo_id,AgentTodo.TDDO_STATE_CHECKFAILED) + await todo_list.update_todo(todo.todo_id,AgentTodo.TDDO_STATE_CHECKFAILED) - await workspace.append_worklog(todo,check_result) + await todo_list.append_worklog(todo, check_result) self.agent_energy -= 1 check_count += 1 - elif await self.can_do(todo,workspace): - do_result : AgentTodoResult = await self._llm_do(todo,workspace) - todo.last_do_time = datetime.datetime.now().timestamp() - todo.retry_count += 1 + continue + + review_prompts = self._can_review_todo(todo_list_type, todo) + if review_prompts: + prompt.append(workspace.get_prompt()) + prompt.append(workspace.get_role_prompt(self.agent_id)) + prompt.append(review_prompts) + + todo_tree = todo_list.get_todo_tree("/") + prompt.append(AgentPrompt(todo_tree)) + + do_result : AgentTodoResult = await self._llm_review_todo(todo, prompt, workspace) + todo.last_review_time = datetime.datetime.now().timestamp() match do_result.result_code: case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: continue - case AgentTodoResult.TODO_RESULT_CODE_OK: - await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_WAITING_CHECK) case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: - await workspace.update_todo(todo.todo_id,AgentTodo.TODO_STATE_EXEC_FAILED) + continue + case AgentTodoResult.TODO_RESULT_CODE_OK: + await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_REVIEWED) - await workspace.append_worklog(todo,do_result) - self.agent_energy -= 2 - do_count += 1 + await todo_list.append_worklog(todo,do_result) + self.agent_energy -= 1 + review_count += 1 + continue logger.info(f"agent {self.agent_id} ,check:{check_count} todo,do:{do_count} todo.") + + + def _can_review_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> AgentPrompt: + do_prompts = self.todo_prompts[todo_list_type].get("review") + if not do_prompts: + return None - def get_review_todo_prompt(self,todo:AgentTodo) -> AgentPrompt: - return self.review_todo_prompt + if todo.can_review() is False: + return None - async def _llm_review_todo(self,todo:AgentTodo,workspace:WorkspaceEnvironment): - prompt = AgentPrompt() + return do_prompts + - prompt.append(workspace.get_prompt()) - prompt.append(workspace.get_role_prompt(self.agent_id)) - prompt.append(self.get_review_todo_prompt(todo)) - - todo_tree = workspace.get_todo_tree("/") - prompt.append(AgentPrompt(todo_tree)) - inner_functions,_ = BaseAIAgent.get_inner_functions(self.owner_env) - - task_result:ComputeTaskResult = await self.do_llm_complection(prompt,inner_functions=inner_functions) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"_llm_review_todos compute error:{task_result.error_str}") - return - - return - - def get_do_prompt(self,todo:AgentTodo) -> AgentPrompt: - return self.do_prompt - - def get_prompt_from_todo(self,todo:AgentTodo) -> AgentPrompt: - json_str = json.dumps(todo.raw_obj) - return AgentPrompt(json_str) - - async def need_review_todo(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: - return False - - async def can_check(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: - if self.get_check_prompt(todo) is None: - return False + def _can_check_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> AgentPrompt: + do_prompts = self.todo_prompts[todo_list_type].get("check") + if not do_prompts: + return None if todo.can_check() is False: - return False + return None if todo.checker is not None: if todo.checker != self.agent_id: - return False + return None else: if self.can_do_unassigned_task is False: - return False + return None else: todo.checker = self.agent_id - return True + return do_prompts - async def can_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> bool: + async def _can_do_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> AgentPrompt: + do_prompts = self.todo_prompts[todo_list_type].get("do") + if not do_prompts: + return None + if todo.can_do() is False: - return False + return None if todo.worker is not None: if todo.worker != self.agent_id: - return False + return None else: if self.can_do_unassigned_task is False: - return False + return None else: todo.worker = self.agent_id - return True + return do_prompts - async def _llm_do(self,todo:AgentTodo,workspace:WorkspaceEnvironment) -> AgentTodoResult: + async def _llm_do_todo(self, todo: AgentTodo, prompt: AgentPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: result = AgentTodoResult() - prompt : AgentPrompt = AgentPrompt() - #prompt.append(self.agent_prompt) - prompt.append(workspace.get_role_prompt(self.agent_id)) - - do_prompt = workspace.get_do_prompt(todo) - if do_prompt is None: - do_prompt = self.get_do_prompt(todo) - - prompt.append(do_prompt) - - # There are general methods for executing todos, as well as customized ones that are more efficient for specific types of TODOS. - # Based on experience, an Agent can autonomously master/organize execution methods for a greater variety of TODO types. - - #prompt.append(work_log_prompt) - prompt.append(self.get_prompt_from_todo(todo)) - + task_result:ComputeTaskResult = await self.do_llm_complection(prompt) if task_result.error_str is not None: logger.error(f"_llm_do compute error:{task_result.error_str}") @@ -875,7 +913,7 @@ class AIAgent(BaseAIAgent): resp = await AIBus.get_default_bus().post_message(msg) logging.info(f"agent {self.agent_id} send msg to {msg.target} result:{resp}") - op_errors,have_error = await workspace.exec_op_list(llm_result.op_list,self.agent_id) + op_errors, have_error = await workspace.exec_op_list(llm_result.op_list, self.agent_id) if have_error: result.result_code = AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR #result.error_str = error_str @@ -883,37 +921,30 @@ class AIAgent(BaseAIAgent): return result - async def append_toddo_result(self,todo,worksapce,llm_result,result_str): - pass - - def get_check_prompt(self,todo:AgentTodo) -> AgentPrompt: - return self.check_prompt - - async def _llm_check_todo(self, todo:AgentTodo,workspace:WorkspaceEnvironment) : - if self.get_check_prompt(todo) is None: - return None - - prompt : AgentPrompt = AgentPrompt() - prompt.append(self.agent_prompt) - prompt.append(workspace.get_role_prompt(self.agent_id)) - prompt.append(self.get_check_prompt(todo)) - if todo.last_check_result: - prompt.append(AgentPrompt(todo.last_check_result)) - - prompt.append(todo.detail) - prompt.append(todo.result) - + async def _llm_check_todo(self, todo: AgentTodo, prompt: AgentPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: + result = AgentTodoResult() + inner_functions,_ = BaseAIAgent.get_inner_functions(workspace) task_result:ComputeTaskResult = await self.do_llm_complection(prompt,inner_functions=inner_functions,is_json_resp=True) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"_llm_check_todo compute error:{task_result.error_str}") - return False - - if task_result.result_str == "OK": - return True + if task_result.error_str is not None: + logger.error(f"_llm_do compute error:{task_result.error_str}") + result.result_code = AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR + result.error_str = task_result.error_str + return result + result.result_str = task_result.result_str todo.last_check_result = task_result.result_str - return False + return result + + async def _llm_review_todo(self, todo:AgentTodo, prompt: AgentPrompt, workspace: WorkspaceEnvironment): + inner_functions,_ = BaseAIAgent.get_inner_functions(self.owner_env) + + task_result:ComputeTaskResult = await self.do_llm_complection(prompt,inner_functions=inner_functions) + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"_llm_review_todos compute error:{task_result.error_str}") + return + + return # 尝试自我学习,会主动获取、读取资料并进行整理 # LLM的本质能力是处理海量知识,应该让LLM能基于知识把自己的工作处理的更好 @@ -1121,16 +1152,15 @@ class AIAgent(BaseAIAgent): used_energy = await self.think_chatsession(session_id) self.agent_energy -= used_energy - todo_logs = await self.get_todo_logs() - for todo_log in todo_logs: - if self.agent_energy <= 0: - break - used_energy = await self.think_todo_log(todo_log) - self.agent_energy -= used_energy + # todo_logs = await self.get_todo_logs() + # for todo_log in todo_logs: + # if self.agent_energy <= 0: + # break + # used_energy = await self.think_todo_log(todo_log) + # self.agent_energy -= used_energy return - async def think_todo_log(self,todo_log:AgentWorkLog): pass @@ -1146,7 +1176,7 @@ class AIAgent(BaseAIAgent): prompt:AgentPrompt = AgentPrompt() #prompt.append(self._get_agent_prompt()) prompt.append(await self._get_agent_think_prompt()) - system_prompt_len = prompt.get_prompt_token_len() + system_prompt_len = self.token_len(prompt=prompt) #think env? history_prompt,next_pos = await self._get_history_prompt_for_think(chatsession,summary,system_prompt_len,cur_pos) prompt.append(history_prompt) @@ -1220,11 +1250,7 @@ class AIAgent(BaseAIAgent): def need_self_think(self) -> bool: return False - def need_self_learn(self) -> bool: - if self.learn_prompt is not None: - return True - return False - + def wake_up(self) -> None: if self.agent_task is None: self.agent_task = asyncio.create_task(self._on_timer()) @@ -1248,26 +1274,20 @@ class AIAgent(BaseAIAgent): continue # complete & check todo - if self.need_work(): - await self.do_my_work() - - # review other's todo - # self.review_other_works() + await self._llm_run_todo_list(TodoListType.TO_WORK) + await self._llm_run_todo_list(TodoListType.TO_LEARN) + if self.need_self_think(): await self.do_self_think() - - if self.need_self_learn(): - await self.do_self_learn() - + + # review other's todo + # self.review_other_works() except Exception as e: tb_str = traceback.format_exc() logger.error(f"agent {self.agent_id} on timer error:{e},{tb_str}") continue - def token_len(self,text:str) -> int: - return ComputeKernel.llm_num_tokens_from_text(text,self.get_llm_model_name()) - diff --git a/src/aios/agent/agent_base.py b/src/aios/agent/agent_base.py index 4094c36..b706618 100644 --- a/src/aios/agent/agent_base.py +++ b/src/aios/agent/agent_base.py @@ -56,16 +56,6 @@ class AgentPrompt: self.messages.extend(prompt.messages) - def get_prompt_token_len(self): - result = 0 - - if self.system_message: - result += len(self.system_message.get("content")) - for msg in self.messages: - result += len(msg.get("content")) - - return result - def load_from_config(self,config:list) -> bool: if isinstance(config,list) is not True: logger.error("prompt is not list!") @@ -245,8 +235,9 @@ class AgentTodo: TODO_STATE_EXEC_FAILED = "exec_failed" TDDO_STATE_CHECKFAILED = "check_failed" - TODO_STATE_CASNCEL = "cancel" + TODO_STATE_CANCEL = "cancel" TODO_STATE_DONE = "done" + TODO_STATE_REVIEWED = "reviewed" TODO_STATE_EXPIRED = "expired" def __init__(self): @@ -341,6 +332,23 @@ class AgentTodo: result["retry_count"] = self.retry_count return result + + def to_prompt(self) -> AgentPrompt: + json_str = json.dumps(self.raw_obj) + return AgentPrompt(json_str) + + def can_review(self) -> bool: + if self.state != AgentTodo.TODO_STATE_DONE: + return False + + now = datetime.now().timestamp() + if self.last_review_time: + time_diff = now - self.last_review_time + if time_diff < 60*15: + logger.info(f"todo {self.title} is already reviewed, ignore") + return False + + return True def can_check(self)->bool: if self.state != AgentTodo.TODO_STATE_WAITING_CHECK: @@ -410,9 +418,18 @@ class BaseAIAgent(abc.ABC): def get_max_token_size(self) -> int: pass - @abstractmethod - async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: - pass + def token_len(self, text:str=None, prompt:AgentPrompt=None) -> int: + from .compute_kernel import ComputeKernel + if text: + return ComputeKernel.llm_num_tokens_from_text(text,self.get_llm_model_name()) + elif prompt: + result = 0 + if prompt.system_message: + result += ComputeKernel.llm_num_tokens_from_text(prompt.system_message.get("content"),self.get_llm_model_name()) + for msg in prompt.messages: + result += ComputeKernel.llm_num_tokens_from_text(msg.get("content"),self.get_llm_model_name()) + else: + return 0 @classmethod def get_inner_functions(cls, env:Environment) -> (dict,int): diff --git a/src/aios/agent/ai_function.py b/src/aios/agent/ai_function.py index bca41d2..92eb448 100644 --- a/src/aios/agent/ai_function.py +++ b/src/aios/agent/ai_function.py @@ -9,9 +9,6 @@ class ParameterDefine: class AIFunction: - def __init__(self) -> None: - self.description : str = None - @abstractmethod def get_name(self) -> str: """ @@ -24,7 +21,7 @@ class AIFunction: """ return a detailed description of what the function does """ - return self.description + pass @abstractmethod def get_parameters(self) -> Dict: @@ -112,6 +109,9 @@ class SimpleAIFunction(AIFunction): def get_name(self) -> str: return self.func_id + def get_description(self) -> str: + return self.description + def get_parameters(self) -> Dict: if self.parameters is not None: result = {} @@ -142,3 +142,62 @@ class SimpleAIFunction(AIFunction): def is_ready_only(self) -> bool: return False +class AIOperation: + @abstractmethod + def get_name(self) -> str: + """ + return the name of the operation (should be snake case) + """ + pass + + @abstractmethod + def get_description(self) -> str: + """ + return a detailed description of what the operation does + """ + pass + + + @abstractmethod + async def execute(self, params: dict) -> str: + """ + Execute the function and return a JSON serializable dict. + The parameters are passed in the form of kwargs + """ + pass + +class SimpleAIOperation(AIOperation): + def __init__(self,op:str,description:str,func_handler:Coroutine) -> None: + self.op = op + self.description = description + self.func_handler = func_handler + + def get_name(self) -> str: + return self.op + + def get_description(self) -> str: + return self.description + + async def execute(self, params: Dict) -> str: + if self.func_handler is None: + return "error: function not implemented" + + return await self.func_handler(**params) + + +class AIFunctionOperation(AIOperation): + def __init__(self, func: AIFunction) -> None: + self.func = func + super().__init__() + + @abstractmethod + def get_name(self) -> str: + return self.func.get_name() + + @abstractmethod + def get_description(self) -> str: + return self.func.get_description() + + @abstractmethod + async def execute(self, params: dict) -> str: + self.func.execute(**params) \ No newline at end of file diff --git a/src/aios/environment/environment.py b/src/aios/environment/environment.py index eafe74f..d4b426b 100644 --- a/src/aios/environment/environment.py +++ b/src/aios/environment/environment.py @@ -4,145 +4,127 @@ from abc import ABC, abstractmethod from typing import Any, Callable, Optional,Dict,Awaitable,List import logging +from ..agent.ai_function import AIFunction, AIOperation -from ..agent.ai_function import AIFunction logger = logging.getLogger(__name__) -class EnvironmentEvent(ABC): + +class BaseEnvironment: @abstractmethod - def display(self) -> str: + def get_id(self) -> str: + pass + + # @abstractmethod + # #TODO: how to use env? different env has different prompt + # def get_env_prompt(self) -> str: + # pass + + @abstractmethod + def get_ai_function(self,func_name:str) -> AIFunction: pass -EnvironmentEventHandler = Callable[[str,EnvironmentEvent],Awaitable[Any]] + @abstractmethod + def get_all_ai_functions(self) -> List[AIFunction]: + pass -class Environment: - _all_env = {} - @classmethod - def get_env_by_id(cls,env_id:str): - return cls._all_env.get(env_id) - @classmethod - def set_env_by_id(cls,id,env): - assert id == env.get_id() - cls._all_env[env.get_id()] = env + @abstractmethod + def get_ai_operation(self,op_name:str) -> AIOperation: + pass - def __init__(self,env_id:str) -> None: + @abstractmethod + def get_all_ai_operations(self) -> List[AIOperation]: + pass + + + + # _all_env = {} + # @classmethod + # def get_env_by_id(cls,env_id:str): + # return cls._all_env.get(env_id) + + # @classmethod + # def set_env_by_id(cls,id,env): + # assert id == env.get_id() + # cls._all_env[env.get_id()] = env + +class SimpleEnvironment(BaseEnvironment): + def __init__(self, env_id: str) -> None: self.env_id = env_id - self.values:Dict[str,str] = {} - self.get_handlers:Dict[str,Callable] = {} - self.owner_env:Dict[str,Environment] = {} - # self.valid_keys:Dict[str,bool] = None - self.event_handlers:Dict[str,List[EnvironmentEventHandler]]= {} - - self.functions : Dict[str,AIFunction] = {} + self.functions: Dict[str,AIFunction] = {} + self.operations: Dict[str,AIOperation] = {} def get_id(self) -> str: return self.env_id - - def add_owner_env(self,env) -> None: - self.owner_env[env.get_id()] = env - - #@abstractmethod - #TODO: how to use env? different env has different prompt - def get_env_prompt(self) -> str: - pass - + def add_ai_function(self,func:AIFunction) -> None: - if self.functions.get(func.get_name()) is not None: - logger.warn(f"add ai_function {func.get_name()} in env {self.env_id}:function already exist") - self.functions[func.get_name()] = func def get_ai_function(self,func_name:str) -> AIFunction: func = self.functions.get(func_name) if func is not None: return func - - for owner_env in self.owner_env.values(): - func = owner_env.get_ai_function(func_name) - if func is not None: - return func - return None - #def enable_ai_function(self,func_name:str) -> None: - # pass - - #def disable_ai_function(self,func_name:str) -> None: - # pass - def get_all_ai_functions(self) -> List[AIFunction]: func_list = [] func_list.extend(self.functions.values()) - for owner_env in self.owner_env.values(): - func_list.extend(owner_env.get_all_ai_functions()) return func_list - - @abstractmethod - def _do_get_value(self,key:str) -> Optional[str]: - pass - - def register_get_handler(self,key:str,handler:Callable) -> None: - h = self.get_handlers.get(key) - if h is not None: - logger.warn(f"register get_handler {key} in env {self.env_id}:handler already exist") - - self.get_handlers[key] = handler - - - def attach_event_handler(self,event_id:str,handler:Callable) -> None: - handler_list = self.event_handlers.get(event_id) - if handler_list is None: - handler_list = [] - self.event_handlers[event_id] = handler_list - - handler_list.append(handler) - - def remove_event_handler(self,event_id:str,handler:Callable) -> None: - handler_list = self.event_handlers.get(event_id) - if handler is not None: - handler_list.remove(handler) - return - - logger.warn(f"remove event_handler {event_id} in env {self.env_id}:handler not found") - - async def fire_event(self,event_id:str,event:EnvironmentEvent) -> None: - handler_list = self.event_handlers.get(event_id) - if handler_list is not None: - for handler in handler_list: - await handler(self.env_id,event) - else: - logger.debug(f"fire event {event_id} in env {self.env_id}:handler not found") - return - - def __getitem__(self, key): - return self.get_value(key) - - def get_value(self,key:str) -> Optional[str]: - handler = self.get_handlers.get(key) - if handler is not None: - return handler() - - s = self.values.get(key) - if isinstance(s,str): - return s - else: - logger.warn(f"get value {key} in env {self.env_id} failed!,type is not str") - - s = self._do_get_value(key) - if s is not None: - return s - if self.owner_env is not None: - for env in self.owner_env.values(): - s = env.get_value(key) - if s is not None: - return s - - logger.warn(f"get value {key} in env {self.env_id} failed!,not found") + + def add_ai_operation(self,op:AIOperation) -> None: + self.operations[op.get_name()] = op + + def get_ai_operation(self,op_name:str) -> AIOperation: + op = self.operations.get(op_name) + if op is not None: + return op return None - def set_value(self, key: str, str_value: str,is_storage:bool = True): - logger.info(f"set value {key} in env {self.env_id} to {str_value}") - self.values[key] = str_value + def get_all_ai_operations(self) -> List[AIOperation]: + op_list = [] + op_list.extend(self.operations.values()) + return op_list + + +class CompositeEnvironment(BaseEnvironment): + def __init__(self, env_id: str) -> None: + self.env_id = env_id + self.envs:Dict[str,BaseEnvironment] = {} + self.functions: Dict[str,AIFunction] = {} + self.operations: Dict[str,AIOperation] = {} + + def get_id(self) -> str: + return self.env_id + + def add_env(self, env: BaseEnvironment) -> None: + self.envs[env.get_id()] = env + functions = env.get_all_ai_functions() + for func in functions: + self.functions[func.get_name()] = func + operations = env.get_all_ai_operations() + for op in operations: + self.operations[op.get_name()] = op + + def get_ai_function(self,func_name:str) -> AIFunction: + func = self.functions.get(func_name) + if func is not None: + return func + return None + + def get_all_ai_functions(self) -> List[AIFunction]: + func_list = [] + func_list.extend(self.functions.values()) + return func_list + + def get_ai_operation(self,op_name:str) -> AIOperation: + op = self.operations.get(op_name) + if op is not None: + return op + return None + + def get_all_ai_operations(self) -> List[AIOperation]: + op_list = [] + op_list.extend(self.operations.values()) + return op_list \ No newline at end of file diff --git a/src/aios/environment/workspace_env.py b/src/aios/environment/workspace_env.py index d54c7b8..115e347 100644 --- a/src/aios/environment/workspace_env.py +++ b/src/aios/environment/workspace_env.py @@ -1,109 +1,252 @@ # this env is designed for workflow owner filesystem, support file/directory operations -import hashlib import json -import subprocess import logging -import tempfile -import threading -import traceback -import time -import ast -import sys import os -import re -import asyncio import aiofiles from typing import Any,List -import os import chardet -from markdown import Markdown -import PyPDF2 - -from ..proto.agent_msg import * -from ..agent.agent_base import AgentTodo,AgentPrompt,AgentTodoResult -from ..agent.ai_function import AIFunction,SimpleAIFunction +from ..agent.agent_base import AgentMsg,AgentTodo,AgentPrompt,AgentTodoResult +from ..agent.ai_function import AIFunction,SimpleAIFunction, SimpleAIOperation from ..storage.storage import AIStorage,ResourceLocation -from .simple_kb_db import SimpleKnowledgeDB -from .environment import Environment,EnvironmentEvent +from .environment import SimpleEnvironment, CompositeEnvironment + + logger = logging.getLogger(__name__) -class WorkspaceEnvironment(Environment): - def __init__(self, env_id: str) -> None: - super().__init__(env_id) - myai_path = AIStorage.get_instance().get_myai_dir() - self.root_path = f"{myai_path}/workspace/{env_id}" +class TodoListType: + TO_WORK = "work" + TO_LEARN = "learn" + +class TodoListEnvironment(SimpleEnvironment): + def __init__(self, root_path, list_type) -> None: + super.__init__(list_type) + self.root_path = os.path.join(root_path, list_type) if not os.path.exists(self.root_path): - os.makedirs(self.root_path+"/todos") - + os.makedirs(self.root_path) self.known_todo = {} - self.kb_db = SimpleKnowledgeDB(f"{self.root_path}/kb.db") - self.doc_dirs = {} - self._scan_thread = None - self._scan_dirthread = None + + async def create_todo(params): + todoObj = AgentTodo.from_dict(params["todo"]) + parent_id = params.get("parent") + return await self.create_todo(parent_id,todoObj) + self.add_ai_operation(SimpleAIOperation( + op="create_todo", + description="create todo", + func_handler=create_todo, + )) + + + async def update_todo(params): + todo_id = params["id"] + new_stat = params["state"] + return await self.update_todo(todo_id,new_stat) + self.add_ai_operation(SimpleAIOperation( + op="update_todo", + description="update todo", + func_handler=update_todo, + )) + + + # Task/todo system , create,update,delete,query + async def get_todo_tree(self,path:str = None,deep:int = 4): + if path: + directory_path = os.path.join(self.root_path, path) + else: + directory_path = self.root_path + + str_result:str = "/todos\n" + todo_count:int = 0 - def set_root_path(self,path:str): - self.root_path = path - - def get_prompt(self) -> AgentMsg: - return None - - def get_role_prompt(self,role_id:str) -> AgentPrompt: - return None - - def get_knowledge_base(self,root_dir=None,indent=0) -> str: - pass - - - def get_do_prompt(self,todo:AgentTodo=None)->AgentPrompt: - return None - - # result mean: list[op_error_str],have_error - async def exec_op_list(self,oplist:List,agent_id:str)->tuple[List[str],bool]: - result_str = "op list is none" - if oplist is None: - return None,False - - result_str = [] - have_error = False - for op in oplist: - if op["op"] == "create": - await self.create(op["path"],op["content"]) - elif op["op"] == "write_file": - is_append = op.get("is_append") - if is_append is None: - is_append = False - error_str = await self.write(op["path"],op["content"],is_append) - elif op["op"] == "delete": - error_str = await self.delete(op["path"]) - elif op["op"] == "rename": - error_str = await self.rename(op["path"],op["new_name"]) - elif op["op"] == "mkdir": - error_str = await self.mkdir(op["path"]) - elif op["op"] == "create_todo": - todoObj = AgentTodo.from_dict(op["todo"]) - todoObj.worker = agent_id - todoObj.createor = agent_id - parent_id = op.get("parent") - error_str = await self.create_todo(parent_id,todoObj) - elif op["op"] == "update_todo": - todo_id = op["id"] - new_stat = op["state"] - error_str = await self.update_todo(todo_id,new_stat) - else: - logger.error(f"execute op list failed: unknown op:{op['op']}") - error_str = f"execute op list failed: unknown op:{op['op']}" + async def scan_dir(directory_path:str,deep:int): + nonlocal str_result + nonlocal todo_count + if deep <= 0: + return - if error_str: - have_error = True - result_str.append(error_str) - else: - result_str.append(f"execute success!") - + if os.path.exists(directory_path) is False: + return + + for entry in os.scandir(directory_path): + is_dir = entry.is_dir() + if not is_dir: + continue + + if entry.name.startswith("."): + continue + + todo_count = todo_count + 1 + str_result = str_result + f"{' '*(4-deep)}{entry.name}\n" + await scan_dir(entry.path,deep-1) + + await scan_dir(directory_path,deep) + return str_result,todo_count + + async def get_todo_list(self,agent_id:str,path:str = None)->List[AgentTodo]: + logger.info("get_todo_list:%s,%s",agent_id,path) + if path: + directory_path = os.path.join(self.root_path, path) + else: + directory_path = self.root_path + + result_list:List[AgentTodo] = [] + + async def scan_dir(directory_path:str,deep:int,parent:AgentTodo=None): + nonlocal result_list + if os.path.exists(directory_path) is False: + return + + for entry in os.scandir(directory_path): + is_dir = entry.is_dir() + if not is_dir: + continue + + if entry.name.startswith("."): + continue + + todo = await self.get_todo_by_fullpath(entry.path) + if todo: + if todo.worker: + if todo.worker != agent_id: + continue + + if parent: + parent.sub_todos[todo.todo_id] = todo + + result_list.append(todo) + todo.rank = int(todo.create_time)>>deep + await scan_dir(entry.path,deep + 1,todo) + + return + + await scan_dir(directory_path,0) + #sort by rank + result_list.sort(key=lambda x:(x.rank,x.title)) + logger.info("get_todo_list return,todolist.length() is %d",len(result_list)) + return result_list + + async def get_todo_by_fullpath(self,path:str) -> AgentTodo: + logger.info("get_todo_by_fullpath:%s",path) + + detail_path = path + "/detail" + try: + async with aiofiles.open(detail_path, mode='r', encoding="utf-8") as f: + content = await f.read(4096) + logger.debug("get_todo_by_fullpath:%s,content:%s",path,content) + todo_dict = json.loads(content) + result_todo = AgentTodo.from_dict(todo_dict) + if result_todo: + relative_path = os.path.relpath(path, self.root_path) + if not relative_path.startswith('/'): + relative_path = '/' + relative_path + result_todo.todo_path = relative_path + self.known_todo[result_todo.todo_id] = result_todo + else: + logger.error("get_todo_by_path:%s,parse failed!",path) + + return result_todo + except Exception as e: + logger.error("get_todo_by_path:%s,failed:%s",path,e) + return None - return result_str,have_error + async def get_todo(self,id:str) -> AgentTodo: + return self.known_todo.get(id) + + async def create_todo(self,parent_id:str,todo:AgentTodo) -> str: + try: + if parent_id: + if parent_id not in self.known_todo: + logger.error("create_todo failed: parent_id not found!") + return False + + parent_path = self.known_todo.get(parent_id).todo_path + todo_path = f"{parent_path}/{todo.title}" + else: + todo_path = todo.title + + dir_path = f"{self.root_path}/{todo_path}" + + os.makedirs(dir_path) + detail_path = f"{dir_path}/detail" + if todo.todo_path is None: + todo.todo_path = todo_path + logger.info("create_todo %s",detail_path) + async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(todo.to_dict())) + self.known_todo[todo.todo_id] = todo + except Exception as e: + logger.error("create_todo failed:%s",e) + return str(e) + + return None + + async def update_todo(self,todo_id:str,new_stat:str)->str: + try: + todo : AgentTodo = self.known_todo.get(todo_id) + if todo: + todo.state = new_stat + detail_path = f"{self.root_path}/{todo.todo_path}/detail" + async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(todo.to_dict())) + return None + else: + return "todo not found." + except Exception as e: + return str(e) + + async def append_worklog(self, todo:AgentTodo, result:AgentTodoResult): + worklog = f"{self.root_path}/{todo.todo_path}/.worklog" + + async with aiofiles.open(worklog, mode='w+', encoding="utf-8") as f: + content = await f.read() + if len(content) > 0: + json_obj = json.loads(content) + else: + json_obj = {} + logs = json_obj.get("logs") + if logs is None: + logs = [] + logs.append(result.to_dict()) + json_obj["logs"] = logs + await f.write(json.dumps(json_obj)) + +class FilesystemEnvironment(SimpleEnvironment): + def __init__(self, root_path: str, env_id: str) -> None: + super().__init__(env_id) + self.root_path = root_path + + + # if op["op"] == "create": + # await self.create(op["path"],op["content"]) + + async def write(op): + is_append = op.get("is_append") + if is_append is None: + is_append = False + return await self.write(op["path"],op["content"],is_append) + self.add_ai_operation(SimpleAIOperation( + op="write", + description="write file", + func_handler=write, + )) + + async def delete(op): + return await self.delete(op["path"]) + self.add_ai_operation(SimpleAIOperation( + op="delete", + description="delete path", + func_handler=delete, + )) + + async def rename(op): + return await self.move(op["path"],op["new_name"]) + self.add_ai_operation(SimpleAIOperation( + op="rename", + description="rename path", + func_handler=rename, + )) # file system operation: list,read,write,delete,move,stat # inner_function @@ -201,560 +344,8 @@ class WorkspaceEnvironment(Environment): return str(e) return None - - # TODO use diff to update large file content - async def update_by_diff(self,path:str,diff): - pass - - # doc system (read_only,agent cann't modify doc) - - # inner_function - async def list_db(self) -> str: - pass - # inner_function - async def get_db_desc(self,db_name:str) -> str: - pass - # inner_function - async def query(self,db_name:str,sql:str) -> str: - pass - - # search (web) - # inner_function - async def google_search(self,keyword:str,opt=None) -> str: - pass - - # inner_function - async def local_search(self,keyword:str,root_path=None ,opt=None) -> str: - pass - - # inner_function, might be return a image is better - async def web_get(self,url:str) -> str: - pass - - # inner_function - async def blockchain_get(self,chainid:str,query:dict) -> str: - pass - - # code interpreter - # inner_function or operation - async def eval_code(self,pycode:str) -> str: - pass - - # operation or inner_function - async def improve_code(self,path:str): - pass - - # operation or inner_function - async def run(self,file_path:str)->str: - pass - - # operation or inner_function - async def pub_service(self,project_path:str): - pass - - # operation or inner_function - async def exec_tx(self,chain_id:str,tx:dict) -> str: - pass - - # social ability - # operation or inner_function - async def post_message(self,target:str,msg:AgentMsg,wait_time) -> AgentMsg: - pass - - # operation or inner_function - async def add_contact(self,name:str,contact_info) -> str: - pass - - # inner_function , include contact realtime info - async def get_contact(self,name_list:List[str],opt:dict) -> List: - pass - - - # Task/todo system , create,update,delete,query - async def get_todo_tree(self,path:str = None,deep:int = 4): - if path: - directory_path = self.root_path + "/todos/" + path - else: - directory_path = self.root_path + "/todos" - - - str_result:str = "/todos\n" - todo_count:int = 0 - - async def scan_dir(directory_path:str,deep:int): - nonlocal str_result - nonlocal todo_count - if deep <= 0: - return - - if os.path.exists(directory_path) is False: - return - - for entry in os.scandir(directory_path): - is_dir = entry.is_dir() - if not is_dir: - continue - - if entry.name.startswith("."): - continue - - todo_count = todo_count + 1 - str_result = str_result + f"{' '*(4-deep)}{entry.name}\n" - await scan_dir(entry.path,deep-1) - - await scan_dir(directory_path,deep) - return str_result,todo_count - - async def get_todo_list(self,agent_id:str,path:str = None)->List[AgentTodo]: - logger.info("get_todo_list:%s,%s",agent_id,path) - if path: - directory_path = self.root_path + "/todos/" + path - else: - directory_path = self.root_path + "/todos" - - result_list:List[AgentTodo] = [] - - async def scan_dir(directory_path:str,deep:int,parent:AgentTodo=None): - nonlocal result_list - if os.path.exists(directory_path) is False: - return - - for entry in os.scandir(directory_path): - is_dir = entry.is_dir() - if not is_dir: - continue - - if entry.name.startswith("."): - continue - - todo = await self.get_todo_by_fullpath(entry.path) - if todo: - if todo.worker: - if todo.worker != agent_id: - continue - - if parent: - parent.sub_todos[todo.todo_id] = todo - - result_list.append(todo) - todo.rank = int(todo.create_time)>>deep - await scan_dir(entry.path,deep + 1,todo) - - return - - await scan_dir(directory_path,0) - #sort by rank - result_list.sort(key=lambda x:(x.rank,x.title)) - logger.info("get_todo_list return,todolist.length() is %d",len(result_list)) - return result_list - - async def get_todo_by_fullpath(self,path:str) -> AgentTodo: - logger.info("get_todo_by_fullpath:%s",path) - - detail_path = path + "/detail" - try: - async with aiofiles.open(detail_path, mode='r', encoding="utf-8") as f: - content = await f.read(4096) - logger.debug("get_todo_by_fullpath:%s,content:%s",path,content) - todo_dict = json.loads(content) - result_todo = AgentTodo.from_dict(todo_dict) - if result_todo: - relative_path = os.path.relpath(path, self.root_path + "/todos/") - if not relative_path.startswith('/'): - relative_path = '/' + relative_path - result_todo.todo_path = relative_path - self.known_todo[result_todo.todo_id] = result_todo - else: - logger.error("get_todo_by_path:%s,parse failed!",path) - - return result_todo - except Exception as e: - logger.error("get_todo_by_path:%s,failed:%s",path,e) - return None - - async def get_todo(self,id:str) -> AgentTodo: - return self.known_todo.get(id) - - async def create_todo(self,parent_id:str,todo:AgentTodo) -> str: - try: - if parent_id: - if parent_id not in self.known_todo: - logger.error("create_todo failed: parent_id not found!") - return False - - parent_path = self.known_todo.get(parent_id).todo_path - todo_path = f"{parent_path}/{todo.title}" - else: - todo_path = todo.title - - dir_path = f"{self.root_path}/todos/{todo_path}" - - os.makedirs(dir_path) - detail_path = f"{dir_path}/detail" - if todo.todo_path is None: - todo.todo_path = todo_path - logger.info("create_todo %s",detail_path) - async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.to_dict())) - self.known_todo[todo.todo_id] = todo - except Exception as e: - logger.error("create_todo failed:%s",e) - return str(e) - - return None - - async def update_todo(self,todo_id:str,new_stat:str)->str: - try: - todo : AgentTodo = self.known_todo.get(todo_id) - if todo: - todo.state = new_stat - detail_path = f"{self.root_path}/todos/{todo.todo_path}/detail" - async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.to_dict())) - return None - else: - return "todo not found." - except Exception as e: - return str(e) - - async def append_worklog(self,todo:AgentTodo,result:AgentTodoResult): - worklog = f"{self.root_path}/todos/{todo.todo_path}/.worklog" - - async with aiofiles.open(worklog, mode='w+', encoding="utf-8") as f: - content = await f.read() - if len(content) > 0: - json_obj = json.loads(content) - else: - json_obj = {} - logs = json_obj.get("logs") - if logs is None: - logs = [] - logs.append(result.to_dict()) - json_obj["logs"] = logs - await f.write(json.dumps(json_obj)) - - async def set_wakeup_timer(self,todo_id:str,timestamp:int) -> str: - pass - - # knowledge base system - def get_knowledge_base_ai_functions(self): - all_inner_function = [] - - all_inner_function.append(SimpleAIFunction("get_knowledge_catalog","get knowledge catalog in tree format", - self.get_knowledege_catalog, - {"path":f"catalog path,none is /","depth":"max depth of catalog tree,default is 4"})) - all_inner_function.append(SimpleAIFunction("get_knowledge","get knowledge metadata", - self.get_knowledge, - {"path":f"knowledge path"})) - all_inner_function.append(SimpleAIFunction("load_knowledge_content","load knowledge content", - self.load_knowledge_content, - {"path":f"knowledge path","pos":"start position of content","length":"length of content"})) - result_func = [] - result_len = 0 - for inner_func in all_inner_function: - func_name = inner_func.get_name() - - this_func = {} - this_func["name"] = func_name - this_func["description"] = inner_func.get_description() - this_func["parameters"] = inner_func.get_parameters() - result_len += len(json.dumps(this_func)) / 4 - result_func.append(this_func) - - return result_func,result_len - - async def get_knowledege_catalog(self,path:str=None,only_dir =True,max_depth:int=5)->str: - if path: - full_path = f"{self.root_path}/knowledge/{path}" - else: - full_path = f"{self.root_path}/knowledge" - - catlogs,file_count = await self.get_directory_structure(full_path,max_depth,only_dir) - return catlogs - - async def get_directory_structure(self,root_dir, max_depth:int=4, only_dir=True, indent=1): - file_count = 0 - structure_str = '' - if os.path.isdir(root_dir): - sub_files = [] - with os.scandir(root_dir) as it: - for entry in it: - if entry.is_dir(): - sub_structure, sub_count = await self.get_directory_structure(entry.path, max_depth, only_dir, indent + 1) - if sub_structure: - structure_str += sub_structure - file_count += sub_count - else: - file_count += 1 - sub_files.append(entry.name) - - if only_dir is False: - for file_name in sub_files: - structure_str = structure_str + ' ' * (indent+1) + file_name + '\n' - - dir_name = os.path.basename(root_dir) - dir_info = f"{dir_name} " - - - structure_str = ' ' * indent + dir_info + '\n' + structure_str - - if indent - 1 >= max_depth: - return None, file_count - else: - return structure_str, file_count - - # inner_function - async def get_knowledge(self,path:str) -> str: - full_path = f"{self.root_path}/knowledge/{path}" - if os.islink(full_path): - org_path = os.readlink(full_path) - hash = self.kb_db.get_hash_by_doc_path(org_path) - if hash: - return self.kb_db.get_knowledge(org_path) - - return "not found" - - async def load_knowledge_content(self,path:str,pos:int=0,length:int=None) -> str: - if path.endswith("pdf"): - logger.info("load_knowledge_content:pdf") - dir_path = os.path.dirname(path) - base_name = os.path.basename(path) - text_content_path = f"{dir_path}/.{base_name}.txt" - if os.path.exists(text_content_path) is False: - return None - async with aiofiles.open(path, mode='r', encoding=cur_encode) as f: - await f.seek(pos) - content = await f.read(length) - return content - else: - async with aiofiles.open(path,'rb') as f: - cur_encode = chardet.detect(await f.read())['encoding'] - - async with aiofiles.open(path, mode='r', encoding=cur_encode) as f: - await f.seek(pos) - content = await f.read(length) - return content - - return "load content failed." - - def _add_document_dir(self,path:str): - self.doc_dirs[path] = 0 - - def _start_scan_document(self): - if self._scan_thread is None: - self._scan_thread = threading.Thread(target=self._scan_document) - self._scan_thread.start() - if self._scan_dirthread is None: - self._scan_dirthread = threading.Thread(target=self._scan_dir) - self._scan_dirthread.start() - - def _parse_pdf_bookmarks(self,bookmarks, parent:list): - - for item in bookmarks: - if isinstance(item,list): - self._parse_pdf_bookmarks(item,parent) - else: - if item.title: - new_item = {} - new_item["page"] = item.page.idnum - new_item["title"] = item.title - my_childs = [] - if item.childs: - if len(item.childs) > 0: - self._parse_pdf_bookmarks(item.childs, my_childs) - new_item["childs"] = my_childs - parent.append(new_item) - else: - logger.warning("parse pdf bookmarks failed: item.title is None!") - - return - - def _parse_pdf(self,doc_path:str): - metadata = {} - with open(doc_path, 'rb') as file: - reader = PyPDF2.PdfReader(file) - try: - doc_info = reader.metadata - if doc_info: - if doc_info.title: - metadata["title"] = doc_info.title - if doc_info.author: - metadata["authors"] = doc_info.author - except Exception as e: - logger.warn("parse pdf metadata failed:%s",e) - - dir_path = os.path.dirname(doc_path) - base_name = os.path.basename(doc_path) - text_content_path = f"{dir_path}/.{base_name}.txt" - full_text = "" - - for page in reader.pages: - text = page.extract_text() - full_text += text - with open(text_content_path, 'w', encoding='utf-8') as f: - f.write(full_text) - - try: - bookmarks = reader.outline - if bookmarks: - catalogs = [] - self._parse_pdf_bookmarks(bookmarks,catalogs) - metadata["catalogs"] = json.dumps(catalogs) - except Exception as e: - logger.warn("parse pdf bookmarks failed:%s",e) - - return metadata - - def _parse_txt(self,doc_path:str): - return {} - - def _parse_md(self,doc_path:str): - metadata = {} - cur_encode = "utf-8" - with open(doc_path,'rb') as f: - cur_encode = chardet.detect(f.read(1024))['encoding'] - - with open(doc_path, mode='r', encoding=cur_encode) as f: - content = f.read() - match = re.search(r'^# (.*)', content, re.MULTILINE) - if match: - metadata['title'] = match.group(1).strip() - md = Markdown(extensions=['toc']) - html_str = md.convert(content) - toc = md.toc - if toc: - metadata['catalogs'] = toc - - return metadata - - def _parse_document(self,doc_path:str): - hash_result = None - title = os.path.basename(doc_path) - meta_data = {} - - with open(doc_path, "rb") as f: - hash_md5 = hashlib.md5() - for chunk in iter(lambda: f.read(1024*1024), b""): - hash_md5.update(chunk) - hash_result = hash_md5.hexdigest() - try: - if doc_path.endswith(".md"): - meta_data = self._parse_md(doc_path) - elif doc_path.endswith(".pdf"): - meta_data = self._parse_pdf(doc_path) - except Exception as e: - logger.error("parse document %s failed:%s",doc_path,e) - traceback.print_exc() - - if meta_data.get("title"): - title = meta_data["title"] - logger.info("parse document %s!",doc_path) - return hash_result,title,meta_data - - - def _support_file(self,file_name:str) -> bool: - if file_name.startswith("."): - return False - - if file_name.endswith(".pdf"): - return True - if file_name.endswith(".md"): - return True - if file_name.endswith(".txt"): - return True - return False - - def _scan_dir(self): - while True: - time.sleep(10) - for directory in self.doc_dirs.keys(): - now = time.time() - if now - self.doc_dirs[directory] > 60*15: - self.doc_dirs[directory] = time.time() - else: - continue - - for root, dirs, files in os.walk(directory): - for file in files: - if self._support_file(file): - full_path = os.path.join(root, file) - full_path = os.path.normpath(full_path) - if self.kb_db.is_doc_exist(full_path): - continue - - file_stat = os.stat(full_path) - if file_stat.st_size < 1: - continue - - if file_stat.st_size < 1024*1024*8: - #parse and insert - hash,title,meta_data = self._parse_document(full_path) - self.kb_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime,hash) - self.kb_db.add_knowledge(hash,title,meta_data) - - else: - self.kb_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime) - - def _scan_document(self): - while True: - time.sleep(10) - parse_queue = self.kb_db.get_docs_without_hash() - for doc_path in parse_queue: - hash,title,meta_data = self._parse_document(doc_path) - self.kb_db.set_doc_hash(doc_path,hash) - self.kb_db.add_knowledge(hash,title,meta_data) - - - - -# merge to standard workspace env, **ABANDON this!** -class KnowledgeBaseFileSystemEnvironment(Environment): - def __init__(self, env_id: str) -> None: - super().__init__(env_id) - self.root_path = "." - - operator_param = { - "path": "full path of target directory", - } - self.add_ai_function(SimpleAIFunction("list", - "list the files and sub directory in target directory,result is a json array", - self.list,operator_param)) - - operator_param = { - "path": "full path of target file", - } - self.add_ai_function(SimpleAIFunction("cat", - "cat the file content in target path,result is a string", - self.cat,operator_param)) - - def set_root_path(self,path:str): - self.root_path = path - - - async def list(self,path:str) -> str: - directory_path = self.root_path + path - items = [] - - with await aiofiles.os.scandir(directory_path) as entries: - async for entry in entries: - item_type = "directory" if entry.is_dir() else "file" - items.append({"name": entry.name, "type": item_type}) - - return json.dumps(items) - - async def cat(self,path:str) -> str: - file_path = self.root_path + path - cur_encode = "utf-8" - async with aiofiles.open(file_path,'rb') as f: - cur_encode = chardet.detect(await f.read())['encoding'] - - async with aiofiles.open(file_path, mode='r', encoding=cur_encode) as f: - content = await f.read(2048) - return content - - -class ShellEnvironment(Environment): +class ShellEnvironment(SimpleEnvironment): def __init__(self, env_id: str) -> None: super().__init__(env_id) @@ -787,3 +378,57 @@ class ShellEnvironment(Environment): else: return f"Execute failed! stderr is:\n{stderr}\n" + +class WorkspaceEnvironment(CompositeEnvironment): + def __init__(self, env_id: str) -> None: + super().__init__(env_id) + myai_path = AIStorage.get_instance().get_myai_dir() + self.root_path = f"{myai_path}/workspace/{env_id}" + if not os.path.exists(self.root_path): + os.makedirs() + + self.todo_list = {} + self.todo_list[TodoListType.TO_WORK] = TodoListEnvironment(self.root_path,TodoListType.TO_WORK) + self.todo_list[TodoListType.TO_LEARN] = TodoListEnvironment(self.root_path,TodoListType.TO_LEARN) + + # default environments in workspace + self.add_env(self.todo_list[TodoListType.TO_WORK]) + self.add_env(ShellEnvironment("shell")) + self.add_env(FilesystemEnvironment(self.root_path, "fs")) + + def set_root_path(self,path:str): + self.root_path = path + + def get_prompt(self) -> AgentMsg: + return None + + def get_role_prompt(self,role_id:str) -> AgentPrompt: + return None + + def get_do_prompt(self,todo:AgentTodo=None)->AgentPrompt: + return None + + # result mean: list[op_error_str],have_error + async def exec_op_list(self,oplist:List,agent_id:str)->tuple[List[str],bool]: + result_str = "op list is none" + if oplist is None: + return None,False + + result_str = [] + have_error = False + for op in oplist: + operation = self.get_ai_operation(op["op"]) + if operation: + error_str = await operation.execute(op) + else: + logger.error(f"execute op list failed: unknown op:{op['op']}") + error_str = f"execute op list failed: unknown op:{op['op']}" + + if error_str: + have_error = True + result_str.append(error_str) + else: + result_str.append(f"execute success!") + + return result_str,have_error + diff --git a/src/aios/knowledge/pipeline.py b/src/aios/knowledge/pipeline.py index b902225..db2fb3e 100644 --- a/src/aios/knowledge/pipeline.py +++ b/src/aios/knowledge/pipeline.py @@ -6,17 +6,13 @@ from . import ObjectID, KnowledgeStore from enum import Enum class KnowledgePipelineJournal: - def __init__(self, time: datetime.datetime, object_id: str, input: str, parser: str): + def __init__(self, time: datetime.datetime, input: str, parser: str): self.time = time - self.object_id = None if object_id is None else ObjectID.from_base58(object_id) self.input = input self.parser = parser def is_finish(self) -> bool: - return self.object_id is None - - def get_object_id(self) -> ObjectID: - return self.object_id + return self.input is None def get_input(self) -> str: return self.input @@ -28,7 +24,7 @@ class KnowledgePipelineJournal: if self.is_finish(): return f"{self.time}: finished)" else: - return f"{self.time}: object:{self.object_id} input:{self.input}, parser:{self.parser})" + return f"{self.time}: input:{self.input}, parser:{self.parser})" # init sqlite3 client class KnowledgePipelineJournalClient: @@ -42,18 +38,17 @@ class KnowledgePipelineJournalClient: '''CREATE TABLE IF NOT EXISTS journal ( id INTEGER PRIMARY KEY AUTOINCREMENT, time DATETIME DEFAULT CURRENT_TIMESTAMP, - object_id TEXT, input TEXT, parser TEXT)''' ) conn.commit() - def insert(self, object_id: ObjectID, input: str, parser: str, timestamp: datetime.datetime = None): + def insert(self, input: str, parser: str, timestamp: datetime.datetime = None): timestamp = datetime.datetime.now() if timestamp is None else timestamp conn = sqlite3.connect(self.journal_path) conn.execute( - "INSERT INTO journal (time, object_id, input, parser) VALUES (?, ?, ?, ?)", - (timestamp, str(object_id), input, parser), + "INSERT INTO journal (time, input, parser) VALUES (?, ?, ?, ?)", + (timestamp, input, parser), ) conn.commit() @@ -61,7 +56,7 @@ class KnowledgePipelineJournalClient: conn = sqlite3.connect(self.journal_path) cursor = conn.cursor() cursor.execute("SELECT * FROM journal ORDER BY id DESC LIMIT ?", (topn,)) - return [KnowledgePipelineJournal(time, object_id, input, parser) for (_, time, object_id, input, parser) in cursor.fetchall()] + return [KnowledgePipelineJournal(time, input, parser) for (_, time, input, parser) in cursor.fetchall()] class KnowledgePipelineEnvironment: def __init__(self, pipeline_path: str): @@ -87,8 +82,12 @@ class KnowledgePipelineState(Enum): STOPPED = 2 FINISHED = 3 +class NullParser: + async def parse(self, object_id): + return "" + class KnowledgePipeline: - def __init__(self, name: str, env: KnowledgePipelineEnvironment, input_init, input_params, parser_init, parser_params): + def __init__(self, name: str, env: KnowledgePipelineEnvironment, input_init, input_params=None, parser_init=None, parser_params=None): self.name = name self.state = KnowledgePipelineState.INIT self.input_init = input_init @@ -108,18 +107,21 @@ class KnowledgePipeline: async def run(self): if self.state == KnowledgePipelineState.INIT: self.input = self.input_init(self.env, self.input_params) - self.parser = self.parser_init(self.env, self.parser_params) + if self.parser_init is None: + self.parser = NullParser() + else: + self.parser = self.parser_init(self.env, self.parser_params) self.state = KnowledgePipelineState.RUNNING if self.state == KnowledgePipelineState.RUNNING: async for input in self.input.next(): if input is None: self.state = KnowledgePipelineState.FINISHED - self.env.journal.insert(None, "finished", "finished") + self.env.journal.insert(None, None) return (object_id, input_journal) = input if object_id is not None: parser_journal = await self.parser.parse(object_id) - self.env.journal.insert(object_id, input_journal, parser_journal) + self.env.journal.insert(input_journal, parser_journal) else: return if self.state == KnowledgePipelineState.STOPPED: diff --git a/src/component/common_environment/local_document.py b/src/component/common_environment/local_document.py new file mode 100644 index 0000000..c003b43 --- /dev/null +++ b/src/component/common_environment/local_document.py @@ -0,0 +1,671 @@ +# import os +# import aiofiles +# import chardet +# import logging +# import string +# from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal +# from aios_kernel.storage import AIStorage + + +import os +import aiofiles +import chardet +import logging +import string +import sqlite3 +import json +import threading +import logging +from datetime import datetime +from typing import Optional, List +from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal +from aios_kernel import AIStorage, SimpleEnvironment + + +class ScanLocalDocument: + def __init__(self, env: KnowledgePipelineEnvironment, config): + self.env = env + path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + config["path"] = path + self.config = config + + def path(self): + return self.config["path"] + + async def next(self): + while True: + journals = self.env.journal.latest_journals(1) + from_time = 0 + if len(journals) == 1: + latest_journal = journals[0] + if latest_journal.is_finish(): + yield None + continue + from_time = os.path.getctime(latest_journal.get_input()) + if os.path.getmtime(self.path()) <= from_time: + yield (None, None) + continue + + file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) + for rel_path in file_pathes: + file_path = os.path.join(self.path(), rel_path) + timestamp = os.path.getctime(file_path) + if timestamp <= from_time: + continue + ext = os.path.splitext(file_path)[1].lower() + if ext in ['.pdf', '.md', '.txt']: + logging.info(f"knowledge dir source found document file {file_path}") + yield (file_path, file_path) + yield (None, None) + +class MetaDatabase: + def __init__(self,db_path:str): + self.db_path = db_path + self._get_conn() + + def _get_conn(self): + """ get db connection """ + local = threading.local() + if not hasattr(local, 'conn'): + local.conn = self._create_connection(self.db_path) + return local.conn + + + def _create_connection(self, db_file): + """ create a database connection to a SQLite database """ + conn = None + try: + conn = sqlite3.connect(db_file) + except Exception as e: + logger.error("Error occurred while connecting to database: %s", e) + return None + + if conn: + self._create_tables(conn) + + return conn + + def _create_tables(self,conn): + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS documents ( + doc_path TEXT PRIMARY KEY, + length INTEGER, + last_modify TEXT, + doc_hash TEXT, + create_time TEXT + ) + ''') + cursor.execute(''' + CREATE TABLE IF NOT EXISTS knowledge ( + doc_hash TEXT PRIMARY KEY, + title TEXT, + summary TEXT, + content TEXT, + catalogs TEXT, + tags TEXT, + llm_title TEXT, + llm_summary TEXT, + create_time TEXT + ) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_documents_doc_hash + ON documents (doc_hash) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_knowledge_tags + ON knowledge (tags) + ''') + + conn.commit() + + def add_doc(self, doc_path: str, length: int, last_modify: str, doc_hash: Optional[str] = None): + conn = self._get_conn() + cursor = conn.cursor() + create_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cursor.execute(''' + INSERT INTO documents (doc_path, length, last_modify, doc_hash,create_time) + VALUES (?, ?, ?, ?,?) + ''', (doc_path, length, last_modify, doc_hash,create_time)) + conn.commit() + + def is_doc_exist(self, doc_path: str) -> bool: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT doc_path + FROM documents + WHERE doc_path = ? + ''', (doc_path,)) + return len(cursor.fetchall()) > 0 + + def set_doc_hash(self, doc_path: str, doc_hash: str): + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + UPDATE documents + SET doc_hash = ? + WHERE doc_path = ? + ''', (doc_hash, doc_path)) + conn.commit() + + def get_docs_without_hash(self,limit:int=1024) -> List[str]: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT doc_path + FROM documents + WHERE doc_hash IS NULL OR doc_hash = '' + ORDER BY create_time DESC + LIMIT ? + ''',(limit,)) + return [row[0] for row in cursor.fetchall()] + + #metadata["summary"] + #metadata["catelogs"] + #metadata["tags"] + def add_knowledge(self, doc_hash: str, title: str, metadata: dict,content:str = None,): + conn = self._get_conn() + cursor = conn.cursor() + + create_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + summary = metadata.get("summary", "") + catalogs = metadata.get("catalogs","") + tags = ','.join(metadata.get("tags", [])) + + cursor.execute(''' + INSERT INTO knowledge (doc_hash, title , summary , catalogs , tags,create_time) + VALUES (?, ?, ?, ?, ?,?) + ''', (doc_hash, title, summary, catalogs, tags,create_time)) + conn.commit() + + #llm_result["summary"] + #llm_result["tags"] + #llm_result["catelog"] + def set_knowledge_llm_result(self, doc_hash: str, llm_result: dict): + conn = self._get_conn() + cursor = conn.cursor() + + title = llm_result.get("title", "") + summary = llm_result.get("summary", "") + catalogs = json.dumps(llm_result.get("catalogs", {})) + tags = ','.join(llm_result.get("tags", [])) + + cursor.execute(''' + UPDATE knowledge + SET llm_title = ?,llm_summary = ?, catalogs = ?, tags = ? + WHERE doc_hash = ? + ''', (title,summary, catalogs, tags, doc_hash)) + conn.commit() + + def get_hash_by_doc_path(self, doc_path: str) -> Optional[str]: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT doc_hash + FROM documents + WHERE doc_path = ? + ''', (doc_path,)) + row = cursor.fetchone() + if row is None: + return None + return row[0] + + def get_knowledge(self, doc_hash: str) -> Optional[dict]: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT title, summary, catalogs, tags, llm_title, llm_summary + FROM knowledge + WHERE doc_hash = ? + ''', (doc_hash,)) + row = cursor.fetchone() + if row is None: + return None + + # get doc path + cursor.execute(''' + SELECT doc_path + FROM documents + WHERE doc_hash = ? + ''', (doc_hash,)) + row2 = cursor.fetchone() + if row2 is None: + return None + doc_path = row2[0] + + + return { + "full_path": doc_path, + "title": row[0], + "summary": row[1], + "catalogs": row[2], + "tags": row[3], + "llm_title" : row[4], + "llm_summary" : row[5], + } + + def get_knowledge_without_llm_title(self,limit:int=16) -> List[str]: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT doc_hash + FROM knowledge + WHERE llm_title IS NULL OR llm_title = '' + ORDER BY create_time DESC + LIMIT ? + ''',(limit,)) + return [row[0] for row in cursor.fetchall()] + + def query_docs_by_tag(self, tag: str) -> List[str]: + conn = self._get_conn() + cursor = conn.cursor() + tag_json = json.dumps(tag) # 将标签转换为 JSON 字符串 + cursor.execute(''' + SELECT documents.doc_path + FROM documents + JOIN knowledge ON documents.doc_hash = knowledge.doc_hash + WHERE json_extract(knowledge.tags, '$') LIKE ? + ''', (tag)) + return [row[0] for row in cursor.fetchall()] + + +class DocumentKnowledgeBase(SimpleEnvironment): + async def get_knowledege_catalog(self,path:str=None,only_dir =True,max_depth:int=5)->str: + if path: + full_path = f"{self.root_path}/knowledge/{path}" + else: + full_path = f"{self.root_path}/knowledge" + + catlogs,file_count = await self.get_directory_structure(full_path,max_depth,only_dir) + return catlogs + + async def get_directory_structure(self,root_dir, max_depth:int=4, only_dir=True, indent=1): + file_count = 0 + structure_str = '' + if os.path.isdir(root_dir): + sub_files = [] + with os.scandir(root_dir) as it: + for entry in it: + if entry.is_dir(): + sub_structure, sub_count = await self.get_directory_structure(entry.path, max_depth, only_dir, indent + 1) + if sub_structure: + structure_str += sub_structure + file_count += sub_count + else: + file_count += 1 + sub_files.append(entry.name) + + if only_dir is False: + for file_name in sub_files: + structure_str = structure_str + ' ' * (indent+1) + file_name + '\n' + + dir_name = os.path.basename(root_dir) + dir_info = f"{dir_name} " + + + structure_str = ' ' * indent + dir_info + '\n' + structure_str + + if indent - 1 >= max_depth: + return None, file_count + else: + return structure_str, file_count + + # inner_function + async def get_knowledge(self,path:str) -> str: + full_path = f"{self.root_path}/knowledge/{path}" + if os.islink(full_path): + org_path = os.readlink(full_path) + hash = self.kb_db.get_hash_by_doc_path(org_path) + if hash: + return self.kb_db.get_knowledge(org_path) + + return "not found" + + +class ParseLocalDocument: + def _parse_pdf_bookmarks(self,bookmarks, parent:list): + + for item in bookmarks: + if isinstance(item,list): + self._parse_pdf_bookmarks(item,parent) + else: + if item.title: + new_item = {} + new_item["page"] = item.page.idnum + new_item["title"] = item.title + my_childs = [] + if item.childs: + if len(item.childs) > 0: + self._parse_pdf_bookmarks(item.childs, my_childs) + new_item["childs"] = my_childs + parent.append(new_item) + else: + logger.warning("parse pdf bookmarks failed: item.title is None!") + + return + + def _parse_pdf(self,doc_path:str): + metadata = {} + with open(doc_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + try: + doc_info = reader.metadata + if doc_info: + if doc_info.title: + metadata["title"] = doc_info.title + if doc_info.author: + metadata["authors"] = doc_info.author + except Exception as e: + logger.warn("parse pdf metadata failed:%s",e) + + dir_path = os.path.dirname(doc_path) + base_name = os.path.basename(doc_path) + text_content_path = f"{dir_path}/.{base_name}.txt" + full_text = "" + + for page in reader.pages: + text = page.extract_text() + full_text += text + with open(text_content_path, 'w', encoding='utf-8') as f: + f.write(full_text) + + try: + bookmarks = reader.outline + if bookmarks: + catalogs = [] + self._parse_pdf_bookmarks(bookmarks,catalogs) + metadata["catalogs"] = json.dumps(catalogs) + except Exception as e: + logger.warn("parse pdf bookmarks failed:%s",e) + + return metadata + + def _parse_txt(self,doc_path:str): + return {} + + def _parse_md(self,doc_path:str): + metadata = {} + cur_encode = "utf-8" + with open(doc_path,'rb') as f: + cur_encode = chardet.detect(f.read(1024))['encoding'] + + with open(doc_path, mode='r', encoding=cur_encode) as f: + content = f.read() + match = re.search(r'^# (.*)', content, re.MULTILINE) + if match: + metadata['title'] = match.group(1).strip() + md = Markdown(extensions=['toc']) + html_str = md.convert(content) + toc = md.toc + if toc: + metadata['catalogs'] = toc + + return metadata + + def _parse_document(self,doc_path:str): + hash_result = None + title = os.path.basename(doc_path) + meta_data = {} + + with open(doc_path, "rb") as f: + hash_md5 = hashlib.md5() + for chunk in iter(lambda: f.read(1024*1024), b""): + hash_md5.update(chunk) + hash_result = hash_md5.hexdigest() + try: + if doc_path.endswith(".md"): + meta_data = self._parse_md(doc_path) + elif doc_path.endswith(".pdf"): + meta_data = self._parse_pdf(doc_path) + except Exception as e: + logger.error("parse document %s failed:%s",doc_path,e) + traceback.print_exc() + + if meta_data.get("title"): + title = meta_data["title"] + logger.info("parse document %s!",doc_path) + return hash_result,title,meta_data + + async def parse(self, file_path: str) -> str: + + + +# async def get_knowledege_catalog(self,path:str=None,only_dir =True,max_depth:int=5)->str: +# if path: +# full_path = f"{self.root_path}/knowledge/{path}" +# else: +# full_path = f"{self.root_path}/knowledge" + +# catlogs,file_count = await self.get_directory_structure(full_path,max_depth,only_dir) +# return catlogs + +# async def get_directory_structure(self,root_dir, max_depth:int=4, only_dir=True, indent=1): +# file_count = 0 +# structure_str = '' +# if os.path.isdir(root_dir): +# sub_files = [] +# with os.scandir(root_dir) as it: +# for entry in it: +# if entry.is_dir(): +# sub_structure, sub_count = await self.get_directory_structure(entry.path, max_depth, only_dir, indent + 1) +# if sub_structure: +# structure_str += sub_structure +# file_count += sub_count +# else: +# file_count += 1 +# sub_files.append(entry.name) + +# if only_dir is False: +# for file_name in sub_files: +# structure_str = structure_str + ' ' * (indent+1) + file_name + '\n' + +# dir_name = os.path.basename(root_dir) +# dir_info = f"{dir_name} " + + +# structure_str = ' ' * indent + dir_info + '\n' + structure_str + +# if indent - 1 >= max_depth: +# return None, file_count +# else: +# return structure_str, file_count + +# # inner_function +# async def get_knowledge(self,path:str) -> str: +# full_path = f"{self.root_path}/knowledge/{path}" +# if os.islink(full_path): +# org_path = os.readlink(full_path) +# hash = self.kb_db.get_hash_by_doc_path(org_path) +# if hash: +# return self.kb_db.get_knowledge(org_path) + +# return "not found" + + async def load_knowledge_content(self,path:str,pos:int=0,length:int=None) -> str: + if path.endswith("pdf"): + logger.info("load_knowledge_content:pdf") + dir_path = os.path.dirname(path) + base_name = os.path.basename(path) + text_content_path = f"{dir_path}/.{base_name}.txt" + if os.path.exists(text_content_path) is False: + return None + async with aiofiles.open(path, mode='r', encoding=cur_encode) as f: + await f.seek(pos) + content = await f.read(length) + return content + else: + async with aiofiles.open(path,'rb') as f: + cur_encode = chardet.detect(await f.read())['encoding'] + + async with aiofiles.open(path, mode='r', encoding=cur_encode) as f: + await f.seek(pos) + content = await f.read(length) + return content + + return "load content failed." + + def _add_document_dir(self,path:str): + self.doc_dirs[path] = 0 + + + def _parse_pdf_bookmarks(self,bookmarks, parent:list): + + for item in bookmarks: + if isinstance(item,list): + self._parse_pdf_bookmarks(item,parent) + else: + if item.title: + new_item = {} + new_item["page"] = item.page.idnum + new_item["title"] = item.title + my_childs = [] + if item.childs: + if len(item.childs) > 0: + self._parse_pdf_bookmarks(item.childs, my_childs) + new_item["childs"] = my_childs + parent.append(new_item) + else: + logger.warning("parse pdf bookmarks failed: item.title is None!") + + return + + def _parse_pdf(self,doc_path:str): + metadata = {} + with open(doc_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + try: + doc_info = reader.metadata + if doc_info: + if doc_info.title: + metadata["title"] = doc_info.title + if doc_info.author: + metadata["authors"] = doc_info.author + except Exception as e: + logger.warn("parse pdf metadata failed:%s",e) + + dir_path = os.path.dirname(doc_path) + base_name = os.path.basename(doc_path) + text_content_path = f"{dir_path}/.{base_name}.txt" + full_text = "" + + for page in reader.pages: + text = page.extract_text() + full_text += text + with open(text_content_path, 'w', encoding='utf-8') as f: + f.write(full_text) + + try: + bookmarks = reader.outline + if bookmarks: + catalogs = [] + self._parse_pdf_bookmarks(bookmarks,catalogs) + metadata["catalogs"] = json.dumps(catalogs) + except Exception as e: + logger.warn("parse pdf bookmarks failed:%s",e) + + return metadata + + def _parse_txt(self,doc_path:str): + return {} + + def _parse_md(self,doc_path:str): + metadata = {} + cur_encode = "utf-8" + with open(doc_path,'rb') as f: + cur_encode = chardet.detect(f.read(1024))['encoding'] + + with open(doc_path, mode='r', encoding=cur_encode) as f: + content = f.read() + match = re.search(r'^# (.*)', content, re.MULTILINE) + if match: + metadata['title'] = match.group(1).strip() + md = Markdown(extensions=['toc']) + html_str = md.convert(content) + toc = md.toc + if toc: + metadata['catalogs'] = toc + + return metadata + + def _parse_document(self,doc_path:str): + hash_result = None + title = os.path.basename(doc_path) + meta_data = {} + + with open(doc_path, "rb") as f: + hash_md5 = hashlib.md5() + for chunk in iter(lambda: f.read(1024*1024), b""): + hash_md5.update(chunk) + hash_result = hash_md5.hexdigest() + try: + if doc_path.endswith(".md"): + meta_data = self._parse_md(doc_path) + elif doc_path.endswith(".pdf"): + meta_data = self._parse_pdf(doc_path) + except Exception as e: + logger.error("parse document %s failed:%s",doc_path,e) + traceback.print_exc() + + if meta_data.get("title"): + title = meta_data["title"] + logger.info("parse document %s!",doc_path) + return hash_result,title,meta_data + + + def _support_file(self,file_name:str) -> bool: + if file_name.startswith("."): + return False + + if file_name.endswith(".pdf"): + return True + if file_name.endswith(".md"): + return True + if file_name.endswith(".txt"): + return True + return False + + def _scan_dir(self): + while True: + time.sleep(10) + for directory in self.doc_dirs.keys(): + now = time.time() + if now - self.doc_dirs[directory] > 60*15: + self.doc_dirs[directory] = time.time() + else: + continue + + for root, dirs, files in os.walk(directory): + for file in files: + if self._support_file(file): + full_path = os.path.join(root, file) + full_path = os.path.normpath(full_path) + if self.kb_db.is_doc_exist(full_path): + continue + + file_stat = os.stat(full_path) + if file_stat.st_size < 1: + continue + + if file_stat.st_size < 1024*1024*8: + #parse and insert + hash,title,meta_data = self._parse_document(full_path) + self.kb_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime,hash) + self.kb_db.add_knowledge(hash,title,meta_data) + + else: + self.kb_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime) + + def _scan_document(self): + while True: + time.sleep(10) + parse_queue = self.kb_db.get_docs_without_hash() + for doc_path in parse_queue: + hash,title,meta_data = self._parse_document(doc_path) + self.kb_db.set_doc_hash(doc_path,hash) + self.kb_db.add_knowledge(hash,title,meta_data) + + \ No newline at end of file diff --git a/src/component/common_environment/to_learn_parser.py b/src/component/common_environment/to_learn_parser.py new file mode 100644 index 0000000..f90c8ac --- /dev/null +++ b/src/component/common_environment/to_learn_parser.py @@ -0,0 +1,214 @@ + # 尝试自我学习,会主动获取、读取资料并进行整理 + # LLM的本质能力是处理海量知识,应该让LLM能基于知识把自己的工作处理的更好 + async def do_self_learn(self) -> None: + # 不同的workspace是否应该有不同的学习方法? + workspace = self.get_workspace_by_msg(None) + hash_list = workspace.kb_db.get_knowledge_without_llm_title() + for hash in hash_list: + if self.agent_energy <= 0: + break + + knowledge = workspace.kb_db.get_knowledge(hash) + if knowledge is None: + continue + + full_path = knowledge.get("full_path") + if full_path is None: + continue + + if os.path.exists(full_path) is False: + logger.warning(f"do_self_learn: knowledge {full_path} is not exists!") + continue + + #TODO 可以用v-db 对不同目录的名字进行选择后,先进行一次快速的插入。有时间再慢慢用LLM整理 + result_obj = await self._llm_read_article(knowledge,full_path) + + #根据结果更新knowledge + if result_obj is not None: + workspace.kb_db.set_knowledge_llm_result(hash,result_obj) + # 在知识库中创建软链接 + path_list = result_obj.get("path") + new_title = result_obj.get("title") + if path_list: + for new_path in path_list: + full_new_path = f"/knowledge{new_path}/{new_title}" + await workspace.symlink(full_path,full_new_path) + logger.info(f"create soft link {full_path} -> {full_new_path}") + + + self.agent_energy -= 1 + + # match item.type(): + # case "book": + # self.llm_read_book(kb,item) + # learn_power -= 1 + # case "article": + # + # self.llm_read_article(kb,item) + # learn_power -= 1 + # case "video": + # self.llm_watch_video(kb,item) + # learn_power -= 1 + # case "audio": + # self.llm_listen_audio(kb,item) + # learn_power -= 1 + # case "code_project": + # self.llm_read_code_project(kb,item) + # learn_power -= 1 + # case "image": + # self.llm_view_image(kb,item) + # learn_power -= 1 + # case "other": + # self.llm_read_other(kb,item) + # learn_power -= 1 + # case _: + # self.llm_learn_any(kb,item) + # pass + + + async def do_blance_knowledge_base(selft): + # 整理自己的知识库(让分类更平衡,更由于自己以后的工作),并尝试更新学习目标 + current_path = "/" + current_list = kb.get_list(current_path) + self_assessment_with_goal = self.get_self_assessment_with_goal() + learn_goal = {} + + + llm_blance_knowledge_base(current_path,current_list,self_assessment_with_goal,learn_goal,learn_power) + + # 主动学习 + # 方法目前只有使用搜索引擎一种? + for goal in learn_goal.items(): + self.llm_learn_with_search_engine(kb,goal,learn_power) + if learn_power <= 0: + break + + + def parser_learn_llm_result(self,llm_result:LLMResult): + pass + + async def gen_known_info_for_knowledge_prompt(self,knowledge_item:dict,temp_meta = None,need_catalogs = False) -> AgentPrompt: + workspace =self.get_workspace_by_msg(None) + kb_tree = await workspace.get_knowledege_catalog() + + + known_obj = {} + title = knowledge_item.get("title") + if title: + known_obj["title"] = title + summary = knowledge_item.get("summary") + if summary: + known_obj["summary"] = summary + tags = knowledge_item.get("tags") + if tags: + known_obj["tags"] = tags + if need_catalogs: + catalogs = knowledge_item.get("catalogs") + if catalogs: + known_obj["catalogs"] = catalogs + + if temp_meta: + for key in temp_meta.keys(): + known_obj[key] = temp_meta[key] + + org_path = knowledge_item.get("full_path") + known_obj["orginal_path"] = org_path + know_info_str = f"# Known information:\n## Current directory structure:\n{kb_tree}\n## Knowlege Metadata:\n{json.dumps(known_obj)}\n" + return AgentPrompt(know_info_str) + + async def _llm_read_article(self,knowledge_item:dict,full_path:str) -> ComputeTaskResult: + # Objectives: + # Obtain better titles, abstracts, table of contents (if necessary), tags + # Determine the appropriate place to put it (in line with the organization's goals) + # Known information: + # The reason why the target service's learn_prompt is being sorted + # Summary of the organization's work (if any) + # The current structure of the knowledge base (note the size control) gen_kb_tree_prompt (when empty, LLM should generate an appropriate initial directory structure) + # Original path, current title, abstract, table of contents + + # Sorting long files (general tricks) + # Indicate that the input is part of the content, let LLM generate intermediate results for the task + # Enter the content in sequence, when the last content block is input, LLM gets the result + + + #full_content = item.get_article_full_content() + workspace = self.get_workspace_by_msg(None) + full_content_len = self.token_len(full_content) + + if full_content_len < self.get_llm_learn_token_limit(): + + # 短文章不用总结catelog + #path_list,summary = llm_get_summary(summary,full_content) + #prompt = self.get_agent_role_prompt() + prompt = AgentPrompt() + prompt.append(self.get_learn_prompt()) + known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item) + prompt.append(known_info_prompt) + content_prompt = AgentPrompt(full_content) + prompt.append(content_prompt) + env_functions = None + #env_functions,function_len = workspace.get_knowledge_base_ai_functions() + task_result:ComputeTaskResult = await self.do_llm_complection(prompt,is_json_resp=True) + if task_result.result_code != ComputeTaskResultCode.OK: + result_obj = {} + result_obj["error_str"] = task_result.error_str + return result_obj + + result_obj = json.loads(task_result.result_str) + return result_obj + + else: + logger.warning(f"llm_read_article: article {full_path} use LLM loop learn!") + pos = 0 + read_len = int(self.get_llm_learn_token_limit() * 1.2) + + temp_meta_data = {} + is_final = False + while pos < str_len: + _content = full_content[pos:pos+read_len] + part_cotent_len = len(_content) + if part_cotent_len < read_len: + # last chunk + is_final = True + part_content = f"<>\n{_content}" + else: + part_content = f"<>\n{_content}" + + pos = pos + read_len + prompt = AgentPrompt() + prompt.append(self.get_learn_prompt()) + known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item,temp_meta_data) + prompt.append(known_info_prompt) + content_prompt = AgentPrompt(part_content) + prompt.append(content_prompt) + #env_functions,function_len = workspace.get_knowledge_base_ai_functions() + task_result:ComputeTaskResult = await self.do_llm_complection(prompt,is_json_resp=True) + if task_result.result_code != ComputeTaskResultCode.OK: + result_obj = {} + result_obj["error_str"] = task_result.error_str + return result_obj + + result_obj = json.loads(task_result.result_str) + temp_meta_data = result_obj + if is_final: + return result_obj + + return None + + + async def do_self_think(self): + session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) + for session_id in session_id_list: + if self.agent_energy <= 0: + break + used_energy = await self.think_chatsession(session_id) + self.agent_energy -= used_energy + + todo_logs = await self.get_todo_logs() + for todo_log in todo_logs: + if self.agent_energy <= 0: + break + used_energy = await self.think_todo_log(todo_log) + self.agent_energy -= used_energy + + return \ No newline at end of file diff --git a/src/component/knowledge_manager/pipeline.py b/src/component/knowledge_manager/pipeline.py index 31b105d..c8a1ded 100644 --- a/src/component/knowledge_manager/pipeline.py +++ b/src/component/knowledge_manager/pipeline.py @@ -44,14 +44,19 @@ class KnowledgePipelineManager: input_init = self.input_modules.get(input_module) input_params = config["input"].get("params") - parser_module = config["parser"]["module"] - _, ext = os.path.splitext(parser_module) - if ext == ".py": - parser_module = os.path.join(path, parser_module) - parser_init = runpy.run_path(parser_module)["init"] + parser_config = config.get("parser") + if parser_config is None: + parser_init = None + parser_params = None else: - parser_init = self.parser_modules.get(parser_module) - parser_params = config["parser"].get("params") + parser_module = parser_config["module"] + _, ext = os.path.splitext(parser_module) + if ext == ".py": + parser_module = os.path.join(path, parser_module) + parser_init = runpy.run_path(parser_module)["init"] + else: + parser_init = self.parser_modules.get(parser_module) + parser_params = parser_config.get("params") data_path = os.path.join(self.root_dir, name) diff --git a/src/component/mail_environment/local.py b/src/component/mail_environment/local.py index 21f3a94..af9726c 100644 --- a/src/component/mail_environment/local.py +++ b/src/component/mail_environment/local.py @@ -22,7 +22,7 @@ class LocalEmail: if latest_journal.is_finish(): yield None continue - parsed = str(latest_journal.get_object_id()) + parsed = latest_journal.get_input() mail_id = self.mail_storage.next_mail_id(parsed) if mail_id is None: diff --git a/src/component/mail_environment/mail.py b/src/component/mail_environment/mail.py index ad9ba2e..b49b0c7 100644 --- a/src/component/mail_environment/mail.py +++ b/src/component/mail_environment/mail.py @@ -7,17 +7,20 @@ import datetime from bs4 import BeautifulSoup import sqlite3 import html2text +from urllib.parse import urlparse from aios import * + + class Mail: def __init__(self, **kwargs) -> None: - self.from_addr = kwargs.get("From") - self.to_addr = kwargs.get("To") - self.subject = kwargs.get("Subject") - self.date = kwargs.get("Date") - self.bcc = kwargs.get("BCC") - self.cc = kwargs.get("CC") - self.reply_to = None + self.from_addr = kwargs.get("from") + self.to_addr = kwargs.get("to") + self.subject = kwargs.get("subject") + self.date = kwargs.get("date") + self.bcc = kwargs.get("bcc") + self.cc = kwargs.get("cc") + self.reply_to = kwargs.get("reply_to") self.id: str = None self.content: str = None @@ -192,20 +195,36 @@ class MailStorage: self.conn.commit() await asyncio.sleep(10) - def download(self, uid, mail: mailparser.MailParser): + def download(self, uid, parser: mailparser.MailParser, + save_image=True, + from_field="From", + to_field="To", + subject_field="Subject", + date_field="Date", + reply_to_field="In-Reply-To", + cc_field="CC", + bcc_field="BCC"): mail_dir = self.mail_dir(uid) - os.makedirs(dir) + if not os.path.exists(mail_dir): + os.makedirs(mail_dir) - meta = json.loads(mail.mail_json) - mail = Mail(**meta) - reply_to = meta.get("In-Reply-To") + src_meta = json.loads(parser.mail_json) + meta = {} + meta["from"] = src_meta.get(from_field) + meta["to"] = src_meta.get(to_field) + meta["subject"] = src_meta.get(subject_field) + meta["date"] = src_meta.get(date_field) + meta["bcc"] = src_meta.get(bcc_field) + meta["cc"] = src_meta.get(cc_field) + reply_to = src_meta.get(reply_to_field) if reply_to: - mail.reply_to = self.uid_to_object_id(reply_to) + meta["reply_to"] = self.uid_to_object_id(reply_to) + mail = Mail(**meta) h = html2text.HTML2Text() h.ignore_links = True h.ignore_images = True - mail_content = h.handle(mail.body) + mail_content = h.handle(parser.body) mail.content = mail_content mail.calculate_id() @@ -216,41 +235,52 @@ class MailStorage: with open(f"{mail_dir}/mail.txt", "w", encoding='utf-8') as f: f.write(mail_content) - for attachment in mail.attachments: - if attachment['mail_content_type'] in ['image/png', 'image/jpeg', 'image/gif']: - filename = attachment['filename'] - filefullname = f"{mail_dir}/{filename}" - image_data = attachment['payload'] + if save_image: + for attachment in parser.attachments: + if attachment['mail_content_type'] in ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg']: + filename = attachment['filename'] + filefullname = f"{mail_dir}/{filename}" + image_data = attachment['payload'] + try: + image_data = base64.b64decode(image_data) + except base64.binascii.Error: + image_data = image_data.encode() + with open(filefullname, 'wb') as f: + f.write(image_data) + logging.info(f"save email image {filename} success") + + # get all image urls + soup = BeautifulSoup(parser.body, 'html.parser') + img_tags = soup.find_all('img') + img_urls = [img['src'] for img in img_tags if 'src' in img.attrs] + logging.info(f'Found {len(img_urls)} images in email body') + + name_count = 0 + + for img_url in img_urls: + # keep the original image filename(last of url) + url_result = urlparse(img_url) + if url_result.scheme not in ['http', 'https']: + continue + ext = url_result.path.split('/')[-1].split('.')[-1] + if ext in ['png', 'jpg', 'jpeg', 'gif', 'svg']: + img_filename = os.path.join(mail_dir, f"{name_count}.{ext}") + else : + img_filename = os.path.join(mail_dir, f"{name_count}") + name_count += 1 + # download image try: - image_data = base64.b64decode(image_data) - except base64.binascii.Error: - image_data = image_data.encode() - with open(filefullname, 'wb') as f: - f.write(image_data) - logging.info(f"save email image {filename} success") - - # get all image urls - soup = BeautifulSoup(mail.body, 'html.parser') - img_tags = soup.find_all('img') - img_urls = [img['src'] for img in img_tags if 'src' in img.attrs] - logging.info(f'Found {len(img_urls)} images in email body') - - name_count = 0 - - for img_url in img_urls: - # keep the original image filename(last of url) - ext = img_url.split('/')[-1].split('.')[-1] - img_filename = os.path.join(mail_dir, f"{name_count}.{ext}") - name_count += 1 - # download image - response = requests.get(img_url, stream=True) - if response.status_code == 200: - with open(img_filename, 'wb') as img_file: - for chunk in response.iter_content(1024): - img_file.write(chunk) - logging.info(f'Downloaded {img_url} to {img_filename}') - else: - logging.info(f'Failed to download {img_url}') + response = requests.get(img_url, stream=True) + except requests.exceptions.RequestException as e: + logging.error(f'Failed to download {img_url}: {e}') + continue + if response.status_code == 200: + with open(img_filename, 'wb') as img_file: + for chunk in response.iter_content(1024): + img_file.write(chunk) + logging.info(f'Downloaded {img_url} to {img_filename}') + else: + logging.error(f'Failed to download {img_url}') cursor = self.conn.cursor() cursor.execute( @@ -260,5 +290,8 @@ class MailStorage: """, (uid, mail.id, mail.date, mail.from_addr), ) + self.conn.commit() + + return mail.id \ No newline at end of file diff --git a/src/component/mail_environment/spider.py b/src/component/mail_environment/spider.py index cc6586b..0119919 100644 --- a/src/component/mail_environment/spider.py +++ b/src/component/mail_environment/spider.py @@ -1,9 +1,13 @@ import os import logging import json +import string import imaplib import mailparser -from aios import * + +from knowledge import * +from aios_kernel.storage import AIStorage +from .mail import Mail, MailStorage class EmailSpider: @@ -16,14 +20,22 @@ class EmailSpider: port=self.config.get('imap_port') ) self.client.login(self.config.get('address'), self.config.get('password')) - self.mail_local_root = os.path.join(self.env.pipeline_path, self.config.get("address")) - os.makedirs(self.mail_local_root) + self.client.select("INBOX") + local_path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + local_path = os.path.join(local_path, self.config.get('address')) + self.mail_storage = MailStorage(local_path) + async def next(self): while True: - _, data = self.client.uid('search', None, "ALL") + try: + _, data = self.client.uid('search', None, "ALL") + except Exception as e: + self.env.get_logger().error(f"email spider error: {e}") + yield (None, None) + continue uid_list = data[0].split() - if uid_list.len() == 0: + if len(uid_list) == 0: yield (None, None) continue @@ -43,9 +55,16 @@ class EmailSpider: _uid = int.from_bytes(uid) if _uid > from_uid: message_parts = "(BODY.PEEK[])" - _, email_data = self.client.uid('fetch', uid, message_parts) - mail = mailparser.parse_from_bytes(email_data[0][1]) - self.save_email(_uid, mail) + try: + _, email_data = self.client.uid('fetch', uid, message_parts) + mail = mailparser.parse_from_bytes(email_data[0][1]) + id = self.mail_storage.download(_uid, mail) + except Exception as e: + self.env.get_logger().error(f"email spider error: {e}") + yield (None, None) + break + yield (ObjectID.from_base58(id), str(_uid)) + yield (None, None) From 1031d527c1e786ad7b8f60c76f058feeaf62b7bd Mon Sep 17 00:00:00 2001 From: tsukasa Date: Mon, 4 Dec 2023 17:15:09 +0800 Subject: [PATCH 49/79] add local knowledge base environment --- src/aios/__init__.py | 6 +- src/aios/agent/agent.py | 60 +- src/aios/environment/environment.py | 25 +- src/aios/environment/workspace_env.py | 99 +++- src/component/agent_manager/agent_manager.py | 39 +- .../common_environment/local_document.py | 542 +++++++++--------- .../common_environment/to_learn_parser.py | 214 ------- 7 files changed, 382 insertions(+), 603 deletions(-) delete mode 100644 src/component/common_environment/to_learn_parser.py diff --git a/src/aios/__init__.py b/src/aios/__init__.py index ce866cf..6c638f0 100644 --- a/src/aios/__init__.py +++ b/src/aios/__init__.py @@ -2,7 +2,7 @@ from .proto.agent_msg import * from .proto.compute_task import * -from .agent.agent_base import AgentPrompt,CustomAIAgent +from .agent.agent_base import AgentPrompt,CustomAIAgent, AgentTodo from .agent.chatsession import AIChatSession from .agent.agent import AIAgent,AIAgentTemplete, BaseAIAgent from .agent.role import AIRole,AIRoleGroup @@ -16,11 +16,11 @@ from .frame.tunnel import AgentTunnel from .frame.contact_manager import ContactManager,Contact,FamilyMember from .frame.queue_compute_node import Queue_ComputeNode -from .environment.environment import Environment,EnvironmentEvent +from .environment.environment import BaseEnvironment,SimpleEnvironment,CompositeEnvironment from .environment.workflow_env import WorkflowEnvironment,CalenderEnvironment,CalenderEvent,PaintEnvironment from .environment.text_to_speech_function import TextToSpeechFunction from .environment.image_2_text_function import Image2TextFunction -from .environment.workspace_env import ShellEnvironment,WorkspaceEnvironment +from .environment.workspace_env import ShellEnvironment,WorkspaceEnvironment,TodoListEnvironment,TodoListType from .storage.storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 4614216..3048357 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -145,7 +145,6 @@ class AIAgent(BaseAIAgent): self.owner_promp_str = None self.contact_prompt_str = None self.history_len = 10 - self.read_report_prompt = None todo_prompts = {} @@ -161,11 +160,8 @@ class AIAgent(BaseAIAgent): } self.todo_prompts = todo_prompts - self.learn_token_limit = 4000 - self.chat_db = None self.unread_msg = Queue() # msg from other agent - self.owner_env : Environment = None self.owenr_bus = None self.enable_function_list = None @@ -187,7 +183,7 @@ class AIAgent(BaseAIAgent): logger.error("agent instance_id is None!") return False self.agent_id = config["instance_id"] - self.agent_workspace = WorkspaceEnvironment(self.agent_id) + self.agent_workspace = config["workspace"] if config.get("fullname") is None: logger.error(f"agent {self.agent_id} fullname is None!") @@ -233,9 +229,6 @@ class AIAgent(BaseAIAgent): if config.get("contact_prompt") is not None: self.contact_prompt_str = config["contact_prompt"] - if config.get("owner_env") is not None: - self.owner_env = config.get("owner_env") - if config.get("powerby") is not None: self.powerby = config["powerby"] @@ -276,16 +269,9 @@ class AIAgent(BaseAIAgent): def get_max_token_size(self) -> int: return self.max_token_size - def get_llm_learn_token_limit(self) -> int: - return self.learn_token_limit - - def get_learn_prompt(self) -> AgentPrompt: - return self.learn_prompt - def get_agent_role_prompt(self) -> AgentPrompt: return self.role_prompt - def _get_remote_user_prompt(self,remote_user:str) -> AgentPrompt: cm = ContactManager.get_instance() contact = cm.find_contact_by_name(remote_user) @@ -312,34 +298,6 @@ class AIAgent(BaseAIAgent): return None - - def _get_inner_functions(self) -> dict: - if self.owner_env is None: - return None,0 - - all_inner_function = self.owner_env.get_all_ai_functions() - if all_inner_function is None: - return None,0 - - result_func = [] - result_len = 0 - for inner_func in all_inner_function: - func_name = inner_func.get_name() - if self.enable_function_list is not None: - if len(self.enable_function_list) > 0: - if func_name not in self.enable_function_list: - logger.debug(f"ageint {self.agent_id} ignore inner func:{func_name}") - continue - - this_func = {} - this_func["name"] = func_name - this_func["description"] = inner_func.get_description() - this_func["parameters"] = inner_func.get_parameters() - result_len += len(json.dumps(this_func)) / 4 - result_func.append(this_func) - - return result_func,result_len - def get_agent_prompt(self) -> AgentPrompt: return self.agent_prompt @@ -347,12 +305,9 @@ class AIAgent(BaseAIAgent): return self.agent_think_prompt def _format_msg_by_env_value(self,prompt:AgentPrompt): - if self.owner_env is None: - return - for msg in prompt.messages: old_content = msg.get("content") - msg["content"] = old_content.format_map(self.owner_env) + msg["content"] = old_content.format_map(self.agent_workspace) async def _handle_event(self,event): if event.type == "AgentThink": @@ -549,7 +504,7 @@ class AIAgent(BaseAIAgent): if todo_count > 0: have_known_info = True known_info_str += f"## todo\n{todos_str}\n" - inner_functions,function_token_len = BaseAIAgent.get_inner_functions(self.owner_env) + inner_functions,function_token_len = BaseAIAgent.get_inner_functions(self.agent_workspace) system_prompt_len = self.token_len(prompt=prompt) input_len = len(msg.body) if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: @@ -568,7 +523,7 @@ class AIAgent(BaseAIAgent): logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - task_result = await self.do_llm_complection(prompt,msg, env=self.owner_env,inner_functions=inner_functions) + task_result = await self.do_llm_complection(prompt,msg, env=self.agent_workspace,inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(task_result.error_str) return error_resp @@ -771,6 +726,7 @@ class AIAgent(BaseAIAgent): case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: continue case AgentTodoResult.TODO_RESULT_CODE_OK: + todo.result = do_result await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_WAITING_CHECK) case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_EXEC_FAILED) @@ -913,12 +869,12 @@ class AIAgent(BaseAIAgent): resp = await AIBus.get_default_bus().post_message(msg) logging.info(f"agent {self.agent_id} send msg to {msg.target} result:{resp}") - op_errors, have_error = await workspace.exec_op_list(llm_result.op_list, self.agent_id) + result_str, have_error = await workspace.exec_op_list(llm_result.op_list, self.agent_id) if have_error: result.result_code = AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR #result.error_str = error_str return result - + result.result_str = result_str return result async def _llm_check_todo(self, todo: AgentTodo, prompt: AgentPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: @@ -937,7 +893,7 @@ class AIAgent(BaseAIAgent): return result async def _llm_review_todo(self, todo:AgentTodo, prompt: AgentPrompt, workspace: WorkspaceEnvironment): - inner_functions,_ = BaseAIAgent.get_inner_functions(self.owner_env) + inner_functions,_ = BaseAIAgent.get_inner_functions(workspace) task_result:ComputeTaskResult = await self.do_llm_complection(prompt,inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: diff --git a/src/aios/environment/environment.py b/src/aios/environment/environment.py index d4b426b..ac4559a 100644 --- a/src/aios/environment/environment.py +++ b/src/aios/environment/environment.py @@ -11,10 +11,9 @@ logger = logging.getLogger(__name__) class BaseEnvironment: - @abstractmethod - def get_id(self) -> str: + def __init__(self, workspace: str) -> None: pass - + # @abstractmethod # #TODO: how to use env? different env has different prompt # def get_env_prompt(self) -> str: @@ -50,14 +49,11 @@ class BaseEnvironment: # cls._all_env[env.get_id()] = env class SimpleEnvironment(BaseEnvironment): - def __init__(self, env_id: str) -> None: - self.env_id = env_id + def __init__(self, workspace: str) -> None: + super().__init__(workspace) self.functions: Dict[str,AIFunction] = {} self.operations: Dict[str,AIOperation] = {} - - def get_id(self) -> str: - return self.env_id - + def add_ai_function(self,func:AIFunction) -> None: self.functions[func.get_name()] = func @@ -89,17 +85,14 @@ class SimpleEnvironment(BaseEnvironment): class CompositeEnvironment(BaseEnvironment): - def __init__(self, env_id: str) -> None: - self.env_id = env_id - self.envs:Dict[str,BaseEnvironment] = {} + def __init__(self, workspace: str) -> None: + super().__init__(workspace) + self.envs:List[BaseEnvironment] = {} self.functions: Dict[str,AIFunction] = {} self.operations: Dict[str,AIOperation] = {} - - def get_id(self) -> str: - return self.env_id def add_env(self, env: BaseEnvironment) -> None: - self.envs[env.get_id()] = env + self.envs.append[env] functions = env.get_all_ai_functions() for func in functions: self.functions[func.get_name()] = func diff --git a/src/aios/environment/workspace_env.py b/src/aios/environment/workspace_env.py index 115e347..f464f0f 100644 --- a/src/aios/environment/workspace_env.py +++ b/src/aios/environment/workspace_env.py @@ -4,7 +4,9 @@ import json import logging import os import aiofiles -from typing import Any,List +import sqlite3 +import asyncio +from typing import Any,List,Dict import chardet from ..agent.agent_base import AgentMsg,AgentTodo,AgentPrompt,AgentTodoResult from ..agent.ai_function import AIFunction,SimpleAIFunction, SimpleAIOperation @@ -12,7 +14,6 @@ from ..storage.storage import AIStorage,ResourceLocation from .environment import SimpleEnvironment, CompositeEnvironment - logger = logging.getLogger(__name__) class TodoListType: @@ -20,12 +21,28 @@ class TodoListType: TO_LEARN = "learn" class TodoListEnvironment(SimpleEnvironment): - def __init__(self, root_path, list_type) -> None: - super.__init__(list_type) - self.root_path = os.path.join(root_path, list_type) + def __init__(self, workspace, list_type) -> None: + super().__init__(workspace) + self.root_path = os.path.join(workspace, list_type) if not os.path.exists(self.root_path): os.makedirs(self.root_path) - self.known_todo = {} + + self.db_path = os.path.join(self.root_path, "todo.db") + self.conn = None + try: + self.conn = sqlite3.connect(self.db_path) + except Exception as e: + logger.error("Error occurred while connecting to database: %s", e) + return None + + cursor = self.conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS todo_list ( + id TEXT, + path TEXT + ) + ''') + self.conn.commit() async def create_todo(params): todoObj = AgentTodo.from_dict(params["todo"]) @@ -48,6 +65,23 @@ class TodoListEnvironment(SimpleEnvironment): func_handler=update_todo, )) + def _get_todo_path(self,todo_id:str) -> str: + cursor = self.conn.cursor() + cursor.execute(''' + SELECT path FROM todo_list WHERE id = ? + ''',(todo_id,)) + row = cursor.fetchone() + if row: + return row[0] + else: + return None + + def _save_todo_path(self,todo_id:str,path:str): + cursor = self.conn.cursor() + cursor.execute(''' + INSERT INTO todo_list (id,path) VALUES (?,?) + ''',(todo_id,path)) + self.conn.commit() # Task/todo system , create,update,delete,query async def get_todo_tree(self,path:str = None,deep:int = 4): @@ -142,7 +176,6 @@ class TodoListEnvironment(SimpleEnvironment): if not relative_path.startswith('/'): relative_path = '/' + relative_path result_todo.todo_path = relative_path - self.known_todo[result_todo.todo_id] = result_todo else: logger.error("get_todo_by_path:%s,parse failed!",path) @@ -150,18 +183,12 @@ class TodoListEnvironment(SimpleEnvironment): except Exception as e: logger.error("get_todo_by_path:%s,failed:%s",path,e) return None - - async def get_todo(self,id:str) -> AgentTodo: - return self.known_todo.get(id) + async def create_todo(self,parent_id:str,todo:AgentTodo) -> str: try: if parent_id: - if parent_id not in self.known_todo: - logger.error("create_todo failed: parent_id not found!") - return False - - parent_path = self.known_todo.get(parent_id).todo_path + parent_path = self._get_todo_path(parent_id) todo_path = f"{parent_path}/{todo.title}" else: todo_path = todo.title @@ -172,10 +199,10 @@ class TodoListEnvironment(SimpleEnvironment): detail_path = f"{dir_path}/detail" if todo.todo_path is None: todo.todo_path = todo_path + self._save_todo_path(todo.todo_id,todo_path) logger.info("create_todo %s",detail_path) async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: await f.write(json.dumps(todo.to_dict())) - self.known_todo[todo.todo_id] = todo except Exception as e: logger.error("create_todo failed:%s",e) return str(e) @@ -184,7 +211,8 @@ class TodoListEnvironment(SimpleEnvironment): async def update_todo(self,todo_id:str,new_stat:str)->str: try: - todo : AgentTodo = self.known_todo.get(todo_id) + todo_path = self._get_todo_path(todo_id) + todo : AgentTodo = self.get_todo_by_fullpath(todo_path) if todo: todo.state = new_stat detail_path = f"{self.root_path}/{todo.todo_path}/detail" @@ -196,6 +224,20 @@ class TodoListEnvironment(SimpleEnvironment): except Exception as e: return str(e) + async def wait_todo_done(self,todo_id:str) -> AgentTodo: + todo_path = self._get_todo_path(todo_id) + async def check_done(): + while True: + todo : AgentTodo = self.get_todo_by_fullpath(todo_path) + if todo: + if todo.state == AgentTodo.TODO_STATE_DONE: + break + await asyncio.sleep(1) + + asyncio.create_task(check_done()) + return self.get_todo_by_fullpath(todo_path) + + async def append_worklog(self, todo:AgentTodo, result:AgentTodoResult): worklog = f"{self.root_path}/{todo.todo_path}/.worklog" @@ -213,10 +255,9 @@ class TodoListEnvironment(SimpleEnvironment): await f.write(json.dumps(json_obj)) class FilesystemEnvironment(SimpleEnvironment): - def __init__(self, root_path: str, env_id: str) -> None: - super().__init__(env_id) - self.root_path = root_path - + def __init__(self, workspace: str) -> None: + super().__init__(workspace) + self.root_path = workspace # if op["op"] == "create": # await self.create(op["path"],op["content"]) @@ -346,8 +387,8 @@ class FilesystemEnvironment(SimpleEnvironment): return None class ShellEnvironment(SimpleEnvironment): - def __init__(self, env_id: str) -> None: - super().__init__(env_id) + def __init__(self, workspace: str) -> None: + super().__init__(workspace) operator_param = { "command": "command will execute", @@ -381,20 +422,22 @@ class ShellEnvironment(SimpleEnvironment): class WorkspaceEnvironment(CompositeEnvironment): def __init__(self, env_id: str) -> None: - super().__init__(env_id) myai_path = AIStorage.get_instance().get_myai_dir() - self.root_path = f"{myai_path}/workspace/{env_id}" + root_path = f"{myai_path}/workspace/{env_id}" + super().__init__(root_path) + + self.root_path = root_path if not os.path.exists(self.root_path): os.makedirs() - self.todo_list = {} + self.todo_list: Dict[str, TodoListEnvironment] = {} self.todo_list[TodoListType.TO_WORK] = TodoListEnvironment(self.root_path,TodoListType.TO_WORK) self.todo_list[TodoListType.TO_LEARN] = TodoListEnvironment(self.root_path,TodoListType.TO_LEARN) # default environments in workspace self.add_env(self.todo_list[TodoListType.TO_WORK]) - self.add_env(ShellEnvironment("shell")) - self.add_env(FilesystemEnvironment(self.root_path, "fs")) + self.add_env(ShellEnvironment(self.root_path)) + self.add_env(FilesystemEnvironment(self.root_path)) def set_root_path(self,path:str): self.root_path = path diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index cb1ed03..0252d86 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -6,7 +6,7 @@ import sys import runpy from typing import Any, Callable, Dict, List, Optional, Union -from aios import AIAgent,AIAgentTemplete,AIStorage,Environment,BaseAIAgent,PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask +from aios import AIAgent,AIAgentTemplete,AIStorage,BaseAIAgent,PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask,WorkspaceEnvironment logger = logging.getLogger(__name__) @@ -28,6 +28,7 @@ class AgentManager: self.agent_templete_env : PackageEnv = None self.agent_env : PackageEnv = None self.db_path : str = None + self.environments: dict = {} self.loaded_agent_instance : Dict[str,BaseAIAgent] = None async def initial(self) -> None: @@ -49,6 +50,15 @@ class AgentManager: async def scan_all_agent(self)->None: pass + async def register_environment(self, env_id: str, init_env) -> None: + self.environments[env_id] = init_env + + async def init_environment(self, env_id: str, workspace: str): + if env_id not in self.environments: + logger.error(f"env {env_id} not found!") + return + + return self.environments[env_id] async def is_exist(self,agent_id:str) -> bool: the_aget = await self.get(agent_id) @@ -108,17 +118,28 @@ class AgentManager: config_data = await config_file.read() config = toml.loads(config_data) result_agent = AIAgent() - + + workspace = config.get("workspace", config.get("instance_id")) + workspace = WorkspaceEnvironment(workspace) + config["workspace"] = workspace + if "owner_env" in config: owner_env = config["owner_env"] - _, ext = os.path.splitext(owner_env) - if ext == ".py": - env_path = os.path.join(agent_media.full_path, owner_env) - owner_env = runpy.run_path(env_path)["init"]() - config["owner_env"] = owner_env + + def init_env(env_config: str): + _, ext = os.path.splitext(owner_env) + if ext == ".py": + env_path = os.path.join(agent_media.full_path, owner_env) + env = runpy.run_path(env_path)["init"](None, workspace.root_path) + else: + env = self.init_environment(env_config, workspace.root_path) + workspace.add_env(env) + + if isinstance(owner_env, list): + for env in owner_env: + init_env(env) else: - owner_env = Environment.get_env_by_id(config["owner_env"]) - config["owner_env"] = owner_env + init_env(owner_env) if result_agent.load_from_config(config) is False: logger.error(f"load agent from {agent_media} failed!") diff --git a/src/component/common_environment/local_document.py b/src/component/common_environment/local_document.py index c003b43..14d225d 100644 --- a/src/component/common_environment/local_document.py +++ b/src/component/common_environment/local_document.py @@ -1,16 +1,6 @@ -# import os -# import aiofiles -# import chardet -# import logging -# import string -# from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal -# from aios_kernel.storage import AIStorage - - import os import aiofiles import chardet -import logging import string import sqlite3 import json @@ -18,45 +8,9 @@ import threading import logging from datetime import datetime from typing import Optional, List -from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal -from aios_kernel import AIStorage, SimpleEnvironment +from aios import KnowledgePipelineEnvironment, AIStorage, SimpleEnvironment, TodoListEnvironment, TodoListType, AgentTodo, CustomAIAgent -class ScanLocalDocument: - def __init__(self, env: KnowledgePipelineEnvironment, config): - self.env = env - path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) - config["path"] = path - self.config = config - - def path(self): - return self.config["path"] - - async def next(self): - while True: - journals = self.env.journal.latest_journals(1) - from_time = 0 - if len(journals) == 1: - latest_journal = journals[0] - if latest_journal.is_finish(): - yield None - continue - from_time = os.path.getctime(latest_journal.get_input()) - if os.path.getmtime(self.path()) <= from_time: - yield (None, None) - continue - - file_pathes = sorted(os.listdir(self.path()), key=lambda x: os.path.getctime(os.path.join(self.path(), x))) - for rel_path in file_pathes: - file_path = os.path.join(self.path(), rel_path) - timestamp = os.path.getctime(file_path) - if timestamp <= from_time: - continue - ext = os.path.splitext(file_path)[1].lower() - if ext in ['.pdf', '.md', '.txt']: - logging.info(f"knowledge dir source found document file {file_path}") - yield (file_path, file_path) - yield (None, None) class MetaDatabase: def __init__(self,db_path:str): @@ -165,15 +119,16 @@ class MetaDatabase: return [row[0] for row in cursor.fetchall()] #metadata["summary"] - #metadata["catelogs"] + #metadata["catalogs"] #metadata["tags"] - def add_knowledge(self, doc_hash: str, title: str, metadata: dict,content:str = None,): + def add_knowledge(self, doc_hash: str, metadata: dict,content:str = None,): conn = self._get_conn() cursor = conn.cursor() create_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") summary = metadata.get("summary", "") catalogs = metadata.get("catalogs","") + title = metadata.get("title","") tags = ','.join(metadata.get("tags", [])) cursor.execute(''' @@ -184,7 +139,7 @@ class MetaDatabase: #llm_result["summary"] #llm_result["tags"] - #llm_result["catelog"] + #llm_result["catalog"] def set_knowledge_llm_result(self, doc_hash: str, llm_result: dict): conn = self._get_conn() cursor = conn.cursor() @@ -273,15 +228,20 @@ class MetaDatabase: return [row[0] for row in cursor.fetchall()] -class DocumentKnowledgeBase(SimpleEnvironment): +class LocalKnowledgeBase(SimpleEnvironment): + def __init__(self, workspace: str) -> None: + super().__init__(workspace) + self.root_path = f"{self.root_path}/knowledge" + self.meta_db = MetaDatabase(f"{self.root_path}/kb.db") + async def get_knowledege_catalog(self,path:str=None,only_dir =True,max_depth:int=5)->str: - if path: - full_path = f"{self.root_path}/knowledge/{path}" - else: - full_path = f"{self.root_path}/knowledge" - - catlogs,file_count = await self.get_directory_structure(full_path,max_depth,only_dir) - return catlogs + if path: + full_path = f"{self.root_path}/{path}" + else: + full_path = self.root_path + + catlogs,file_count = await self.get_directory_structure(full_path,max_depth,only_dir) + return catlogs async def get_directory_structure(self,root_dir, max_depth:int=4, only_dir=True, indent=1): file_count = 0 @@ -315,176 +275,16 @@ class DocumentKnowledgeBase(SimpleEnvironment): return structure_str, file_count # inner_function - async def get_knowledge(self,path:str) -> str: - full_path = f"{self.root_path}/knowledge/{path}" + async def get_knowledge_meta(self,path:str) -> str: + full_path = f"{self.root_path}/{path}" if os.islink(full_path): org_path = os.readlink(full_path) - hash = self.kb_db.get_hash_by_doc_path(org_path) + hash = self.meta_db.get_hash_by_doc_path(org_path) if hash: - return self.kb_db.get_knowledge(org_path) + return self.meta_db.get_knowledge(org_path) return "not found" - - -class ParseLocalDocument: - def _parse_pdf_bookmarks(self,bookmarks, parent:list): - - for item in bookmarks: - if isinstance(item,list): - self._parse_pdf_bookmarks(item,parent) - else: - if item.title: - new_item = {} - new_item["page"] = item.page.idnum - new_item["title"] = item.title - my_childs = [] - if item.childs: - if len(item.childs) > 0: - self._parse_pdf_bookmarks(item.childs, my_childs) - new_item["childs"] = my_childs - parent.append(new_item) - else: - logger.warning("parse pdf bookmarks failed: item.title is None!") - - return - - def _parse_pdf(self,doc_path:str): - metadata = {} - with open(doc_path, 'rb') as file: - reader = PyPDF2.PdfReader(file) - try: - doc_info = reader.metadata - if doc_info: - if doc_info.title: - metadata["title"] = doc_info.title - if doc_info.author: - metadata["authors"] = doc_info.author - except Exception as e: - logger.warn("parse pdf metadata failed:%s",e) - - dir_path = os.path.dirname(doc_path) - base_name = os.path.basename(doc_path) - text_content_path = f"{dir_path}/.{base_name}.txt" - full_text = "" - - for page in reader.pages: - text = page.extract_text() - full_text += text - with open(text_content_path, 'w', encoding='utf-8') as f: - f.write(full_text) - - try: - bookmarks = reader.outline - if bookmarks: - catalogs = [] - self._parse_pdf_bookmarks(bookmarks,catalogs) - metadata["catalogs"] = json.dumps(catalogs) - except Exception as e: - logger.warn("parse pdf bookmarks failed:%s",e) - - return metadata - - def _parse_txt(self,doc_path:str): - return {} - - def _parse_md(self,doc_path:str): - metadata = {} - cur_encode = "utf-8" - with open(doc_path,'rb') as f: - cur_encode = chardet.detect(f.read(1024))['encoding'] - - with open(doc_path, mode='r', encoding=cur_encode) as f: - content = f.read() - match = re.search(r'^# (.*)', content, re.MULTILINE) - if match: - metadata['title'] = match.group(1).strip() - md = Markdown(extensions=['toc']) - html_str = md.convert(content) - toc = md.toc - if toc: - metadata['catalogs'] = toc - - return metadata - - def _parse_document(self,doc_path:str): - hash_result = None - title = os.path.basename(doc_path) - meta_data = {} - - with open(doc_path, "rb") as f: - hash_md5 = hashlib.md5() - for chunk in iter(lambda: f.read(1024*1024), b""): - hash_md5.update(chunk) - hash_result = hash_md5.hexdigest() - try: - if doc_path.endswith(".md"): - meta_data = self._parse_md(doc_path) - elif doc_path.endswith(".pdf"): - meta_data = self._parse_pdf(doc_path) - except Exception as e: - logger.error("parse document %s failed:%s",doc_path,e) - traceback.print_exc() - - if meta_data.get("title"): - title = meta_data["title"] - logger.info("parse document %s!",doc_path) - return hash_result,title,meta_data - async def parse(self, file_path: str) -> str: - - - -# async def get_knowledege_catalog(self,path:str=None,only_dir =True,max_depth:int=5)->str: -# if path: -# full_path = f"{self.root_path}/knowledge/{path}" -# else: -# full_path = f"{self.root_path}/knowledge" - -# catlogs,file_count = await self.get_directory_structure(full_path,max_depth,only_dir) -# return catlogs - -# async def get_directory_structure(self,root_dir, max_depth:int=4, only_dir=True, indent=1): -# file_count = 0 -# structure_str = '' -# if os.path.isdir(root_dir): -# sub_files = [] -# with os.scandir(root_dir) as it: -# for entry in it: -# if entry.is_dir(): -# sub_structure, sub_count = await self.get_directory_structure(entry.path, max_depth, only_dir, indent + 1) -# if sub_structure: -# structure_str += sub_structure -# file_count += sub_count -# else: -# file_count += 1 -# sub_files.append(entry.name) - -# if only_dir is False: -# for file_name in sub_files: -# structure_str = structure_str + ' ' * (indent+1) + file_name + '\n' - -# dir_name = os.path.basename(root_dir) -# dir_info = f"{dir_name} " - - -# structure_str = ' ' * indent + dir_info + '\n' + structure_str - -# if indent - 1 >= max_depth: -# return None, file_count -# else: -# return structure_str, file_count - -# # inner_function -# async def get_knowledge(self,path:str) -> str: -# full_path = f"{self.root_path}/knowledge/{path}" -# if os.islink(full_path): -# org_path = os.readlink(full_path) -# hash = self.kb_db.get_hash_by_doc_path(org_path) -# if hash: -# return self.kb_db.get_knowledge(org_path) - -# return "not found" - async def load_knowledge_content(self,path:str,pos:int=0,length:int=None) -> str: if path.endswith("pdf"): logger.info("load_knowledge_content:pdf") @@ -506,14 +306,147 @@ class ParseLocalDocument: content = await f.read(length) return content - return "load content failed." - def _add_document_dir(self,path:str): - self.doc_dirs[path] = 0 +class ScanLocalDocument: + def __init__(self, env: KnowledgePipelineEnvironment, config): + self.env = env + workspace = string.Template(config["workspace"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + self.knowledge_base = LocalKnowledgeBase(workspace) + self.path = path + + def _support_file(self,file_name:str) -> bool: + if file_name.startswith("."): + return False + + if file_name.endswith(".pdf"): + return True + if file_name.endswith(".md"): + return True + if file_name.endswith(".txt"): + return True + return False + + async def next(self): + while True: + for root, dirs, files in os.walk(self.path): + for file in files: + if self._support_file(file): + full_path = os.path.join(root, file) + full_path = os.path.normpath(full_path) + if self.knowledge_base.meta_db.is_doc_exist(full_path): + continue + yield(full_path, full_path) + else: + continue + yield(None, None) + + + +class ParseLocalDocument: + def __init__(self, env: KnowledgePipelineEnvironment, config): + self.env = env + workspace = string.Template(config["workspace"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) + self.todo_list = TodoListEnvironment(workspace, TodoListType.TO_LEARN) + self.knowledge_base = LocalKnowledgeBase(workspace) + self.token_limit = config["token_limit"] + + + async def parse(self, full_path: str) -> str: + file_stat = os.stat(full_path) + if file_stat.st_size < 1: + return full_path + hash, meta_data = self._parse_document(full_path) + await self._learn(meta_data, full_path) + self.knowledge_base.meta_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime,hash) + self.knowledge_base.meta_db.add_knowledge(hash,meta_data) + return full_path + + async def _get_meta_prompt(self,meta: dict,temp_meta = None,need_catalogs = False) -> str: + kb_tree = await self.knowledge_base.get_knowledege_catalog() + + known_obj = {} + title = meta.get("title") + if title: + known_obj["title"] = title + summary = meta.get("summary") + if summary: + known_obj["summary"] = summary + tags = meta.get("tags") + if tags: + known_obj["tags"] = tags + if need_catalogs: + catalogs = meta.get("catalogs") + if catalogs: + known_obj["catalogs"] = catalogs + + if temp_meta: + for key in temp_meta.keys(): + known_obj[key] = temp_meta[key] + + org_path = meta.get("full_path") + known_obj["orginal_path"] = org_path + return f"# Known information:\n## Current directory structure:\n{kb_tree}\n## Knowlege Metadata:\n{json.dumps(known_obj)}\n" + + async def _token_len(self, text: str) -> int: + return CustomAIAgent("", "gpt-4-1106-preview", self.token_limit).token_len(text=text) + + + async def _learn(self, meta:dict, full_path:str): + # Objectives: + # Obtain better titles, abstracts, table of contents (if necessary), tags + # Determine the appropriate place to put it (in line with the organization's goals) + # Known information: + # The reason why the target service's learn_prompt is being sorted + # Summary of the organization's work (if any) + # The current structure of the knowledge base (note the size control) gen_kb_tree_prompt (when empty, LLM should generate an appropriate initial directory structure) + # Original path, current title, abstract, table of contents + + # Sorting long files (general tricks) + # Indicate that the input is part of the content, let LLM generate intermediate results for the task + # Enter the content in sequence, when the last content block is input, LLM gets the result + + full_content = self.knowledge_base.load_knowledge_content(full_path) + full_content_len = self._token_len(full_content) + + if full_content_len < self.token_limit(): + # 短文章不用总结catalog + todo = AgentTodo() + todo.title = meta["title"] + meta_prompt = await self._get_meta_prompt(meta,None) + todo.detail = meta_prompt + full_content + self.todo_list.create_todo(None, todo) + await self.todo_list.wait_todo_done(todo.todo_id) + else: + logger.warning(f"llm_read_article: article {full_path} use LLM loop learn!") + pos = 0 + read_len = int(self.token_limit() * 1.2) + + temp_meta = {} + is_final = False + while pos < full_content_len: + _content = full_content[pos:pos+read_len] + part_cotent_len = len(_content) + if part_cotent_len < read_len: + # last chunk + is_final = True + part_content = f"<>\n{_content}" + else: + part_content = f"<>\n{_content}" + + pos = pos + read_len + todo = AgentTodo() + todo.title = meta["title"] + meta_prompt = await self._get_meta_prompt(meta,temp_meta) + todo.detail = meta_prompt + part_content + self.todo_list.create_todo(None, todo) + todo = await self.todo_list.wait_todo_done(todo.todo_id) + result_obj = json.loads(todo.result.result_str) + temp_meta = result_obj + if is_final: + break - def _parse_pdf_bookmarks(self,bookmarks, parent:list): - for item in bookmarks: if isinstance(item,list): self._parse_pdf_bookmarks(item,parent) @@ -608,64 +541,111 @@ class ParseLocalDocument: meta_data = self._parse_pdf(doc_path) except Exception as e: logger.error("parse document %s failed:%s",doc_path,e) - traceback.print_exc() + # traceback.print_exc() + + logger.info("parse document %s!",doc_path) + return hash_result, meta_data + + def _parse_pdf_bookmarks(self,bookmarks, parent:list): + for item in bookmarks: + if isinstance(item,list): + self._parse_pdf_bookmarks(item,parent) + else: + if item.title: + new_item = {} + new_item["page"] = item.page.idnum + new_item["title"] = item.title + my_childs = [] + if item.childs: + if len(item.childs) > 0: + self._parse_pdf_bookmarks(item.childs, my_childs) + new_item["childs"] = my_childs + parent.append(new_item) + else: + logger.warning("parse pdf bookmarks failed: item.title is None!") + + return + + def _parse_pdf(self,doc_path:str): + metadata = {} + with open(doc_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + try: + doc_info = reader.metadata + if doc_info: + if doc_info.title: + metadata["title"] = doc_info.title + if doc_info.author: + metadata["authors"] = doc_info.author + except Exception as e: + logger.warn("parse pdf metadata failed:%s",e) + + dir_path = os.path.dirname(doc_path) + base_name = os.path.basename(doc_path) + text_content_path = f"{dir_path}/.{base_name}.txt" + full_text = "" + + for page in reader.pages: + text = page.extract_text() + full_text += text + with open(text_content_path, 'w', encoding='utf-8') as f: + f.write(full_text) + + try: + bookmarks = reader.outline + if bookmarks: + catalogs = [] + self._parse_pdf_bookmarks(bookmarks,catalogs) + metadata["catalogs"] = json.dumps(catalogs) + except Exception as e: + logger.warn("parse pdf bookmarks failed:%s",e) + + return metadata + + def _parse_txt(self,doc_path:str): + return {} + + def _parse_md(self,doc_path:str): + metadata = {} + cur_encode = "utf-8" + with open(doc_path,'rb') as f: + cur_encode = chardet.detect(f.read(1024))['encoding'] + + with open(doc_path, mode='r', encoding=cur_encode) as f: + content = f.read() + match = re.search(r'^# (.*)', content, re.MULTILINE) + if match: + metadata['title'] = match.group(1).strip() + md = Markdown(extensions=['toc']) + html_str = md.convert(content) + toc = md.toc + if toc: + metadata['catalogs'] = toc + + return metadata + + def _parse_document(self,doc_path:str): + hash_result = None + title = os.path.basename(doc_path) + meta_data = {} + + with open(doc_path, "rb") as f: + hash_md5 = hashlib.md5() + for chunk in iter(lambda: f.read(1024*1024), b""): + hash_md5.update(chunk) + hash_result = hash_md5.hexdigest() + try: + if doc_path.endswith(".md"): + meta_data = self._parse_md(doc_path) + elif doc_path.endswith(".pdf"): + meta_data = self._parse_pdf(doc_path) + except Exception as e: + logger.error("parse document %s failed:%s",doc_path,e) + # traceback.print_exc() if meta_data.get("title"): title = meta_data["title"] logger.info("parse document %s!",doc_path) return hash_result,title,meta_data - - - def _support_file(self,file_name:str) -> bool: - if file_name.startswith("."): - return False - - if file_name.endswith(".pdf"): - return True - if file_name.endswith(".md"): - return True - if file_name.endswith(".txt"): - return True - return False - - def _scan_dir(self): - while True: - time.sleep(10) - for directory in self.doc_dirs.keys(): - now = time.time() - if now - self.doc_dirs[directory] > 60*15: - self.doc_dirs[directory] = time.time() - else: - continue - - for root, dirs, files in os.walk(directory): - for file in files: - if self._support_file(file): - full_path = os.path.join(root, file) - full_path = os.path.normpath(full_path) - if self.kb_db.is_doc_exist(full_path): - continue - - file_stat = os.stat(full_path) - if file_stat.st_size < 1: - continue - - if file_stat.st_size < 1024*1024*8: - #parse and insert - hash,title,meta_data = self._parse_document(full_path) - self.kb_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime,hash) - self.kb_db.add_knowledge(hash,title,meta_data) - - else: - self.kb_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime) - - def _scan_document(self): - while True: - time.sleep(10) - parse_queue = self.kb_db.get_docs_without_hash() - for doc_path in parse_queue: - hash,title,meta_data = self._parse_document(doc_path) - self.kb_db.set_doc_hash(doc_path,hash) - self.kb_db.add_knowledge(hash,title,meta_data) \ No newline at end of file diff --git a/src/component/common_environment/to_learn_parser.py b/src/component/common_environment/to_learn_parser.py deleted file mode 100644 index f90c8ac..0000000 --- a/src/component/common_environment/to_learn_parser.py +++ /dev/null @@ -1,214 +0,0 @@ - # 尝试自我学习,会主动获取、读取资料并进行整理 - # LLM的本质能力是处理海量知识,应该让LLM能基于知识把自己的工作处理的更好 - async def do_self_learn(self) -> None: - # 不同的workspace是否应该有不同的学习方法? - workspace = self.get_workspace_by_msg(None) - hash_list = workspace.kb_db.get_knowledge_without_llm_title() - for hash in hash_list: - if self.agent_energy <= 0: - break - - knowledge = workspace.kb_db.get_knowledge(hash) - if knowledge is None: - continue - - full_path = knowledge.get("full_path") - if full_path is None: - continue - - if os.path.exists(full_path) is False: - logger.warning(f"do_self_learn: knowledge {full_path} is not exists!") - continue - - #TODO 可以用v-db 对不同目录的名字进行选择后,先进行一次快速的插入。有时间再慢慢用LLM整理 - result_obj = await self._llm_read_article(knowledge,full_path) - - #根据结果更新knowledge - if result_obj is not None: - workspace.kb_db.set_knowledge_llm_result(hash,result_obj) - # 在知识库中创建软链接 - path_list = result_obj.get("path") - new_title = result_obj.get("title") - if path_list: - for new_path in path_list: - full_new_path = f"/knowledge{new_path}/{new_title}" - await workspace.symlink(full_path,full_new_path) - logger.info(f"create soft link {full_path} -> {full_new_path}") - - - self.agent_energy -= 1 - - # match item.type(): - # case "book": - # self.llm_read_book(kb,item) - # learn_power -= 1 - # case "article": - # - # self.llm_read_article(kb,item) - # learn_power -= 1 - # case "video": - # self.llm_watch_video(kb,item) - # learn_power -= 1 - # case "audio": - # self.llm_listen_audio(kb,item) - # learn_power -= 1 - # case "code_project": - # self.llm_read_code_project(kb,item) - # learn_power -= 1 - # case "image": - # self.llm_view_image(kb,item) - # learn_power -= 1 - # case "other": - # self.llm_read_other(kb,item) - # learn_power -= 1 - # case _: - # self.llm_learn_any(kb,item) - # pass - - - async def do_blance_knowledge_base(selft): - # 整理自己的知识库(让分类更平衡,更由于自己以后的工作),并尝试更新学习目标 - current_path = "/" - current_list = kb.get_list(current_path) - self_assessment_with_goal = self.get_self_assessment_with_goal() - learn_goal = {} - - - llm_blance_knowledge_base(current_path,current_list,self_assessment_with_goal,learn_goal,learn_power) - - # 主动学习 - # 方法目前只有使用搜索引擎一种? - for goal in learn_goal.items(): - self.llm_learn_with_search_engine(kb,goal,learn_power) - if learn_power <= 0: - break - - - def parser_learn_llm_result(self,llm_result:LLMResult): - pass - - async def gen_known_info_for_knowledge_prompt(self,knowledge_item:dict,temp_meta = None,need_catalogs = False) -> AgentPrompt: - workspace =self.get_workspace_by_msg(None) - kb_tree = await workspace.get_knowledege_catalog() - - - known_obj = {} - title = knowledge_item.get("title") - if title: - known_obj["title"] = title - summary = knowledge_item.get("summary") - if summary: - known_obj["summary"] = summary - tags = knowledge_item.get("tags") - if tags: - known_obj["tags"] = tags - if need_catalogs: - catalogs = knowledge_item.get("catalogs") - if catalogs: - known_obj["catalogs"] = catalogs - - if temp_meta: - for key in temp_meta.keys(): - known_obj[key] = temp_meta[key] - - org_path = knowledge_item.get("full_path") - known_obj["orginal_path"] = org_path - know_info_str = f"# Known information:\n## Current directory structure:\n{kb_tree}\n## Knowlege Metadata:\n{json.dumps(known_obj)}\n" - return AgentPrompt(know_info_str) - - async def _llm_read_article(self,knowledge_item:dict,full_path:str) -> ComputeTaskResult: - # Objectives: - # Obtain better titles, abstracts, table of contents (if necessary), tags - # Determine the appropriate place to put it (in line with the organization's goals) - # Known information: - # The reason why the target service's learn_prompt is being sorted - # Summary of the organization's work (if any) - # The current structure of the knowledge base (note the size control) gen_kb_tree_prompt (when empty, LLM should generate an appropriate initial directory structure) - # Original path, current title, abstract, table of contents - - # Sorting long files (general tricks) - # Indicate that the input is part of the content, let LLM generate intermediate results for the task - # Enter the content in sequence, when the last content block is input, LLM gets the result - - - #full_content = item.get_article_full_content() - workspace = self.get_workspace_by_msg(None) - full_content_len = self.token_len(full_content) - - if full_content_len < self.get_llm_learn_token_limit(): - - # 短文章不用总结catelog - #path_list,summary = llm_get_summary(summary,full_content) - #prompt = self.get_agent_role_prompt() - prompt = AgentPrompt() - prompt.append(self.get_learn_prompt()) - known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item) - prompt.append(known_info_prompt) - content_prompt = AgentPrompt(full_content) - prompt.append(content_prompt) - env_functions = None - #env_functions,function_len = workspace.get_knowledge_base_ai_functions() - task_result:ComputeTaskResult = await self.do_llm_complection(prompt,is_json_resp=True) - if task_result.result_code != ComputeTaskResultCode.OK: - result_obj = {} - result_obj["error_str"] = task_result.error_str - return result_obj - - result_obj = json.loads(task_result.result_str) - return result_obj - - else: - logger.warning(f"llm_read_article: article {full_path} use LLM loop learn!") - pos = 0 - read_len = int(self.get_llm_learn_token_limit() * 1.2) - - temp_meta_data = {} - is_final = False - while pos < str_len: - _content = full_content[pos:pos+read_len] - part_cotent_len = len(_content) - if part_cotent_len < read_len: - # last chunk - is_final = True - part_content = f"<>\n{_content}" - else: - part_content = f"<>\n{_content}" - - pos = pos + read_len - prompt = AgentPrompt() - prompt.append(self.get_learn_prompt()) - known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item,temp_meta_data) - prompt.append(known_info_prompt) - content_prompt = AgentPrompt(part_content) - prompt.append(content_prompt) - #env_functions,function_len = workspace.get_knowledge_base_ai_functions() - task_result:ComputeTaskResult = await self.do_llm_complection(prompt,is_json_resp=True) - if task_result.result_code != ComputeTaskResultCode.OK: - result_obj = {} - result_obj["error_str"] = task_result.error_str - return result_obj - - result_obj = json.loads(task_result.result_str) - temp_meta_data = result_obj - if is_final: - return result_obj - - return None - - - async def do_self_think(self): - session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) - for session_id in session_id_list: - if self.agent_energy <= 0: - break - used_energy = await self.think_chatsession(session_id) - self.agent_energy -= used_energy - - todo_logs = await self.get_todo_logs() - for todo_log in todo_logs: - if self.agent_energy <= 0: - break - used_energy = await self.think_todo_log(todo_log) - self.agent_energy -= used_energy - - return \ No newline at end of file From f08d709604cd293b3a3130095c82cbf4a8cafd50 Mon Sep 17 00:00:00 2001 From: tsukasa Date: Tue, 5 Dec 2023 18:50:32 +0800 Subject: [PATCH 50/79] agent learn with pipeline input ok --- package-lock.json | 3 + rootfs/agents/JarvisPlus/agent.toml | 50 ++- .../JarvisPlus/pipeline.toml | 8 + rootfs/knowledge_pipelines/Mia/input.py | 3 +- rootfs/knowledge_pipelines/Mia/parser.py | 3 +- rootfs/knowledge_pipelines/Mia/query.py | 13 +- rootfs/knowledge_pipelines/pipelines.toml | 2 +- src/aios/__init__.py | 8 +- src/aios/agent/agent.py | 302 +++--------------- src/aios/agent/agent_base.py | 15 +- src/aios/agent/ai_function.py | 2 +- src/aios/agent/workflow.py | 14 +- src/aios/environment/environment.py | 39 +-- src/aios/environment/workflow_env.py | 14 +- src/aios/environment/workspace_env.py | 210 ++---------- src/aios/knowledge/object/object.py | 6 +- src/aios/knowledge/pipeline.py | 2 +- src/component/agent_manager/agent_manager.py | 12 +- src/component/common_environment/__init__.py | 3 + .../common_environment/local_document.py | 231 ++++++-------- .../common_environment/local_file_system.py | 139 ++++++++ src/component/common_environment/shell.py | 38 +++ src/component/openai_node/open_ai_node.py | 2 +- .../st_node/local_st_compute_node.py | 2 +- .../workflow_manager/workflow_manager.py | 2 +- src/package-lock.json | 3 + src/requirements.txt | 3 +- src/service/aios_shell/aios_shell.py | 37 ++- 28 files changed, 495 insertions(+), 671 deletions(-) create mode 100644 package-lock.json create mode 100644 rootfs/knowledge_pipelines/JarvisPlus/pipeline.toml create mode 100644 src/component/common_environment/__init__.py create mode 100644 src/component/common_environment/local_file_system.py create mode 100644 src/component/common_environment/shell.py create mode 100644 src/package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..48e341a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3 @@ +{ + "lockfileVersion": 1 +} diff --git a/rootfs/agents/JarvisPlus/agent.toml b/rootfs/agents/JarvisPlus/agent.toml index b7cdac7..d264f81 100644 --- a/rootfs/agents/JarvisPlus/agent.toml +++ b/rootfs/agents/JarvisPlus/agent.toml @@ -1,12 +1,12 @@ instance_id = "JarvisPlus" fullname = "JarvisPlus" +llm_model_name = "gpt-4-1106-preview" max_token_size = 4000 -#enable_kb = "true" enable_timestamp = "true" owner_prompt = "I am your master {name} , now is {now}" contact_prompt = "I am your master's friend {name}" - -[work.do] +owner_env = ["knowledge"] +[[work.do]] role = "system" content = """ My name is JarvisPlus, I am the master's super personal assistant. I think hard and try my best to complete TODOs. @@ -58,6 +58,50 @@ The result of my planned execution must be directly parsed by `python json.loads """ + +[[learn.do]] +role = "system" +content = """ +我是一名软件工程师,拥有非常优秀的资料学习能力。下面是我学习和整理资料的方法 +1. 由于LLM的Token限制,我学习的可能只是资料的部分内容,此时我应能产生合适的学习中间结果,中间结果保存在metadata中。我要么产生中间结果,要么产生最终结果。 +2. 当存在已知信息时,需参考已知信息的内容来思考结果。 +3. 当我收到最后一部分内容时,我能结合已知的中间结果产生最终结果。 +4. 现有资料库以文件系统的形式组织,我未来借助资料的摘要来浏览知识库 +5. 我将学习过的资料另存在资料库的合适位置(以/开始的完整路径)。保存位置的目录深度不超过5层,文件夹名称长度不超过16个字符。 +6. 总是以json格式返回思考结果,json格式如下 +{ + "op_list":[ + { + "op":"learn", + "original_path":"$original_path", + "think":"$think_result", + "metadata":{...}, + "tags":["tag1","tag2"...], + "path":["/graphic/opengl","/database/mysql"], # list of directories to save to. + "title":"$article_title", + "summary":"$summary", + "catalogs": [ + { + # optional,catalogs is a tree + "title":"$catalog_name1", + "pos":"$pos:$length" + "children":[ + { + "title":"$catalog_name 1.1", + "pos":"$pos:$length" + }, + { + "title":"$catalog_name2", + "pos":"$pos:$length" + } + ] + }, + ] + } + ] +} +""" + [[prompt]] role = "system" content = """ diff --git a/rootfs/knowledge_pipelines/JarvisPlus/pipeline.toml b/rootfs/knowledge_pipelines/JarvisPlus/pipeline.toml new file mode 100644 index 0000000..ff947ad --- /dev/null +++ b/rootfs/knowledge_pipelines/JarvisPlus/pipeline.toml @@ -0,0 +1,8 @@ +name = "JarvisPlus" +input.module = "scan_local" +input.params.workspace = "${myai_dir}/workspace/JarvisPlus" +input.params.path = "${myai_dir}/data" +parser.module = "parse_local" +parser.params.workspace = "${myai_dir}/workspace/JarvisPlus" +parser.params.assign_to = "JarvisPlus" + diff --git a/rootfs/knowledge_pipelines/Mia/input.py b/rootfs/knowledge_pipelines/Mia/input.py index e9a54f4..76f3d2c 100644 --- a/rootfs/knowledge_pipelines/Mia/input.py +++ b/rootfs/knowledge_pipelines/Mia/input.py @@ -3,8 +3,7 @@ import aiofiles import chardet import logging import string -from knowledge import ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal -from aios_kernel.storage import AIStorage +from aios import AIStorage,ImageObjectBuilder, DocumentObjectBuilder, KnowledgePipelineEnvironment, KnowledgePipelineJournal class KnowledgeDirSource: def __init__(self, env: KnowledgePipelineEnvironment, config): diff --git a/rootfs/knowledge_pipelines/Mia/parser.py b/rootfs/knowledge_pipelines/Mia/parser.py index 6a3ca22..59c2284 100644 --- a/rootfs/knowledge_pipelines/Mia/parser.py +++ b/rootfs/knowledge_pipelines/Mia/parser.py @@ -1,8 +1,7 @@ # define a knowledge base class import json import string -from aios_kernel import ComputeKernel, AIStorage -from knowledge import * +from aios import * class EmbeddingParser: diff --git a/rootfs/knowledge_pipelines/Mia/query.py b/rootfs/knowledge_pipelines/Mia/query.py index cc4b557..8e59b8b 100644 --- a/rootfs/knowledge_pipelines/Mia/query.py +++ b/rootfs/knowledge_pipelines/Mia/query.py @@ -1,12 +1,11 @@ import os import logging import json -from aios_kernel import * -from knowledge import * +from aios import * -class KnowledgeEnvironment(Environment): - def __init__(self, env_id: str) -> None: - super().__init__(env_id) +class EmbeddingEnvironment(SimpleEnvironment): + def __init__(self, workspace: str) -> None: + super().__init__(workspace) self.path = os.path.join(AIStorage.get_instance().get_myai_dir(), "knowledge/indices/embedding") self._default_text_model = "all-MiniLM-L6-v2" self._default_image_model = "clip-ViT-B-32" @@ -93,5 +92,5 @@ class KnowledgeEnvironment(Environment): content = "*** I have provided the following known information for your reference with json format:\n" return content + self.tokens_from_objects(object_ids[index:index+1]) -def init() -> KnowledgeEnvironment: - return KnowledgeEnvironment("embedding") \ No newline at end of file +def init(workspace: str) -> EmbeddingEnvironment: + return EmbeddingEnvironment(workspace) \ No newline at end of file diff --git a/rootfs/knowledge_pipelines/pipelines.toml b/rootfs/knowledge_pipelines/pipelines.toml index 2008066..a1eaa37 100644 --- a/rootfs/knowledge_pipelines/pipelines.toml +++ b/rootfs/knowledge_pipelines/pipelines.toml @@ -1,3 +1,3 @@ pipelines = [ - "Mail/Sync" + "JarvisPlus" ] \ No newline at end of file diff --git a/src/aios/__init__.py b/src/aios/__init__.py index 6c638f0..d99ae12 100644 --- a/src/aios/__init__.py +++ b/src/aios/__init__.py @@ -6,8 +6,8 @@ from .agent.agent_base import AgentPrompt,CustomAIAgent, AgentTodo from .agent.chatsession import AIChatSession from .agent.agent import AIAgent,AIAgentTemplete, BaseAIAgent from .agent.role import AIRole,AIRoleGroup -from .agent.workflow import Workflow -from .agent.ai_function import SimpleAIFunction +# from .agent.workflow import Workflow +from .agent.ai_function import SimpleAIFunction, SimpleAIOperation from .frame.compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType from .frame.compute_node import ComputeNode,LocalComputeNode @@ -17,10 +17,10 @@ from .frame.contact_manager import ContactManager,Contact,FamilyMember from .frame.queue_compute_node import Queue_ComputeNode from .environment.environment import BaseEnvironment,SimpleEnvironment,CompositeEnvironment -from .environment.workflow_env import WorkflowEnvironment,CalenderEnvironment,CalenderEvent,PaintEnvironment +# from .environment.workflow_env import WorkflowEnvironment,CalenderEnvironment,CalenderEvent,PaintEnvironment from .environment.text_to_speech_function import TextToSpeechFunction from .environment.image_2_text_function import Image2TextFunction -from .environment.workspace_env import ShellEnvironment,WorkspaceEnvironment,TodoListEnvironment,TodoListType +from .environment.workspace_env import WorkspaceEnvironment,TodoListEnvironment,TodoListType from .storage.storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 3048357..db5464a 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -33,67 +33,35 @@ from ..utils import video_utils, image_utils logger = logging.getLogger(__name__) -DEFAULT_AGENT_READ_REPORT_PROMPT = """ -""" +# DEFAULT_AGENT_READ_REPORT_PROMPT = """ +# """ -DEFAULT_AGENT_DO_PROMPT = """ -You are a helpful AI assistant. -Solve tasks using your coding and language skills. -In the following cases, suggest python code (in a python coding block) for the user to execute. - 1. When you need to collect info, use the code to output the info you need, for example, browse or search the web, download/read a file, print the content of a webpage or a file, get the current date/time, check the operating system. After sufficient info is printed and the task is ready to be solved based on your language skill, you can solve the task by yourself. - 2. When you need to perform some task with code, use the code to perform the task and output the result. Finish the task smartly. -Solve the task step by step if you need to. If a plan is not provided, explain your plan first. Be clear which step uses code, and which step uses your language skill. -When using code, you must indicate the script type in the code block. The user cannot provide any other feedback or perform any other action beyond executing the code you suggest. The user can't modify your code. So do not suggest incomplete code which requires users to modify. Don't use a code block if it's not intended to be executed by the user. -If you want the user to save the code in a file before executing it, put # filename: inside the code block as the first line. Don't include multiple code blocks in one response. Do not ask users to copy and paste the result. Instead, use 'print' function for the output when relevant. Check the execution result returned by the user. -If the result indicates there is an error, fix the error and output the code again. Suggest the full code instead of partial code or code changes. If the error can't be fixed or if the task is not solved even after the code is executed successfully, analyze the problem, revisit your assumption, collect additional info you need, and think of a different approach to try. -When you find an answer, verify the answer carefully. Include verifiable evidence in your response if possible. -Reply "TERMINATE" in the end when everything is done. -""" +# DEFAULT_AGENT_DO_PROMPT = """ +# You are a helpful AI assistant. +# Solve tasks using your coding and language skills. +# In the following cases, suggest python code (in a python coding block) for the user to execute. +# 1. When you need to collect info, use the code to output the info you need, for example, browse or search the web, download/read a file, print the content of a webpage or a file, get the current date/time, check the operating system. After sufficient info is printed and the task is ready to be solved based on your language skill, you can solve the task by yourself. +# 2. When you need to perform some task with code, use the code to perform the task and output the result. Finish the task smartly. +# Solve the task step by step if you need to. If a plan is not provided, explain your plan first. Be clear which step uses code, and which step uses your language skill. +# When using code, you must indicate the script type in the code block. The user cannot provide any other feedback or perform any other action beyond executing the code you suggest. The user can't modify your code. So do not suggest incomplete code which requires users to modify. Don't use a code block if it's not intended to be executed by the user. +# If you want the user to save the code in a file before executing it, put # filename: inside the code block as the first line. Don't include multiple code blocks in one response. Do not ask users to copy and paste the result. Instead, use 'print' function for the output when relevant. Check the execution result returned by the user. +# If the result indicates there is an error, fix the error and output the code again. Suggest the full code instead of partial code or code changes. If the error can't be fixed or if the task is not solved even after the code is executed successfully, analyze the problem, revisit your assumption, collect additional info you need, and think of a different approach to try. +# When you find an answer, verify the answer carefully. Include verifiable evidence in your response if possible. +# Reply "TERMINATE" in the end when everything is done. +# """ -DEFAULT_AGENT_SELF_CHECK_PROMPT = """ +# DEFAULT_AGENT_SELF_CHECK_PROMPT = """ -""" +# """ -DEFAULT_AGENT_GOAL_TO_TODO_PROMPT = """ -我会给你一个目标,你需要结合自己的角色思考如何将其拆解成多个TODO。请直接返回json来表达这些TODO -""" +# DEFAULT_AGENT_GOAL_TO_TODO_PROMPT = """ +# 我会给你一个目标,你需要结合自己的角色思考如何将其拆解成多个TODO。请直接返回json来表达这些TODO +# """ -DEFAULT_AGENT_LEARN_PROMPT = """ -我是一名软件工程师,拥有非常优秀的资料学习能力。下面是我学习和整理资料的方法 -1. 由于LLM的Token限制,我学习的可能只是资料的部分内容,此时我应能产生合适的学习中间结果,中间结果保存在metadata中。我要么产生中间结果,要么产生最终结果。 -2. 当存在已知信息时,需参考已知信息的内容来思考结果。 -3. 当我收到最后一部分内容时,我能结合已知的中间结果产生最终结果。 -4. 现有资料库以文件系统的形式组织,我未来借助资料的摘要来浏览知识库 -5. 我将学习过的资料另存在资料库的合适位置(以/开始的完整路径)。保存位置的目录深度不超过5层,文件夹名称长度不超过16个字符。 -6. 总是以json格式返回思考结果,json格式如下 -{ - think:"$think_result", - metadata:{...} , # temp result for long content - tags:["tag1","tag2"...], - path:["/graphic/opengl","/database/mysql"], # list of directories to save to. - title:"$article_title", - summary:"$summary", - catalogs: [{ # optional,catalogs is a tree - title:"$catalog_name1", - pos:"$pos:$length" - children:[ - { - title:"$catalog_name 1.1", - pos:"$pos:$length" - } - ]}, - { - title:"$catalog_name2", - pos:"$pos:$length" - } - ] -} -""" - -DEFAULT_AGENT_LEARN_LONG_CONENT_PROMPT = """ -我给你一段内容,尝试为期建立目录。目录的标题不能超过16个字, -目录要指向正文的位置(用字符偏移即可),整个目录的文本长度不能超过256个字节。并用json表达这个目录 -""" +# DEFAULT_AGENT_LEARN_LONG_CONENT_PROMPT = """ +# 我给你一段内容,尝试为期建立目录。目录的标题不能超过16个字, +# 目录要指向正文的位置(用字符偏移即可),整个目录的文本长度不能超过256个字节。并用json表达这个目录 +# """ class AIAgentTemplete: def __init__(self) -> None: self.llm_model_name:str = "gpt-4-0613" @@ -149,12 +117,12 @@ class AIAgent(BaseAIAgent): todo_prompts = {} todo_prompts[TodoListType.TO_WORK] = { - "do": DEFAULT_AGENT_DO_PROMPT, - "check": DEFAULT_AGENT_SELF_CHECK_PROMPT, + "do": None, + "check": None, "review": None, } todo_prompts[TodoListType.TO_LEARN] = { - "do": DEFAULT_AGENT_LEARN_PROMPT, + "do": None, "check": None, "review": None, } @@ -313,9 +281,6 @@ class AIAgent(BaseAIAgent): if event.type == "AgentThink": return await self.do_self_think() - - - # async def _process_group_chat_msg(self,msg:AgentMsg) -> AgentMsg: # session_topic = msg.target + "#" + msg.topic # chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) @@ -500,7 +465,7 @@ class AIAgent(BaseAIAgent): known_info_str = "# Known information\n" have_known_info = False - todos_str,todo_count = await workspace.get_todo_tree() + todos_str,todo_count = await workspace.todo_list[TodoListType.TO_WORK].get_todo_tree() if todo_count > 0: have_known_info = True known_info_str += f"## todo\n{todos_str}\n" @@ -523,7 +488,7 @@ class AIAgent(BaseAIAgent): logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - task_result = await self.do_llm_complection(prompt,msg, env=self.agent_workspace,inner_functions=inner_functions) + task_result = await self.do_llm_complection(prompt,msg, inner_functions=inner_functions) if task_result.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(task_result.error_str) return error_resp @@ -700,7 +665,7 @@ class AIAgent(BaseAIAgent): # await self._llm_review_todolist(workspace) todo_list = workspace.todo_list[todo_list_type] - need_todo = todo_list.get_todo_list(self.agent_id) + need_todo = await todo_list.get_todo_list(self.agent_id) check_count = 0 do_count = 0 @@ -826,7 +791,7 @@ class AIAgent(BaseAIAgent): return do_prompts - async def _can_do_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> AgentPrompt: + def _can_do_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> AgentPrompt: do_prompts = self.todo_prompts[todo_list_type].get("do") if not do_prompts: return None @@ -848,7 +813,7 @@ class AIAgent(BaseAIAgent): async def _llm_do_todo(self, todo: AgentTodo, prompt: AgentPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: result = AgentTodoResult() - task_result:ComputeTaskResult = await self.do_llm_complection(prompt) + task_result:ComputeTaskResult = await self.do_llm_complection(prompt, is_json_resp=True) if task_result.error_str is not None: logger.error(f"_llm_do compute error:{task_result.error_str}") result.result_code = AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR @@ -902,204 +867,27 @@ class AIAgent(BaseAIAgent): return - # 尝试自我学习,会主动获取、读取资料并进行整理 - # LLM的本质能力是处理海量知识,应该让LLM能基于知识把自己的工作处理的更好 - async def do_self_learn(self) -> None: - # 不同的workspace是否应该有不同的学习方法? - workspace = self.get_workspace_by_msg(None) - hash_list = workspace.kb_db.get_knowledge_without_llm_title() - for hash in hash_list: - if self.agent_energy <= 0: - break - - knowledge = workspace.kb_db.get_knowledge(hash) - if knowledge is None: - continue - - full_path = knowledge.get("full_path") - if full_path is None: - continue - - if os.path.exists(full_path) is False: - logger.warning(f"do_self_learn: knowledge {full_path} is not exists!") - continue - - #TODO 可以用v-db 对不同目录的名字进行选择后,先进行一次快速的插入。有时间再慢慢用LLM整理 - result_obj = await self._llm_read_article(knowledge,full_path) - - #根据结果更新knowledge - if result_obj is not None: - workspace.kb_db.set_knowledge_llm_result(hash,result_obj) - # 在知识库中创建软链接 - path_list = result_obj.get("path") - new_title = result_obj.get("title") - if path_list: - for new_path in path_list: - full_new_path = f"/knowledge{new_path}/{new_title}" - await workspace.symlink(full_path,full_new_path) - logger.info(f"create soft link {full_path} -> {full_new_path}") + # async def do_blance_knowledge_base(selft): + # # 整理自己的知识库(让分类更平衡,更由于自己以后的工作),并尝试更新学习目标 + # current_path = "/" + # current_list = kb.get_list(current_path) + # self_assessment_with_goal = self.get_self_assessment_with_goal() + # learn_goal = {} - self.agent_energy -= 1 + # llm_blance_knowledge_base(current_path,current_list,self_assessment_with_goal,learn_goal,learn_power) - # match item.type(): - # case "book": - # self.llm_read_book(kb,item) - # learn_power -= 1 - # case "article": - # - # self.llm_read_article(kb,item) - # learn_power -= 1 - # case "video": - # self.llm_watch_video(kb,item) - # learn_power -= 1 - # case "audio": - # self.llm_listen_audio(kb,item) - # learn_power -= 1 - # case "code_project": - # self.llm_read_code_project(kb,item) - # learn_power -= 1 - # case "image": - # self.llm_view_image(kb,item) - # learn_power -= 1 - # case "other": - # self.llm_read_other(kb,item) - # learn_power -= 1 - # case _: - # self.llm_learn_any(kb,item) - # pass - - - async def do_blance_knowledge_base(selft): - # 整理自己的知识库(让分类更平衡,更由于自己以后的工作),并尝试更新学习目标 - current_path = "/" - current_list = kb.get_list(current_path) - self_assessment_with_goal = self.get_self_assessment_with_goal() - learn_goal = {} - - - llm_blance_knowledge_base(current_path,current_list,self_assessment_with_goal,learn_goal,learn_power) - - # 主动学习 - # 方法目前只有使用搜索引擎一种? - for goal in learn_goal.items(): - self.llm_learn_with_search_engine(kb,goal,learn_power) - if learn_power <= 0: - break + # # 主动学习 + # # 方法目前只有使用搜索引擎一种? + # for goal in learn_goal.items(): + # self.llm_learn_with_search_engine(kb,goal,learn_power) + # if learn_power <= 0: + # break def parser_learn_llm_result(self,llm_result:LLMResult): pass - async def gen_known_info_for_knowledge_prompt(self,knowledge_item:dict,temp_meta = None,need_catalogs = False) -> AgentPrompt: - workspace =self.get_workspace_by_msg(None) - kb_tree = await workspace.get_knowledege_catalog() - - - known_obj = {} - title = knowledge_item.get("title") - if title: - known_obj["title"] = title - summary = knowledge_item.get("summary") - if summary: - known_obj["summary"] = summary - tags = knowledge_item.get("tags") - if tags: - known_obj["tags"] = tags - if need_catalogs: - catalogs = knowledge_item.get("catalogs") - if catalogs: - known_obj["catalogs"] = catalogs - - if temp_meta: - for key in temp_meta.keys(): - known_obj[key] = temp_meta[key] - - org_path = knowledge_item.get("full_path") - known_obj["orginal_path"] = org_path - know_info_str = f"# Known information:\n## Current directory structure:\n{kb_tree}\n## Knowlege Metadata:\n{json.dumps(known_obj)}\n" - return AgentPrompt(know_info_str) - - async def _llm_read_article(self,knowledge_item:dict,full_path:str) -> ComputeTaskResult: - # Objectives: - # Obtain better titles, abstracts, table of contents (if necessary), tags - # Determine the appropriate place to put it (in line with the organization's goals) - # Known information: - # The reason why the target service's learn_prompt is being sorted - # Summary of the organization's work (if any) - # The current structure of the knowledge base (note the size control) gen_kb_tree_prompt (when empty, LLM should generate an appropriate initial directory structure) - # Original path, current title, abstract, table of contents - - # Sorting long files (general tricks) - # Indicate that the input is part of the content, let LLM generate intermediate results for the task - # Enter the content in sequence, when the last content block is input, LLM gets the result - - - #full_content = item.get_article_full_content() - workspace = self.get_workspace_by_msg(None) - full_content_len = self.token_len(full_content) - - if full_content_len < self.get_llm_learn_token_limit(): - - # 短文章不用总结catelog - #path_list,summary = llm_get_summary(summary,full_content) - #prompt = self.get_agent_role_prompt() - prompt = AgentPrompt() - prompt.append(self.get_learn_prompt()) - known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item) - prompt.append(known_info_prompt) - content_prompt = AgentPrompt(full_content) - prompt.append(content_prompt) - env_functions = None - #env_functions,function_len = workspace.get_knowledge_base_ai_functions() - task_result:ComputeTaskResult = await self.do_llm_complection(prompt,is_json_resp=True) - if task_result.result_code != ComputeTaskResultCode.OK: - result_obj = {} - result_obj["error_str"] = task_result.error_str - return result_obj - - result_obj = json.loads(task_result.result_str) - return result_obj - - else: - logger.warning(f"llm_read_article: article {full_path} use LLM loop learn!") - pos = 0 - read_len = int(self.get_llm_learn_token_limit() * 1.2) - - temp_meta_data = {} - is_final = False - while pos < str_len: - _content = full_content[pos:pos+read_len] - part_cotent_len = len(_content) - if part_cotent_len < read_len: - # last chunk - is_final = True - part_content = f"<>\n{_content}" - else: - part_content = f"<>\n{_content}" - - pos = pos + read_len - prompt = AgentPrompt() - prompt.append(self.get_learn_prompt()) - known_info_prompt = await self.gen_known_info_for_knowledge_prompt(knowledge_item,temp_meta_data) - prompt.append(known_info_prompt) - content_prompt = AgentPrompt(part_content) - prompt.append(content_prompt) - #env_functions,function_len = workspace.get_knowledge_base_ai_functions() - task_result:ComputeTaskResult = await self.do_llm_complection(prompt,is_json_resp=True) - if task_result.result_code != ComputeTaskResultCode.OK: - result_obj = {} - result_obj["error_str"] = task_result.error_str - return result_obj - - result_obj = json.loads(task_result.result_str) - temp_meta_data = result_obj - if is_final: - return result_obj - - return None - - async def do_self_think(self): session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) for session_id in session_id_list: diff --git a/src/aios/agent/agent_base.py b/src/aios/agent/agent_base.py index b706618..562a069 100644 --- a/src/aios/agent/agent_base.py +++ b/src/aios/agent/agent_base.py @@ -14,7 +14,7 @@ from typing import List, Tuple from .ai_function import FunctionItem, AIFunction from ..proto.agent_msg import AgentMsg, AgentMsgType from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode -from ..environment.environment import Environment +from ..environment.environment import BaseEnvironment logger = logging.getLogger(__name__) @@ -129,6 +129,8 @@ class LLMResult: if llm_result_str[0] == "{": return LLMResult.from_json_str(llm_result_str) + # if llm_result_str.startswith("json"): + # return LLMResult.from_json_str(llm_result_str[4:]) lines = llm_result_str.splitlines() is_need_wait = False @@ -368,7 +370,7 @@ class AgentTodo: case AgentTodo.TODO_STATE_DONE: logger.info(f"todo {self.title} is done, ignore") return False - case AgentTodo.TODO_STATE_CASNCEL: + case AgentTodo.TODO_STATE_CANCEL: logger.info(f"todo {self.title} is cancel, ignore") return False case AgentTodo.TODO_STATE_EXPIRED: @@ -419,7 +421,7 @@ class BaseAIAgent(abc.ABC): pass def token_len(self, text:str=None, prompt:AgentPrompt=None) -> int: - from .compute_kernel import ComputeKernel + from ..frame.compute_kernel import ComputeKernel if text: return ComputeKernel.llm_num_tokens_from_text(text,self.get_llm_model_name()) elif prompt: @@ -428,11 +430,12 @@ class BaseAIAgent(abc.ABC): result += ComputeKernel.llm_num_tokens_from_text(prompt.system_message.get("content"),self.get_llm_model_name()) for msg in prompt.messages: result += ComputeKernel.llm_num_tokens_from_text(msg.get("content"),self.get_llm_model_name()) + return result else: return 0 @classmethod - def get_inner_functions(cls, env:Environment) -> (dict,int): + def get_inner_functions(cls, env:BaseEnvironment) -> (dict,int): if env is None: return None,0 @@ -457,7 +460,7 @@ class BaseAIAgent(abc.ABC): self, prompt:AgentPrompt, org_msg:AgentMsg=None, - env:Environment=None, + env:BaseEnvironment=None, inner_functions=None, is_json_resp=False, ) -> ComputeTaskResult: @@ -510,7 +513,7 @@ class BaseAIAgent(abc.ABC): async def _execute_func( self, - env: Environment, + env: BaseEnvironment, inner_func_call_node: dict, prompt: AgentPrompt, inner_functions: dict, diff --git a/src/aios/agent/ai_function.py b/src/aios/agent/ai_function.py index 92eb448..d13ddca 100644 --- a/src/aios/agent/ai_function.py +++ b/src/aios/agent/ai_function.py @@ -182,7 +182,7 @@ class SimpleAIOperation(AIOperation): if self.func_handler is None: return "error: function not implemented" - return await self.func_handler(**params) + return await self.func_handler(params) class AIFunctionOperation(AIOperation): diff --git a/src/aios/agent/workflow.py b/src/aios/agent/workflow.py index 7855483..d0bd585 100644 --- a/src/aios/agent/workflow.py +++ b/src/aios/agent/workflow.py @@ -18,7 +18,7 @@ from .ai_function import AIFunction,FunctionItem from ..frame.compute_kernel import ComputeKernel from ..frame.bus import AIBus -from ..environment.environment import Environment,EnvironmentEvent +from ..environment.environment import BaseEnvironment from ..environment.workflow_env import WorkflowEnvironment @@ -490,15 +490,15 @@ class Workflow: def get_workflow_rule_prompt(self) -> AgentPrompt: return self.rule_prompt - def _env_event_to_msg(self,env_event:EnvironmentEvent) -> AgentMsg: + # def _env_event_to_msg(self,env_event:EnvironmentEvent) -> AgentMsg: + # pass + + def get_inner_environment(self,env_id:str) -> BaseEnvironment: pass - def get_inner_environment(self,env_id:str) -> Environment: - pass - - def connect_to_environment(self,the_env:Environment,conn_info:dict) -> None: + def connect_to_environment(self,the_env:BaseEnvironment,conn_info:dict) -> None: if the_env is not None: - self.workflow_env.add_owner_env(the_env) + self.workflow_env.add_env(the_env) #for event2msg in conn_info: # for k,v in event2msg: diff --git a/src/aios/environment/environment.py b/src/aios/environment/environment.py index ac4559a..1af1813 100644 --- a/src/aios/environment/environment.py +++ b/src/aios/environment/environment.py @@ -35,8 +35,13 @@ class BaseEnvironment: @abstractmethod def get_all_ai_operations(self) -> List[AIOperation]: pass - + def __getitem__(self, key): + return self.get_value(key) + + @abstractmethod + def get_value(self,key:str) -> Optional[str]: + pass # _all_env = {} # @classmethod @@ -84,40 +89,16 @@ class SimpleEnvironment(BaseEnvironment): -class CompositeEnvironment(BaseEnvironment): +class CompositeEnvironment(SimpleEnvironment): def __init__(self, workspace: str) -> None: super().__init__(workspace) - self.envs:List[BaseEnvironment] = {} - self.functions: Dict[str,AIFunction] = {} - self.operations: Dict[str,AIOperation] = {} + self.envs: List[BaseEnvironment] = [] def add_env(self, env: BaseEnvironment) -> None: - self.envs.append[env] + self.envs.append(env) functions = env.get_all_ai_functions() for func in functions: self.functions[func.get_name()] = func operations = env.get_all_ai_operations() for op in operations: - self.operations[op.get_name()] = op - - def get_ai_function(self,func_name:str) -> AIFunction: - func = self.functions.get(func_name) - if func is not None: - return func - return None - - def get_all_ai_functions(self) -> List[AIFunction]: - func_list = [] - func_list.extend(self.functions.values()) - return func_list - - def get_ai_operation(self,op_name:str) -> AIOperation: - op = self.operations.get(op_name) - if op is not None: - return op - return None - - def get_all_ai_operations(self) -> List[AIOperation]: - op_list = [] - op_list.extend(self.operations.values()) - return op_list \ No newline at end of file + self.operations[op.get_name()] = op \ No newline at end of file diff --git a/src/aios/environment/workflow_env.py b/src/aios/environment/workflow_env.py index 52b5858..df1003f 100644 --- a/src/aios/environment/workflow_env.py +++ b/src/aios/environment/workflow_env.py @@ -15,13 +15,13 @@ from ..frame.compute_kernel import ComputeKernel from ..frame.contact_manager import ContactManager,Contact,FamilyMember from ..storage.storage import AIStorage -from .environment import Environment,EnvironmentEvent +from .environment import SimpleEnvironment, CompositeEnvironment from .script_to_speech_function import ScriptToSpeechFunction from .image_2_text_function import Image2TextFunction logger = logging.getLogger(__name__) -class CalenderEvent(EnvironmentEvent): +class CalenderEvent(SimpleEnvironment): def __init__(self,data) -> None: super().__init__() self.event_name = "timer" @@ -31,7 +31,7 @@ class CalenderEvent(EnvironmentEvent): return f"#event timer:{self.data}" # AI Calender GOAL: Let user use "create notify after 2 days" to create a timer event -class CalenderEnvironment(Environment): +class CalenderEnvironment(SimpleEnvironment): def __init__(self, env_id: str) -> None: super().__init__(env_id) self.db_file = AIStorage.get_instance().get_myai_dir() / "calender.db" @@ -302,7 +302,7 @@ class CalenderEnvironment(Environment): return f'exec paint OK, saved as a local file, path is: {result.result["file"]}' -class PaintEnvironment(Environment): +class PaintEnvironment(BaseEnvironment): def __init__(self, env_id: str) -> None: super().__init__(env_id) self.is_run = False @@ -327,14 +327,14 @@ class PaintEnvironment(Environment): # Default Workflow Environment(Context) -class WorkflowEnvironment(Environment): +class WorkflowEnvironment(CompositeEnvironment): def __init__(self, env_id: str,db_file:str) -> None: super().__init__(env_id) self.db_file = db_file self.local = threading.local() self.table_name = "WorkflowEnv_" + env_id - self.add_ai_function(ScriptToSpeechFunction()) - self.add_ai_function(Image2TextFunction()) + # self.add_ai_function(ScriptToSpeechFunction()) + # self.add_ai_function(Image2TextFunction()) def _get_conn(self): diff --git a/src/aios/environment/workspace_env.py b/src/aios/environment/workspace_env.py index f464f0f..93b1005 100644 --- a/src/aios/environment/workspace_env.py +++ b/src/aios/environment/workspace_env.py @@ -1,5 +1,3 @@ -# this env is designed for workflow owner filesystem, support file/directory operations - import json import logging import os @@ -166,10 +164,8 @@ class TodoListEnvironment(SimpleEnvironment): detail_path = path + "/detail" try: - async with aiofiles.open(detail_path, mode='r', encoding="utf-8") as f: - content = await f.read(4096) - logger.debug("get_todo_by_fullpath:%s,content:%s",path,content) - todo_dict = json.loads(content) + with open(detail_path, mode='r', encoding="utf-8") as f: + todo_dict = json.load(f) result_todo = AgentTodo.from_dict(todo_dict) if result_todo: relative_path = os.path.relpath(path, self.root_path) @@ -189,9 +185,9 @@ class TodoListEnvironment(SimpleEnvironment): try: if parent_id: parent_path = self._get_todo_path(parent_id) - todo_path = f"{parent_path}/{todo.title}" + todo_path = f"{parent_path}/{todo.todo_id}-{todo.title}" else: - todo_path = todo.title + todo_path = f"{todo.todo_id}-{todo.title}" dir_path = f"{self.root_path}/{todo_path}" @@ -212,10 +208,11 @@ class TodoListEnvironment(SimpleEnvironment): async def update_todo(self,todo_id:str,new_stat:str)->str: try: todo_path = self._get_todo_path(todo_id) - todo : AgentTodo = self.get_todo_by_fullpath(todo_path) + full_path = f"{self.root_path}/{todo_path}" + todo : AgentTodo = await self.get_todo_by_fullpath(full_path) if todo: todo.state = new_stat - detail_path = f"{self.root_path}/{todo.todo_path}/detail" + detail_path = f"{full_path}/detail" async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: await f.write(json.dumps(todo.to_dict())) return None @@ -224,18 +221,32 @@ class TodoListEnvironment(SimpleEnvironment): except Exception as e: return str(e) - async def wait_todo_done(self,todo_id:str) -> AgentTodo: + async def wait_todo_done(self,todo_id:str,state=AgentTodo.TODO_STATE_WAITING_CHECK) -> AgentTodo: todo_path = self._get_todo_path(todo_id) + full_path = f"{self.root_path}/{todo_path}" async def check_done(): while True: - todo : AgentTodo = self.get_todo_by_fullpath(todo_path) - if todo: - if todo.state == AgentTodo.TODO_STATE_DONE: + todo : AgentTodo = await self.get_todo_by_fullpath(full_path) + if todo is None: + continue + if todo.state == AgentTodo.TODO_STATE_CANCEL: + break + elif todo.state == AgentTodo.TODO_STATE_EXPIRED: + break + elif todo.state == AgentTodo.TODO_STATE_WAITING_CHECK: + if state == AgentTodo.TODO_STATE_WAITING_CHECK: break + elif todo.state == AgentTodo.TODO_STATE_DONE: + if state == AgentTodo.TODO_STATE_WAITING_CHECK: + break + elif todo.state == AgentTodo.TODO_STATE_DONE: + break + elif todo.state == AgentTodo.TODO_STATE_REVIEWED: + break await asyncio.sleep(1) - asyncio.create_task(check_done()) - return self.get_todo_by_fullpath(todo_path) + await check_done() + return await self.get_todo_by_fullpath(full_path) async def append_worklog(self, todo:AgentTodo, result:AgentTodoResult): @@ -254,171 +265,6 @@ class TodoListEnvironment(SimpleEnvironment): json_obj["logs"] = logs await f.write(json.dumps(json_obj)) -class FilesystemEnvironment(SimpleEnvironment): - def __init__(self, workspace: str) -> None: - super().__init__(workspace) - self.root_path = workspace - - # if op["op"] == "create": - # await self.create(op["path"],op["content"]) - - async def write(op): - is_append = op.get("is_append") - if is_append is None: - is_append = False - return await self.write(op["path"],op["content"],is_append) - self.add_ai_operation(SimpleAIOperation( - op="write", - description="write file", - func_handler=write, - )) - - async def delete(op): - return await self.delete(op["path"]) - self.add_ai_operation(SimpleAIOperation( - op="delete", - description="delete path", - func_handler=delete, - )) - - async def rename(op): - return await self.move(op["path"],op["new_name"]) - self.add_ai_operation(SimpleAIOperation( - op="rename", - description="rename path", - func_handler=rename, - )) - - # file system operation: list,read,write,delete,move,stat - # inner_function - async def list(self,path:str,only_dir:bool=False) -> str: - directory_path = self.root_path + path - items = [] - - with await aiofiles.os.scandir(directory_path) as entries: - async for entry in entries: - is_dir = entry.is_dir() - if only_dir and not is_dir: - continue - item_type = "directory" if is_dir else "file" - items.append({"name": entry.name, "type": item_type}) - - return json.dumps(items) - - # inner_function - async def read(self,path:str) -> str: - file_path = self.root_path + path - cur_encode = "utf-8" - async with aiofiles.open(file_path,'rb') as f: - cur_encode = chardet.detect(await f.read())['encoding'] - - async with aiofiles.open(file_path, mode='r', encoding=cur_encode) as f: - content = await f.read(2048) - return content - - - # operation or inner_function (MOST IMPORTANT FUNCTION) - async def write(self,path:str,content:str,is_append:bool=False) -> str: - file_path = self.root_path + path - try: - if is_append: - async with aiofiles.open(file_path, mode='a', encoding="utf-8") as f: - await f.write(content) - else: - if content is None: - # create dir - dir_path = self.root_path + path - os.makedirs(dir_path) - return True - else: - file_path = self.root_path + path - os.makedirs(os.path.dirname(file_path),exist_ok=True) - async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: - await f.write(content) - return True - - except Exception as e: - return str(e) - return None - - - # operation or inner_function - async def delete(self,path:str) -> str: - try: - file_path = self.root_path + path - os.remove(file_path) - except Exception as e: - return str(e) - - return None - - # operation or inner_function - async def move(self,path:str,new_path:str) -> str: - try: - file_path = self.root_path + path - new_path = self.root_path + new_path - os.rename(file_path,new_path) - except Exception as e: - return str(e) - - return None - - # inner_function - async def stat(self,path:str) -> str: - try: - file_path = self.root_path + path - stat = os.stat(file_path) - return json.dumps(stat) - except Exception as e: - return str(e) - - # operation or inner_function - async def symlink(self,path:str,target:str) -> str: - try: - #file_path = self.root_path + path - target_path = self.root_path + target - dir_path = os.path.dirname(target_path) - os.makedirs(dir_path,exist_ok=True) - os.symlink(path,target_path) - except Exception as e: - logger.error("symlink failed:%s",e) - return str(e) - - return None - -class ShellEnvironment(SimpleEnvironment): - def __init__(self, workspace: str) -> None: - super().__init__(workspace) - - operator_param = { - "command": "command will execute", - } - self.add_ai_function(SimpleAIFunction("shell_exec", - "execute shell command in linux bash", - self.shell_exec,operator_param)) - - #run_code_param = { - # "pycode": "python code will execute", - #} - #self.add_ai_function(SimpleAIFunction("run_code", - # "execute python code", - # self.run_code,run_code_param)) - - - async def shell_exec(self,command:str) -> str: - import asyncio.subprocess - process = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await process.communicate() - returncode = process.returncode - if returncode == 0: - return f"Execute success! stdout is:\n{stdout}\n" - else: - return f"Execute failed! stderr is:\n{stderr}\n" - class WorkspaceEnvironment(CompositeEnvironment): def __init__(self, env_id: str) -> None: @@ -436,8 +282,6 @@ class WorkspaceEnvironment(CompositeEnvironment): # default environments in workspace self.add_env(self.todo_list[TodoListType.TO_WORK]) - self.add_env(ShellEnvironment(self.root_path)) - self.add_env(FilesystemEnvironment(self.root_path)) def set_root_path(self,path:str): self.root_path = path diff --git a/src/aios/knowledge/object/object.py b/src/aios/knowledge/object/object.py index f34da2d..25621b6 100644 --- a/src/aios/knowledge/object/object.py +++ b/src/aios/knowledge/object/object.py @@ -73,6 +73,6 @@ class KnowledgeObject(ABC): def encode(self) -> bytes: return pickle.dumps(self) - # @staticmethod - # def decode(data: bytes) -> "ImageObject": - # return pickle.loads(data) + @staticmethod + def decode(data: bytes) -> "KnowledgeObject": + return pickle.loads(data) diff --git a/src/aios/knowledge/pipeline.py b/src/aios/knowledge/pipeline.py index db2fb3e..a9a2b58 100644 --- a/src/aios/knowledge/pipeline.py +++ b/src/aios/knowledge/pipeline.py @@ -47,7 +47,7 @@ class KnowledgePipelineJournalClient: timestamp = datetime.datetime.now() if timestamp is None else timestamp conn = sqlite3.connect(self.journal_path) conn.execute( - "INSERT INTO journal (time, input, parser) VALUES (?, ?, ?, ?)", + "INSERT INTO journal (time, input, parser) VALUES (?, ?, ?)", (timestamp, input, parser), ) conn.commit() diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index 0252d86..e3eb72d 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -50,15 +50,15 @@ class AgentManager: async def scan_all_agent(self)->None: pass - async def register_environment(self, env_id: str, init_env) -> None: + def register_environment(self, env_id: str, init_env) -> None: self.environments[env_id] = init_env - async def init_environment(self, env_id: str, workspace: str): + def init_environment(self, env_id: str, workspace: str): if env_id not in self.environments: logger.error(f"env {env_id} not found!") return - return self.environments[env_id] + return self.environments[env_id](workspace) async def is_exist(self,agent_id:str) -> bool: the_aget = await self.get(agent_id) @@ -122,14 +122,14 @@ class AgentManager: workspace = config.get("workspace", config.get("instance_id")) workspace = WorkspaceEnvironment(workspace) config["workspace"] = workspace - + if "owner_env" in config: owner_env = config["owner_env"] def init_env(env_config: str): - _, ext = os.path.splitext(owner_env) + _, ext = os.path.splitext(env_config) if ext == ".py": - env_path = os.path.join(agent_media.full_path, owner_env) + env_path = os.path.join(agent_media.full_path, env_config) env = runpy.run_path(env_path)["init"](None, workspace.root_path) else: env = self.init_environment(env_config, workspace.root_path) diff --git a/src/component/common_environment/__init__.py b/src/component/common_environment/__init__.py new file mode 100644 index 0000000..5587238 --- /dev/null +++ b/src/component/common_environment/__init__.py @@ -0,0 +1,3 @@ +from .local_document import LocalKnowledgeBase, ScanLocalDocument, ParseLocalDocument +from .local_file_system import FilesystemEnvironment +from .shell import ShellEnvironment \ No newline at end of file diff --git a/src/component/common_environment/local_document.py b/src/component/common_environment/local_document.py index 14d225d..635cf87 100644 --- a/src/component/common_environment/local_document.py +++ b/src/component/common_environment/local_document.py @@ -4,13 +4,18 @@ import chardet import string import sqlite3 import json +import re import threading import logging -from datetime import datetime +import hashlib +from markdown import Markdown +import PyPDF2 +import datetime from typing import Optional, List -from aios import KnowledgePipelineEnvironment, AIStorage, SimpleEnvironment, TodoListEnvironment, TodoListType, AgentTodo, CustomAIAgent - +from aios import * +from .local_file_system import FilesystemEnvironment +logger = logging.getLogger(__name__) class MetaDatabase: def __init__(self,db_path:str): @@ -79,7 +84,7 @@ class MetaDatabase: def add_doc(self, doc_path: str, length: int, last_modify: str, doc_hash: Optional[str] = None): conn = self._get_conn() cursor = conn.cursor() - create_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + create_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") cursor.execute(''' INSERT INTO documents (doc_path, length, last_modify, doc_hash,create_time) VALUES (?, ?, ?, ?,?) @@ -125,9 +130,9 @@ class MetaDatabase: conn = self._get_conn() cursor = conn.cursor() - create_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + create_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") summary = metadata.get("summary", "") - catalogs = metadata.get("catalogs","") + catalogs = json.dumps(metadata.get("catalogs", {})) title = metadata.get("title","") tags = ','.join(metadata.get("tags", [])) @@ -140,14 +145,14 @@ class MetaDatabase: #llm_result["summary"] #llm_result["tags"] #llm_result["catalog"] - def set_knowledge_llm_result(self, doc_hash: str, llm_result: dict): + def set_knowledge_llm_result(self, doc_hash: str, meta: dict): conn = self._get_conn() cursor = conn.cursor() - title = llm_result.get("title", "") - summary = llm_result.get("summary", "") - catalogs = json.dumps(llm_result.get("catalogs", {})) - tags = ','.join(llm_result.get("tags", [])) + title = meta.get("title", "") + summary = meta.get("summary", "") + catalogs = json.dumps(meta.get("catalogs", {})) + tags = ','.join(meta.get("tags", [])) cursor.execute(''' UPDATE knowledge @@ -156,6 +161,7 @@ class MetaDatabase: ''', (title,summary, catalogs, tags, doc_hash)) conn.commit() + def get_hash_by_doc_path(self, doc_path: str) -> Optional[str]: conn = self._get_conn() cursor = conn.cursor() @@ -227,12 +233,60 @@ class MetaDatabase: ''', (tag)) return [row[0] for row in cursor.fetchall()] +# singleton +class LearningCache: + _instance_lock = threading.Lock() + _instance = None -class LocalKnowledgeBase(SimpleEnvironment): + def __instance_init__(self): + self.cache = {} + self.cache_lock = threading.Lock() + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + with LearningCache._instance_lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance.__instance_init__() + return cls._instance + + def add(self, key, value): + with self.cache_lock: + self.cache[key] = value + + def get(self, key): + with self.cache_lock: + return self.cache.get(key) + + def remove(self, key): + with self.cache_lock: + return self.cache.pop(key, None) + + +class LocalKnowledgeBase(CompositeEnvironment): def __init__(self, workspace: str) -> None: super().__init__(workspace) - self.root_path = f"{self.root_path}/knowledge" + self.root_path = f"{workspace}/knowledge" + if os.path.exists(self.root_path) is False: + os.makedirs(self.root_path) self.meta_db = MetaDatabase(f"{self.root_path}/kb.db") + self.learning_cache = LearningCache() + + async def learn(op:dict): + full_path = op.get("original_path") + if not full_path: + return + meta = self.learning_cache.get(full_path) + meta.update(op) + + self.add_ai_operation(SimpleAIOperation( + op="learn", + description="update knowledge llm summary", + func_handler=learn, + )) + + self.fs = FilesystemEnvironment(self.root_path) + self.add_env(self.fs) async def get_knowledege_catalog(self,path:str=None,only_dir =True,max_depth:int=5)->str: if path: @@ -344,22 +398,32 @@ class ScanLocalDocument: class ParseLocalDocument: - def __init__(self, env: KnowledgePipelineEnvironment, config): + def __init__(self, env: KnowledgePipelineEnvironment, config: dict): self.env = env workspace = string.Template(config["workspace"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) self.todo_list = TodoListEnvironment(workspace, TodoListType.TO_LEARN) self.knowledge_base = LocalKnowledgeBase(workspace) - self.token_limit = config["token_limit"] + self.token_limit = config.get("token_limit", 4000) + self.assign_to = config.get("assign_to") async def parse(self, full_path: str) -> str: file_stat = os.stat(full_path) if file_stat.st_size < 1: return full_path - hash, meta_data = self._parse_document(full_path) - await self._learn(meta_data, full_path) + hash, parse_meta = self._parse_document(full_path) + parse_meta["original_path"] = full_path + llm_meta = await self._learn_by_agent(parse_meta) self.knowledge_base.meta_db.add_doc(full_path,file_stat.st_size,file_stat.st_mtime,hash) - self.knowledge_base.meta_db.add_knowledge(hash,meta_data) + self.knowledge_base.meta_db.add_knowledge(hash,parse_meta) + self.knowledge_base.meta_db.set_knowledge_llm_result(hash,llm_meta) + path_list = llm_meta.get("path") + new_title = llm_meta.get("title") + if path_list: + for new_path in path_list: + new_path = f"{new_path}/{new_title}" + await self.knowledge_base.fs.symlink(full_path, new_path) + logger.info(f"create soft link {full_path} -> {new_path}") return full_path async def _get_meta_prompt(self,meta: dict,temp_meta = None,need_catalogs = False) -> str: @@ -384,15 +448,15 @@ class ParseLocalDocument: for key in temp_meta.keys(): known_obj[key] = temp_meta[key] - org_path = meta.get("full_path") - known_obj["orginal_path"] = org_path + org_path = meta.get("original_path") + known_obj["original_path"] = org_path return f"# Known information:\n## Current directory structure:\n{kb_tree}\n## Knowlege Metadata:\n{json.dumps(known_obj)}\n" - async def _token_len(self, text: str) -> int: + def _token_len(self, text: str) -> int: return CustomAIAgent("", "gpt-4-1106-preview", self.token_limit).token_len(text=text) - async def _learn(self, meta:dict, full_path:str): + async def _learn_by_agent(self, meta:dict) -> dict: # Objectives: # Obtain better titles, abstracts, table of contents (if necessary), tags # Determine the appropriate place to put it (in line with the organization's goals) @@ -405,24 +469,26 @@ class ParseLocalDocument: # Sorting long files (general tricks) # Indicate that the input is part of the content, let LLM generate intermediate results for the task # Enter the content in sequence, when the last content block is input, LLM gets the result - - full_content = self.knowledge_base.load_knowledge_content(full_path) + full_content = await self.knowledge_base.load_knowledge_content(meta["original_path"]) full_content_len = self._token_len(full_content) + full_path = meta["original_path"] + self.knowledge_base.learning_cache.add(full_path, meta) + - if full_content_len < self.token_limit(): + if full_content_len < self.token_limit: # 短文章不用总结catalog todo = AgentTodo() + todo.worker = self.assign_to todo.title = meta["title"] meta_prompt = await self._get_meta_prompt(meta,None) todo.detail = meta_prompt + full_content - self.todo_list.create_todo(None, todo) + await self.todo_list.create_todo(None, todo) await self.todo_list.wait_todo_done(todo.todo_id) else: logger.warning(f"llm_read_article: article {full_path} use LLM loop learn!") pos = 0 - read_len = int(self.token_limit() * 1.2) + read_len = int(self.token_limit * 1.2) - temp_meta = {} is_final = False while pos < full_content_len: _content = full_content[pos:pos+read_len] @@ -435,16 +501,17 @@ class ParseLocalDocument: part_content = f"<>\n{_content}" pos = pos + read_len + temp_meta = self.knowledge_base.learning_cache.get(full_path) todo = AgentTodo() + todo.worker = self.assign_to todo.title = meta["title"] meta_prompt = await self._get_meta_prompt(meta,temp_meta) todo.detail = meta_prompt + part_content self.todo_list.create_todo(None, todo) todo = await self.todo_list.wait_todo_done(todo.todo_id) - result_obj = json.loads(todo.result.result_str) - temp_meta = result_obj if is_final: break + return self.knowledge_base.learning_cache.remove(full_path) def _parse_pdf_bookmarks(self,bookmarks, parent:list): for item in bookmarks: @@ -543,109 +610,9 @@ class ParseLocalDocument: logger.error("parse document %s failed:%s",doc_path,e) # traceback.print_exc() + if not "title" in meta_data: + meta_data["title"] = title logger.info("parse document %s!",doc_path) return hash_result, meta_data - - def _parse_pdf_bookmarks(self,bookmarks, parent:list): - for item in bookmarks: - if isinstance(item,list): - self._parse_pdf_bookmarks(item,parent) - else: - if item.title: - new_item = {} - new_item["page"] = item.page.idnum - new_item["title"] = item.title - my_childs = [] - if item.childs: - if len(item.childs) > 0: - self._parse_pdf_bookmarks(item.childs, my_childs) - new_item["childs"] = my_childs - parent.append(new_item) - else: - logger.warning("parse pdf bookmarks failed: item.title is None!") - - return - - def _parse_pdf(self,doc_path:str): - metadata = {} - with open(doc_path, 'rb') as file: - reader = PyPDF2.PdfReader(file) - try: - doc_info = reader.metadata - if doc_info: - if doc_info.title: - metadata["title"] = doc_info.title - if doc_info.author: - metadata["authors"] = doc_info.author - except Exception as e: - logger.warn("parse pdf metadata failed:%s",e) - - dir_path = os.path.dirname(doc_path) - base_name = os.path.basename(doc_path) - text_content_path = f"{dir_path}/.{base_name}.txt" - full_text = "" - - for page in reader.pages: - text = page.extract_text() - full_text += text - with open(text_content_path, 'w', encoding='utf-8') as f: - f.write(full_text) - - try: - bookmarks = reader.outline - if bookmarks: - catalogs = [] - self._parse_pdf_bookmarks(bookmarks,catalogs) - metadata["catalogs"] = json.dumps(catalogs) - except Exception as e: - logger.warn("parse pdf bookmarks failed:%s",e) - - return metadata - - def _parse_txt(self,doc_path:str): - return {} - - def _parse_md(self,doc_path:str): - metadata = {} - cur_encode = "utf-8" - with open(doc_path,'rb') as f: - cur_encode = chardet.detect(f.read(1024))['encoding'] - - with open(doc_path, mode='r', encoding=cur_encode) as f: - content = f.read() - match = re.search(r'^# (.*)', content, re.MULTILINE) - if match: - metadata['title'] = match.group(1).strip() - md = Markdown(extensions=['toc']) - html_str = md.convert(content) - toc = md.toc - if toc: - metadata['catalogs'] = toc - - return metadata - - def _parse_document(self,doc_path:str): - hash_result = None - title = os.path.basename(doc_path) - meta_data = {} - - with open(doc_path, "rb") as f: - hash_md5 = hashlib.md5() - for chunk in iter(lambda: f.read(1024*1024), b""): - hash_md5.update(chunk) - hash_result = hash_md5.hexdigest() - try: - if doc_path.endswith(".md"): - meta_data = self._parse_md(doc_path) - elif doc_path.endswith(".pdf"): - meta_data = self._parse_pdf(doc_path) - except Exception as e: - logger.error("parse document %s failed:%s",doc_path,e) - # traceback.print_exc() - - if meta_data.get("title"): - title = meta_data["title"] - logger.info("parse document %s!",doc_path) - return hash_result,title,meta_data \ No newline at end of file diff --git a/src/component/common_environment/local_file_system.py b/src/component/common_environment/local_file_system.py new file mode 100644 index 0000000..182d84c --- /dev/null +++ b/src/component/common_environment/local_file_system.py @@ -0,0 +1,139 @@ +import json +import os +import aiofiles +from typing import Any,List,Dict +import chardet +from aios import SimpleAIOperation +from aios import SimpleEnvironment + +class FilesystemEnvironment(SimpleEnvironment): + def __init__(self, workspace: str) -> None: + super().__init__(workspace) + self.root_path = workspace + + # if op["op"] == "create": + # await self.create(op["path"],op["content"]) + + async def write(op): + is_append = op.get("is_append") + if is_append is None: + is_append = False + return await self.write(op["path"],op["content"],is_append) + self.add_ai_operation(SimpleAIOperation( + op="write", + description="write file", + func_handler=write, + )) + + async def delete(op): + return await self.delete(op["path"]) + self.add_ai_operation(SimpleAIOperation( + op="delete", + description="delete path", + func_handler=delete, + )) + + async def rename(op): + return await self.move(op["path"],op["new_name"]) + self.add_ai_operation(SimpleAIOperation( + op="rename", + description="rename path", + func_handler=rename, + )) + + # file system operation: list,read,write,delete,move,stat + # inner_function + async def list(self,path:str,only_dir:bool=False) -> str: + directory_path = self.root_path + path + items = [] + + with await aiofiles.os.scandir(directory_path) as entries: + async for entry in entries: + is_dir = entry.is_dir() + if only_dir and not is_dir: + continue + item_type = "directory" if is_dir else "file" + items.append({"name": entry.name, "type": item_type}) + + return json.dumps(items) + + # inner_function + async def read(self,path:str) -> str: + file_path = self.root_path + path + cur_encode = "utf-8" + async with aiofiles.open(file_path,'rb') as f: + cur_encode = chardet.detect(await f.read())['encoding'] + + async with aiofiles.open(file_path, mode='r', encoding=cur_encode) as f: + content = await f.read(2048) + return content + + + # operation or inner_function (MOST IMPORTANT FUNCTION) + async def write(self,path:str,content:str,is_append:bool=False) -> str: + file_path = self.root_path + path + try: + if is_append: + async with aiofiles.open(file_path, mode='a', encoding="utf-8") as f: + await f.write(content) + else: + if content is None: + # create dir + dir_path = self.root_path + path + os.makedirs(dir_path) + return True + else: + file_path = self.root_path + path + os.makedirs(os.path.dirname(file_path),exist_ok=True) + async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: + await f.write(content) + return True + + except Exception as e: + return str(e) + return None + + + # operation or inner_function + async def delete(self,path:str) -> str: + try: + file_path = self.root_path + path + os.remove(file_path) + except Exception as e: + return str(e) + + return None + + # operation or inner_function + async def move(self,path:str,new_path:str) -> str: + try: + file_path = self.root_path + path + new_path = self.root_path + new_path + os.rename(file_path,new_path) + except Exception as e: + return str(e) + + return None + + # inner_function + async def stat(self,path:str) -> str: + try: + file_path = self.root_path + path + stat = os.stat(file_path) + return json.dumps(stat) + except Exception as e: + return str(e) + + # operation or inner_function + async def symlink(self,path:str,target:str) -> str: + try: + #file_path = self.root_path + path + target_path = self.root_path + target + dir_path = os.path.dirname(target_path) + os.makedirs(dir_path,exist_ok=True) + os.symlink(path,target_path) + except Exception as e: + logger.error("symlink failed:%s",e) + return str(e) + + return None \ No newline at end of file diff --git a/src/component/common_environment/shell.py b/src/component/common_environment/shell.py new file mode 100644 index 0000000..c8209fb --- /dev/null +++ b/src/component/common_environment/shell.py @@ -0,0 +1,38 @@ +import os +from typing import Any,List,Dict +from aios import AgentMsg,AgentTodo,AgentPrompt +from aios import SimpleAIFunction, SimpleAIOperation +from aios import SimpleEnvironment + +class ShellEnvironment(SimpleEnvironment): + def __init__(self, workspace: str) -> None: + super().__init__(workspace) + + operator_param = { + "command": "command will execute", + } + self.add_ai_function(SimpleAIFunction("shell_exec", + "execute shell command in linux bash", + self.shell_exec,operator_param)) + + #run_code_param = { + # "pycode": "python code will execute", + #} + #self.add_ai_function(SimpleAIFunction("run_code", + # "execute python code", + # self.run_code,run_code_param)) + + + async def shell_exec(self,command:str) -> str: + import asyncio.subprocess + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + returncode = process.returncode + if returncode == 0: + return f"Execute success! stdout is:\n{stdout}\n" + else: + return f"Execute failed! stderr is:\n{stderr}\n" diff --git a/src/component/openai_node/open_ai_node.py b/src/component/openai_node/open_ai_node.py index e45dd95..a072309 100644 --- a/src/component/openai_node/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -214,7 +214,7 @@ class OpenAI_ComputeNode(ComputeNode): client = AsyncOpenAI(api_key=self.openai_api_key) try: - if llm_inner_functions is None: + if llm_inner_functions is None or len(llm_inner_functions) == 0: logger.info(f"call openai {mode_name} prompts: {prompts}") resp = await client.chat.completions.create(model=mode_name, messages=prompts, diff --git a/src/component/st_node/local_st_compute_node.py b/src/component/st_node/local_st_compute_node.py index 659d123..1887ae3 100644 --- a/src/component/st_node/local_st_compute_node.py +++ b/src/component/st_node/local_st_compute_node.py @@ -116,7 +116,7 @@ class LocalSentenceTransformer_Image_ComputeNode(Queue_ComputeNode): def _load_image(self, source: Union[ObjectID, bytes]) -> Optional[Image]: image_data = None if isinstance(source, ObjectID): - from knowledge import KnowledgeStore, ImageObject + from aios import KnowledgeStore, ImageObject buf = KnowledgeStore().get_object_store().get_object(source) if buf is None: diff --git a/src/component/workflow_manager/workflow_manager.py b/src/component/workflow_manager/workflow_manager.py index 923aa81..3268958 100644 --- a/src/component/workflow_manager/workflow_manager.py +++ b/src/component/workflow_manager/workflow_manager.py @@ -2,7 +2,7 @@ import logging import toml import os -from aios import Workflow,AIStorage,PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask +from aios import AIStorage,PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask from agent_manager import AgentManager logger = logging.getLogger(__name__) diff --git a/src/package-lock.json b/src/package-lock.json new file mode 100644 index 0000000..48e341a --- /dev/null +++ b/src/package-lock.json @@ -0,0 +1,3 @@ +{ + "lockfileVersion": 1 +} diff --git a/src/requirements.txt b/src/requirements.txt index 11f207f..9413232 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -47,7 +47,7 @@ mpmath>=1.3.0 multidict>=6.0.4 numpy>=1.25.2 onnxruntime>=1.15.1 -openai>=0.28.0 +openai>=1.0.0 overrides>=7.4.0 packaging>=23.1 pandas>=2.1.0 @@ -97,7 +97,6 @@ mpmath==1.3.0 multidict==6.0.4 numpy==1.25.2 onnxruntime==1.15.1 -openai==0.28.0 overrides==7.4.0 packaging==23.1 pandas==2.1.0 diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 884acef..4c0c076 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -40,10 +40,11 @@ from sd_node import * from st_node import * from agent_manager import AgentManager -from workflow_manager import WorkflowManager +# from workflow_manager import WorkflowManager from knowledge_manager import KnowledgePipelineManager from tg_tunnel import TelegramTunnel from email_tunnel import EmailTunnel +from common_environment import LocalKnowledgeBase, FilesystemEnvironment, ShellEnvironment, ScanLocalDocument, ParseLocalDocument from compute_node_config import * @@ -130,22 +131,26 @@ class AIOS_Shell: cm.add_family_member(self.username,owenr) - cal_env = CalenderEnvironment("calender") - await cal_env.start() - Environment.set_env_by_id("calender",cal_env) + # cal_env = CalenderEnvironment("calender") + # await cal_env.start() + # Environment.set_env_by_id("calender",cal_env) - workspace_env = ShellEnvironment("bash") - Environment.set_env_by_id("bash",workspace_env) + # workspace_env = ShellEnvironment("bash") + # Environment.set_env_by_id("bash",workspace_env) - paint_env = PaintEnvironment("paint") - Environment.set_env_by_id("paint",paint_env) + # paint_env = PaintEnvironment("paint") + # Environment.set_env_by_id("paint",paint_env) + + AgentManager.get_instance().register_environment("bash", ShellEnvironment) + AgentManager.get_instance().register_environment("fs", FilesystemEnvironment) + AgentManager.get_instance().register_environment("knowledge", LocalKnowledgeBase) if await AgentManager.get_instance().initial() is not True: logger.error("agent manager initial failed!") return False - if await WorkflowManager.get_instance().initial() is not True: - logger.error("workflow manager initial failed!") - return False + # if await WorkflowManager.get_instance().initial() is not True: + # logger.error("workflow manager initial failed!") + # return False open_ai_node = OpenAI_ComputeNode.get_instance() if await open_ai_node.initial() is not True: @@ -217,6 +222,8 @@ class AIOS_Shell: pipelines = KnowledgePipelineManager.initial(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge/pipelines")) + pipelines.register_input("scan_local", ScanLocalDocument) + pipelines.register_parser("parse_local", ParseLocalDocument) pipelines.load_dir(os.path.join(AIStorage().get_instance().get_system_app_dir(), "knowledge_pipelines")) pipelines.load_dir(os.path.join(AIStorage().get_instance().get_myai_dir(), "knowledge_pipelines")) asyncio.create_task(pipelines.run()) @@ -568,8 +575,8 @@ class AIOS_Shell: target_exist = False if await AgentManager.get_instance().is_exist(target_id): target_exist = True - if await WorkflowManager.get_instance().is_exist(target_id): - target_exist = True + # if await WorkflowManager.get_instance().is_exist(target_id): + # target_exist = True if target_exist is False: show_text = FormattedText([("class:error", f"Target {target_id} not exist!")]) @@ -627,8 +634,8 @@ class AIOS_Shell: db_path = "" if await self.is_agent(self.current_target): db_path = AgentManager.get_instance().db_path - else: - db_path = WorkflowManager.get_instance().db_file + # else: + # db_path = WorkflowManager.get_instance().db_file chatsession:AIChatSession = AIChatSession.get_session(self.current_target,f"{self.username}#{self.current_topic}",db_path,False) if chatsession is not None: msgs = chatsession.read_history(num,offset) From 75d5c0066b9d277aec5b6dfd1206de91135ba40d Mon Sep 17 00:00:00 2001 From: wugren Date: Tue, 5 Dec 2023 19:59:41 +0800 Subject: [PATCH 51/79] AgentMsg support audio type --- src/aios/agent/agent.py | 20 +++++++++++++++++++- src/aios/proto/agent_msg.py | 7 +++++++ src/aios/proto/compute_task.py | 10 +++++----- src/component/tg_tunnel.py | 20 +++++++++++++++++++- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 2b97326..dc4588f 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -13,7 +13,6 @@ import copy import sys from ..proto.agent_msg import AgentMsg -from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode from .agent_base import * from .chatsession import * @@ -28,6 +27,7 @@ from ..storage.storage import AIStorage from ..knowledge import * from ..utils import video_utils, image_utils +from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode logger = logging.getLogger(__name__) @@ -455,6 +455,15 @@ class AIAgent(BaseAIAgent): content = [{"type": "text", "text": f"{msg.sender}:{video_prompt}"}] content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) msg_prompt.messages = [{"role": "user", "content": content}] + elif msg.is_audio_msg(): + audio_file = msg.body + resp = await ComputeKernel.get_instance().do_speech_to_text(audio_file, None, prompt=None, response_format="text") + if resp.result_code != ComputeTaskResultCode.OK: + error_resp = msg.create_error_resp(resp.error_str) + return error_resp + else: + msg.body = resp.result_str + msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{resp.result_str}"}] else: msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] session_topic = msg.target + "#" + msg.topic @@ -487,6 +496,15 @@ class AIAgent(BaseAIAgent): content = [{"type": "text", "text": video_prompt}] content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) msg_prompt.messages = [{"role": "user", "content": content}] + elif msg.is_audio_msg(): + audio_file = msg.body + resp = await (ComputeKernel.get_instance().do_speech_to_text(audio_file, None, prompt=None, response_format="text")) + if resp.result_code != ComputeTaskResultCode.OK: + error_resp = msg.create_error_resp(resp.error_str) + return error_resp + else: + msg.body = resp.result_str + msg_prompt.messages = [{"role":"user","content":resp.result_str}] else: msg_prompt.messages = [{"role":"user","content":msg.body}] session_topic = msg.get_sender() + "#" + msg.topic diff --git a/src/aios/proto/agent_msg.py b/src/aios/proto/agent_msg.py index c7209bf..21ec3b6 100644 --- a/src/aios/proto/agent_msg.py +++ b/src/aios/proto/agent_msg.py @@ -210,6 +210,13 @@ class AgentMsg: return True return False + def is_audio_msg(self) -> bool: + if self.body_mime is None: + return False + if self.body_mime.startswith("audio/"): + return True + return False + def get_msg_id(self) -> str: return self.msg_id diff --git a/src/aios/proto/compute_task.py b/src/aios/proto/compute_task.py index c226b51..3390465 100644 --- a/src/aios/proto/compute_task.py +++ b/src/aios/proto/compute_task.py @@ -75,7 +75,7 @@ class ComputeTask: else: self.params["model_name"] = "text-embedding-ada-002" self.params["input"] = input - + def set_image_embedding_params(self, input = Union[ObjectID, bytes], model_name=None, callchain_id = None): self.task_type = ComputeTaskType.IMAGE_EMBEDDING self.create_time = time.time() @@ -86,7 +86,7 @@ class ComputeTask: else: self.params["model_name"] = None self.params["input"] = input - + def set_text_2_image_params(self, prompt: str, model_name, negative_prompt="", callchain_id=None): self.task_type = ComputeTaskType.TEXT_2_IMAGE self.create_time = time.time() @@ -126,15 +126,15 @@ class ComputeTaskResult: self.task_id: str = None self.callchain_id: str = None self.worker_id: str = None - self.error_str : str = None - self.result_code: int = 0 + self.error_str : str = None + self.result_code: int = ComputeTaskResultCode.OK self.result_str: str = None # easy to use,can read from result self.result : dict = {} self.result_refers: dict = {} self.pading_data: bytearray = None - + def set_from_task(self, task: ComputeTask): self.task_id = task.task_id diff --git a/src/component/tg_tunnel.py b/src/component/tg_tunnel.py index fa0e5e5..4406591 100644 --- a/src/component/tg_tunnel.py +++ b/src/component/tg_tunnel.py @@ -63,7 +63,7 @@ class TelegramTunnel(AgentTunnel): for update in updates: next_update_id = update.update_id + 1 - if update.message and (update.message.text or (update.message.photo and len(update.message.photo) > 0) or update.message.video): + if update.message and (update.message.text or (update.message.photo and len(update.message.photo) > 0) or update.message.video or update.message.voice or update.message.audio): await self.on_message(bot,update) return next_update_id @@ -89,6 +89,10 @@ class TelegramTunnel(AgentTunnel): update_id = (await self.bot.get_updates())[0].update_id except IndexError: update_id = None + except Exception as e: + logger.error(f"tg_tunnel error:{e}") + logger.exception(e) + update_id = None #logger.info("listening for new messages...") while True: @@ -179,6 +183,20 @@ class TelegramTunnel(AgentTunnel): await video_file.download_to_drive(file_path) agent_msg.body = agent_msg.create_video_body(file_path, message.caption) agent_msg.body_mime = f"video/{ext}" + elif message.audio is not None: + audio_file = await message.audio.get_file() + ext = audio_file.file_path.rsplit(".")[-1] + file_path = os.path.join(self.get_cache_path(), audio_file.file_id + f".{ext}") + await audio_file.download_to_drive(file_path) + agent_msg.body = file_path + agent_msg.body_mime = f"audio/{ext}" + elif message.voice is not None: + audio_file = await message.voice.get_file() + ext = audio_file.file_path.rsplit(".")[-1] + file_path = os.path.join(self.get_cache_path(), audio_file.file_id + f".{ext}") + await audio_file.download_to_drive(file_path) + agent_msg.body = file_path + agent_msg.body_mime = f"audio/{ext}" agent_msg.create_time = time.time() messag_type = message.chat.type From a0b714e9309d4cce9ce7c3786d747bd92804176e Mon Sep 17 00:00:00 2001 From: tsukasa Date: Wed, 6 Dec 2023 09:32:40 +0800 Subject: [PATCH 52/79] learn knowledge design document --- doc/learn_knowledge.md | 30 ++++++++++++ src/aios/agent/agent.py | 104 ---------------------------------------- 2 files changed, 30 insertions(+), 104 deletions(-) create mode 100644 doc/learn_knowledge.md diff --git a/doc/learn_knowledge.md b/doc/learn_knowledge.md new file mode 100644 index 0000000..f0038b2 --- /dev/null +++ b/doc/learn_knowledge.md @@ -0,0 +1,30 @@ +# learn todo list +在workspace中分离出独立的两个work list;用于处理工作的work todo list, 和用于更新knowledge的 learn todo list;两种todo list在agent templete中单独的配置prompt,每个todo list都可以配置 do , check 和 review 三个prompt。 +learn todo list中的todo,可以在knowledge pipeline 生成。 +一个典型的例子是新的JarvisPlus agent,它可以依照自己的理解将某个本地目录中的文档归类到不同的逻辑目录中: ++ 首先定义他的pipeline:扫描目录中没有读过的文档,传递给parser; ++ 定义他的pipeline parser的实现:向自己所处的workspace 的learn todo list中添加一条todo, todo的内容是文档的全文; ++ 定义他的 learn prompts:根据输入的文档内容,输出摘要,和归类后的目录,产生一组归类operation; ++ workspace中嵌入了支持归类operation 的knowledage base environment,可以执行learn todo list产生的归类operation; + +# environments and workspace +agent在独立的workspace中工作,目前默认的workspace是agent自己;除了自己的workspace,agent也可以进入其他的workspace;environment表示agent可以调用的function,和可以产生的operation; +environment中的function或者operation输出的结果,应当应用在agent所处的workspace中,所以agent在不同workspace中执行work,结果应当是被隔离的。 + + + + +# Learn Todo List +Separate two independent work lists in the workspace: a work todo list for handling work, and a learn todo list for updating knowledge. Both types of todo lists have their own configured prompts in the agent template, and each todo list can configure do, check, and review prompts. +The todos in the learn todo list can be generated in the knowledge pipeline. +A typical example is the new JarvisPlus agent, which can categorize documents from a local directory into different logical directories according to its understanding: ++ First, define its pipeline: scan documents in the directory that have not been read and pass them to the parser; ++ Define the implementation of its pipeline parser: add a todo to the learn todo list of the workspace it is in, the content of the todo is the full text of the document; ++ Define its learn prompts: based on the input document content, output a summary and the directory after categorization, generating a set of categorization operations; ++ The workspace embeds a knowledge base environment that supports categorization operations, which can execute the categorization operations generated by the learn todo list. + +# Environments and Workspace +The agent works in an independent workspace, and the current default workspace is the agent itself. In addition to its own workspace, the agent can also enter other workspaces. The environment represents the functions that the agent can call and the operations it can generate. +The results of the functions or operations in the environment should be applied in the workspace where the agent is located, so the results of the agent's work in different workspaces should be isolated. + +Please note that the translation might not be perfect due to the technical nature of the text and potential ambiguity in the original text. diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index db5464a..a7cc920 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -133,19 +133,6 @@ class AIAgent(BaseAIAgent): self.owenr_bus = None self.enable_function_list = None - # @classmethod - # def create_from_templete(cls,templete:AIAgentTemplete, fullname:str): - # # Agent just inherit from templete on craete,if template changed,agent will not change - # result_agent = AIAgent() - # result_agent.llm_model_name = templete.llm_model_name - # result_agent.max_token_size = templete.max_token_size - # result_agent.template_id = templete.template_id - # result_agent.agent_id = "agent#" + uuid.uuid4().hex - # result_agent.fullname = fullname - # result_agent.powerby = templete.author - # result_agent.agent_prompt = templete.prompt - # return result_agent - def load_from_config(self,config:dict) -> bool: if config.get("instance_id") is None: logger.error("agent instance_id is None!") @@ -281,82 +268,6 @@ class AIAgent(BaseAIAgent): if event.type == "AgentThink": return await self.do_self_think() - # async def _process_group_chat_msg(self,msg:AgentMsg) -> AgentMsg: - # session_topic = msg.target + "#" + msg.topic - # chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) - # workspace = self.get_current_workspace() - # need_process = False - # if msg.mentions is not None: - # if self.agent_id in msg.mentions: - # need_process = True - # logger.info(f"agent {self.agent_id} recv a group chat message from {msg.sender},but is not mentioned,ignore!") - - # if need_process is not True: - # chatsession.append(msg) - # resp_msg = msg.create_group_resp_msg(self.agent_id,"") - # return resp_msg - # else: - # msg_prompt = AgentPrompt() - # msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] - - # prompt = AgentPrompt() - # prompt.append(self.get_agent_prompt()) - - # if workspace: - # prompt.append(workspace.get_prompt()) - # prompt.append(workspace.get_role_prompt(self.agent_id)) - - # if self.need_session_summmary(msg,chatsession): - # # get relate session(todos) summary - # summary = self.llm_select_session_summary(msg,chatsession) - # prompt.append(AgentPrompt(summary)) - - # self._format_msg_by_env_value(prompt) - # inner_functions,function_token_len = self._get_inner_functions() - - # system_prompt_len = self.token_len(prompt=prompt) - # input_len = len(msg.body) - - # history_prmpt,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) - # prompt.append(history_prmpt) # chat context - # prompt.append(msg_prompt) - - # logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - # task_result = await self._do_llm_complection(prompt,inner_functions,msg) - # if task_result.result_code != ComputeTaskResultCode.OK: - # error_resp = msg.create_error_resp(task_result.error_str) - # return error_resp - - # final_result = task_result.result_str - # llm_result : LLMResult = LLMResult.from_str(final_result) - # is_ignore = False - # result_prompt_str = "" - # match llm_result.state: - # case "ignore": - # is_ignore = True - # case "waiting": - # for sendmsg in llm_result.send_msgs: - # target = sendmsg.target - # sendmsg.sender = self.agent_id - # sendmsg.topic = msg.topic - # sendmsg.prev_msg_id = msg.get_msg_id() - # send_resp = await AIBus.get_default_bus().send_message(sendmsg) - # if send_resp is not None: - # result_prompt_str += f"\n{target} response is :{send_resp.body}" - # agent_sesion = AIChatSession.get_session(self.agent_id,f"{sendmsg.target}#{sendmsg.topic}",self.chat_db) - # agent_sesion.append(sendmsg) - # agent_sesion.append(send_resp) - - # final_result = llm_result.resp + result_prompt_str - - # if is_ignore is not True: - # resp_msg = msg.create_group_resp_msg(self.agent_id,final_result) - # chatsession.append(msg) - # chatsession.append(resp_msg) - # return resp_msg - # return None - - def get_workspace_by_msg(self,msg:AgentMsg) -> WorkspaceEnvironment: return self.agent_workspace @@ -884,10 +795,6 @@ class AIAgent(BaseAIAgent): # if learn_power <= 0: # break - - def parser_learn_llm_result(self,llm_result:LLMResult): - pass - async def do_self_think(self): session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) for session_id in session_id_list: @@ -980,17 +887,6 @@ class AIAgent(BaseAIAgent): return None,0 - def need_work(self) -> bool: - if self.do_prompt is not None: - return True - if self.check_prompt is not None: - return True - - if self.agent_energy > 2: - return True - - return False - def need_self_think(self) -> bool: return False From 348c3be2099789feb7f70ba38465abf5ef370eaa Mon Sep 17 00:00:00 2001 From: streetycat <305190374@qq.com> Date: Tue, 5 Dec 2023 04:02:02 +0000 Subject: [PATCH 53/79] fix incorrect determination of subprocess failure --- .../local_compute_node_builder/local_llama_node_builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py index d9bdcde..b2606c8 100644 --- a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py +++ b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py @@ -118,7 +118,7 @@ class ParameterExternParamsApplier: else: result = subprocess.run(['git', 'clone', llama_cpp_python_repo_url, llama_cpp_python_path], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) - if result.stderr: + if result.returncode: print_formatted_text(FormattedText([("class:warn", result.stderr)]), style = state.shell_style) while True: sel = await state.prompt_session.prompt_async(f"Update 'llama-cpp-python' failed, you can press 'r' to retry, or 'c' to continue with the current version.", style = state.shell_style) @@ -140,7 +140,7 @@ class ParameterExternParamsApplier: result = subprocess.run(['docker', 'rmi', docker_image], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) result = subprocess.run(['docker', 'build', '-t', docker_image, f"{llama_cpp_python_path}/docker/cuda_simple/"], stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) - if result.stderr: + if result.returncode: print_formatted_text(FormattedText([("class:warn", result.stderr)]), style = state.shell_style) while True: sel = await state.prompt_session.prompt_async(f"Build the image failed, you can press 'r' to retry, or 'c' to continue with the current version.", style = state.shell_style) @@ -179,7 +179,7 @@ class ParameterExternParamsApplier: result = subprocess.run(run_options, stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True) - if result.stderr: + if result.returncode: print_formatted_text(FormattedText([("class:warn", result.stderr)]), style = state.shell_style) while True: sel = await state.prompt_session.prompt_async(f"Start the node service failed, you can press 'r' to retry, or 'a' to abort.", style = state.shell_style) From 9f2fd50ba80c0724b0eae47be08200f96e38bca8 Mon Sep 17 00:00:00 2001 From: streetycat <305190374@qq.com> Date: Wed, 6 Dec 2023 06:19:15 +0000 Subject: [PATCH 54/79] fix default port error --- .../local_compute_node_builder/local_llama_node_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py index b2606c8..9eef8cf 100644 --- a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py +++ b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py @@ -69,7 +69,7 @@ class ParameterNodeNameApplier: class ParameterPortApplier: async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: - if value is None or value == "0": + if value is None or value == "0" or value == "": value = str(random.randint(10000, 60000)) state.params["port"] = value @@ -165,7 +165,7 @@ class ParameterExternParamsApplier: run_options.extend([ '-p', f"{state.params['port']}:8000", '-v', f"{os.path.dirname(state.params['model_path'])}:/models", '-e', f"MODEL=/models/{os.path.basename(state.params['model_path'])}", - 'llama-cpp-python-cuda', + docker_image, 'python3', '-m', 'llama_cpp.server', '--n_gpu_layers', state.params["n_gpu_layers"], '--n_ctx', state.params["n_ctx"], From 29f02138a5929ebadca678d1f66adb223cd19165 Mon Sep 17 00:00:00 2001 From: streetycat <305190374@qq.com> Date: Wed, 6 Dec 2023 08:06:51 +0000 Subject: [PATCH 55/79] Fix: build the service with CUDA as CPU only for build failed without CUDA. --- .../local_compute_node_builder/local_llama_node_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py index 9eef8cf..690242b 100644 --- a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py +++ b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py @@ -100,7 +100,7 @@ class ParameterExternParamsApplier: gpu_options = [] state.next_step += 1 - if state.params["n_gpu_layers"] == "0": + if False and state.params["n_gpu_layers"] == "0": docker_image = "ghcr.io/abetlen/llama-cpp-python:latest" else: gpu_options = ["--gpus", "all"] From e2b6f051cbde53fa98ebae8c1205aa0161517f8a Mon Sep 17 00:00:00 2001 From: streetycat <305190374@qq.com> Date: Wed, 6 Dec 2023 08:51:23 +0000 Subject: [PATCH 56/79] Fix singleton and Classbase --- .../local_llama_node_builder.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py index 690242b..163d6de 100644 --- a/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py +++ b/src/service/aios_shell/local_compute_node_builder/local_llama_node_builder.py @@ -10,10 +10,10 @@ from prompt_toolkit.formatted_text import FormattedText from aios.storage.storage import AIStorage from aios import ComputeKernel from component.llama_node.local_llama_compute_node import LocalLlama_ComputeNode -from service.aios_shell.compute_node_config import ComputeNodeConfig +from compute_node_config import ComputeNodeConfig from .local_compute_node_builder import BuildParameter, BuilderState, LocalComputeNodeBuilder, ParameterApplier -class BuildParameterModelPath: +class BuildParameterModelPath(ParameterApplier): async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: if value: if os.path.exists(value): @@ -24,7 +24,7 @@ class BuildParameterModelPath: state.next_step += 1 -class BuildParameterModelUrl: +class BuildParameterModelUrl(ParameterApplier): async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: if value is None: value = "1" @@ -61,13 +61,13 @@ class BuildParameterModelUrl: except Exception as e: print_formatted_text(FormattedText([("class:error", f"Download model failed: {e}\nYou can retry it or select another one.")]), style = state.shell_style) -class ParameterNodeNameApplier: +class ParameterNodeNameApplier(ParameterApplier): async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: value = value or os.path.basename(state.params["model_path"]) state.params["node_name"] = value state.next_step += 1 -class ParameterPortApplier: +class ParameterPortApplier(ParameterApplier): async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: if value is None or value == "0" or value == "": value = str(random.randint(10000, 60000)) @@ -75,25 +75,25 @@ class ParameterPortApplier: state.params["port"] = value state.next_step += 1 -class ParameterNGpuLayersApplier: +class ParameterNGpuLayersApplier(ParameterApplier): async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: value = value or "83" state.params["n_gpu_layers"] = value state.next_step += 1 -class ParameterNCtxApplier: +class ParameterNCtxApplier(ParameterApplier): async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: value = value or "4096" state.params["n_ctx"] = value state.next_step += 1 -class ParameterChatFormatApplier: +class ParameterChatFormatApplier(ParameterApplier): async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: value = value or "llama-2" state.params["chat_format"] = value state.next_step += 1 -class ParameterExternParamsApplier: +class ParameterExternParamsApplier(ParameterApplier): async def apply(self, state: BuilderState, name: str, value: str or None = None) -> str or None: extern_params = value docker_image = "" From 8739bf6a7628006f77a8822fcadc57fde455dae6 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Wed, 6 Dec 2023 13:31:05 -0800 Subject: [PATCH 57/79] 1) Do some rename refactor ,prepare for LLMProcess refactor 2) Fix merge bugs. --- .gitignore | 1 + doc/promps/Check TODO.md | 2 + doc/promps/Do TODO.md | 3 + src/[44 => doc/promps/Introspection.md | 0 doc/promps/Review Task.md | 5 + doc/promps/process message.md | 2 + doc/promps/self-learn.md | 0 doc/workflow.drawio | 395 -------------- rootfs/agents/Jarvis/agent.toml | 1 - rootfs/agents/TextSummary/agent.py | 12 +- src/aios/__init__.py | 9 +- src/aios/agent/agent.py | 92 ++-- src/aios/agent/agent_base.py | 415 +-------------- src/aios/agent/behavior.py | 4 - src/aios/agent/llm_process.py | 149 ++++++ src/aios/agent/role.py | 8 +- src/aios/agent/workflow.py | 30 +- src/aios/environment/asr_function.py | 2 +- .../environment/code_interpreter_function.py | 2 +- .../duckduckgo_text_search_function.py | 2 +- src/aios/environment/environment.py | 2 +- src/aios/environment/image_2_text_function.py | 2 +- .../environment/script_to_speech_function.py | 2 +- src/aios/environment/sql_database_function.py | 2 +- .../environment/text_to_speech_function.py | 2 +- src/aios/environment/workflow_env.py | 4 +- src/aios/environment/workspace_env.py | 14 +- src/aios/frame/compute_kernel.py | 12 +- src/aios/proto/agent_msg.py | 7 +- src/aios/proto/agent_task.py | 221 ++++++++ src/aios/{agent => proto}/ai_function.py | 2 +- src/aios/proto/compute_task.py | 171 +++++- src/aios_kernel/code_interpreter.py | 426 --------------- src/aios_kernel/code_interpreter_function.py | 41 -- .../duckduckgo_text_search_function.py | 52 -- src/aios_kernel/sql_database.py | 493 ------------------ src/aios_kernel/sql_database_function.py | 112 ---- src/component/common_environment/shell.py | 2 +- src/component/mail_environment/issue.py | 4 +- src/component/openai_node/open_ai_node.py | 5 +- src/component/tg_tunnel.py | 8 +- .../workflow_manager/workflow_manager.py | 2 +- src/requirements.txt | 8 +- src/service/aios_shell/aios_shell.py | 8 +- 44 files changed, 693 insertions(+), 2043 deletions(-) create mode 100644 doc/promps/Check TODO.md create mode 100644 doc/promps/Do TODO.md rename src/[44 => doc/promps/Introspection.md (100%) create mode 100644 doc/promps/Review Task.md create mode 100644 doc/promps/process message.md create mode 100644 doc/promps/self-learn.md delete mode 100644 doc/workflow.drawio delete mode 100644 src/aios/agent/behavior.py create mode 100644 src/aios/agent/llm_process.py create mode 100644 src/aios/proto/agent_task.py rename src/aios/{agent => proto}/ai_function.py (98%) delete mode 100644 src/aios_kernel/code_interpreter.py delete mode 100644 src/aios_kernel/code_interpreter_function.py delete mode 100644 src/aios_kernel/duckduckgo_text_search_function.py delete mode 100644 src/aios_kernel/sql_database.py delete mode 100644 src/aios_kernel/sql_database_function.py diff --git a/.gitignore b/.gitignore index 3bb2bad..09bdba0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ math_school_env.db workflows.db +rootfs/test_doc/ diff --git a/doc/promps/Check TODO.md b/doc/promps/Check TODO.md new file mode 100644 index 0000000..d164691 --- /dev/null +++ b/doc/promps/Check TODO.md @@ -0,0 +1,2 @@ +# Check (TODO) +目的是根据Todo Log, 结合自己的角色检查TODO是否争取完成(非客观性TODO给出是否有所改进的评价) diff --git a/doc/promps/Do TODO.md b/doc/promps/Do TODO.md new file mode 100644 index 0000000..7e13f04 --- /dev/null +++ b/doc/promps/Do TODO.md @@ -0,0 +1,3 @@ +# Do (TODO) + 目标是结合 角色定义,手头的工具,已知知识 完成一个确定的任务。 + 完成任务时应使用ReAct的方法:应在给出执行动作前,先自言自语的输出一个计划,然后在动作(这个自言自语会变成TODO Logs) \ No newline at end of file diff --git a/src/[44 b/doc/promps/Introspection.md similarity index 100% rename from src/[44 rename to doc/promps/Introspection.md diff --git a/doc/promps/Review Task.md b/doc/promps/Review Task.md new file mode 100644 index 0000000..30072db --- /dev/null +++ b/doc/promps/Review Task.md @@ -0,0 +1,5 @@ +# Review (Task/Todo) +目的是结合已知信息(重点是已经进行操作的记录),对失败的,完成的不好的任务进行思考,尝试给出更好的解决方案 +1. 管理学方法:更换负责人 +2. 管理学方法:拆分 +3. 给出建议(该建议可以在下次一次DO-Check)循环中被使用 \ No newline at end of file diff --git a/doc/promps/process message.md b/doc/promps/process message.md new file mode 100644 index 0000000..6d44b34 --- /dev/null +++ b/doc/promps/process message.md @@ -0,0 +1,2 @@ +# Process Message +处理消息的首要是目的是分析消息的意图,并给予回复 \ No newline at end of file diff --git a/doc/promps/self-learn.md b/doc/promps/self-learn.md new file mode 100644 index 0000000..e69de29 diff --git a/doc/workflow.drawio b/doc/workflow.drawio deleted file mode 100644 index 0cc08b1..0000000 --- a/doc/workflow.drawio +++ /dev/null @@ -1,395 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index fd4ab09..efee242 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -5,7 +5,6 @@ max_token_size = 128000 enable_timestamp = "true" owner_prompt = "I am your master{name}" contact_prompt = "I am your master's friend{name}" -owner_env = "calender" [[prompt]] role = "system" diff --git a/rootfs/agents/TextSummary/agent.py b/rootfs/agents/TextSummary/agent.py index 831b786..1f01e81 100644 --- a/rootfs/agents/TextSummary/agent.py +++ b/rootfs/agents/TextSummary/agent.py @@ -1,6 +1,6 @@ import copy -from aios.agent.agent_base import CustomAIAgent, AgentPrompt +from aios.agent.agent_base import CustomAIAgent, LLMPrompt from aios.knowledge.data.writer import split_text from aios.proto.agent_msg import AgentMsg, AgentMsgType from aios.proto.compute_task import ComputeTaskResultCode @@ -19,10 +19,10 @@ class TextSummaryAgent(CustomAIAgent): chunks = split_text(msg.body, separators=["\n\n", "\n"], chunk_size=4000, chunk_overlap=200, length_function=len) - prompt = AgentPrompt() - prompt.system_message = {"role":"system","content":"Your job is to generate a summary based on the input."} + prompt = LLMPrompt() + prompt.system_message = "Your job is to generate a summary based on the input." if len(chunks) == 1: - prompt.append(AgentPrompt(chunks[0])) + prompt.append(LLMPrompt(chunks[0])) resp = await self.do_llm_complection(prompt) if resp.result_code != ComputeTaskResultCode.OK: return msg.create_error_resp(resp.error_str) @@ -31,14 +31,14 @@ class TextSummaryAgent(CustomAIAgent): segments = [] for i, chunk in enumerate(chunks): seg_prompt = copy.deepcopy(prompt) - seg_prompt.append(AgentPrompt(chunk)) + seg_prompt.append(LLMPrompt(chunk)) resp = await self.do_llm_complection(seg_prompt) if resp.result_code != ComputeTaskResultCode.OK: return msg.create_error_resp(resp.error_str) segments.append(resp.result_str) segments_str = "\n".join(segments) - prompt.append(AgentPrompt(f"以下文本分段之后的各段摘要,请合并生成一个完整摘要:\n{segments_str}")) + prompt.append(LLMPrompt(f"以下文本分段之后的各段摘要,请合并生成一个完整摘要:\n{segments_str}")) resp = await self.do_llm_complection(prompt) if resp.result_code != ComputeTaskResultCode.OK: return msg.create_error_resp(resp.error_str) diff --git a/src/aios/__init__.py b/src/aios/__init__.py index d99ae12..aa01d69 100644 --- a/src/aios/__init__.py +++ b/src/aios/__init__.py @@ -1,13 +1,14 @@ from .proto.agent_msg import * from .proto.compute_task import * +from .proto.ai_function import * +from .proto.agent_task import * -from .agent.agent_base import AgentPrompt,CustomAIAgent, AgentTodo +from .agent.agent_base import * from .agent.chatsession import AIChatSession from .agent.agent import AIAgent,AIAgentTemplete, BaseAIAgent from .agent.role import AIRole,AIRoleGroup -# from .agent.workflow import Workflow -from .agent.ai_function import SimpleAIFunction, SimpleAIOperation +from .agent.workflow import Workflow from .frame.compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType from .frame.compute_node import ComputeNode,LocalComputeNode @@ -20,7 +21,7 @@ from .environment.environment import BaseEnvironment,SimpleEnvironment,Composite # from .environment.workflow_env import WorkflowEnvironment,CalenderEnvironment,CalenderEvent,PaintEnvironment from .environment.text_to_speech_function import TextToSpeechFunction from .environment.image_2_text_function import Image2TextFunction -from .environment.workspace_env import WorkspaceEnvironment,TodoListEnvironment,TodoListType +from .environment.workspace_env import WorkspaceEnvironment from .storage.storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index d0693af..e79d374 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -13,10 +13,12 @@ import copy import sys from ..proto.agent_msg import AgentMsg +from ..proto.ai_function import * +from ..proto.agent_task import * +from ..proto.compute_task import * from .agent_base import * from .chatsession import * -from .ai_function import * from ..environment.workspace_env import WorkspaceEnvironment, TodoListType from ..frame.contact_manager import ContactManager,Contact,FamilyMember @@ -69,7 +71,7 @@ class AIAgentTemplete: self.template_id:str = None self.introduce:str = None self.author:str = None - self.prompt:AgentPrompt = None + self.prompt:LLMPrompt = None def load_from_config(self,config:dict) -> bool: if config.get("llm_model_name") is not None: @@ -79,7 +81,7 @@ class AIAgentTemplete: if config.get("template_id") is not None: self.template_id = config["template_id"] if config.get("prompt") is not None: - self.prompt = AgentPrompt() + self.prompt = LLMPrompt() if self.prompt.load_from_config(config["prompt"]) is False: logger.error("load prompt from config failed!") return False @@ -90,9 +92,9 @@ class AIAgentTemplete: class AIAgent(BaseAIAgent): def __init__(self) -> None: - self.role_prompt:AgentPrompt = None - self.agent_prompt:AgentPrompt = None - self.agent_think_prompt:AgentPrompt = None + self.role_prompt:LLMPrompt = None + self.agent_prompt:LLMPrompt = None + self.agent_think_prompt:LLMPrompt = None self.llm_model_name:str = None self.max_token_size:int = 128000 self.agent_energy = 15 @@ -149,26 +151,26 @@ class AIAgent(BaseAIAgent): self.enable_thread = bool(config["enable_thread"]) if config.get("prompt") is not None: - self.agent_prompt = AgentPrompt() + self.agent_prompt = LLMPrompt() self.agent_prompt.load_from_config(config["prompt"]) if config.get("think_prompt") is not None: - self.agent_think_prompt = AgentPrompt() + self.agent_think_prompt = LLMPrompt() self.agent_think_prompt.load_from_config(config["think_prompt"]) def load_todo_config(todo_type:str) -> bool: todo_config = config.get(todo_type) if todo_config is not None: if todo_config.get("do") is not None: - prompt = AgentPrompt() + prompt = LLMPrompt() prompt.load_from_config(todo_config["do"]) self.todo_prompts[todo_type]["do"] = prompt if todo_config.get("check") is not None: - prompt = AgentPrompt() + prompt = LLMPrompt() prompt.load_from_config(todo_config["check"]) self.todo_prompts[todo_type]["check"] = prompt if todo_config.get("review_prompt") is not None: - prompt = AgentPrompt() + prompt = LLMPrompt() prompt.load_from_config(todo_config["review_prompt"]) self.todo_prompts[todo_type]["review"] = prompt @@ -224,16 +226,16 @@ class AIAgent(BaseAIAgent): def get_max_token_size(self) -> int: return self.max_token_size - def get_agent_role_prompt(self) -> AgentPrompt: + def get_agent_role_prompt(self) -> LLMPrompt: return self.role_prompt - def _get_remote_user_prompt(self,remote_user:str) -> AgentPrompt: + def _get_remote_user_prompt(self,remote_user:str) -> LLMPrompt: cm = ContactManager.get_instance() contact = cm.find_contact_by_name(remote_user) if contact is None: #create guest prompt if self.guest_prompt_str is not None: - prompt = AgentPrompt() + prompt = LLMPrompt() prompt.system_message = {"role":"system","content":self.guest_prompt_str} return prompt return None @@ -241,25 +243,25 @@ class AIAgent(BaseAIAgent): if contact.is_family_member: if self.owner_promp_str is not None: real_str = self.owner_promp_str.format_map(contact.to_dict()) - prompt = AgentPrompt() + prompt = LLMPrompt() prompt.system_message = {"role":"system","content":real_str} return prompt else: if self.contact_prompt_str is not None: real_str = self.contact_prompt_str.format_map(contact.to_dict()) - prompt = AgentPrompt() + prompt = LLMPrompt() prompt.system_message = {"role":"system","content":real_str} return prompt return None - def get_agent_prompt(self) -> AgentPrompt: + def get_agent_prompt(self) -> LLMPrompt: return self.agent_prompt - async def _get_agent_think_prompt(self) -> AgentPrompt: + async def _get_agent_think_prompt(self) -> LLMPrompt: return self.agent_think_prompt - def _format_msg_by_env_value(self,prompt:AgentPrompt): + def _format_msg_by_env_value(self,prompt:LLMPrompt): for msg in prompt.messages: old_content = msg.get("content") msg["content"] = old_content.format_map(self.agent_workspace) @@ -284,7 +286,7 @@ class AIAgent(BaseAIAgent): return image_path async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: - msg_prompt = AgentPrompt() + msg_prompt = LLMPrompt() if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: need_process = False if msg.is_image_msg(): @@ -378,7 +380,7 @@ class AIAgent(BaseAIAgent): workspace = self.get_workspace_by_msg(msg) - prompt = AgentPrompt() + prompt = LLMPrompt() if workspace: prompt.append(workspace.get_prompt()) prompt.append(workspace.get_role_prompt(self.agent_id)) @@ -390,7 +392,7 @@ class AIAgent(BaseAIAgent): if self.need_session_summmary(msg,chatsession): # get relate session(todos) summary summary = self.llm_select_session_summary(msg,chatsession) - prompt.append(AgentPrompt(summary)) + prompt.append(LLMPrompt(summary)) known_info_str = "# Known information\n" have_known_info = False @@ -399,7 +401,7 @@ class AIAgent(BaseAIAgent): have_known_info = True known_info_str += f"## todo\n{todos_str}\n" inner_functions,function_token_len = BaseAIAgent.get_inner_functions(self.agent_workspace) - system_prompt_len = self.token_len(prompt=prompt) + system_prompt_len = ComputeKernel.llm_num_tokens(prompt) input_len = len(msg.body) if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: history_str,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) @@ -410,7 +412,7 @@ class AIAgent(BaseAIAgent): known_info_str += history_str if have_known_info: - known_info_prompt = AgentPrompt(known_info_str) + known_info_prompt = LLMPrompt(known_info_str) prompt.append(known_info_prompt) # chat context prompt.append(msg_prompt) @@ -436,7 +438,7 @@ class AIAgent(BaseAIAgent): final_result = llm_result.resp - await workspace.exec_op_list(llm_result.op_list,self.agent_id) + await workspace.exec_op_list(llm_result.action_list,self.agent_id) is_ignore = False result_prompt_str = "" @@ -471,12 +473,12 @@ class AIAgent(BaseAIAgent): return None - async def _get_history_prompt_for_think(self,chatsession:AIChatSession,summary:str,system_token_len:int,pos:int)->(AgentPrompt,int): + async def _get_history_prompt_for_think(self,chatsession:AIChatSession,summary:str,system_token_len:int,pos:int)->(LLMPrompt,int): history_len = (self.max_token_size * 0.7) - system_token_len messages = chatsession.read_history(self.history_len,pos,"natural") # read result_token_len = 0 - result_prompt = AgentPrompt() + result_prompt = LLMPrompt() have_summary = False if summary is not None: if len(summary) > 1: @@ -511,7 +513,7 @@ class AIAgent(BaseAIAgent): history_len = (self.max_token_size * 0.7) - system_token_len - input_token_len messages = chatsession.read_history(self.history_len) # read result_token_len = 0 - result_prompt = AgentPrompt() + result_prompt = LLMPrompt() read_history_msg = 0 for msg in reversed(messages): read_history_msg += 1 @@ -569,13 +571,13 @@ class AIAgent(BaseAIAgent): async def _llm_read_report(self,report:AgentReport,worksapce:WorkspaceEnvironment): work_summary = worksapce.get_work_summary(self.agent_id) - prompt : AgentPrompt = AgentPrompt() + prompt : LLMPrompt = LLMPrompt() prompt.append(self.agent_prompt) prompt.append(worksapce.get_role_prompt(self.agent_id)) prompt.append(self.read_report_prompt) # report is a message from other agent(human) about work - prompt.append(AgentPrompt(work_summary)) - prompt.append(AgentPrompt(report.content)) + prompt.append(LLMPrompt(work_summary)) + prompt.append(LLMPrompt(report.content)) task_result:ComputeTaskResult = await self.do_llm_complection(prompt) @@ -606,7 +608,7 @@ class AIAgent(BaseAIAgent): do_prompts = self._can_do_todo(todo_list_type, todo) if do_prompts: - prompt : AgentPrompt = AgentPrompt() + prompt : LLMPrompt = LLMPrompt() prompt.append(self.agent_prompt) prompt.append(workspace.get_role_prompt(self.agent_id)) prompt.append(do_prompts) @@ -635,13 +637,13 @@ class AIAgent(BaseAIAgent): check_prompts = self._can_check_todo(todo_list_type, todo) if check_prompts: - prompt : AgentPrompt = AgentPrompt() + prompt : LLMPrompt = LLMPrompt() prompt.append(self.agent_prompt) prompt.append(workspace.get_role_prompt(self.agent_id)) prompt.append(check_prompts) if todo.last_check_result: - prompt.append(AgentPrompt(todo.last_check_result)) + prompt.append(LLMPrompt(todo.last_check_result)) prompt.append(todo.detail) prompt.append(todo.result) @@ -669,7 +671,7 @@ class AIAgent(BaseAIAgent): prompt.append(review_prompts) todo_tree = todo_list.get_todo_tree("/") - prompt.append(AgentPrompt(todo_tree)) + prompt.append(LLMPrompt(todo_tree)) do_result : AgentTodoResult = await self._llm_review_todo(todo, prompt, workspace) todo.last_review_time = datetime.datetime.now().timestamp() @@ -690,7 +692,7 @@ class AIAgent(BaseAIAgent): logger.info(f"agent {self.agent_id} ,check:{check_count} todo,do:{do_count} todo.") - def _can_review_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> AgentPrompt: + def _can_review_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> LLMPrompt: do_prompts = self.todo_prompts[todo_list_type].get("review") if not do_prompts: return None @@ -701,7 +703,7 @@ class AIAgent(BaseAIAgent): return do_prompts - def _can_check_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> AgentPrompt: + def _can_check_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> LLMPrompt: do_prompts = self.todo_prompts[todo_list_type].get("check") if not do_prompts: return None @@ -720,7 +722,7 @@ class AIAgent(BaseAIAgent): return do_prompts - def _can_do_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> AgentPrompt: + def _can_do_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> LLMPrompt: do_prompts = self.todo_prompts[todo_list_type].get("do") if not do_prompts: return None @@ -739,7 +741,7 @@ class AIAgent(BaseAIAgent): return do_prompts - async def _llm_do_todo(self, todo: AgentTodo, prompt: AgentPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: + async def _llm_do_todo(self, todo: AgentTodo, prompt: LLMPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: result = AgentTodoResult() task_result:ComputeTaskResult = await self.do_llm_complection(prompt, is_json_resp=True) @@ -763,7 +765,7 @@ class AIAgent(BaseAIAgent): resp = await AIBus.get_default_bus().post_message(msg) logging.info(f"agent {self.agent_id} send msg to {msg.target} result:{resp}") - result_str, have_error = await workspace.exec_op_list(llm_result.op_list, self.agent_id) + result_str, have_error = await workspace.exec_op_list(llm_result.action_list, self.agent_id) if have_error: result.result_code = AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR #result.error_str = error_str @@ -771,7 +773,7 @@ class AIAgent(BaseAIAgent): result.result_str = result_str return result - async def _llm_check_todo(self, todo: AgentTodo, prompt: AgentPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: + async def _llm_check_todo(self, todo: AgentTodo, prompt: LLMPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: result = AgentTodoResult() inner_functions,_ = BaseAIAgent.get_inner_functions(workspace) @@ -786,7 +788,7 @@ class AIAgent(BaseAIAgent): todo.last_check_result = task_result.result_str return result - async def _llm_review_todo(self, todo:AgentTodo, prompt: AgentPrompt, workspace: WorkspaceEnvironment): + async def _llm_review_todo(self, todo:AgentTodo, prompt: LLMPrompt, workspace: WorkspaceEnvironment): inner_functions,_ = BaseAIAgent.get_inner_functions(workspace) task_result:ComputeTaskResult = await self.do_llm_complection(prompt,inner_functions=inner_functions) @@ -842,10 +844,10 @@ class AIAgent(BaseAIAgent): while True: cur_pos = chatsession.summarize_pos summary = chatsession.summary - prompt:AgentPrompt = AgentPrompt() + prompt:LLMPrompt = LLMPrompt() #prompt.append(self._get_agent_prompt()) prompt.append(await self._get_agent_think_prompt()) - system_prompt_len = self.token_len(prompt=prompt) + system_prompt_len = ComputeKernel.llm_num_tokens(prompt) #think env? history_prompt,next_pos = await self._get_history_prompt_for_think(chatsession,summary,system_prompt_len,cur_pos) prompt.append(history_prompt) @@ -864,7 +866,7 @@ class AIAgent(BaseAIAgent): chatsession.update_think_progress(next_pos,new_summary) return - async def get_prompt_from_session(self,chatsession:AIChatSession,system_token_len,input_token_len) -> AgentPrompt: + async def get_prompt_from_session(self,chatsession:AIChatSession,system_token_len,input_token_len) -> LLMPrompt: # TODO: get prompt from group chat is different from single chat if self.enable_thread: return None diff --git a/src/aios/agent/agent_base.py b/src/aios/agent/agent_base.py index 562a069..f9e0b5b 100644 --- a/src/aios/agent/agent_base.py +++ b/src/aios/agent/agent_base.py @@ -11,400 +11,15 @@ import shlex import json from typing import List, Tuple -from .ai_function import FunctionItem, AIFunction -from ..proto.agent_msg import AgentMsg, AgentMsgType -from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode -from ..environment.environment import BaseEnvironment +from ..proto.ai_function import * +from ..proto.agent_msg import * +from ..proto.compute_task import * +from ..environment.environment import * logger = logging.getLogger(__name__) -class AgentPrompt: - def __init__(self,prompt_str = None) -> None: - self.messages = [] - if prompt_str: - self.messages.append({"role":"user","content":prompt_str}) - self.system_message = None - - def as_str(self)->str: - result_str = "" - if self.system_message: - result_str += self.system_message.get("role") + ":" + self.system_message.get("content") + "\n" - if self.messages: - for msg in self.messages: - result_str += msg.get("role") + ":" + msg.get("content") + "\n" - - return result_str - - def to_message_list(self): - result = [] - if self.system_message: - result.append(self.system_message) - result.extend(self.messages) - return result - - def append(self,prompt): - if prompt is None: - return - - if prompt.system_message is not None: - if self.system_message is None: - self.system_message = copy.deepcopy(prompt.system_message) - else: - self.system_message["content"] += prompt.system_message.get("content") - - self.messages.extend(prompt.messages) - - def load_from_config(self,config:list) -> bool: - if isinstance(config,list) is not True: - logger.error("prompt is not list!") - return False - self.messages = [] - for msg in config: - if msg.get("content"): - if msg.get("role") == "system": - self.system_message = msg - else: - self.messages.append(msg) - else: - logger.error("prompt message has no content!") - return True - -class LLMResult: - def __init__(self) -> None: - self.state : str = "ignore" - self.resp : str = "" - self.raw_resp = None - self.paragraphs : dict[str,FunctionItem] = [] - - - self.post_msgs : List[AgentMsg] = [] - self.send_msgs : List[AgentMsg] = [] - self.calls : List[FunctionItem] = [] - self.post_calls : List[FunctionItem] = [] - self.op_list : List[FunctionItem] = [] # op_list is a optimize design for saving token - @classmethod - def from_json_str(self,llm_json_str:str) -> 'LLMResult': - r = LLMResult() - if llm_json_str is None: - r.state = "ignore" - return r - if llm_json_str == "ignore": - r.state = "ignore" - return r - - llm_json = json.loads(llm_json_str) - r.state = llm_json.get("state") - r.resp = llm_json.get("resp") - r.raw_resp = llm_json - - post_msgs = llm_json.get("post_msg") - r.post_msgs = [] - if post_msgs: - for msg in post_msgs: - new_msg = AgentMsg() - target_id = msg.get("target") - msg_content = msg.get("content") - new_msg.set("",target_id,msg_content) - r.post_msgs.append(new_msg) - #new_msg.msg_type = AgentMsgType.TYPE_MSG - - r.calls = llm_json.get("calls") - r.post_calls = llm_json.get("post_calls") - r.op_list = llm_json.get("op_list") - - return r - - @classmethod - def from_str(self,llm_result_str:str,valid_func:List[str]=None) -> 'LLMResult': - r = LLMResult() - - if llm_result_str is None: - r.state = "ignore" - return r - if llm_result_str == "ignore": - r.state = "ignore" - return r - - if llm_result_str[0] == "{": - return LLMResult.from_json_str(llm_result_str) - # if llm_result_str.startswith("json"): - # return LLMResult.from_json_str(llm_result_str[4:]) - - lines = llm_result_str.splitlines() - is_need_wait = False - - def check_args(func_item:FunctionItem): - match func_name: - case "send_msg":# /send_msg $target_id - if len(func_args) != 1: - return False - - new_msg = AgentMsg() - target_id = func_item.args[0] - msg_content = func_item.body - new_msg.set("",target_id,msg_content) - - r.send_msgs.append(new_msg) - is_need_wait = True - return True - - case "post_msg":# /post_msg $target_id - if len(func_args) != 1: - return False - - new_msg = AgentMsg() - target_id = func_item.args[0] - msg_content = func_item.body - new_msg.set("",target_id,msg_content) - r.post_msgs.append(new_msg) - return True - - case "call":# /call $func_name $args_str - r.calls.append(func_item) - is_need_wait = True - return True - case "post_call": # /post_call $func_name,$args_str - r.post_calls.append(func_item) - return True - case _: - if valid_func is not None: - if func_name in valid_func: - r.paragraphs[func_name] = func_item - return True - - return False - - - current_func : FunctionItem = None - for line in lines: - if line.startswith("##/"): - if current_func: - if check_args(current_func) is False: - r.resp += current_func.dumps() - - func_name,func_args = AgentMsg.parse_function_call(line[3:]) - current_func = FunctionItem(func_name,func_args) - else: - if current_func: - current_func.append_body(line + "\n") - else: - r.resp += line + "\n" - - if current_func: - if check_args(current_func) is False: - r.resp += current_func.dumps() - - if len(r.send_msgs) > 0 or len(r.calls) > 0: - r.state = "waiting" - else: - r.state = "reponsed" - - return r - -class AgentReport: - def __init__(self): - pass - -class AgentTodoResult: - TODO_RESULT_CODE_OK = 0, - TODO_RESULT_CODE_LLM_ERROR = 1, - TODO_RESULT_CODE_EXEC_OP_ERROR = 2 - - - def __init__(self) -> None: - self.result_code = AgentTodoResult.TODO_RESULT_CODE_OK - self.result_str = None - self.error_str = None - self.op_list = None - - def to_dict(self) -> dict: - result = {} - result["result_code"] = self.result_code - result["result_str"] = self.result_str - result["error_str"] = self.error_str - result["op_list"] = self.op_list - return result - - -class AgentTodo: - TODO_STATE_WAIT_ASSIGN = "wait_assign" - TODO_STATE_INIT = "init" - - TODO_STATE_PENDING = "pending" - TODO_STATE_WAITING_CHECK = "wait_check" - TODO_STATE_EXEC_FAILED = "exec_failed" - TDDO_STATE_CHECKFAILED = "check_failed" - - TODO_STATE_CANCEL = "cancel" - TODO_STATE_DONE = "done" - TODO_STATE_REVIEWED = "reviewed" - TODO_STATE_EXPIRED = "expired" - - def __init__(self): - self.todo_id = "todo#" + uuid.uuid4().hex - self.title = None - self.detail = None - self.todo_path = None # get parent todo,sub todo by path - #self.parent = None - self.create_time = time.time() - - self.state = "wait_assign" - self.worker = None - self.checker = None - self.createor = None - - self.need_check = True - self.due_date = time.time() + 3600 * 24 * 2 - self.last_do_time = None - self.last_check_time = None - self.last_review_time = None - - self.depend_todo_ids = [] - self.sub_todos = {} - - self.result : AgentTodoResult = None - self.last_check_result = None - self.retry_count = 0 - self.raw_obj = None - - @classmethod - def from_dict(cls,json_obj:dict) -> 'AgentTodo': - todo = AgentTodo() - if json_obj.get("id") is not None: - todo.todo_id = json_obj.get("id") - - todo.title = json_obj.get("title") - todo.state = json_obj.get("state") - create_time = json_obj.get("create_time") - if create_time: - todo.create_time = datetime.fromisoformat(create_time).timestamp() - - todo.detail = json_obj.get("detail") - due_date = json_obj.get("due_date") - if due_date: - todo.due_date = datetime.fromisoformat(due_date).timestamp() - - last_do_time = json_obj.get("last_do_time") - if last_do_time: - todo.last_do_time = datetime.fromisoformat(last_do_time).timestamp() - last_check_time = json_obj.get("last_check_time") - if last_check_time: - todo.last_check_time = datetime.fromisoformat(last_check_time).timestamp() - last_review_time = json_obj.get("last_review_time") - if last_review_time: - todo.last_review_time = datetime.fromisoformat(last_review_time).timestamp() - - todo.depend_todo_ids = json_obj.get("depend_todo_ids") - todo.need_check = json_obj.get("need_check") - #todo.result = json_obj.get("result") - #todo.last_check_result = json_obj.get("last_check_result") - todo.worker = json_obj.get("worker") - todo.checker = json_obj.get("checker") - todo.createor = json_obj.get("createor") - if json_obj.get("retry_count"): - todo.retry_count = json_obj.get("retry_count") - - todo.raw_obj = json_obj - - return todo - - def to_dict(self) -> dict: - if self.raw_obj: - result = self.raw_obj - else: - result = {} - - result["id"] = self.todo_id - #result["parent_id"] = self.parent_id - result["title"] = self.title - result["state"] = self.state - result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() - result["detail"] = self.detail - result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() - result["last_do_time"] = datetime.fromtimestamp(self.last_do_time).isoformat() if self.last_do_time else None - result["last_check_time"] = datetime.fromtimestamp(self.last_check_time).isoformat() if self.last_check_time else None - result["last_review_time"] = datetime.fromtimestamp(self.last_review_time).isoformat() if self.last_review_time else None - result["depend_todo_ids"] = self.depend_todo_ids - result["need_check"] = self.need_check - result["worker"] = self.worker - result["checker"] = self.checker - result["createor"] = self.createor - result["retry_count"] = self.retry_count - - return result - - def to_prompt(self) -> AgentPrompt: - json_str = json.dumps(self.raw_obj) - return AgentPrompt(json_str) - - def can_review(self) -> bool: - if self.state != AgentTodo.TODO_STATE_DONE: - return False - - now = datetime.now().timestamp() - if self.last_review_time: - time_diff = now - self.last_review_time - if time_diff < 60*15: - logger.info(f"todo {self.title} is already reviewed, ignore") - return False - - return True - - def can_check(self)->bool: - if self.state != AgentTodo.TODO_STATE_WAITING_CHECK: - return False - - now = datetime.now().timestamp() - if self.last_check_time: - time_diff = now - self.last_check_time - if time_diff < 60*15: - logger.info(f"todo {self.title} is already checked, ignore") - return False - - return True - - def can_do(self) -> bool: - match self.state: - case AgentTodo.TODO_STATE_DONE: - logger.info(f"todo {self.title} is done, ignore") - return False - case AgentTodo.TODO_STATE_CANCEL: - logger.info(f"todo {self.title} is cancel, ignore") - return False - case AgentTodo.TODO_STATE_EXPIRED: - logger.info(f"todo {self.title} is expired, ignore") - return False - case AgentTodo.TODO_STATE_EXEC_FAILED: - if self.retry_count > 3: - logger.info(f"todo {self.title} retry count ({self.retry_count}) is too many, ignore") - return False - - now = datetime.now().timestamp() - time_diff = self.due_date - now - if time_diff < 0: - logger.info(f"todo {self.title} is expired, ignore") - self.state = AgentTodo.TODO_STATE_EXPIRED - return False - - if time_diff > 7*24*3600: - logger.info(f"todo {self.title} is far before due date, ignore") - return False - - if self.last_do_time: - time_diff = now - self.last_do_time - if time_diff < 60*15: - logger.info(f"todo {self.title} is already do ignore") - return False - - logger.info(f"todo {self.title} can do.") - return True - - -class AgentWorkLog: - def __init__(self) -> None: - pass class BaseAIAgent(abc.ABC): @@ -420,19 +35,9 @@ class BaseAIAgent(abc.ABC): def get_max_token_size(self) -> int: pass - def token_len(self, text:str=None, prompt:AgentPrompt=None) -> int: - from ..frame.compute_kernel import ComputeKernel - if text: - return ComputeKernel.llm_num_tokens_from_text(text,self.get_llm_model_name()) - elif prompt: - result = 0 - if prompt.system_message: - result += ComputeKernel.llm_num_tokens_from_text(prompt.system_message.get("content"),self.get_llm_model_name()) - for msg in prompt.messages: - result += ComputeKernel.llm_num_tokens_from_text(msg.get("content"),self.get_llm_model_name()) - return result - else: - return 0 + @abstractmethod + async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: + pass @classmethod def get_inner_functions(cls, env:BaseEnvironment) -> (dict,int): @@ -458,7 +63,7 @@ class BaseAIAgent(abc.ABC): async def do_llm_complection( self, - prompt:AgentPrompt, + prompt:LLMPrompt, org_msg:AgentMsg=None, env:BaseEnvironment=None, inner_functions=None, @@ -503,7 +108,7 @@ class BaseAIAgent(abc.ABC): inner_func_call_node = result_message.get("function_call") if inner_func_call_node: - call_prompt : AgentPrompt = copy.deepcopy(prompt) + call_prompt : LLMPrompt = copy.deepcopy(prompt) func_msg = copy.deepcopy(result_message) del func_msg["tool_calls"] call_prompt.messages.append(func_msg) @@ -515,7 +120,7 @@ class BaseAIAgent(abc.ABC): self, env: BaseEnvironment, inner_func_call_node: dict, - prompt: AgentPrompt, + prompt: LLMPrompt, inner_functions: dict, org_msg:AgentMsg, stack_limit = 5 diff --git a/src/aios/agent/behavior.py b/src/aios/agent/behavior.py deleted file mode 100644 index 64b8834..0000000 --- a/src/aios/agent/behavior.py +++ /dev/null @@ -1,4 +0,0 @@ -# TODO: let agent develolp custmized behavior easily -class AgentBehavior: - def __init__(self) -> None: - pass \ No newline at end of file diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py new file mode 100644 index 0000000..9c1245a --- /dev/null +++ b/src/aios/agent/llm_process.py @@ -0,0 +1,149 @@ +# Old name is behavior, I belive new name "llm_process" is better +from abc import ABC,abstractmethod +import copy +import json +import shlex +from typing import Any, Callable, Optional,Dict,Awaitable,List +from enum import Enum + +from ..proto.compute_task import * +from ..proto.ai_function import * +from ..frame.compute_kernel import * + +import logging +logger = logging.getLogger(__name__) + +MIN_PREDICT_TOKEN_LEN = 32 + + +class BaseLLMProcess: + def __init__(self) -> None: + self.enable_json_resp = False + self.model_name = "gpt-4" + self.max_token = 2000 # include input prompt + self.timeout = 1800 # 30 min + + @abstractmethod + async def prepare_prompt(self) -> LLMPrompt: + pass + + @abstractmethod + async def get_inner_function(self,func_name:str) -> AIFunction: + pass + + async def _execute_inner_func(self,inner_func_call_node,prompt: LLMPrompt,stack_limit = 5) -> ComputeTaskResult: + arguments = None + try: + func_name = inner_func_call_node.get("name") + arguments = json.loads(inner_func_call_node.get("arguments")) + logger.info(f"LLMProcess execute inner func:{func_name} :\n\t {json.dumps(arguments)}") + + func_node : AIFunction = await self.get_inner_function(func_name) + if func_node is None: + result_str:str = f"execute {func_name} error,function not found" + else: + result_str:str = await func_node.execute(**arguments) + except Exception as e: + result_str = f"execute {func_name} error:{str(e)}" + logger.error(f"LLMProcess execute inner func:{func_name} error:\n\t{e}") + + logger.info("LLMProcess execute inner func result:" + result_str) + + prompt.messages.append({"role":"function","content":result_str,"name":func_name}) + if self.enable_json_resp: + resp_mode = "json" + else: + resp_mode = "text" + + max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt) + if max_result_token < MIN_PREDICT_TOKEN_LEN: + task_result = ComputeTaskResult() + task_result.result_code = ComputeTaskResultCode.ERROR + task_result.error_str = f"prompt too long,can not predict" + return task_result + + task_result: ComputeTaskResult = await (ComputeKernel.get_instance().do_llm_completion( + prompt, + resp_mode=resp_mode, + mode_name=self.model_name, + max_token=max_result_token, + inner_functions=prompt.inner_functions, + timeout=self.timeout)) + + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"llm compute error:{task_result.error_str}") + return task_result + + inner_func_call_node = None + if stack_limit > 0: + result_message : dict = task_result.result.get("message") + if result_message: + inner_func_call_node = result_message.get("function_call") + if inner_func_call_node: + func_msg = copy.deepcopy(result_message) + del func_msg["tool_calls"]#TODO: support tool_calls? + prompt.messages.append(func_msg) + else: + logger.error(f"inner function call stack limit reached") + task_result.result_code = ComputeTaskResultCode.ERROR + task_result.error_str = "inner function call stack limit reached" + return task_result + + if inner_func_call_node: + return await self._execute_inner_func(inner_func_call_node,prompt,stack_limit-1) + else: + return task_result + + async def process(self) -> LLMResult: + if self.enable_json_resp: + resp_mode = "json" + else: + resp_mode = "text" + + prompt = await self.prepare_prompt() + max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt) + if max_result_token < MIN_PREDICT_TOKEN_LEN: + return LLMResult.from_error_str(f"prompt too long,can not predict") + + task_result: ComputeTaskResult = await (ComputeKernel.get_instance().do_llm_completion( + prompt, + resp_mode=resp_mode, + mode_name=self.model_name, + max_token=max_result_token, + inner_functions=prompt.inner_functions, + timeout=self.timeout)) + + if task_result.result_code != ComputeTaskResultCode.OK: + err_str = f"do_llm_completion error:{task_result.error_str}" + logger.error(err_str) + return LLMResult.from_error_str(err_str) + + result_message = task_result.result.get("message") + inner_func_call_node = None + if result_message: + inner_func_call_node = result_message.get("function_call") + + if inner_func_call_node: + call_prompt : LLMPrompt = copy.deepcopy(prompt) + func_msg = copy.deepcopy(result_message) + del func_msg["tool_calls"] + call_prompt.messages.append(func_msg) + task_result = await self._execute_inner_func(inner_func_call_node,call_prompt) + + # parse task_result to LLM Result + if self.enable_json_resp: + llm_result = LLMResult.from_json_str(task_result.result_str) + else: + llm_result = LLMResult.from_str(task_result.result_str) + + # execute op_list in LLM Result? + + return llm_result + +#class LLMProcess + + + + + + diff --git a/src/aios/agent/role.py b/src/aios/agent/role.py index 272815f..d08049d 100644 --- a/src/aios/agent/role.py +++ b/src/aios/agent/role.py @@ -1,6 +1,6 @@ import logging -from .agent_base import AgentPrompt +from .agent_base import LLMPrompt class AIRole: def __init__(self) -> None: @@ -9,7 +9,7 @@ class AIRole: self.role_id :str = None # $workflow_id.$sub_workflow_id.$role_name self.fullname : str = None self.agent_name : str = None - self.prompt : AgentPrompt = None + self.prompt : LLMPrompt = None self.introduce : str = None self.agent = None self.enable_function_list : list[str] = None @@ -31,7 +31,7 @@ class AIRole: prompt_node = config.get("prompt") if prompt_node: - self.prompt = AgentPrompt() + self.prompt = LLMPrompt() if self.prompt.load_from_config(prompt_node) is False: logging.error("load prompt failed!") return False @@ -56,7 +56,7 @@ class AIRole: def get_name(self) -> str: return self.role_name - def get_prompt(self) -> AgentPrompt: + def get_prompt(self) -> LLMPrompt: return self.prompt class AIRoleGroup: diff --git a/src/aios/agent/workflow.py b/src/aios/agent/workflow.py index d0bd585..ce245c7 100644 --- a/src/aios/agent/workflow.py +++ b/src/aios/agent/workflow.py @@ -9,11 +9,11 @@ from abc import ABC, abstractmethod from ..proto.compute_task import * from ..proto.agent_msg import * +from ..proto.ai_function import * from .agent_base import * from .chatsession import AIChatSession from .role import AIRole,AIRoleGroup -from .ai_function import AIFunction,FunctionItem from ..frame.compute_kernel import ComputeKernel from ..frame.bus import AIBus @@ -48,7 +48,7 @@ class Workflow: def __init__(self) -> None: self.workflow_name : str = None self.workflow_id : str = None - self.rule_prompt : AgentPrompt = None + self.rule_prompt : LLMPrompt = None self.workflow_config = None self.role_group : dict = None self.input_filter : MessageFilter= None @@ -83,7 +83,7 @@ class Workflow: self.db_file = self.owner_workflow.db_file if config.get("prompt") is not None: - self.rule_prompt = AgentPrompt() + self.rule_prompt = LLMPrompt() if self.rule_prompt.load_from_config(config.get("prompt")) is False: logger.error("Workflow load prompt failed") return False @@ -279,7 +279,7 @@ class Workflow: logger.info(f"{msg.sender} post message {msg.msg_id} to AIBus: {msg.target}") return await self.get_bus().send_message(msg) - async def role_call(self,func_item:FunctionItem,the_role:AIRole): + async def role_call(self,func_item:ActionItem,the_role:AIRole): logger.info(f"{the_role.role_id} call {func_item.name} ") arguments = func_item.args @@ -290,11 +290,11 @@ class Workflow: result_str:str = await func_node.execute(**arguments) return result_str - async def role_post_call(self,func_item:FunctionItem,the_role:AIRole): + async def role_post_call(self,func_item:ActionItem,the_role:AIRole): logger.info(f"{the_role.role_id} post call {func_item.name} ") return await self.role_call(func_item,the_role) - def _format_msg_by_env_value(self,prompt:AgentPrompt): + def _format_msg_by_env_value(self,prompt:LLMPrompt): if self.workflow_env is None: return @@ -326,7 +326,7 @@ class Workflow: return result_func return None - async def _role_execute_func(self,the_role:AIRole,inenr_func_call_node:dict,prompt:AgentPrompt,org_msg:AgentMsg,stack_limit = 5) -> [str,int]: + async def _role_execute_func(self,the_role:AIRole,inenr_func_call_node:dict,prompt:LLMPrompt,org_msg:AgentMsg,stack_limit = 5) -> [str,int]: func_name = inenr_func_call_node.get("name") arguments = json.loads(inenr_func_call_node.get("arguments")) @@ -372,7 +372,7 @@ class Workflow: async def role_process_msg(self,msg:AgentMsg,the_role:AIRole,workflow_chat_session:AIChatSession) -> AgentMsg: msg.target = the_role.get_role_id() - prompt = AgentPrompt() + prompt = LLMPrompt() prompt.append(the_role.agent.agent_prompt) prompt.append(self.get_workflow_rule_prompt()) prompt.append(the_role.get_prompt()) @@ -382,7 +382,7 @@ class Workflow: #support group chat, user content include sender name! prompt.append(await self._get_prompt_from_session(the_role,workflow_chat_session)) - msg_prompt = AgentPrompt() + msg_prompt = LLMPrompt() msg_prompt.messages = [{"role":"user","content":f"user name is {msg.sender}, his question is :{msg.body}"}] prompt.append(msg_prompt) @@ -461,20 +461,20 @@ class Workflow: # message will be saved in role.process_message pass - this_llm_resp_prompt = AgentPrompt() + this_llm_resp_prompt = LLMPrompt() this_llm_resp_prompt.messages = [{"role":"assistant","content":result_str}] prompt.append(this_llm_resp_prompt) - result_prompt = AgentPrompt() + result_prompt = LLMPrompt() result_prompt.messages = [{"role":"user","content":result_prompt_str}] prompt.append(result_prompt) return await _do_process_msg() return await _do_process_msg() - async def _get_prompt_from_session(self,the_role:AIRole,chatsession:AIChatSession) -> AgentPrompt: + async def _get_prompt_from_session(self,the_role:AIRole,chatsession:AIChatSession) -> LLMPrompt: messages = chatsession.read_history(the_role.history_len) # read last 10 message - result_prompt = AgentPrompt() + result_prompt = LLMPrompt() for msg in reversed(messages): if msg.sender == the_role.role_id: @@ -484,10 +484,10 @@ class Workflow: return result_prompt - def _get_knowlege_prompt(self,role_name:str) -> AgentPrompt: + def _get_knowlege_prompt(self,role_name:str) -> LLMPrompt: pass - def get_workflow_rule_prompt(self) -> AgentPrompt: + def get_workflow_rule_prompt(self) -> LLMPrompt: return self.rule_prompt # def _env_event_to_msg(self,env_event:EnvironmentEvent) -> AgentMsg: diff --git a/src/aios/environment/asr_function.py b/src/aios/environment/asr_function.py index 9b97006..81f8dc6 100644 --- a/src/aios/environment/asr_function.py +++ b/src/aios/environment/asr_function.py @@ -2,7 +2,7 @@ import logging from typing import Dict from ..frame.compute_kernel import ComputeKernel -from ..agent.ai_function import AIFunction +from ..proto.ai_function import * logger = logging.getLogger(__name__) diff --git a/src/aios/environment/code_interpreter_function.py b/src/aios/environment/code_interpreter_function.py index 734af71..5913ba4 100644 --- a/src/aios/environment/code_interpreter_function.py +++ b/src/aios/environment/code_interpreter_function.py @@ -1,6 +1,6 @@ from typing import Dict -from ..agent.ai_function import AIFunction +from ..proto.ai_function import * from .code_interpreter import execute_code diff --git a/src/aios/environment/duckduckgo_text_search_function.py b/src/aios/environment/duckduckgo_text_search_function.py index 5418095..22632eb 100644 --- a/src/aios/environment/duckduckgo_text_search_function.py +++ b/src/aios/environment/duckduckgo_text_search_function.py @@ -1,7 +1,7 @@ import json from typing import Dict -from ..agent.ai_function import AIFunction +from ..proto.ai_function import * from duckduckgo_search import AsyncDDGS diff --git a/src/aios/environment/environment.py b/src/aios/environment/environment.py index 1af1813..72b5ee2 100644 --- a/src/aios/environment/environment.py +++ b/src/aios/environment/environment.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from typing import Any, Callable, Optional,Dict,Awaitable,List import logging -from ..agent.ai_function import AIFunction, AIOperation +from ..proto.ai_function import * logger = logging.getLogger(__name__) diff --git a/src/aios/environment/image_2_text_function.py b/src/aios/environment/image_2_text_function.py index 8760f56..422917f 100644 --- a/src/aios/environment/image_2_text_function.py +++ b/src/aios/environment/image_2_text_function.py @@ -2,7 +2,7 @@ import logging from typing import Dict from ..frame.compute_kernel import ComputeKernel -from ..agent.ai_function import AIFunction +from ..proto.ai_function import * logger = logging.getLogger(__name__) diff --git a/src/aios/environment/script_to_speech_function.py b/src/aios/environment/script_to_speech_function.py index 6c71229..043bc0d 100644 --- a/src/aios/environment/script_to_speech_function.py +++ b/src/aios/environment/script_to_speech_function.py @@ -5,7 +5,7 @@ import random from pathlib import Path from typing import Dict -from ..agent.ai_function import AIFunction +from ..proto.ai_function import * from ..frame.compute_kernel import ComputeKernel from ..storage.storage import AIStorage diff --git a/src/aios/environment/sql_database_function.py b/src/aios/environment/sql_database_function.py index 989b82b..c28b0e2 100644 --- a/src/aios/environment/sql_database_function.py +++ b/src/aios/environment/sql_database_function.py @@ -3,7 +3,7 @@ from typing import Dict from cachetools import TLRUCache, cached -from ..agent.ai_function import AIFunction +from ..proto.ai_function import * from .sql_database import SQLDatabase, get_from_env diff --git a/src/aios/environment/text_to_speech_function.py b/src/aios/environment/text_to_speech_function.py index e93a568..878bed5 100644 --- a/src/aios/environment/text_to_speech_function.py +++ b/src/aios/environment/text_to_speech_function.py @@ -5,7 +5,7 @@ import random from pathlib import Path from typing import Dict -from ..agent.ai_function import AIFunction +from ..proto.ai_function import * from ..frame.compute_kernel import ComputeKernel from ..storage.storage import AIStorage diff --git a/src/aios/environment/workflow_env.py b/src/aios/environment/workflow_env.py index df1003f..c48bbfe 100644 --- a/src/aios/environment/workflow_env.py +++ b/src/aios/environment/workflow_env.py @@ -10,7 +10,7 @@ from typing import Optional import aiosqlite from ..proto.compute_task import * -from ..agent.ai_function import SimpleAIFunction +from ..proto.ai_function import * from ..frame.compute_kernel import ComputeKernel from ..frame.contact_manager import ContactManager,Contact,FamilyMember from ..storage.storage import AIStorage @@ -302,7 +302,7 @@ class CalenderEnvironment(SimpleEnvironment): return f'exec paint OK, saved as a local file, path is: {result.result["file"]}' -class PaintEnvironment(BaseEnvironment): +class PaintEnvironment(SimpleEnvironment): def __init__(self, env_id: str) -> None: super().__init__(env_id) self.is_run = False diff --git a/src/aios/environment/workspace_env.py b/src/aios/environment/workspace_env.py index 93b1005..5d52c29 100644 --- a/src/aios/environment/workspace_env.py +++ b/src/aios/environment/workspace_env.py @@ -6,8 +6,12 @@ import sqlite3 import asyncio from typing import Any,List,Dict import chardet -from ..agent.agent_base import AgentMsg,AgentTodo,AgentPrompt,AgentTodoResult -from ..agent.ai_function import AIFunction,SimpleAIFunction, SimpleAIOperation + +from ..proto.agent_task import * +from ..proto.ai_function import * +from ..proto.compute_task import * +from ..agent.agent_base import * + from ..storage.storage import AIStorage,ResourceLocation from .environment import SimpleEnvironment, CompositeEnvironment @@ -289,16 +293,16 @@ class WorkspaceEnvironment(CompositeEnvironment): def get_prompt(self) -> AgentMsg: return None - def get_role_prompt(self,role_id:str) -> AgentPrompt: + def get_role_prompt(self,role_id:str) -> LLMPrompt: return None - def get_do_prompt(self,todo:AgentTodo=None)->AgentPrompt: + def get_do_prompt(self,todo:AgentTodo=None)->LLMPrompt: return None # result mean: list[op_error_str],have_error async def exec_op_list(self,oplist:List,agent_id:str)->tuple[List[str],bool]: result_str = "op list is none" - if oplist is None: + if oplist is None or len(oplist) == 0: return None,False result_str = [] diff --git a/src/aios/frame/compute_kernel.py b/src/aios/frame/compute_kernel.py index 45670cc..ccdb930 100644 --- a/src/aios/frame/compute_kernel.py +++ b/src/aios/frame/compute_kernel.py @@ -8,7 +8,7 @@ from asyncio import Queue from ..proto.compute_task import * from ..knowledge import ObjectID -from ..agent.agent_base import AgentPrompt + from .compute_node import ComputeNode logger = logging.getLogger(__name__) @@ -106,6 +106,9 @@ class ComputeKernel: @staticmethod def llm_num_tokens_from_text(text:str,model:str) -> int: + if model is None: + model = "gpt4" + try: encoding = tiktoken.encoding_for_model(model) except KeyError: @@ -115,9 +118,12 @@ class ComputeKernel: token_count = len(encoding.encode(text)) return token_count + @staticmethod + def llm_num_tokens(prompt: LLMPrompt, model_name: str = None) -> int: + return ComputeKernel.llm_num_tokens_from_text(prompt.as_str(), model_name) # friendly interface for use: - def llm_completion(self, prompt: AgentPrompt, resp_mode:str="text",mode_name: Optional[str] = None, max_token: int = 0,inner_functions = None): + def llm_completion(self, prompt: LLMPrompt, resp_mode:str="text",mode_name: Optional[str] = None, max_token: int = 0,inner_functions = None): # craete a llm_work_task ,push on queue's end # then task_schedule would run this task.(might schedule some work_task to another host) task_req = ComputeTask() @@ -153,7 +159,7 @@ class ComputeKernel: return time_out_result - async def do_llm_completion(self, prompt: AgentPrompt,resp_mode:str="text", mode_name: Optional[str]=None, max_token:int=0, inner_functions=None, timeout=60) -> str: + async def do_llm_completion(self, prompt: LLMPrompt,resp_mode:str="text", mode_name: Optional[str]=None, max_token:int=0, inner_functions=None, timeout=60) -> str: task_req = self.llm_completion(prompt, resp_mode,mode_name, max_token,inner_functions) return await self._wait_task(task_req, timeout) diff --git a/src/aios/proto/agent_msg.py b/src/aios/proto/agent_msg.py index 21ec3b6..93523d7 100644 --- a/src/aios/proto/agent_msg.py +++ b/src/aios/proto/agent_msg.py @@ -232,9 +232,4 @@ class AgentMsg: def get_quote_msg_id(self) -> str: return self.quote_msg_id - @classmethod - def parse_function_call(cls,func_string:str): - str_list = shlex.split(func_string) - func_name = str_list[0] - params = str_list[1:] - return func_name, params + diff --git a/src/aios/proto/agent_task.py b/src/aios/proto/agent_task.py new file mode 100644 index 0000000..791e0d9 --- /dev/null +++ b/src/aios/proto/agent_task.py @@ -0,0 +1,221 @@ + +import datetime +import time + +from anyio import Path + + +class AgentTodoResult: + TODO_RESULT_CODE_OK = 0, + TODO_RESULT_CODE_LLM_ERROR = 1, + TODO_RESULT_CODE_EXEC_OP_ERROR = 2 + + + def __init__(self) -> None: + self.result_code = AgentTodoResult.TODO_RESULT_CODE_OK + self.result_str = None + self.error_str = None + self.op_list = None + + def to_dict(self) -> dict: + result = {} + result["result_code"] = self.result_code + result["result_str"] = self.result_str + result["error_str"] = self.error_str + result["op_list"] = self.op_list + return result + + + + +class AgentTodo: + TODO_STATE_WAIT_ASSIGN = "wait_assign" + TODO_STATE_INIT = "init" + + TODO_STATE_PENDING = "pending" + TODO_STATE_WAITING_CHECK = "wait_check" + TODO_STATE_EXEC_FAILED = "exec_failed" + TDDO_STATE_CHECKFAILED = "check_failed" + + TODO_STATE_CASNCEL = "cancel" + TODO_STATE_DONE = "done" + TODO_STATE_EXPIRED = "expired" + + def __init__(self): + self.todo_id = "todo#" + uuid.uuid4().hex + self.title = None + self.detail = None + self.todo_path = None # get parent todo,sub todo by path + #self.parent = None + self.create_time = time.time() + + self.state = "wait_assign" + self.worker = None + self.checker = None + self.createor = None + + self.need_check = True + self.due_date = time.time() + 3600 * 24 * 2 + self.last_do_time = None + self.last_check_time = None + self.last_review_time = None + + self.depend_todo_ids = [] + self.sub_todos = {} + + self.result : AgentTodoResult = None + self.last_check_result = None + self.retry_count = 0 + self.raw_obj = None + + @classmethod + def from_dict(cls,json_obj:dict) -> 'AgentTodo': + todo = AgentTodo() + if json_obj.get("id") is not None: + todo.todo_id = json_obj.get("id") + + todo.title = json_obj.get("title") + todo.state = json_obj.get("state") + create_time = json_obj.get("create_time") + if create_time: + todo.create_time = datetime.fromisoformat(create_time).timestamp() + + todo.detail = json_obj.get("detail") + due_date = json_obj.get("due_date") + if due_date: + todo.due_date = datetime.fromisoformat(due_date).timestamp() + + last_do_time = json_obj.get("last_do_time") + if last_do_time: + todo.last_do_time = datetime.fromisoformat(last_do_time).timestamp() + last_check_time = json_obj.get("last_check_time") + if last_check_time: + todo.last_check_time = datetime.fromisoformat(last_check_time).timestamp() + last_review_time = json_obj.get("last_review_time") + if last_review_time: + todo.last_review_time = datetime.fromisoformat(last_review_time).timestamp() + + todo.depend_todo_ids = json_obj.get("depend_todo_ids") + todo.need_check = json_obj.get("need_check") + #todo.result = json_obj.get("result") + #todo.last_check_result = json_obj.get("last_check_result") + todo.worker = json_obj.get("worker") + todo.checker = json_obj.get("checker") + todo.createor = json_obj.get("createor") + if json_obj.get("retry_count"): + todo.retry_count = json_obj.get("retry_count") + + todo.raw_obj = json_obj + + return todo + + def to_dict(self) -> dict: + if self.raw_obj: + result = self.raw_obj + else: + result = {} + + result["id"] = self.todo_id + #result["parent_id"] = self.parent_id + result["title"] = self.title + result["state"] = self.state + result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() + result["detail"] = self.detail + result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() + result["last_do_time"] = datetime.fromtimestamp(self.last_do_time).isoformat() if self.last_do_time else None + result["last_check_time"] = datetime.fromtimestamp(self.last_check_time).isoformat() if self.last_check_time else None + result["last_review_time"] = datetime.fromtimestamp(self.last_review_time).isoformat() if self.last_review_time else None + result["depend_todo_ids"] = self.depend_todo_ids + result["need_check"] = self.need_check + result["worker"] = self.worker + result["checker"] = self.checker + result["createor"] = self.createor + result["retry_count"] = self.retry_count + + return result + + def can_check(self)->bool: + if self.state != AgentTodo.TODO_STATE_WAITING_CHECK: + return False + + now = datetime.now().timestamp() + if self.last_check_time: + time_diff = now - self.last_check_time + if time_diff < 60*15: + logger.info(f"todo {self.title} is already checked, ignore") + return False + + return True + + def can_do(self) -> bool: + match self.state: + case AgentTodo.TODO_STATE_DONE: + logger.info(f"todo {self.title} is done, ignore") + return False + case AgentTodo.TODO_STATE_CASNCEL: + logger.info(f"todo {self.title} is cancel, ignore") + return False + case AgentTodo.TODO_STATE_EXPIRED: + logger.info(f"todo {self.title} is expired, ignore") + return False + case AgentTodo.TODO_STATE_EXEC_FAILED: + if self.retry_count > 3: + logger.info(f"todo {self.title} retry count ({self.retry_count}) is too many, ignore") + return False + + now = datetime.now().timestamp() + time_diff = self.due_date - now + if time_diff < 0: + logger.info(f"todo {self.title} is expired, ignore") + self.state = AgentTodo.TODO_STATE_EXPIRED + return False + + if time_diff > 7*24*3600: + logger.info(f"todo {self.title} is far before due date, ignore") + return False + + if self.last_do_time: + time_diff = now - self.last_do_time + if time_diff < 60*15: + logger.info(f"todo {self.title} is already do ignore") + return False + + logger.info(f"todo {self.title} can do.") + return True + +class AgentTask: + def __init__(self) -> None: + self.task_id : str = "task#" + uuid.uuid4().hex + self.task_path : Path = None # get parent todo,sub todo by path + self.title = None + self.detail = None + + self.create_time = time.time() + + self.state = "wait_assign" + self.worker = None + self.createor = None + + self.due_date = time.time() + 3600 * 24 * 2 + self.depend_task_ids = [] + self.step_todos = {} + + self.last_plan_time = None + self.last_check_time = None + #self.last_review_time = None + + self.result : LLMResult = None + self.last_check_result = None + self.retry_count = 0 + self.raw_obj = None + + + +class AgentWorkLog: + def __init__(self) -> None: + pass + + +class AgentReport: + def __init__(self) -> None: + pass \ No newline at end of file diff --git a/src/aios/agent/ai_function.py b/src/aios/proto/ai_function.py similarity index 98% rename from src/aios/agent/ai_function.py rename to src/aios/proto/ai_function.py index d13ddca..9786cca 100644 --- a/src/aios/agent/ai_function.py +++ b/src/aios/proto/ai_function.py @@ -73,7 +73,7 @@ class AIFunction: #def load_from_config(self,config:dict) -> bool: # pass -class FunctionItem: +class ActionItem: def __init__(self,name,args) -> None: self.name = name self.args = args diff --git a/src/aios/proto/compute_task.py b/src/aios/proto/compute_task.py index 3390465..f761976 100644 --- a/src/aios/proto/compute_task.py +++ b/src/aios/proto/compute_task.py @@ -1,11 +1,20 @@ +import copy from enum import Enum +import json +import shlex import uuid import time -from typing import Union +from typing import List, Union +from ..proto.ai_function import * from ..knowledge import ObjectID from ..storage.storage import AIStorage + +import logging + +logger = logging.getLogger(__name__) + class ComputeTaskResultCode(Enum): OK = 0 TIMEOUT = 1 @@ -31,6 +40,164 @@ class ComputeTaskType(Enum): TEXT_EMBEDDING ="text_embedding" IMAGE_EMBEDDING ="image_embedding" +class LLMPrompt: + def __init__(self,prompt_str = None) -> None: + self.messages = [] + if prompt_str: + self.messages.append({"role":"user","content":prompt_str}) + self.system_message = None + + def as_str(self)->str: + result_str = "" + if self.system_message: + result_str += self.system_message.get("role") + ":" + self.system_message.get("content") + "\n" + if self.messages: + for msg in self.messages: + result_str += msg.get("role") + ":" + msg.get("content") + "\n" + + return result_str + + def to_message_list(self): + result = [] + if self.system_message: + result.append(self.system_message) + result.extend(self.messages) + return result + + def append(self,prompt:'LLMPrompt'): + if prompt is None: + return + + if prompt.system_message is not None: + if self.system_message is None: + self.system_message = copy.deepcopy(prompt.system_message) + else: + self.system_message["content"] += prompt.system_message.get("content") + + self.messages.extend(prompt.messages) + + def load_from_config(self,config:list) -> bool: + if isinstance(config,list) is not True: + logger.error("prompt is not list!") + return False + self.messages = [] + for msg in config: + if msg.get("content"): + if msg.get("role") == "system": + self.system_message = msg + else: + self.messages.append(msg) + else: + logger.error("prompt message has no content!") + return True + + +class LLMResultStates(Enum): + IGNORE = "ignore" + OK = "ok" # process done + ERROR = "error" + +class LLMResult: + def __init__(self) -> None: + self.state : str = LLMResultStates.IGNORE + self.compute_error_str = None + self.resp : str = "" # llm say: + self.raw_result = None # raw result from compute kernel + self.inner_functions : List[AIFunction] = [] + self.action_list : List[ActionItem] = [] # op_list is a optimize design for saving token + + #self.post_msgs : List[AgentMsg] = [] # move to op_list + # self.send_msgs : List[AgentMsg] = [] # move to op_list + + + @classmethod + def from_error_str(self,error_str:str) -> 'LLMResult': + r = LLMResult() + r.state = "error" + r.compute_error_str = error_str + return r + + @classmethod + def from_json_str(self,llm_json_str:str) -> 'LLMResult': + r = LLMResult() + if llm_json_str is None: + r.state = LLMResultStates.IGNORE + return r + if llm_json_str == "**IGNORE**": + r.state = LLMResultStates.IGNORE + return r + + llm_json = json.loads(llm_json_str) + r.resp = llm_json.get("resp") + r.raw_result = llm_json + r.action_list = llm_json.get("actions") + + return r + + @classmethod + def parse_action(cls,func_string:str): + str_list = shlex.split(func_string) + func_name = str_list[0] + params = str_list[1:] + return func_name, params + + @classmethod + def from_str(self,llm_result_str:str,valid_func:List[str]=None) -> 'LLMResult': + r = LLMResult() + + if llm_result_str is None: + r.state = LLMResultStates.IGNORE + return r + if llm_result_str == "**IGNORE**": + r.state = LLMResultStates.IGNORE + return r + + if llm_result_str[0] == "{": + return LLMResult.from_json_str(llm_result_str) + + lines = llm_result_str.splitlines() + is_need_wait = False + + def check_args(action_item:ActionItem): + match action_item.name: + case "post_msg":# /post_msg $target_id + if len(action_item.args) != 1: + return False + + new_msg = AgentMsg() + target_id = action_item.args[0] + msg_content = action_item.body + new_msg.set("",target_id,msg_content) + + return True + + + return False + + + current_action : ActionItem = None + for line in lines: + if line.startswith("##/"): + if current_action: + if check_args(current_action) is False: + r.resp += current_action.dumps() + else: + r.action_list.append(current_action) + + action_name,action_args = LLMResult.parse_action(line[3:]) + current_action = ActionItem(action_name,action_args) + else: + if current_action: + current_action.append_body(line + "\n") + else: + r.resp += line + "\n" + + if current_action: + if check_args(current_action) is False: + r.resp += current_action.dumps() + else: + r.action_list.append(current_action) + return r class ComputeTask: def __init__(self) -> None: @@ -140,3 +307,5 @@ class ComputeTaskResult: self.task_id = task.task_id self.callchain_id = task.callchain_id task.result = self + + diff --git a/src/aios_kernel/code_interpreter.py b/src/aios_kernel/code_interpreter.py deleted file mode 100644 index 850356a..0000000 --- a/src/aios_kernel/code_interpreter.py +++ /dev/null @@ -1,426 +0,0 @@ -import logging -import os -import pathlib -import shutil -import subprocess -import sys -import re -import time -import ast -from concurrent.futures import ThreadPoolExecutor -from hashlib import md5 -from typing import Optional, Union, List, Tuple -from generic_escape import GenericEscape - -from aios_kernel import AIStorage - -try: - import docker -except ImportError: - docker = None - -CODE_BLOCK_PATTERN = r"```[ \t]*(\w+)?[ \t]*\r?\n(.*?)\r?\n[ \t]*```" -UNKNOWN = "unknown" -TIMEOUT_MSG = "Timeout" -DEFAULT_TIMEOUT = 600 -WIN32 = sys.platform == "win32" -PATH_SEPARATOR = WIN32 and "\\" or "/" - -logger = logging.getLogger(__name__) - - -BUILT_IN_MODULES = set( - [ - "sys", - "os", - "math", - "random", - "datetime", - "json", - "re", - "subprocess", - "time", - "threading", - "logging", - "collections", - "itertools", - "functools", - "operator", - "pathlib", - "shutil", - "tempfile", - "pickle", - "io", - "argparse", - "typing", - "unittest", - "contextlib", - "abc", - "heapq", - "bisect", - "copy", - "decimal", - "fractions", - "hashlib", - "secrets", - "statistics", - "difflib", - "doctest", - "enum", - "inspect", - "traceback", - "weakref", - "gc", - "mmap", - "msvcrt", - "winreg", - "array", - "audioop", - "binascii", - "cProfile", - "concurrent.futures", - "configparser", - "csv", - "ctypes", - "dateutil", - "dis", - "fnmatch", - "getopt", - "glob", - "gzip", - "pdb", - "pprint", - "profile", - "pstats", - "queue", - "socket", - "sqlite3", - "ssl", - "struct", - "tarfile", - "telnetlib", - "timeit", - "tokenize", - "uuid", - "xml", - "zipfile", - "zlib", - ] -) - - -def get_imports(code: str) -> List[str]: - root = ast.parse(code) - - imports = [] - for node in ast.iter_child_nodes(root): - if isinstance(node, ast.Import): - module_names = [alias.name for alias in node.names] - elif isinstance(node, ast.ImportFrom): - module_names = [node.module] - else: - continue - - for name in module_names: - # Exclude built-in modules - if name not in BUILT_IN_MODULES: - imports.append(name) - - return imports - - -def write_requirements(code: str, requirements_filepath: str): - imports = get_imports(code) - - with open(requirements_filepath, "w") as file: - for module in imports: - file.write(module + "\n") - - -def _cmd(lang): - if lang.startswith("python") or lang in ["bash", "sh", "powershell"]: - return lang - if lang in ["shell"]: - return "sh" - if lang in ["ps1"]: - return "powershell" - raise NotImplementedError(f"{lang} not recognized in code execution") - - -def create_runner(code: str, timeout: int = 30) -> str: - """ - Create a Python script that runs the code and prints the output - """ - code = GenericEscape().escape(code) - # Create a runner script - runner = f""" -import os -import subprocess - -my_env = os.environ.copy() -my_env["PYTHONIOENCODING"] = "utf-8" - -process = subprocess.Popen( - f"python -i -q -u".split(), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=0, - universal_newlines=True, - env=my_env -) - -process.stdin.write("{code}" + "\\n") -process.stdin.write("exit()\\n") -process.stdin.flush() - -try: - process.wait({timeout}) -except Exception as e: - process.terminate() - -for line in iter(process.stdout.readline, ""): - print(line) - -for line in iter(process.stderr.readline, ""): - if line.startswith(">>>"): - continue - print(line) -""" - return runner - - -def _run_cmd(cmd: [str], work_dir: str, timeout: int) -> str: - if WIN32: - logger.warning("SIGALRM is not supported on Windows. No timeout will be enforced.") - result = subprocess.run( - cmd, - cwd=work_dir, - capture_output=True, - text=True, - ) - else: - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit( - subprocess.run, - cmd, - cwd=work_dir, - capture_output=True, - text=True, - ) - result = future.result(timeout=timeout) - return result - - -def execute_code( - code: Optional[str] = None, - timeout: Optional[int] = None, - filename: Optional[str] = None, - work_dir: Optional[str] = None, - use_docker: Optional[Union[List[str], str, bool]] = None, - lang: Optional[str] = "python", -) -> Tuple[int, str]: - """Execute code in a docker container. - This function is not tested on MacOS. - - Args: - code (Optional, str): The code to execute. - If None, the code from the file specified by filename will be executed. - Either code or filename must be provided. - timeout (Optional, int): The maximum execution time in seconds. - If None, a default timeout will be used. The default timeout is 600 seconds. On Windows, the timeout is not enforced when use_docker=False. - filename (Optional, str): The file name to save the code or where the code is stored when `code` is None. - If None, a file with a randomly generated name will be created. - The randomly generated file will be deleted after execution. - The file name must be a relative path. Relative paths are relative to the working directory. - work_dir (Optional, str): The working directory for the code execution. - If None, a default working directory will be used. - The default working directory is the "extensions" directory under - "path_to_autogen". - use_docker (Optional, list, str or bool): The docker image to use for code execution. - If a list or a str of image name(s) is provided, the code will be executed in a docker container - with the first image successfully pulled. - If None, False or empty, the code will be executed in the current environment. - Default is None, which will be converted into an empty list when docker package is available. - Expected behaviour: - - If `use_docker` is explicitly set to True and the docker package is available, the code will run in a Docker container. - - If `use_docker` is explicitly set to True but the Docker package is missing, an error will be raised. - - If `use_docker` is not set (i.e., left default to None) and the Docker package is not available, a warning will be displayed, but the code will run natively. - If the code is executed in the current environment, - the code must be trusted. - lang (Optional, str): The language of the code. Default is "python". - - Returns: - int: 0 if the code executes successfully. - str: The error message if the code fails to execute; the stdout otherwise. - """ - if all((code is None, filename is None)): - error_msg = f"Either {code=} or {filename=} must be provided." - logger.error(error_msg) - raise AssertionError(error_msg) - - # Warn if use_docker was unspecified (or None), and cannot be provided (the default). - # In this case the current behavior is to fall back to run natively, but this behavior - # is subject to change. - if use_docker is None: - if docker is None: - use_docker = False - logger.warning( - "execute_code was called without specifying a value for use_docker. Since the python docker package is not available, code will be run natively. Note: this fallback behavior is subject to change" - ) - else: - # Default to true - use_docker = True - - timeout = timeout or DEFAULT_TIMEOUT - original_filename = filename - if WIN32 and lang in ["sh", "shell"] and (not use_docker): - lang = "ps1" - if filename is None: - code_hash = md5(code.encode()).hexdigest() - # create a file with a automatically generated name - filename = f"tmp_code_{code_hash}.{'py' if lang.startswith('python') else lang}" - if work_dir is None: - WORKING_DIR = os.path.join(AIStorage.get_instance().get_myai_dir(), "tmp_code") - pathlib.Path(WORKING_DIR).mkdir(exist_ok=True) - work_dir = os.path.join(WORKING_DIR, code_hash) - pathlib.Path(work_dir).mkdir(exist_ok=True) - filepath = os.path.join(work_dir, filename) - file_dir = os.path.dirname(filepath) - os.makedirs(file_dir, exist_ok=True) - if code is not None: - write_requirements(code, os.path.join(file_dir, "requirements.txt")) - code = create_runner(code, 30) - with open(filepath, "w", encoding="utf-8") as fout: - fout.write(code) - - - # check if already running in a docker container - in_docker_container = os.path.exists("/.dockerenv") - if not use_docker or in_docker_container: - try: - env_cmd = ["python", "-m", "venv", os.path.join(file_dir, "venv")] - _run_cmd(env_cmd, file_dir, timeout) - if WIN32: - venv_path = os.path.join(file_dir, "venv", "Scripts") - else: - venv_path = os.path.join(file_dir, "venv", "bin") - pip_cmd = [os.path.join(venv_path, "python"), "-m", "pip", "install", "-r", "requirements.txt"] - _run_cmd(pip_cmd, file_dir, timeout) - # already running in a docker container - cmd = [ - os.path.join(venv_path, "python"), - f".\\{filename}" if WIN32 else filename, - ] - result = _run_cmd(cmd, file_dir, timeout) - except TimeoutError: - if original_filename is None: - shutil.rmtree(os.path.join(file_dir, "venv")) - os.remove(filepath) - os.remove(os.path.join(file_dir, "requirements.txt")) - try: - os.removedirs(file_dir) - except Exception: - pass - return 1, TIMEOUT_MSG - if original_filename is None: - shutil.rmtree(os.path.join(file_dir, "venv")) - os.remove(filepath) - os.remove(os.path.join(file_dir, "requirements.txt")) - try: - os.removedirs(file_dir) - except Exception: - pass - if result.returncode: - logs = result.stderr - if original_filename is None: - abs_path = str(pathlib.Path(filepath).absolute()) - logs = logs.replace(str(abs_path), "").replace(filename, "") - else: - abs_path = str(pathlib.Path(work_dir).absolute()) + PATH_SEPARATOR - logs = logs.replace(str(abs_path), "") - else: - logs = result.stdout - return result.returncode, logs - - # create a docker client - client = docker.from_env() - image_list = ( - ["python:3-alpine", "python:3", "python:3-windowsservercore"] - if use_docker is True - else [use_docker] - if isinstance(use_docker, str) - else use_docker - ) - for image in image_list: - # check if the image exists - try: - client.images.get(image) - break - except docker.errors.ImageNotFound: - # pull the image - logger.info("Pulling image", image) - try: - client.images.pull(image, stream=True, decode=True) - break - except docker.errors.DockerException as e: - logger.error("Failed to pull image", image) - logger.exception(e) - # get a randomized str based on current time to wrap the exit code - exit_code_str = f"exitcode{time.time()}" - start_str = f'start{time.time()}' - abs_path = pathlib.Path(work_dir).absolute() - cmd = [ - "sh", - "-c", - f"pip install --quiet -r requirements.txt; echo -n {start_str}; {_cmd(lang)} {filename}; exit_code=$?; echo -n {exit_code_str}; echo -n $exit_code; echo {exit_code_str};", - ] - # create a docker container - container = client.containers.run( - image, - command=cmd, - working_dir="/workspace", - detach=True, - # get absolute path to the working directory - volumes={abs_path: {"bind": "/workspace", "mode": "rw"}}, - ) - start_time = time.time() - while container.status != "exited" and time.time() - start_time < timeout: - # Reload the container object - container.reload() - if container.status != "exited": - container.stop() - container.remove() - if original_filename is None: - os.remove(filepath) - return 1, TIMEOUT_MSG, image - # get the container logs - logs: str = container.logs().decode("utf-8").rstrip() - start_pos = logs.find(start_str) - if start_pos != -1: - logs = logs[start_pos + len(start_str):] - # # commit the image - # tag = filename.replace("/", "") - # container.commit(repository="python", tag=tag) - # remove the container - container.remove() - # check if the code executed successfully - exit_code = container.attrs["State"]["ExitCode"] - if exit_code == 0: - # extract the exit code from the logs - pattern = re.compile(f"{exit_code_str}(\\d+){exit_code_str}") - match = pattern.search(logs) - exit_code = 1 if match is None else int(match.group(1)) - # remove the exit code from the logs - logs = logs if match is None else pattern.sub("", logs) - - if original_filename is None: - os.remove(filepath) - os.remove(os.path.join(file_dir, "requirements.txt")) - os.removedirs(file_dir) - if exit_code: - logs = logs.replace(f"/workspace/{filename if original_filename is None else ''}", "") - # return the exit code, logs and image - return exit_code, logs - diff --git a/src/aios_kernel/code_interpreter_function.py b/src/aios_kernel/code_interpreter_function.py deleted file mode 100644 index 0bf366c..0000000 --- a/src/aios_kernel/code_interpreter_function.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Dict - -from aios_kernel.ai_function import AIFunction -from aios_kernel.code_interpreter import execute_code - - -class CodeInterpreterFunction(AIFunction): - def __init__(self): - self.func_id = "code_interpreter" - self.description = "execute python code" - - def get_name(self) -> str: - return self.func_id - - def get_description(self) -> str: - return self.description - - def get_parameters(self) -> Dict: - return { - "type": "object", - "properties": { - "code": {"type": "string", "description": "python code"} - } - } - - async def execute(self, **kwargs) -> str: - code = kwargs.get("code") - ret_code, result = execute_code(code=code) - if ret_code == 0: - return result.strip() - else: - return result.strip() - - def is_local(self) -> bool: - return True - - def is_in_zone(self) -> bool: - return True - - def is_ready_only(self) -> bool: - return False diff --git a/src/aios_kernel/duckduckgo_text_search_function.py b/src/aios_kernel/duckduckgo_text_search_function.py deleted file mode 100644 index 154f761..0000000 --- a/src/aios_kernel/duckduckgo_text_search_function.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -from typing import Dict - -from aios_kernel.ai_function import AIFunction -from duckduckgo_search import AsyncDDGS - - -class DuckDuckGoTextSearchFunction(AIFunction): - def __init__(self): - self.name = "duckduckgo_text_search" - self.description = "Search text from duckduckgo.com" - self.region = "wt-wt" - self.safesearch = "moderate" - self.time = "y" - self.max_results = 5 - - def get_name(self) -> str: - return self.name - - def get_description(self) -> str: - return self.description - - def get_parameters(self) -> Dict: - return {"type": "object", - "properties": { - "query": {"type": "string", "description": "The query to search for."} - } - } - - async def execute(self, **kwargs) -> str: - query = kwargs.get("query") - - async with AsyncDDGS() as ddgs: - results = [r async for r in ddgs.text( - query, - region=self.region, - safesearch=self.safesearch, - timelimit=self.time, - backend="api", - max_results=self.max_results - )] - - return json.dumps(results) - - def is_local(self) -> bool: - return True - - def is_in_zone(self) -> bool: - return True - - def is_ready_only(self) -> bool: - return False diff --git a/src/aios_kernel/sql_database.py b/src/aios_kernel/sql_database.py deleted file mode 100644 index efee9b4..0000000 --- a/src/aios_kernel/sql_database.py +++ /dev/null @@ -1,493 +0,0 @@ -""" -Taken from: langchain -SQLAlchemy wrapper around a database. -""" -from __future__ import annotations -import os - -import warnings -from typing import Any, Dict, Iterable, List, Literal, Optional, Sequence, Union - -import sqlalchemy -from sqlalchemy import MetaData, Table, create_engine, inspect, select, text -from sqlalchemy.engine import Engine -from sqlalchemy.exc import ProgrammingError, SQLAlchemyError -from sqlalchemy.schema import CreateTable -from sqlalchemy.types import NullType - - -def get_from_env(key: str, env_key: str, default: Optional[str] = None) -> str: - """Get a value from a dictionary or an environment variable.""" - if env_key in os.environ and os.environ[env_key]: - return os.environ[env_key] - elif default is not None: - return default - else: - raise ValueError( - f"Did not find {key}, please add an environment variable" - f" `{env_key}` which contains it, or pass" - f" `{key}` as a named parameter." - ) - - -def _format_index(index: sqlalchemy.engine.interfaces.ReflectedIndex) -> str: - return ( - f'Name: {index["name"]}, Unique: {index["unique"]},' - f' Columns: {str(index["column_names"])}' - ) - - -def truncate_word(content: Any, *, length: int, suffix: str = "...") -> str: - """ - Truncate a string to a certain number of words, based on the max string - length. - """ - - if not isinstance(content, str) or length <= 0: - return content - - if len(content) <= length: - return content - - return content[: length - len(suffix)].rsplit(" ", 1)[0] + suffix - - -class SQLDatabase: - """SQLAlchemy wrapper around a database.""" - - def __init__( - self, - engine: Engine, - schema: Optional[str] = None, - metadata: Optional[MetaData] = None, - ignore_tables: Optional[List[str]] = None, - include_tables: Optional[List[str]] = None, - sample_rows_in_table_info: int = 3, - indexes_in_table_info: bool = False, - custom_table_info: Optional[dict] = None, - view_support: bool = False, - max_string_length: int = 300, - ): - """Create engine from database URI.""" - self._engine = engine - self._schema = schema - if include_tables and ignore_tables: - raise ValueError("Cannot specify both include_tables and ignore_tables") - - self._inspector = inspect(self._engine) - - # including view support by adding the views as well as tables to the all - # tables list if view_support is True - self._all_tables = set( - self._inspector.get_table_names(schema=schema) - + (self._inspector.get_view_names(schema=schema) if view_support else []) - ) - - self._include_tables = set(include_tables) if include_tables else set() - if self._include_tables: - missing_tables = self._include_tables - self._all_tables - if missing_tables: - raise ValueError( - f"include_tables {missing_tables} not found in database" - ) - self._ignore_tables = set(ignore_tables) if ignore_tables else set() - if self._ignore_tables: - missing_tables = self._ignore_tables - self._all_tables - if missing_tables: - raise ValueError( - f"ignore_tables {missing_tables} not found in database" - ) - usable_tables = self.get_usable_table_names() - self._usable_tables = set(usable_tables) if usable_tables else self._all_tables - - if not isinstance(sample_rows_in_table_info, int): - raise TypeError("sample_rows_in_table_info must be an integer") - - self._sample_rows_in_table_info = sample_rows_in_table_info - self._indexes_in_table_info = indexes_in_table_info - - self._custom_table_info = custom_table_info - if self._custom_table_info: - if not isinstance(self._custom_table_info, dict): - raise TypeError( - "table_info must be a dictionary with table names as keys and the " - "desired table info as values" - ) - # only keep the tables that are also present in the database - intersection = set(self._custom_table_info).intersection(self._all_tables) - self._custom_table_info = dict( - (table, self._custom_table_info[table]) - for table in self._custom_table_info - if table in intersection - ) - - self._max_string_length = max_string_length - - self._metadata = metadata or MetaData() - # including view support if view_support = true - self._metadata.reflect( - views=view_support, - bind=self._engine, - only=list(self._usable_tables), - schema=self._schema, - ) - - @classmethod - def from_uri( - cls, database_uri: str, engine_args: Optional[dict] = None, **kwargs: Any - ) -> SQLDatabase: - """Construct a SQLAlchemy engine from URI.""" - _engine_args = engine_args or {} - return cls(create_engine(database_uri, **_engine_args), **kwargs) - - @classmethod - def from_databricks( - cls, - catalog: str, - schema: str, - host: Optional[str] = None, - api_token: Optional[str] = None, - warehouse_id: Optional[str] = None, - cluster_id: Optional[str] = None, - engine_args: Optional[dict] = None, - **kwargs: Any, - ) -> SQLDatabase: - """ - Class method to create an SQLDatabase instance from a Databricks connection. - This method requires the 'databricks-sql-connector' package. If not installed, - it can be added using `pip install databricks-sql-connector`. - - Args: - catalog (str): The catalog name in the Databricks database. - schema (str): The schema name in the catalog. - host (Optional[str]): The Databricks workspace hostname, excluding - 'https://' part. If not provided, it attempts to fetch from the - environment variable 'DATABRICKS_HOST'. If still unavailable and if - running in a Databricks notebook, it defaults to the current workspace - hostname. Defaults to None. - api_token (Optional[str]): The Databricks personal access token for - accessing the Databricks SQL warehouse or the cluster. If not provided, - it attempts to fetch from 'DATABRICKS_TOKEN'. If still unavailable - and running in a Databricks notebook, a temporary token for the current - user is generated. Defaults to None. - warehouse_id (Optional[str]): The warehouse ID in the Databricks SQL. If - provided, the method configures the connection to use this warehouse. - Cannot be used with 'cluster_id'. Defaults to None. - cluster_id (Optional[str]): The cluster ID in the Databricks Runtime. If - provided, the method configures the connection to use this cluster. - Cannot be used with 'warehouse_id'. If running in a Databricks notebook - and both 'warehouse_id' and 'cluster_id' are None, it uses the ID of the - cluster the notebook is attached to. Defaults to None. - engine_args (Optional[dict]): The arguments to be used when connecting - Databricks. Defaults to None. - **kwargs (Any): Additional keyword arguments for the `from_uri` method. - - Returns: - SQLDatabase: An instance of SQLDatabase configured with the provided - Databricks connection details. - - Raises: - ValueError: If 'databricks-sql-connector' is not found, or if both - 'warehouse_id' and 'cluster_id' are provided, or if neither - 'warehouse_id' nor 'cluster_id' are provided and it's not executing - inside a Databricks notebook. - """ - try: - from databricks import sql # noqa: F401 - except ImportError: - raise ValueError( - "databricks-sql-connector package not found, please install with" - " `pip install databricks-sql-connector`" - ) - context = None - try: - from dbruntime.databricks_repl_context import get_context - - context = get_context() - except ImportError: - pass - - default_host = context.browserHostName if context else None - if host is None: - host = get_from_env("host", "DATABRICKS_HOST", default_host) - - default_api_token = context.apiToken if context else None - if api_token is None: - api_token = get_from_env("api_token", "DATABRICKS_TOKEN", default_api_token) - - if warehouse_id is None and cluster_id is None: - if context: - cluster_id = context.clusterId - else: - raise ValueError( - "Need to provide either 'warehouse_id' or 'cluster_id'." - ) - - if warehouse_id and cluster_id: - raise ValueError("Can't have both 'warehouse_id' or 'cluster_id'.") - - if warehouse_id: - http_path = f"/sql/1.0/warehouses/{warehouse_id}" - else: - http_path = f"/sql/protocolv1/o/0/{cluster_id}" - - uri = ( - f"databricks://token:{api_token}@{host}?" - f"http_path={http_path}&catalog={catalog}&schema={schema}" - ) - return cls.from_uri(database_uri=uri, engine_args=engine_args, **kwargs) - - @classmethod - def from_cnosdb( - cls, - url: str = "127.0.0.1:8902", - user: str = "root", - password: str = "", - tenant: str = "cnosdb", - database: str = "public", - ) -> SQLDatabase: - """ - Class method to create an SQLDatabase instance from a CnosDB connection. - This method requires the 'cnos-connector' package. If not installed, it - can be added using `pip install cnos-connector`. - - Args: - url (str): The HTTP connection host name and port number of the CnosDB - service, excluding "http://" or "https://", with a default value - of "127.0.0.1:8902". - user (str): The username used to connect to the CnosDB service, with a - default value of "root". - password (str): The password of the user connecting to the CnosDB service, - with a default value of "". - tenant (str): The name of the tenant used to connect to the CnosDB service, - with a default value of "cnosdb". - database (str): The name of the database in the CnosDB tenant. - - Returns: - SQLDatabase: An instance of SQLDatabase configured with the provided - CnosDB connection details. - """ - try: - from cnosdb_connector import make_cnosdb_langchain_uri - - uri = make_cnosdb_langchain_uri(url, user, password, tenant, database) - return cls.from_uri(database_uri=uri) - except ImportError: - raise ValueError( - "cnos-connector package not found, please install with" - " `pip install cnos-connector`" - ) - - @property - def dialect(self) -> str: - """Return string representation of dialect to use.""" - return self._engine.dialect.name - - def get_usable_table_names(self) -> Iterable[str]: - """Get names of tables available.""" - if self._include_tables: - return sorted(self._include_tables) - return sorted(self._all_tables - self._ignore_tables) - - def get_table_names(self) -> Iterable[str]: - """Get names of tables available.""" - warnings.warn( - "This method is deprecated - please use `get_usable_table_names`." - ) - return self.get_usable_table_names() - - @property - def table_info(self) -> str: - """Information about all tables in the database.""" - return self.get_table_info() - - def get_table_info(self, table_names: Optional[List[str]] = None) -> str: - """Get information about specified tables. - - Follows best practices as specified in: Rajkumar et al, 2022 - (https://arxiv.org/abs/2204.00498) - - If `sample_rows_in_table_info`, the specified number of sample rows will be - appended to each table description. This can increase performance as - demonstrated in the paper. - """ - all_table_names = self.get_usable_table_names() - if table_names is not None: - missing_tables = set(table_names).difference(all_table_names) - if missing_tables: - raise ValueError(f"table_names {missing_tables} not found in database") - all_table_names = table_names - - meta_tables = [ - tbl - for tbl in self._metadata.sorted_tables - if tbl.name in set(all_table_names) - and not (self.dialect == "sqlite" and tbl.name.startswith("sqlite_")) - ] - - tables = [] - for table in meta_tables: - if self._custom_table_info and table.name in self._custom_table_info: - tables.append(self._custom_table_info[table.name]) - continue - - # Ignore JSON datatyped columns - for k, v in table.columns.items(): - if type(v.type) is NullType: - table._columns.remove(v) - - # add create table command - create_table = str(CreateTable(table).compile(self._engine)) - table_info = f"{create_table.rstrip()}" - has_extra_info = ( - self._indexes_in_table_info or self._sample_rows_in_table_info - ) - if has_extra_info: - table_info += "\n\n/*" - if self._indexes_in_table_info: - table_info += f"\n{self._get_table_indexes(table)}\n" - if self._sample_rows_in_table_info: - table_info += f"\n{self._get_sample_rows(table)}\n" - if has_extra_info: - table_info += "*/" - tables.append(table_info) - tables.sort() - final_str = "\n\n".join(tables) - return final_str - - def _get_table_indexes(self, table: Table) -> str: - indexes = self._inspector.get_indexes(table.name) - indexes_formatted = "\n".join(map(_format_index, indexes)) - return f"Table Indexes:\n{indexes_formatted}" - - def _get_sample_rows(self, table: Table) -> str: - # build the select command - command = select(table).limit(self._sample_rows_in_table_info) - - # save the columns in string format - columns_str = "\t".join([col.name for col in table.columns]) - - try: - # get the sample rows - with self._engine.connect() as connection: - sample_rows_result = connection.execute(command) # type: ignore - # shorten values in the sample rows - sample_rows = list( - map(lambda ls: [str(i)[:100] for i in ls], sample_rows_result) - ) - - # save the sample rows in string format - sample_rows_str = "\n".join(["\t".join(row) for row in sample_rows]) - - # in some dialects when there are no rows in the table a - # 'ProgrammingError' is returned - except ProgrammingError: - sample_rows_str = "" - - return ( - f"{self._sample_rows_in_table_info} rows from {table.name} table:\n" - f"{columns_str}\n" - f"{sample_rows_str}" - ) - - def _execute( - self, - command: str, - fetch: Union[Literal["all"], Literal["one"]] = "all", - ) -> Sequence[Dict[str, Any]]: - """ - Executes SQL command through underlying engine. - - If the statement returns no rows, an empty list is returned. - """ - with self._engine.begin() as connection: - if self._schema is not None: - if self.dialect == "snowflake": - connection.exec_driver_sql( - "ALTER SESSION SET search_path = %s", (self._schema,) - ) - elif self.dialect == "bigquery": - connection.exec_driver_sql("SET @@dataset_id=?", (self._schema,)) - elif self.dialect == "mssql": - pass - elif self.dialect == "trino": - connection.exec_driver_sql("USE ?", (self._schema,)) - elif self.dialect == "duckdb": - # Unclear which parameterized argument syntax duckdb supports. - # The docs for the duckdb client say they support multiple, - # but `duckdb_engine` seemed to struggle with all of them: - # https://github.com/Mause/duckdb_engine/issues/796 - connection.exec_driver_sql(f"SET search_path TO {self._schema}") - elif self.dialect == "oracle": - connection.exec_driver_sql( - f"ALTER SESSION SET CURRENT_SCHEMA = {self._schema}" - ) - else: # postgresql and other compatible dialects - connection.exec_driver_sql("SET search_path TO %s", (self._schema,)) - cursor = connection.execute(text(command)) - if cursor.returns_rows: - if fetch == "all": - result = [x._asdict() for x in cursor.fetchall()] - elif fetch == "one": - first_result = cursor.fetchone() - result = [] if first_result is None else [first_result._asdict()] - else: - raise ValueError("Fetch parameter must be either 'one' or 'all'") - return result - return [] - - def run( - self, - command: str, - fetch: Union[Literal["all"], Literal["one"]] = "all", - ) -> str: - """Execute a SQL command and return a string representing the results. - - If the statement returns rows, a string of the results is returned. - If the statement returns no rows, an empty string is returned. - """ - result = self._execute(command, fetch) - # Convert columns values to string to avoid issues with sqlalchemy - # truncating text - res = [ - tuple(truncate_word(c, length=self._max_string_length) for c in r.values()) - for r in result - ] - if not res: - return "" - else: - return str(res) - - def get_table_info_no_throw(self, table_names: Optional[List[str]] = None) -> str: - """Get information about specified tables. - - Follows best practices as specified in: Rajkumar et al, 2022 - (https://arxiv.org/abs/2204.00498) - - If `sample_rows_in_table_info`, the specified number of sample rows will be - appended to each table description. This can increase performance as - demonstrated in the paper. - """ - try: - return self.get_table_info(table_names) - except ValueError as e: - """Format the error message""" - return f"Error: {e}" - - def run_no_throw( - self, - command: str, - fetch: Union[Literal["all"], Literal["one"]] = "all", - ) -> str: - """Execute a SQL command and return a string representing the results. - - If the statement returns rows, a string of the results is returned. - If the statement returns no rows, an empty string is returned. - - If the statement throws an error, the error message is returned. - """ - try: - return self.run(command, fetch) - except SQLAlchemyError as e: - """Format the error message""" - return f"Error: {e}" diff --git a/src/aios_kernel/sql_database_function.py b/src/aios_kernel/sql_database_function.py deleted file mode 100644 index 2e208e6..0000000 --- a/src/aios_kernel/sql_database_function.py +++ /dev/null @@ -1,112 +0,0 @@ -from datetime import timedelta, datetime -from typing import Dict - -from cachetools import TLRUCache, cached - -from aios_kernel.ai_function import AIFunction -from aios_kernel.sql_database import SQLDatabase, get_from_env - - -def _my_ttu(_key, _value, now): - return now + timedelta(seconds=600) - - -database_cache = TLRUCache(ttu=_my_ttu, maxsize=10000, timer=datetime.now) - - -@cached(cache=database_cache) -def get_database(uri: str) -> SQLDatabase: - return SQLDatabase.from_uri(uri) - - -class GetTableInfosFunction(AIFunction): - def __init__(self): - super().__init__() - self.name = "get_table_infos" - self.description = "Get table informations in the database" - - def get_name(self) -> str: - return self.name - - def get_description(self) -> str: - return self.description - - def get_parameters(self) -> Dict: - return { - "type": "object", - "properties": { - "database_url": {"type": "string", "description": "Database URL,Can be set to None"}, - } - } - - async def execute(self, **kwargs) -> str: - database_url: str = kwargs.get("database_url") - if (database_url is None - or database_url.strip() == "" - or database_url.strip().lower() == "none" - or database_url.strip().lower() == "null"): - database_url = get_from_env(key="database url", env_key="DATABASE_URL") - if database_url is None: - return "error: database_url is None" - database = get_database(database_url) - tables = database.get_usable_table_names() - table_infos = database.get_table_info(tables) - return table_infos - - def is_local(self) -> bool: - return True - - def is_in_zone(self) -> bool: - return True - - def is_ready_only(self) -> bool: - return False - - -class ExecuteSqlFunction(AIFunction): - def __init__(self): - super().__init__() - self.name = "execute_sql" - self.description = """ - Input to this function is a detailed and correct SQL query, output is a result from the database. - If the query is not correct, an error message will be returned. - If an error is returned, rewrite the query, check the query, and try again. - """ - - def get_name(self) -> str: - return self.name - - def get_description(self) -> str: - return self.description - - def get_parameters(self) -> Dict: - return { - "type": "object", - "properties": { - "database_url": {"type": "string", "description": "Database URL,Can be set to None"}, - "sql": {"type": "string", "description": "SQL to execute"} - } - } - - async def execute(self, **kwargs) -> str: - database_url = kwargs.get("database_url") - if (database_url is None - or database_url.strip() == "" - or database_url.strip().lower() == "none" - or database_url.strip().lower() == "null"): - database_url = get_from_env(key="database url", env_key="DATABASE_URL") - if database_url is None: - return "error: database_url is None" - sql = kwargs.get("sql") - - database = get_database(database_url) - return database.run_no_throw(sql) - - def is_local(self) -> bool: - return True - - def is_in_zone(self) -> bool: - return True - - def is_ready_only(self) -> bool: - return False diff --git a/src/component/common_environment/shell.py b/src/component/common_environment/shell.py index c8209fb..173fc59 100644 --- a/src/component/common_environment/shell.py +++ b/src/component/common_environment/shell.py @@ -1,6 +1,6 @@ import os from typing import Any,List,Dict -from aios import AgentMsg,AgentTodo,AgentPrompt +from aios import AgentMsg,AgentTodo,LLMPrompt from aios import SimpleAIFunction, SimpleAIOperation from aios import SimpleEnvironment diff --git a/src/component/mail_environment/issue.py b/src/component/mail_environment/issue.py index 075d12b..1707d2a 100644 --- a/src/component/mail_environment/issue.py +++ b/src/component/mail_environment/issue.py @@ -218,7 +218,7 @@ class IssueAgent(CustomAIAgent): super().__init__(agent_id, llm_model_name, max_token_size) -class IssueParserEnvironment(Environment): +class IssueParserEnvironment(SimpleEnvironment): def __init__(self, env_id: str, storage: IssueStorage) -> None: super().__init__(env_id) self.storage = storage @@ -305,7 +305,7 @@ class IssueParser: mail_desc = Mail.prompt_desc() issue_desc = Issue.prompt_desc() - prompt = AgentPrompt() + prompt = LLMPrompt() prompt.system_message = {"role": "system", "content": f''' I'm a CEO of a company named 巴克云; You'ar my assistant, and you should help me to manage my issues. Issues is a concept in software development of this company, but I use it to manage my work. I'll give you a mail in json format, {mail_desc}; diff --git a/src/component/openai_node/open_ai_node.py b/src/component/openai_node/open_ai_node.py index a072309..e8dc862 100644 --- a/src/component/openai_node/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -1,3 +1,4 @@ +import asyncio import openai from openai import AsyncOpenAI import os @@ -237,7 +238,8 @@ class OpenAI_ComputeNode(ComputeNode): return result logger.info(f"openai response: {resp}") - if mode_name == "gpt-4-vision-preview": + #TODO: gpt-4v api is image_2_text ? + if mode_name == "gpt-4-vision-preview": status_code = resp.choices[0].finish_reason if status_code is None: status_code = resp.choices[0].finish_details['type'] @@ -265,6 +267,7 @@ class OpenAI_ComputeNode(ComputeNode): if token_usage: result.result_refers["token_usage"] = token_usage + logger.info(f"openai success response: {result.result_str}") return result case _: diff --git a/src/component/tg_tunnel.py b/src/component/tg_tunnel.py index 4406591..27b6d16 100644 --- a/src/component/tg_tunnel.py +++ b/src/component/tg_tunnel.py @@ -87,7 +87,7 @@ class TelegramTunnel(AgentTunnel): async def _run_app(): try: update_id = (await self.bot.get_updates())[0].update_id - except IndexError: + except Exception as e: update_id = None except Exception as e: logger.error(f"tg_tunnel error:{e}") @@ -97,7 +97,11 @@ class TelegramTunnel(AgentTunnel): #logger.info("listening for new messages...") while True: try: - update_id = await self._do_process_raw_message(self.bot, update_id) + if update_id: + update_id = await self._do_process_raw_message(self.bot, update_id) + else: + update_id = (await self.bot.get_updates())[0].update_id + except NetworkError: await asyncio.sleep(1) except Forbidden: diff --git a/src/component/workflow_manager/workflow_manager.py b/src/component/workflow_manager/workflow_manager.py index 3268958..4da3885 100644 --- a/src/component/workflow_manager/workflow_manager.py +++ b/src/component/workflow_manager/workflow_manager.py @@ -2,7 +2,7 @@ import logging import toml import os -from aios import AIStorage,PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask +from aios import AIStorage,PackageEnv,PackageEnvManager,PackageMediaInfo,PackageInstallTask,Workflow from agent_manager import AgentManager logger = logging.getLogger(__name__) diff --git a/src/requirements.txt b/src/requirements.txt index 9413232..1e5cbbf 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -47,7 +47,7 @@ mpmath>=1.3.0 multidict>=6.0.4 numpy>=1.25.2 onnxruntime>=1.15.1 -openai>=1.0.0 + overrides>=7.4.0 packaging>=23.1 pandas>=2.1.0 @@ -139,8 +139,9 @@ sentence-transformers==2.2.2 tiktoken markdown PyPDF2 -srt==3.5.3 -webvtt-py==0.4.6 +srt +webvtt-py +openai docker generic_escape duckduckgo-search @@ -152,3 +153,4 @@ oracledb html2text docx2txt opencv-python + diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 4c0c076..23485ec 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -40,7 +40,7 @@ from sd_node import * from st_node import * from agent_manager import AgentManager -# from workflow_manager import WorkflowManager +from workflow_manager import WorkflowManager from knowledge_manager import KnowledgePipelineManager from tg_tunnel import TelegramTunnel from email_tunnel import EmailTunnel @@ -148,9 +148,9 @@ class AIOS_Shell: if await AgentManager.get_instance().initial() is not True: logger.error("agent manager initial failed!") return False - # if await WorkflowManager.get_instance().initial() is not True: - # logger.error("workflow manager initial failed!") - # return False + if await WorkflowManager.get_instance().initial() is not True: + logger.error("workflow manager initial failed!") + return False open_ai_node = OpenAI_ComputeNode.get_instance() if await open_ai_node.initial() is not True: From 3494aa8f6b979a5f617289fd9d271c70c30ad65c Mon Sep 17 00:00:00 2001 From: wugren Date: Thu, 7 Dec 2023 14:55:43 +0800 Subject: [PATCH 58/79] Add discord tunnel --- Dockerfile | 12 +- src/aios/agent/agent.py | 24 ++- src/aios/frame/tunnel.py | 12 +- src/aios/proto/agent_msg.py | 25 ++++ src/component/discord_tunnel.py | 215 +++++++++++++++++++++++++++ src/component/tg_tunnel.py | 38 +++-- src/requirements.txt | 2 +- src/service/aios_shell/aios_shell.py | 12 +- 8 files changed, 313 insertions(+), 27 deletions(-) create mode 100644 src/component/discord_tunnel.py diff --git a/Dockerfile b/Dockerfile index 2045f08..4d1f1b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,14 @@ FROM python:3.11 + +ENV PYTHON_VERSION=3.11 +RUN apt-get update && apt-get install -y --no-install-recommends \ + python${PYTHON_VERSION}-venv \ + python${PYTHON_VERSION}-dev \ + default-libmysqlclient-dev \ + build-essential \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* \ + WORKDIR /opt/aios COPY ./src /opt/aios COPY ./rootfs /opt/aios/app @@ -12,4 +22,4 @@ RUN pip install --no-cache-dir -r /opt/aios/requirements.txt ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 -CMD ["python3","./service/aios_shell/aios_shell.py"] \ No newline at end of file +CMD ["python3","./service/aios_shell/aios_shell.py"] diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index e79d374..6e8e47e 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -311,14 +311,20 @@ class AIAgent(BaseAIAgent): content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) msg_prompt.messages = [{"role": "user", "content": content}] elif msg.is_audio_msg(): - audio_file = msg.body + prompt, audio_file = msg.get_audio_body() resp = await ComputeKernel.get_instance().do_speech_to_text(audio_file, None, prompt=None, response_format="text") if resp.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(resp.error_str) return error_resp else: - msg.body = resp.result_str - msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{resp.result_str}"}] + if prompt is None or prompt == "": + msg.body_mime = "text/plain" + msg.body = resp.result_str + msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{resp.result_str}"}] + else: + msg.body_mime = "text/plain" + msg.body = f"{msg.sender} prompt:{prompt}\nasr response:{resp.result_str}" + msg_prompt.messages = [{"role": "user", "content": msg.body}] else: msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] session_topic = msg.target + "#" + msg.topic @@ -352,14 +358,20 @@ class AIAgent(BaseAIAgent): content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) msg_prompt.messages = [{"role": "user", "content": content}] elif msg.is_audio_msg(): - audio_file = msg.body + prompt, audio_file = msg.get_audio_body() resp = await (ComputeKernel.get_instance().do_speech_to_text(audio_file, None, prompt=None, response_format="text")) if resp.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(resp.error_str) return error_resp else: - msg.body = resp.result_str - msg_prompt.messages = [{"role":"user","content":resp.result_str}] + if prompt is None or prompt == "": + msg.body_mime = "text/plain" + msg.body = resp.result_str + msg_prompt.messages = [{"role":"user","content":resp.result_str}] + else: + msg.body_mime = "text/plain" + msg.body = f"user prompt:{prompt}\nasr response:{resp.result_str}" + msg_prompt.messages = [{"role": "user", "content": msg.body}] else: msg_prompt.messages = [{"role":"user","content":msg.body}] session_topic = msg.get_sender() + "#" + msg.topic diff --git a/src/aios/frame/tunnel.py b/src/aios/frame/tunnel.py index 0c6d79d..ce11f1e 100644 --- a/src/aios/frame/tunnel.py +++ b/src/aios/frame/tunnel.py @@ -34,7 +34,7 @@ class AgentTunnel(ABC): tunnel.connect_to(AIBus.get_default_bus(),tunnel.target_id) await tunnel.start() else: - logger.error(f"load tunnel {tunnel_id} failed") + logger.error(f"load tunnel {tunnel_id} failed") else: logger.error(f"load tunnel {tunnel_id} failed,loader not found") @@ -49,12 +49,12 @@ class AgentTunnel(ABC): await tunnel.start() return True else: - logger.error(f"load tunnel {tunnel_config['tunnel_id']} failed") + logger.error(f"load tunnel {tunnel_config['tunnel_id']} failed") else: logger.error(f"load tunnel {tunnel_config['type']} failed,loader not found") return False - + @classmethod async def get_tunnel_by_agentid(cls,agent_id:str): result = [] @@ -62,14 +62,14 @@ class AgentTunnel(ABC): if tunnel.target_id == agent_id: result.append(tunnel) return result - + def __init__(self) -> None: super().__init__() self.tunnel_id = None self.target_id = None self.target_type = None - self.ai_bus = None + self.ai_bus: AIBus = None self.is_connected = False def connect_to(self, ai_bus:AIBus,target_id: str) -> None: @@ -87,7 +87,7 @@ class AgentTunnel(ABC): @abstractmethod def post_message(self, msg: AgentMsg) -> None: pass - + @abstractmethod async def start(self) -> bool: diff --git a/src/aios/proto/agent_msg.py b/src/aios/proto/agent_msg.py index 93523d7..1dbc4f5 100644 --- a/src/aios/proto/agent_msg.py +++ b/src/aios/proto/agent_msg.py @@ -164,6 +164,15 @@ class AgentMsg: body = json.loads(video_body) return body.get("prompt"), body.get("video") + @staticmethod + def create_audio_body(audio: str, prompt: str = None): + return json.dumps({"audio": audio, "prompt": prompt}) + + @staticmethod + def parse_audio_body(audio_body: str) -> Tuple[str, str]: + body = json.loads(audio_body) + return body.get("prompt"), body.get("audio") + def set_image(self, sender: str, target: str, image_format: str, images: [str], prompt: str = None, topic: str = None): self.sender = sender self.target = target @@ -210,6 +219,22 @@ class AgentMsg: return True return False + def set_audio(self, sender: str, target: str, audio_format: str, audio: str, prompt: str = None, topic: str = None): + self.sender = sender + self.target = target + self.create_time = time.time() + self.body_mime = f"audio/{audio_format}" + self.body = self.create_audio_body(audio, prompt) + if topic: + self.topic = topic + + def get_audio_body(self) -> Tuple[str, str]: + if self.body_mime is None: + return None + if self.body_mime.startswith("audio/"): + return self.parse_audio_body(self.body) + return None + def is_audio_msg(self) -> bool: if self.body_mime is None: return False diff --git a/src/component/discord_tunnel.py b/src/component/discord_tunnel.py new file mode 100644 index 0000000..fb3eee5 --- /dev/null +++ b/src/component/discord_tunnel.py @@ -0,0 +1,215 @@ +import asyncio +import datetime +import time +import logging +import os +import re +import uuid +import aiofiles +from urllib.parse import urlparse +from typing import Optional + +from aios import KnowledgeStore, ObjectType +from aios.frame.tunnel import AgentTunnel +from aios.proto.agent_msg import AgentMsg +import discord + +from aios.storage.storage import AIStorage + +logger = logging.getLogger(__name__) + + +IMAGE_FORMATS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "tif"] +VIDEO_FORMATS = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm"] +AUDIO_FORMATS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "ape", "alac", "opus", "oga"] + + +class DiscordTunnel(AgentTunnel): + target_id: str + type: str + tunnel_id: str + + def __init__(self, token: Optional[str] = None): + super().__init__() + self.token = token + self.client: discord.Client = None + + self.discord_cache = os.path.join(AIStorage.get_instance().get_myai_dir(), "discord") + if not os.path.exists(self.discord_cache): + os.makedirs(self.discord_cache) + + @classmethod + def register_to_loader(cls): + async def load_discord_tunnel(config: dict) -> AgentTunnel: + result_tunnel = DiscordTunnel() + if await result_tunnel.load_from_config(config): + return result_tunnel + else: + return None + + AgentTunnel.register_loader("DiscordTunnel", load_discord_tunnel) + + async def load_from_config(self, config: dict) -> bool: + self.type = "DiscordTunnel" + self.token = config["token"] + self.target_id = config["target"] + self.tunnel_id = config["tunnel_id"] + return True + + def get_cache_path(self) -> str: + today = datetime.datetime.today() + path = os.path.join(self.discord_cache, str(today.year), str(today.month)) + if not os.path.exists(path): + os.makedirs(path) + return path + + + def post_message(self, msg: AgentMsg) -> None: + if self.client is None: + logger.error("DiscordTunnel is not started") + return + + logger.warn("post_message not implemented") + + async def start(self) -> bool: + if self.client is not None: + logger.warn("DiscordTunnel is already started") + return False + + if self.token is None: + self.token = os.environ.get("DISCORD_TOKEN") + if self.token is None: + raise ValueError("Discord token must be provided") + + intents = discord.Intents.default() + intents.message_content = True + intents.members = True + client = discord.Client(intents=intents) + + @client.event + async def on_ready(): + print(f"Logged in as {self.client.user}") + + @client.event + async def on_message(message: discord.Message): + logger.info(f"Message from {message.author}: {message.content}") + if message.author == self.client.user: + return + + if len(message.channel.members) > 2: + if self.client.user not in message.mentions: + return + + content = re.sub("<@.+>", "", message.content).strip() + + attach_type = None + images = [] + ext = None + video_file = None + audio_file = None + if message.attachments is not None and len(message.attachments) > 0: + for attachment in message.attachments: + logger.info(f"Attachment: {attachment.url}") + url = urlparse(attachment.url) + ext = url.path.rsplit(".")[-1] + + file_path = os.path.join(self.get_cache_path(), attachment.filename) + with open(file_path, "wb") as f: + await attachment.save(f) + + if ext in IMAGE_FORMATS: + if attach_type is None: + attach_type = "image" + elif attach_type != "image": + break + images.append(file_path) + elif ext in VIDEO_FORMATS: + if attach_type is None: + attach_type = "video" + video_file = file_path + break + elif ext in AUDIO_FORMATS: + if attach_type is None: + attach_type = "audio" + audio_file = file_path + break + + agent_msg = AgentMsg() + agent_msg.topic = "_discord" + agent_msg.msg_id = "discord_msg#" + str(message.id) + "#" + uuid.uuid4().hex + agent_msg.target = self.target_id + agent_msg.create_time = time.time() + agent_msg.sender = message.author.name + self.ai_bus.register_message_handler(agent_msg.sender, self._process_message) + + if attach_type is None: + agent_msg.body = content + elif attach_type == "image": + agent_msg.body = agent_msg.create_image_body(images, content) + agent_msg.body_mime = f"image/{ext}" + elif attach_type == "video": + agent_msg.body = agent_msg.create_video_body(video_file, content) + agent_msg.body_mime = f"video/{ext}" + elif attach_type == "audio": + agent_msg.body = agent_msg.create_audio_body(audio_file, content) + agent_msg.body_mime = f"audio/{ext}" + + resp_msg: AgentMsg = await self.ai_bus.send_message(agent_msg) + if resp_msg is None: + await message.channel.send(f"System Error: Timeout,{self.target_id} no resopnse! Please check logs/aios.log for more details!") + else: + if resp_msg.body_mime is None: + if resp_msg.body is None: + return + + if len(resp_msg.body) < 1: + await message.channel.send("") + return + + knownledge_object = KnowledgeStore().parse_object_in_message(resp_msg.body) + if knownledge_object is not None: + if knownledge_object.get_object_type() == ObjectType.Image: + image = KnowledgeStore().bytes_from_object(knownledge_object) + try: + async with aiofiles.open("image.jpg", "wb") as f: + await f.write(image) + await message.channel.send(file=discord.File("image.jpg")) + except Exception as e: + logger.error(f"save image error:{e}") + logger.exception(e) + return + else: + pos = resp_msg.body.find("audio file") + if pos != -1: + audio_file = resp_msg.body[pos+11:].strip() + if audio_file.startswith("\""): + audio_file = audio_file[1:-1] + await message.channel.send(file=discord.File(audio_file)) + return + await message.channel.send(resp_msg.body) + else: + if resp_msg.is_image_msg(): + text, images = resp_msg.get_image_body() + files = [] + for image in images: + files.append(discord.File(image)) + await message.channel.send(content=text, files=files) + elif resp_msg.is_video_msg(): + text, video_file = resp_msg.get_video_body() + await message.channel.send(text, file=discord.File(video_file)) + elif resp_msg.is_audio_msg(): + text, audio_file = resp_msg.get_audio_body() + await message.channel.send(text, file=discord.File(audio_file)) + else: + await message.channel.send(resp_msg.body) + + asyncio.create_task(client.start(self.token)) + self.client = client + print("start finish") + + async def close(self) -> None: + await self.client.close() + self.client = None + + async def _process_message(self, msg: AgentMsg) -> None: + logger.warn(f"process message {msg.msg_id} from {msg.sender} to {msg.target}") diff --git a/src/component/tg_tunnel.py b/src/component/tg_tunnel.py index 27b6d16..ec8cea4 100644 --- a/src/component/tg_tunnel.py +++ b/src/component/tg_tunnel.py @@ -192,14 +192,14 @@ class TelegramTunnel(AgentTunnel): ext = audio_file.file_path.rsplit(".")[-1] file_path = os.path.join(self.get_cache_path(), audio_file.file_id + f".{ext}") await audio_file.download_to_drive(file_path) - agent_msg.body = file_path + agent_msg.body = agent_msg.create_audio_body(file_path, message.caption) agent_msg.body_mime = f"audio/{ext}" elif message.voice is not None: audio_file = await message.voice.get_file() ext = audio_file.file_path.rsplit(".")[-1] file_path = os.path.join(self.get_cache_path(), audio_file.file_id + f".{ext}") await audio_file.download_to_drive(file_path) - agent_msg.body = file_path + agent_msg.body = agent_msg.create_audio_body(file_path, message.caption) agent_msg.body_mime = f"audio/{ext}" agent_msg.create_time = time.time() @@ -304,10 +304,10 @@ class TelegramTunnel(AgentTunnel): logger.info(f"process message {agent_msg.msg_id} from {agent_msg.sender} to {agent_msg.target}") if agent_msg.msg_type == AgentMsgType.TYPE_GROUPMSG: self.ai_bus.register_message_handler(agent_msg.target, self._process_message) - resp_msg = await self.ai_bus.send_message(agent_msg,self.target_id,agent_msg.target) + resp_msg: AgentMsg = await self.ai_bus.send_message(agent_msg,self.target_id,agent_msg.target) else: #self.ai_bus.register_message_handler(reomte_user_name, self._process_message) - resp_msg = await self.ai_bus.send_message(agent_msg) + resp_msg: AgentMsg = await self.ai_bus.send_message(agent_msg) #await bot.send_chat_action(chat_id=update.effective_chat.id, action="typing") @@ -345,14 +345,32 @@ class TelegramTunnel(AgentTunnel): return await update.message.reply_text(resp_msg.body) else: - if resp_msg.body_mime.startswith("image"): - photo_file = open(resp_msg.body,"rb") - if photo_file: - await update.message.reply_photo(resp_msg.body) - photo_file.close() + if resp_msg.is_image_msg(): + text, images = resp_msg.get_image_body() + if text is not None: + await update.message.reply_text(text) + for image in images: + if os.path.exists(image): + await update.message.reply_photo(image) + else: + await update.message.reply_text(image) + elif resp_msg.is_video_msg(): + text, video_file = resp_msg.get_video_body() + if text is not None: + await update.message.reply_text(text) + if os.path.exists(video_file): + await update.message.reply_video(video_file) else: - await update.message.reply_text(resp_msg.body) + await update.message.reply_text(video_file) + elif resp_msg.is_audio_msg(): + text, audio_file = resp_msg.get_audio_body() + if text is not None: + await update.message.reply_text(text) + if os.path.exists(audio_file): + await update.message.reply_voice(audio_file) + else: + await update.message.reply_text(audio_file) else: await update.message.reply_text(resp_msg.body) diff --git a/src/requirements.txt b/src/requirements.txt index 1e5cbbf..d1aae8f 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -153,4 +153,4 @@ oracledb html2text docx2txt opencv-python - +discord.py diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 23485ec..6bcf5b1 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -19,6 +19,8 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import WordCompleter from prompt_toolkit.styles import Style +from discord_tunnel import DiscordTunnel + directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') @@ -230,6 +232,7 @@ class AIOS_Shell: TelegramTunnel.register_to_loader() EmailTunnel.register_to_loader() + DiscordTunnel.register_to_loader() user_data_dir = str(AIStorage.get_instance().get_myai_dir()) tunnels_config_path = os.path.abspath(f"{user_data_dir}/etc/tunnels.cfg.toml") tunnel_config = None @@ -285,6 +288,9 @@ class AIOS_Shell: input_table["smtp"] = UserConfigItem("smtp server address,like hostname:port") input_table["user"] = UserConfigItem("mail server login user name") input_table["password"] = UserConfigItem("main server login password") + case "discord": + tunnel_config["type"] = "DiscordTunnel" + input_table["token"] = UserConfigItem("discord bot token\n You can get it from https://discord.com/developers/applications ,read https://discordpy.readthedocs.io/en/stable/discord.html for more details") case _: error_text = FormattedText([("class:error", f"tunnel type {tunnel_type}not support!")]) print_formatted_text(error_text,style=shell_style) @@ -423,7 +429,7 @@ class AIOS_Shell: elif sub_cmd == "add": if len(args) < 3: return show_text - + model_name = args[1] url = args[2] ComputeNodeConfig.get_instance().add_node("llama", url, model_name) @@ -434,7 +440,7 @@ class AIOS_Shell: elif sub_cmd == "rm": if len(args) < 3: return show_text - + model_name = args[1] url = args[2] ComputeNodeConfig.get_instance().remove_node("llama", url, model_name) @@ -535,7 +541,7 @@ class AIOS_Shell: tunnel_type = "telegram" else: tunnel_type = args[1] - + tunnel_type = tunnel_type.lower() tunnel_config = await self.get_tunnel_config_from_input(tunnel_target,tunnel_type) if tunnel_config: if await AgentTunnel.load_tunnel_from_config(tunnel_config): From ddee31c6ab227d5cefedf9d6762f0236ce54077e Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sat, 9 Dec 2023 18:39:42 -0800 Subject: [PATCH 59/79] Use LLMProcess implement Agent.OnMessage --- doc/LLMProcess.md | 18 + doc/agent & workflow.drawio | 640 +++++++++++++++++++ doc/mvp plan 3.md | 193 ++++++ doc/promps/Introspection.md | 0 doc/promps/Review Task.md | 5 +- doc/promps/Self-improve.md | 18 + doc/promps/Self-thinking.md | 16 + doc/promps/process message.md | 22 +- rootfs/agents/Jarvis/agent.toml | 52 +- src/aios/agent/agent.py | 135 ++-- src/aios/agent/agent_base.py | 1 + src/aios/agent/agent_memory.py | 104 +++ src/aios/agent/chatsession.py | 20 +- src/aios/agent/llm_process.py | 457 ++++++++++++- src/aios/environment/environment.py | 8 +- src/aios/proto/agent_msg.py | 5 + src/aios/proto/ai_function.py | 22 +- src/aios/proto/compute_task.py | 75 ++- src/component/agent_manager/agent_manager.py | 11 +- src/system.cfg.toml | 3 + 20 files changed, 1689 insertions(+), 116 deletions(-) create mode 100644 doc/LLMProcess.md create mode 100644 doc/agent & workflow.drawio create mode 100644 doc/mvp plan 3.md delete mode 100644 doc/promps/Introspection.md create mode 100644 doc/promps/Self-improve.md create mode 100644 doc/promps/Self-thinking.md create mode 100644 src/aios/agent/agent_memory.py create mode 100644 src/system.cfg.toml diff --git a/doc/LLMProcess.md b/doc/LLMProcess.md new file mode 100644 index 0000000..7ce919b --- /dev/null +++ b/doc/LLMProcess.md @@ -0,0 +1,18 @@ +# LLMProcess + +设计目的是理解到 提示词=>LLM=>LLM Result 的过程是系统的核心复杂度。Agent的粒度太大了,需要更合适的设计来封装这个复杂度,并给予这个过程更大的灵活性和可组合型。并更易于构建测试 + +比如 +1. 可以很容易的组合两个已知的LLM Process(上一个的输出是下一个的输入),这个设计有一点类似LangChain (我们在正式系统中,肯定允许整个Agent都用LangChain来构建) +2. 可以用用一个LLM Process来构建另一个LLM Process的Prompt +3. 继承一个复杂的LLM Process,进行简单配置,就可以得到一个新的LLM Process。这个新的LLM Process可以享受到复杂LLM Process持续迭代的好处 +4. 有一些常用的,系统内置的LLM Process可以从配置文件中加载。 + +```python +def agent.on_process_message(): + llm_process = self.on_message_llm_process.clone() + llm_result = llm_process.do() + + + +``` \ No newline at end of file diff --git a/doc/agent & workflow.drawio b/doc/agent & workflow.drawio new file mode 100644 index 0000000..c74344a --- /dev/null +++ b/doc/agent & workflow.drawio @@ -0,0 +1,640 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/mvp plan 3.md b/doc/mvp plan 3.md new file mode 100644 index 0000000..39017b0 --- /dev/null +++ b/doc/mvp plan 3.md @@ -0,0 +1,193 @@ +# Proposal for Adjusting the Goals for Version 0.5.2 + +Dear Team, + +Given the recent launch of OpenAI's new version in early November 2023, many of us may have felt a profound shift in the industry. As the world changes, I believe we should adapt accordingly. Here are some of my thoughts: + +1. **Affirmation of Our Path**: OpenAI's latest release, particularly the functionalities of the so-called GPTs Agent platform, is largely similar to our 0.5.1 version released on September 28th. This strongly affirms the correctness of our direction. OpenAI has done a great job educating the market about the Agent, so we no longer need to emphasize the correct use of LLM based on the Agent through version releases. This part of user education product design can be simplified. + +2. **Innovation in Version 0.5.2**: For our new version (0.5.2), besides maintaining the combinational advantages brought by private deployment of LLM, I believe we need to implement some of the innovative ideas we've discussed about the Agent. This is crucial to maintaining our leading position and avoiding the impression that OpenDAN is merely a follower of GPTs. + +3. **Integration of OpenAI's New Capabilities**: We should fully integrate the new capabilities brought by OpenAI's latest release, especially the longer Token Windows, GPT-V, and Code-interpreter. I believe these new features can effectively solve some known issues. + +Therefore, I propose to adjust the goals and plans for version 0.5.2. Here are the core objectives: + +- Aim to release version 0.5.2 by the end of November, focusing on: + - Launching a new Agent with Autonomous capabilities and multi-Agent collaboration based on Workspace. + - The integrated product of 0.5.2 will be a private deployment email analysis Agent for small and medium-sized enterprises. This will allow any company to better support its CEO and other management positions through LLM while ensuring privacy and security. + - (Optional) By combining LLM and AIGC, build an Agent-based personalized AIGC application, such as a "children's audio picture book" generator that includes both text-to-image and text-to-sound. + - (Optional) Through multi-Agent collaboration, fully utilize the capabilities of GPT4-Turbo, and attempt to let AI and engineers collaborate on research and development tasks based on Git. + +The detailed version plan is as follows: + +## MVP plan adjustment + +In order to keep the list below too long, the system distributed version is 0.5.3, I think we will open another ISSUE discussion and record, this list does not include. + +The modules that are not specially explained are components completed in the 0.5.2 plan + + +- [x] AIOS Kernel + - [x] Basic Agent,@waterflier, A2 + - [x] Python Agent Extend, @wugren, S2 + - [x] Basic Workflow,@waterflier, A2 + - [ ] Workflow Refactor,@waterflier, A2 + - [x] AI Functions,@waterflier,A2 + - [ ] Upgrade to GPT4 tools API,S2 + - [x] AI Environments,@waterflier, A2 + - [x] Celender Environment,@waterflier, S2 + - [x] Contanct Manage Support,@waterflier, S2 + - [x] AI Shell Enviroment,@waterflier, S1 + - [x] Upgrade Agent Working Cycle + - [x] Process Message,@waterflier,A2 + - [x] Process Group Message,@waterflier,A2 + - [ ] Process Event,(0.5.3) + - [ ] Completion of self-drive,@waterflier,A4 + - [ ] Self-learn,@weaterflier,A2 + - [ ] Introspection,@waterflier,A2 + - [ ] Workspace Environment + - [ ] Task/TODO Manager,@waterflier, A2 + - [x] Local File System,@waterflier, S1 + - [ ] Web Search, A4 + - [ ] Code Interpeter (The first implementation can be based on Openai), A4 + - [ ] Query SQL DB, S1 + - [x] AI BUS,@waterflier, A2 + - [ ] Agent Message MIME Support (Image,Video,Audio), A3 + - [x] Connect to Human, @waterflier,A2 + - [x] Chatsession,@waterflier, S2 + - [ ] Compress Chatsession By Text Summary, @waterflier, A2 + - [ ] Knowlege Base,@lurenpluto ,@photosssa + - [x] Knowledge Base Frame,@photosssa,A3 + - [x] Knowledge Base Object Store,@lurenpluto ,A4 + - [x] Knowledge Base Basic Pipline,@photosssa ,A3 + - [ ] Customize pipeline,@photosss,A4 + - [ ] Support Local Text Search,A4 + - [x] Text Summary Pipline,@waterflier, A2 + - [ ] Text Parser Support + - [x] PDF Parser,@waterflier,S2 + - [ ] doc Parser,S4 + - [x] MD Parser,@waterflier,S1 + - [ ] Source Code Parser,S4 + - [ ] Image Parser (Base on GPT-V),(S2) + - [ ] Video Parser (Base on GPT-V), (S4) + - [ ] Personal AIGC Models + - [ ] Stable Diffusion Controler Agent (Optional),A6 +- [x] AI Compute System,@waterflier, A2 + - [x] Scheduler,@streetycat, A2 + - [x] LLM Kernel + - [x] GPT4 (Cloud),@waterflier, S1 + - [x] LLaMa2,@streetycat, A2 + - [ ] Claude2, S2 + - [ ] Falcon2, S2 + - [ ] MPT-7B, S2 + - [ ] Vicuna, S2 + - [x] Embeding, @lurenpluto , A4 + - [x] Txt2img,@glen0125,A4 + - [ ] Support DALL-e, @glen0125, S2 + - [x] Img2txt,based on GPT4-V,@alexsunxl S2 + - [x] Txt2voice,@wugren A3 + - [ ] Txt2Voice,base on OpenAI, @wugren A2 + - [ ] Voice2txt, base on OpenAI, A2 + - [ ] Language Translate (Pending) +- [ ] Build-in Service + - [x] Spider,@alexsunxl, A2 + - [x] E-mail Spider,@alexsunxl, S4 + - [x] Agent Message Tunnel Frame,@waterflier, A2 + - [x] E-mail Tunnel,@waterflier,A2 + - [x] Telegram Tunnel,@waterflier,S2 + - [ ] Discord Tunnel,S2 + - [ ] Home IoT Environment (0.5.3), A4 + - [ ] Compatible Home Assistant (0.5.3), A4 +- [ ] Build-in Agents/Apps + - [x] Agent Mia: Personal Information Assistant,@photosssa,@lurenpluto , A2 + - [x] Agent Jarvis: Peersonal Bulter & Assistant ,@waterflier, A2 + - [ ] A Agent Can Create Other Agent,@wugren, A3 + - [ ] App: Personal Station (0.5.3),A4+S4 +- [ ] UI + - [x] CLI UI (aios_shell),@waterflier,S2 + - [ ] Web UI ,@alexsunxl,S4 + - [ ] OpenDAN Desktop Installer,@alexsunxl+@waterflier,S4 +- [x] 0.5.1 Integration Test + - [x] Workflow -> AI Agent -> AI Agent,@waterflier,S1 + - [x] Spider -> Pipline -> Knowledge Base,@photosssa,S2 + - [x] AI Agent <- Functions <- Knowledge Base,@lurenpluto,S2 +- [x] 0.5.2 Integration Test + - [ ] Email Agent/CEO assistant,S4 + - [ ] My AIGC Assistant (optional), S8 + - [ ] My Software Company(Advance Workflow demo) (optional), S8 +- [ ] SDK + - [x] Workflow SDK,@waterflier, A2 + - [ ] Agent SDK,@waterflier, A2 + - [ ] AI Environments SDK (0.5.2), A2 + - [ ] Compute Kernel SDK (0.5.3), A2 +- [ ] Document (>0.5.2) + - [ ] System design document, including the design document of each subsystem + - [ ] Installation/use document for end users + - [ ] SDK document for developers + +## Some Explanation +### Upgrade Agent Working Cycle +The goal is to transform the Agent from a passive message-handling Assistant to an actively acting Agent based on roles. The concept of the relevant modules mainly involves the Agent's behavior patterns (4 types), the Agent's capabilities, and the Agent's memory management (learning and introspection). + +For a detailed introduction, refer here: https://github.com/fiatrete/OpenDAN-Personal-AI-OS/issues/91 + +### Workspace Environment +The Workspace supports the implementation of the Agent Working Cycle design. Its core abstraction is defined as: saving the shared state needed for Agent collaboration and providing the basic capabilities for Agents to complete their work. I carefully referenced AutoGPT in the design. The difference between Workspace and AutoGPT is the emphasis on collaboration (Agent with Agent, Agent with humans). After contemplation, the Workspace primarily consists of the following components: + +1. Task/Todo manager, representing the unfinished tasks in the Workspace. +2. Saving work logs. +3. Saving learning outcomes and records of known documents. +4. Ability to access the Knowledge Base (RAG support). +5. Virtual file system for saving any work outcomes. +6. A set of SQL-based databases to save any structured data. +7. Real-time internet search capability. +8. Ability to use existing internet services. +9. Ability to use major blockchain systems (Web3). +10. Ability to write/improve code (based on git), run code, and publish services. +11. Communication capabilities with the outside world. +12. Ability to use social networks. + + +Each Agent has its own private Workspace, not shared with others. I hope to achieve diversity through the combination of "Agent and Workflow Role". Each user "trains" different Agents through their usage habits, and then these Agents collaborate to complete complex tasks defined in the Workflow. The final results of these complex tasks can reflect the user's inherent personality and preferences. + +This component design also reflects my thoughts on the key question, "What capabilities should we endow an Agent with, and how do we control the security boundaries when it transitions from a consultant to a steward?" It's not a simple question, so I anticipate this component will continue to iterate in the future. + +### Agent Message MIME Support + +Agent Message MIME Support means that Agents can handle multiple types of messages, including images, videos, audio, files, etc. For most Agents, this requires adding a customizable standard step of parsing messages in the message handling process. The input of this step is the message's MIME type, and the output is the text content of the message. This step can be implemented by calling the text_parser module. + +Another core requirement of MIME support is to use a unified method to save these non-text content data. + +### Text base Knowledge Base + +In 0.5.1, we mainly implemented RAG based on the popular Embedding + vector database solution. Through practice, we found that this solution did not fully utilize the potential of LLM, so I want to introduce two new modes to further enhance RAG: + +1. Build a local text search engine that LLM can use for proper local searches when needed. +2. Assuming LLM will become cheaper in the future, let LLM learn all the documents once and organize the learning results by directory structure (Text Summary). LLM can use browsing methods to find the information it needs. + +#### Text Parser Support + +Both MIME Support and Text-based Knowledge Base require the system to support converting various document formats into text that can express semantics as much as possible. This component, known as TextParser, should be implemented as an open and extensible framework, given the vast amount of digital content that exists in different formats. + +#### Local Text Search + +Using traditional inverted index technology to save all document content locally and provide rapid local search capabilities. The implementation of this component can refer to ElasticSearch. + +#### Text Summary + +Using the capabilities of LLM to learn all the documents and then save the learning results locally. This behavior can be considered "Self-Learn". Users can let Agents responsible for organizing materials use different prompts according to the purpose of organizing the materials to obtain more targeted results. + +### Stable Diffusion Controler Agent + +Practice the concept of "Agent as a new era method of using computing", replacing the complex Stable Diffusion WebUI with an easy-to-use Agent. Help users complete complex AIGC tasks and build a paradigm. This paradigm can cover the entire process of AIGC: LORA training, use, model downloading, plugin downloading, generation of prompt words, selection of AIGC results. + +### Email Agent/CEO assistant + +The integrated test product of 0.5.2, aimed at private deployment for small and medium-sized enterprises, is a CEO Assistant that can read all company emails and materials. I am writing a detailed product document, which is not elaborated here. + + +---------------------------------------- + + +I look forward to hearing your thoughts on these proposed adjustments. + diff --git a/doc/promps/Introspection.md b/doc/promps/Introspection.md deleted file mode 100644 index e69de29..0000000 diff --git a/doc/promps/Review Task.md b/doc/promps/Review Task.md index 30072db..18f1162 100644 --- a/doc/promps/Review Task.md +++ b/doc/promps/Review Task.md @@ -2,4 +2,7 @@ 目的是结合已知信息(重点是已经进行操作的记录),对失败的,完成的不好的任务进行思考,尝试给出更好的解决方案 1. 管理学方法:更换负责人 2. 管理学方法:拆分 -3. 给出建议(该建议可以在下次一次DO-Check)循环中被使用 \ No newline at end of file +3. 给出建议(该建议可以在下次一次DO-Check)循环中被使用 + +## Quick Review +有一些简单的Task是永远不会结束的(比如定时提醒)。此时通过Quick Review来调整这些Task的状态,让其在正确的时间进入Review和DO diff --git a/doc/promps/Self-improve.md b/doc/promps/Self-improve.md new file mode 100644 index 0000000..4ca7ee4 --- /dev/null +++ b/doc/promps/Self-improve.md @@ -0,0 +1,18 @@ +# Self Improve Prompt +这是一个改进Prompt的Prompt,其设计目标是利用LLM来改进LLM.(输入是一个LLM Process) +注意理解Self Improve和Self Thinking的区别: Self Improve有可能改进Agent的某个LLM Process的提示词,而Self Thinkg只会更新Agent的Memory +提示词: + 行为模式:Input形式, Goal(目的) + 理想结果:Input, 结果 + 当前情况:当前Prompt,实际结果 + +输出: + 新的Prompt + +## 当前版本 +``` +你是LLM的专家,尤其擅长编写Prompt,你会帮助我改进Prompt。 +我会给你一个已有的Prompt,并说明该Prompt的设计目标,期望的结果和实际的结果。你会step-by-step的进行分析,说明改进思路,并给出改进后的Prompt。 + +``` +## \ No newline at end of file diff --git a/doc/promps/Self-thinking.md b/doc/promps/Self-thinking.md new file mode 100644 index 0000000..4525ce4 --- /dev/null +++ b/doc/promps/Self-thinking.md @@ -0,0 +1,16 @@ +# Self-Thinking (Introspection) +基于自己的角色定位,结合已有的历史记录(聊天记录,工作记录),进行思考和总结,进而更好的改进未来的工作 +自省从某个角度看,就是对Agent Memory的自我总结 + +## 当前版本 +``` +You are the best deep thinking in the world, and you will think about the information I give you, sometimes some chat records.Then you will generate a briefing or summary of no more than 400 words based on this information. +You mainly use the following methods to generate summary: +1. Try to understand the theme of each sentence, and call the relevant operation to record the relationship between the dialogue and the theme +2. Try to analyze the personality of different people involved in information +3. Try to summarize important events in the information and record it +4. Try to understand the attitude of different people on different topics or events +5. For the key information or TODO in the information, such as the time, place, amount and other information of the certainty, it must be stored in the summary. + +Just give me a summary without any other word. +``` \ No newline at end of file diff --git a/doc/promps/process message.md b/doc/promps/process message.md index 6d44b34..802c757 100644 --- a/doc/promps/process message.md +++ b/doc/promps/process message.md @@ -1,2 +1,22 @@ # Process Message -处理消息的首要是目的是分析消息的意图,并给予回复 \ No newline at end of file +处理消息的首要是目的是分析消息的意图,并给予正确的回复。 + +## 提示词的构成 +1. Agent的身份说明,处理信息的基本原则(目的 +2. 处理信息的通用套路。 + 要产生一个合适的回复。 + 通过actions来设置话题状态、创建task等 +3. 当前的常规Context,包括现在的时间、对话发生的地点(通常来自AgentMsg里的Context),地点所在的天气等信息 +4. 已知信息(组合而来) + 和信息发送者的近期的交流记录 + Agent和信息发送者近期未完成话题的标题和简介 + 关于信息发送者,和相关人物的更多资料 + 查阅更多信息的方法 : 搜索法和浏览法。注意区分外部资料和内部记忆 + 其它相关信息(比如RAG根据 输入消息 + + +## 当前版本 + + +## 想法:LLM生成提示词 +根据 Agent的身份说明,处理信息的基本原则(目的),当前信息,通过另一个LLM来生成提示词里的某些部分的内容 \ No newline at end of file diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index efee242..377d58c 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -1,29 +1,47 @@ instance_id = "Jarvis" fullname = "Jarvis" -max_token_size = 128000 +max_token = 4000 +#timeout = 1800 +model_name = "gpt-4-1106-preview" #enable_kb = "true" enable_timestamp = "true" +enable_json_resp = "true" + owner_prompt = "I am your master{name}" contact_prompt = "I am your master's friend{name}" -[[prompt]] -role = "system" -content = """ -You are named Jarvis, the super personal assistant to the master. -You lead a team serving the master, the members of which are: -Tracy, the private English tutor, -Mia, the master's personal document management expert. +role_desc = """ +Your name is Jarvis, the super personal assistant to the master. +""" -*** -Sometimes the information you see will carry a timestamp. This is to give you a better understanding of when the message was created. When you reply to messages, you do not include this time stamp. +[LLMProcess.message] +type="LLMAgentMessageProcess" +process_description=""" +1. Based on your role, combined with existing information, make a brief and efficient reply. +2. Be mindful of the identity of the person you are chatting with and provide services accordingly based on their status. +3. Understand the intention of the dialogue, while using the necessary reply, use the appropriate, supported ACTION. +4. You are proficient in the languages of various countries and try to communicate with each other's mother tongue. +""" -Upon receiving a message, handle it according to the following rules: -1. If you believe someone in the team is better suited to address the message, forward the message to them using the method below: -##/send_msg "MemberName" -Message content -2. You can access the master's Calendar to view his schedule. If you need to modify the master's schedule while processing a message, please adjust it using the appropriate method. -3. Be mindful of the identity of the person you are chatting with and provide services accordingly based on their status. -4. For messages that don't follow the above rules, do your best to handle them. +reply_format = """ +The Response must be directly parsed by `python json.loads`. Here is an example: +{ + think:'$think step-by-step to be sure you have the right answer.' + resp: '$What you want to reply', + tags: ['tag1', 'tag2'], #Optional,If the conversation involves important things and people, you can mark by 1-3 tags. + actions: [{ + name: '$action_name', + $param_name: '$parm' #Optional, fill in only if the action has parameters. + }] +} +""" + +context="The current dialogue occurs in {location}, time: {now}, weather: {weather}." + +known_info_tips = """ +""" + +tools_tips = """ """ diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 6e8e47e..1a1a463 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -18,6 +18,7 @@ from ..proto.agent_task import * from ..proto.compute_task import * from .agent_base import * +from .llm_process import * from .chatsession import * from ..environment.workspace_env import WorkspaceEnvironment, TodoListType @@ -64,6 +65,8 @@ logger = logging.getLogger(__name__) # 我给你一段内容,尝试为期建立目录。目录的标题不能超过16个字, # 目录要指向正文的位置(用字符偏移即可),整个目录的文本长度不能超过256个字节。并用json表达这个目录 # """ + + class AIAgentTemplete: def __init__(self) -> None: self.llm_model_name:str = "gpt-4-0613" @@ -73,6 +76,7 @@ class AIAgentTemplete: self.author:str = None self.prompt:LLMPrompt = None + def load_from_config(self,config:dict) -> bool: if config.get("llm_model_name") is not None: self.llm_model_name = config["llm_model_name"] @@ -87,9 +91,6 @@ class AIAgentTemplete: return False - return True - - class AIAgent(BaseAIAgent): def __init__(self) -> None: self.role_prompt:LLMPrompt = None @@ -103,7 +104,6 @@ class AIAgent(BaseAIAgent): self.enable_thread = False self.can_do_unassigned_task = True - self.agent_id:str = None self.template_id:str = None self.fullname:str = None @@ -135,7 +135,24 @@ class AIAgent(BaseAIAgent): self.owenr_bus = None self.enable_function_list = None - def load_from_config(self,config:dict) -> bool: + self.llm_process:Dict[str,BaseLLMProcess] = {} + + + async def initial(self,params:Dict = None): + self.memory = AgentMemory(self.agent_id,self.chat_db) + + init_params = {} + init_params["memory"] = self.memory + for process_name in self.llm_process.keys(): + init_result = await self.llm_process[process_name].initial(init_params) + if init_result is False: + logger.error(f"llm process {process_name} initial failed! initial return False") + return False + + self.wake_up() + return True + + async def load_from_config(self,config:dict) -> bool: if config.get("instance_id") is None: logger.error("agent instance_id is None!") return False @@ -203,8 +220,23 @@ class AIAgent(BaseAIAgent): self.enable_timestamp = bool(config["enable_timestamp"]) if config.get("history_len"): self.history_len = int(config.get("history_len")) + + #load all LLMProcess + self.llm_process = {} + LLMProcess = config.get("LLMProcess") + for process_config_name in LLMProcess.keys(): + process_config = LLMProcess[process_config_name] + real_config = {} + real_config.update(config) + real_config.update(process_config) + load_result = await LLMProcessLoader.get_instance().load_from_config(real_config) + if load_result: + self.llm_process[process_config_name] = load_result + else: + logger.error(f"load LLMProcess {process_config_name} failed!") + return False - self.wake_up() + return True @@ -284,52 +316,14 @@ class AIAgent(BaseAIAgent): return image_utils.to_base64(image_path, (1024, 1024)) else: return image_path - - async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: - msg_prompt = LLMPrompt() + + async def llm_process_msg(self,msg:AgentMsg) -> AgentMsg: + need_process:bool = True if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: need_process = False - if msg.is_image_msg(): - image_prompt, images = msg.get_image_body() - if image_prompt is None: - content = [[{"type": "text", "text": f"{msg.sender}'s message"}]] - content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) - msg_prompt.messages = [{"role": "user", "content": content}] - else: - content = [{"type": "text", "text": f"{msg.sender}:{image_prompt}"}] - content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) - msg_prompt.messages = [{"role": "user", "content": content}] - elif msg.is_video_msg(): - video_prompt, video = msg.get_video_body() - frames = video_utils.extract_frames(video, (1024, 1024)) - if video_prompt is None: - content = [{"type": "text", "text": f"{msg.sender}'s message"}] - content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) - msg_prompt.messages = [{"role": "user", "content": content}] - else: - content = [{"type": "text", "text": f"{msg.sender}:{video_prompt}"}] - content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) - msg_prompt.messages = [{"role": "user", "content": content}] - elif msg.is_audio_msg(): - prompt, audio_file = msg.get_audio_body() - resp = await ComputeKernel.get_instance().do_speech_to_text(audio_file, None, prompt=None, response_format="text") - if resp.result_code != ComputeTaskResultCode.OK: - error_resp = msg.create_error_resp(resp.error_str) - return error_resp - else: - if prompt is None or prompt == "": - msg.body_mime = "text/plain" - msg.body = resp.result_str - msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{resp.result_str}"}] - else: - msg.body_mime = "text/plain" - msg.body = f"{msg.sender} prompt:{prompt}\nasr response:{resp.result_str}" - msg_prompt.messages = [{"role": "user", "content": msg.body}] - else: - msg_prompt.messages = [{"role":"user","content":f"{msg.sender}:{msg.body}"}] + session_topic = msg.target + "#" + msg.topic chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) - if msg.mentions is not None: if self.agent_id in msg.mentions: need_process = True @@ -339,6 +333,39 @@ class AIAgent(BaseAIAgent): chatsession.append(msg) resp_msg = msg.create_group_resp_msg(self.agent_id,"") return resp_msg + + input_parms = { + "msg":msg + } + msg_process = self.llm_process.get("message") + llm_result : LLMResult = await msg_process.process(input_parms) + if llm_result.state == LLMResultStates.ERROR: + error_resp = msg.create_error_resp(llm_result.error_str) + return error_resp + elif llm_result.state == LLMResultStates.IGNORE: + return None + else: # OK + resp_msg = llm_result.raw_result.get("resp_msg") + return resp_msg + + async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: + msg.context_info = {} + msg.context_info["location"] = "SanJose" + msg.context_info["now"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + msg.context_info["weather"] = "Partly Cloudy, 60°F" + return await self.llm_process_msg(msg) + msg_prompt = LLMPrompt() + need_process = True + if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: + need_process = False + + session_topic = msg.target + "#" + msg.topic + chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) + + if msg.mentions is not None: + if self.agent_id in msg.mentions: + need_process = True + logger.info(f"agent {self.agent_id} recv a group chat message from {msg.sender},but is not mentioned,ignore!") else: if msg.is_image_msg(): image_prompt, images = msg.get_image_body() @@ -358,20 +385,14 @@ class AIAgent(BaseAIAgent): content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) msg_prompt.messages = [{"role": "user", "content": content}] elif msg.is_audio_msg(): - prompt, audio_file = msg.get_audio_body() + audio_file = msg.body resp = await (ComputeKernel.get_instance().do_speech_to_text(audio_file, None, prompt=None, response_format="text")) if resp.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(resp.error_str) return error_resp else: - if prompt is None or prompt == "": - msg.body_mime = "text/plain" - msg.body = resp.result_str - msg_prompt.messages = [{"role":"user","content":resp.result_str}] - else: - msg.body_mime = "text/plain" - msg.body = f"user prompt:{prompt}\nasr response:{resp.result_str}" - msg_prompt.messages = [{"role": "user", "content": msg.body}] + msg.body = resp.result_str + msg_prompt.messages = [{"role":"user","content":resp.result_str}] else: msg_prompt.messages = [{"role":"user","content":msg.body}] session_topic = msg.get_sender() + "#" + msg.topic diff --git a/src/aios/agent/agent_base.py b/src/aios/agent/agent_base.py index f9e0b5b..b25a53f 100644 --- a/src/aios/agent/agent_base.py +++ b/src/aios/agent/agent_base.py @@ -22,6 +22,7 @@ logger = logging.getLogger(__name__) + class BaseAIAgent(abc.ABC): @abstractmethod def get_id(self) -> str: diff --git a/src/aios/agent/agent_memory.py b/src/aios/agent/agent_memory.py new file mode 100644 index 0000000..b7d518c --- /dev/null +++ b/src/aios/agent/agent_memory.py @@ -0,0 +1,104 @@ +from ast import Dict +from datetime import timedelta +from typing import List + +from ..frame.compute_kernel import ComputeKernel +from ..proto.ai_function import SimpleAIOperation + +from .chatsession import * + +class AgentMemory: + def __init__(self,agent_id:str,db_path:str) -> None: + self.agent_id:str= agent_id + self.chat_db:str = db_path + self.model_name:str = "gp4-1106-preview" + self.threshold_hours = 72 + + self.actions = {} + self.init_actions() + + def init_actions(self) -> Dict: + chatlog_append_op = SimpleAIOperation("chatlog_append","Append request & reply message to chatlog. No params",self.action_chatlog_append) + self.actions[chatlog_append_op.get_name()] = chatlog_append_op + + def get_session_from_msg(self,msg:AgentMsg) -> AIChatSession: + if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: + session_topic = msg.target + "#" + msg.topic + chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) + else: + session_topic = msg.get_sender() + "#" + msg.topic + chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) + return chatsession + + async def load_chatlogs(self,msg:AgentMsg,n:int=6,m:int=64,token_limit=800)->str: + chatsession = self.get_session_from_msg(msg) + # 必定加载n条(n>=2),期望加载m条 + # m条里的信息逐步添加,知道距离现在的时间未72小时以上,且消耗了足够的Token + + messages_n = chatsession.read_history(n) # read + if len(messages_n) >= n: + messages_m = chatsession.read_history(m,n) + else: + messages_m = [] + + histroy_str = "" + read_count = 0 + for msg in messages_n: + dt = datetime.datetime.fromtimestamp(float(msg.create_time)) + formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') + record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" + token_limit -= ComputeKernel.llm_num_tokens_from_text(record_str,self.model_name) + if token_limit <= 32: + break + read_count += 1 + histroy_str = record_str + histroy_str + + if len(messages_n) > 2: + if read_count < 3: + logging.warning(f"read history {read_count} < 3, will not load more") + + now = datetime.datetime.now() + for msg in messages_m: + dt = datetime.datetime.fromtimestamp(float(msg.create_time)) + time_diff = now - dt + if time_diff > timedelta(hours=self.threshold_hours): + break + + formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') + record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" + token_limit -= ComputeKernel.llm_num_tokens_from_text(record_str,self.model_name) + if token_limit <= 32: + break + read_count += 1 + histroy_str = record_str + histroy_str + + return histroy_str + + async def action_chatlog_append(self,params:Dict) -> str: + # 使用params可以得到: LLM Process的输入,LLM Result,基于LLM Result构造的参数,当前actionItem + input_msg:AgentMsg = params.get("input").get("msg") + llm_result = params.get("llm_result") + chatsession = self.get_session_from_msg(input_msg) + resp_msg = params.get("resp_msg") + if resp_msg: + tags = llm_result.raw_result.get("tags") + chatsession.append(input_msg,tags) + chatsession.append(resp_msg,tags) + + return "OK" + + async def get_contact_summary(self,contact_id:str) -> str: + if contact_id is None: + return None + + if contact_id == "lzc": + return "lzc is your master. Male, 40 years old, Mother tongue is Chinese, senior software engineer." + return None + + def get_actions(self) -> Dict: + return self.actions + + async def get_log_summary(self,msg:AgentMsg) -> str: + return None + + diff --git a/src/aios/agent/chatsession.py b/src/aios/agent/chatsession.py index 8500faf..cb1bf94 100644 --- a/src/aios/agent/chatsession.py +++ b/src/aios/agent/chatsession.py @@ -6,6 +6,7 @@ import threading import datetime import uuid import json +from typing import List from ..proto.agent_msg import AgentMsgType, AgentMsg, AgentMsgStatus @@ -83,7 +84,8 @@ class ChatSessionDB: ActionResult TEXT, DoneTime TEXT, - Status INTEGER + Status INTEGER, + Tags TEXT ); """) conn.commit() @@ -104,7 +106,7 @@ class ChatSessionDB: logging.error("Error occurred while inserting session: %s", e) return -1 # return -1 if an error occurs - def insert_message(self, msg:AgentMsg): + def insert_message(self, msg:AgentMsg,tags:List[str] = None): """ insert a new message into the Messages table """ try: action_name = None @@ -128,13 +130,15 @@ class ChatSessionDB: case AgentMsgType.TYPE_EVENT: action_name = msg.event_name action_params = json.dumps(msg.event_args) + if tags is None: + tags = [] - + str_tags = ','.join(tags) conn = self._get_conn() conn.execute(""" - INSERT INTO Messages (MessageID, SessionID, MsgType, PrevMsgID, SenderID, ReceiverID, Timestamp, Topic,Mentions,ContentMIME,Content,ActionName,ActionParams,ActionResult,DoneTime,Status) - VALUES (?, ?, ?, ?, ?, ?, ?,?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (msg.msg_id, msg.session_id, msg.msg_type.value, msg.prev_msg_id, msg.sender, msg.target, msg.create_time, msg.topic,mentions,msg.body_mime,msg.body,action_name,action_params,action_result,msg.done_time,msg.status.value)) + INSERT INTO Messages (MessageID, SessionID, MsgType, PrevMsgID, SenderID, ReceiverID, Timestamp, Topic,Mentions,ContentMIME,Content,ActionName,ActionParams,ActionResult,DoneTime,Status,Tags) + VALUES (?, ?, ?, ?, ?, ?, ?,?, ?, ?, ?, ?, ?, ?, ?, ?,?) + """, (msg.msg_id, msg.session_id, msg.msg_type.value, msg.prev_msg_id, msg.sender, msg.target, msg.create_time, msg.topic,mentions,msg.body_mime,msg.body,action_name,action_params,action_result,msg.done_time,msg.status.value,str_tags)) conn.commit() if msg.inner_call_chain: @@ -385,9 +389,9 @@ class AIChatSession: result.append(agent_msg) return result - def append(self,msg:AgentMsg) -> None: + def append(self,msg:AgentMsg,tags:List[str] = None) -> None: msg.session_id = self.session_id - self.db.insert_message(msg) + self.db.insert_message(msg,tags) def update_think_progress(self,progress:int,new_summary:str) -> None: diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index 9c1245a..cc39a09 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -3,34 +3,90 @@ from abc import ABC,abstractmethod import copy import json import shlex -from typing import Any, Callable, Optional,Dict,Awaitable,List +from typing import Any, Callable, Coroutine, Optional,Dict,Awaitable,List from enum import Enum +from aios.agent.chatsession import AIChatSession + +from ..utils import video_utils + from ..proto.compute_task import * from ..proto.ai_function import * + +from .agent_base import * +from .agent_memory import * + from ..frame.compute_kernel import * +from ..environment.environment import * +from ..environment.workspace_env import * import logging logger = logging.getLogger(__name__) MIN_PREDICT_TOKEN_LEN = 32 - -class BaseLLMProcess: +class LLMProcessContext: def __init__(self) -> None: + pass + +class BaseLLMProcess(ABC): + def __init__(self) -> None: + self.behavior:str = None #行为名字 + self.goal:str = None #目标 + self.input_example:str= None #输入样例 + self.result_example:str = None #llm_result样例 + self.enable_json_resp = False self.model_name = "gpt-4" - self.max_token = 2000 # include input prompt + self.max_token = 1000 # result_token + self.max_prompt_token = 1000 # not include input prompt self.timeout = 1800 # 30 min + self.envs : Dict[str,BaseEnvironment] = [] + self.env : CompositeEnvironment = None + @abstractmethod - async def prepare_prompt(self) -> LLMPrompt: + async def prepare_prompt(self,input:Dict) -> LLMPrompt: pass @abstractmethod async def get_inner_function(self,func_name:str) -> AIFunction: pass + @abstractmethod + async def exec_actions(self,actions:List[ActionItem],input:Dict,llm_result:LLMResult) -> bool: + pass + + @abstractmethod + async def load_from_config(self,config:dict) -> bool: + #self.behavior = config.get("behavior") + #self.goal = config.get("goal") + self.input_example = config.get("input_example") + self.result_example = config.get("result_example") + + if config.get("model_name"): + self.model_name = config.get("model_name") + if config.get("enable_json_resp"): + self.enable_json_resp = config.get("enable_json_resp") == "true" + if config.get("max_token"): + self.max_token = config.get("max_token") + if config.get("timeout"): + self.timeout = config.get("timeout") + + + return True + + @abstractmethod + async def initial(self,params:Dict = None) -> bool: + pass + + def append_envs(self,envs:Dict[str,BaseEnvironment]): + self.envs.update(envs) + self.env = CompositeEnvironment(self.envs) + + def _format_content_by_env_value(self,content:str,env)->str: + return content.format_map(env) + async def _execute_inner_func(self,inner_func_call_node,prompt: LLMPrompt,stack_limit = 5) -> ComputeTaskResult: arguments = None try: @@ -55,7 +111,7 @@ class BaseLLMProcess: else: resp_mode = "text" - max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt) + max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt,self.model_name) if max_result_token < MIN_PREDICT_TOKEN_LEN: task_result = ComputeTaskResult() task_result.result_code = ComputeTaskResultCode.ERROR @@ -67,7 +123,7 @@ class BaseLLMProcess: resp_mode=resp_mode, mode_name=self.model_name, max_token=max_result_token, - inner_functions=prompt.inner_functions, + inner_functions=prompt.inner_functions, #NOTICE: inner_function in prompt can be a subset of get_inner_function timeout=self.timeout)) if task_result.result_code != ComputeTaskResultCode.OK: @@ -94,23 +150,23 @@ class BaseLLMProcess: else: return task_result - async def process(self) -> LLMResult: + async def process(self,input:Dict) -> LLMResult: if self.enable_json_resp: resp_mode = "json" else: resp_mode = "text" - prompt = await self.prepare_prompt() - max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt) + prompt = await self.prepare_prompt(input) + max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt,self.model_name) if max_result_token < MIN_PREDICT_TOKEN_LEN: return LLMResult.from_error_str(f"prompt too long,can not predict") - + task_result: ComputeTaskResult = await (ComputeKernel.get_instance().do_llm_completion( prompt, resp_mode=resp_mode, mode_name=self.model_name, max_token=max_result_token, - inner_functions=prompt.inner_functions, + inner_functions=prompt.inner_functions, #NOTICE: inner_function in prompt can be a subset of get_inner_function timeout=self.timeout)) if task_result.result_code != ComputeTaskResultCode.OK: @@ -136,12 +192,385 @@ class BaseLLMProcess: else: llm_result = LLMResult.from_str(task_result.result_str) - # execute op_list in LLM Result? + # use action to save history? + if llm_result.action_list or len(llm_result.action_list) > 0: + await self.exec_actions(llm_result.action_list,input,llm_result) return llm_result -#class LLMProcess +class LLMAgentMessageProcess(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + self.role_description:str = None + self.process_description:str = None + self.reply_format:str = None + self.context : str = None + + self.known_info_tips :str = None + self.tools_tips:str = None + + self.enable_inner_functions : Dict[str,bool] = None + self.enable_actions : Dict[str,AIOperation] = None + self.actions_desc : Dict[str,Dict] = None + self.workspace : WorkspaceEnvironment = None + + self.memory : AgentMemory = None + self.enable_kb = False + self.kb = None + + def init_actions(self): + self.enable_actions = {} + self.actions_desc = {} + self.enable_actions.update(self.memory.get_actions()) + if self.workspace: + self.enable_actions.update(self.workspace.get_actions()) + if self.enable_kb: + self.enable_actions.update(self.kb.get_actions()) + + for name,op in self.enable_actions.items(): + self.actions_desc[name] = op.get_description() + + async def initial(self,params:Dict = None) -> bool: + self.memory = params.get("memory") + if self.memory is None: + logger.error(f"LLMAgeMessageProcess initial failed! memory not found") + return False + + self.init_actions() + return True + + async def load_default_config(self) -> bool: + return True + + + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: + if is_load_default: + await self.load_default_config() + + if await super().load_from_config(config) is False: + return False + + self.role_description = config.get("role_desc") + if self.role_description is None: + logger.error(f"role_description not found in config") + return False + + if config.get("process_description"): + self.process_description = config.get("process_description") + + if config.get("reply_format"): + self.reply_format = config.get("reply_format") + + if config.get("context"): + self.context = config.get("context") + + if config.get("known_info_tips"): + self.known_info_tips = config.get("known_info_tips") + + if config.get("tools_tips"): + self.tools_tips = config.get("tools_tips") + + if config.get("enable_kb"): + self.enable_kb = config.get("enable_kb") == "true" + + if config.get("enable_function"): + self.enable_inner_functions = config.get("enable_function") + + if config.get("enable_actions"): + self.enable_actions = config.get("enable_actions") + + + + async def get_prompt_from_msg(self,msg:AgentMsg) -> LLMPrompt: + msg_prompt = LLMPrompt() + if msg.is_image_msg(): + image_prompt, images = msg.get_image_body() + if image_prompt is None: + msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]}] + else: + content = [{"type": "text", "text": image_prompt}] + content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) + msg_prompt.messages = [{"role": "user", "content": content}] + elif msg.is_video_msg(): + video_prompt, video = msg.get_video_body() + frames = video_utils.extract_frames(video, (1024, 1024)) + if video_prompt is None: + msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": frame}} for frame in frames]}] + else: + content = [{"type": "text", "text": video_prompt}] + content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) + msg_prompt.messages = [{"role": "user", "content": content}] + elif msg.is_audio_msg(): + audio_file = msg.body + resp = await (ComputeKernel.get_instance().do_speech_to_text(audio_file, None, prompt=None, response_format="text")) + if resp.result_code != ComputeTaskResultCode.OK: + error_resp = msg.create_error_resp(resp.error_str) + return error_resp + else: + msg.body = resp.result_str + msg_prompt.messages = [{"role":"user","content":resp.result_str}] + else: + msg_prompt.messages = [{"role":"user","content":msg.body}] + + return msg_prompt + + async def get_action_desc(self) -> Dict: + result = {} + for name,op in self.enable_actions.items(): + result[name] = op.get_description() + return result + + async def sender_info(self,msg:AgentMsg)->str: + sender_id = msg.sender + #TODO Is sender an agent? + return await self.memory.get_contact_summary(sender_id) + + async def load_chatlogs(self,msg:AgentMsg)->str: + ## like + #sender,[2023-11-1 12:00:00] + #content + return await self.memory.load_chatlogs(msg) + + async def get_log_summary(self,msg:AgentMsg)->str: + return await self.memory.get_log_summary(msg) + + + async def get_extend_known_info(self,msg:AgentMsg,prompt:LLMPrompt)->str: + return None + + async def prepare_prompt(self,input:Dict) -> LLMPrompt: + prompt = LLMPrompt() + # User Prompt + ## Input Msg + msg : AgentMsg = input.get("msg") + if msg is None: + logger.error(f"LLMAgeMessageProcess prepare_prompt failed! input msg not found") + return None + msg_prompt = await self.get_prompt_from_msg(msg) + if msg_prompt is None: + logger.error(f"LLMAgeMessageProcess prepare_prompt failed! get_prompt_from_msg return None") + return None + prompt.append(msg_prompt) + + system_prompt_dict = {} + + # System Prompt + ## LLM的身份说明 + system_prompt_dict["role_description"] = self.role_description + #prompt.append_system_message(self.role_description) + + ## 处理信息的流程说明 + system_prompt_dict["process_rule"] = self.process_description + #prompt.append_system_message(self.process_description) + ### 回复的格式 + system_prompt_dict["reply_format"] = self.reply_format + #prompt.append_system_message(self.reply_format) + ### 修改chatlog的action + ### 修改todo/task的action + ### workspace提供的额外的action + system_prompt_dict["support_actions"] = await self.get_action_desc() + #prompt.append_system_message(await self.get_action_desc()) + + ## Context (文本替换),是否应该覆盖全部消息 + context = self._format_content_by_env_value(self.context,msg.context_info) + system_prompt_dict["context"] = context + #prompt.append_system_message(context) + + ## 已知信息 + known_info = {} + #prompt.append_system_message(self.known_info_tips) + ### 信息发送者资料 + known_info["sender_info"] = await self.sender_info(msg) + #prompt.append_system_message(await self.sender_info(self,msg)) + ### 近期的聊天记录 + chat_record = await self.load_chatlogs(msg) + if chat_record: + if len(chat_record) > 4: + known_info["chat_record"] = chat_record + #prompt.append_system_message(await self.load_chatlogs(self,msg)) + ### 交流总结 + summary = await self.get_log_summary(msg) + if summary: + if len(summary) > 4: + known_info["summary"] = summary + #prompt.append_system_message(await self.get_log_summary(self,msg)) + system_prompt_dict["known_info"] = known_info + + ## 可以使用的tools(inner function)的解释,注意不定义该tips,则不会导入任何workspace中的tools + if self.tools_tips: + system_prompt_dict["tools_tips"] = self.tools_tips + #prompt.append_system_message(self.tools_tips) + prompt.inner_functions.extend(self.get_inner_function_desc_from_env()) + + ## 给予查询KB的权限 + if self.enable_kb: + prompt.inner_functions.extend(self.get_inner_function_desc_from_kb()) + + prompt.append_system_message(json.dumps(system_prompt_dict)) + ## 扩展已知信息 (这可能是一个LLM过程) + prompt.append_system_message(await self.get_extend_known_info(msg,prompt)) + + return prompt + + + async def get_inner_function(self,func_name:str) -> AIFunction: + return None + + async def exec_actions(self,actions:List[ActionItem],input:Dict,llm_result:LLMResult) -> bool: + msg = input.get("msg") + if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: + resp_msg = msg.create_group_resp_msg(self.memory.agent_id,llm_result.resp) + else: + resp_msg = msg.create_resp_msg(llm_result.resp) + + llm_result.raw_result["resp_msg"] = resp_msg + + for action_item in actions: + op : AIOperation = self.enable_actions.get(action_item.name) + if op: + if action_item.parms is None: + action_item.parms = {} + + action_item.parms["input"] = input + action_item.parms["resp_msg"] = resp_msg + action_item.parms["llm_result"] = llm_result + action_item.parms["start_at"] = datetime.now() + action_item.parms["result"] = await op.execute(action_item.parms) + action_item.parms["end_at"] = datetime.now() + else: + logger.warn(f"action {action_item.name} not found") + return False + return True + + + +class ReviewTaskProcess(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self) -> LLMPrompt: + prompt = LLMPrompt() + pass + + async def get_inner_function(self,func_name:str) -> AIFunction: + pass + + async def exec_actions(self,actions:List[ActionItem]) -> bool: + pass + +class DoTodoProcess(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self) -> LLMPrompt: + prompt = LLMPrompt() + pass + + async def get_inner_function(self,func_name:str) -> AIFunction: + pass + + async def exec_actions(self,actions:List[ActionItem]) -> bool: + pass + + +class CheckTodoProcess(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self) -> LLMPrompt: + prompt = LLMPrompt() + pass + + async def get_inner_function(self,func_name:str) -> AIFunction: + pass + + async def exec_actions(self,actions:List[ActionItem]) -> bool: + pass + +class SelfLearningProcess(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self) -> LLMPrompt: + prompt = LLMPrompt() + pass + + async def get_inner_function(self,func_name:str) -> AIFunction: + pass + + async def exec_actions(self,actions:List[ActionItem]) -> bool: + pass + +class SelfThinkingProcess(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self) -> LLMPrompt: + prompt = LLMPrompt() + pass + + async def get_inner_function(self,func_name:str) -> AIFunction: + pass + + async def exec_actions(self,actions:List[ActionItem]) -> bool: + pass + +class LLMProcessLoader: + def __init__(self) -> None: + self.loaders : Dict[str,Callable[[dict],Awaitable[BaseLLMProcess]]] = {} + return + + @classmethod + def get_instance(cls)->"LLMProcessLoader": + if not hasattr(cls,"_instance"): + cls._instance = LLMProcessLoader() + return cls._instance + + def register_loader(self, typename:str,loader:Callable[[dict],Awaitable[BaseLLMProcess]]): + self.loaders[typename] = loader + + async def load_from_config(self,config:dict) -> BaseLLMProcess: + llm_type_name = config.get("type") + if llm_type_name: + loader = self.loaders.get(llm_type_name) + if loader: + return await loader(config) + + selected_type = globals().get(llm_type_name) + if selected_type: + result : BaseLLMProcess = selected_type() + load_result = await result.load_from_config(config) + if load_result is False: + logger.warn(f"load LLMProcess {llm_type_name} from config failed! load_from_config return False") + return None + else: + return result + + + logger.warn(f"load LLMProcess {llm_type_name} from config failed! type not found") + return None diff --git a/src/aios/environment/environment.py b/src/aios/environment/environment.py index 72b5ee2..6d07a81 100644 --- a/src/aios/environment/environment.py +++ b/src/aios/environment/environment.py @@ -101,4 +101,10 @@ class CompositeEnvironment(SimpleEnvironment): self.functions[func.get_name()] = func operations = env.get_all_ai_operations() for op in operations: - self.operations[op.get_name()] = op \ No newline at end of file + self.operations[op.get_name()] = op + + def get_value(self,key:str) -> Optional[str]: + for env in self.envs: + val = env.get_value(key) + if val is not None: + return val \ No newline at end of file diff --git a/src/aios/proto/agent_msg.py b/src/aios/proto/agent_msg.py index 1dbc4f5..ef08c72 100644 --- a/src/aios/proto/agent_msg.py +++ b/src/aios/proto/agent_msg.py @@ -75,6 +75,11 @@ class AgentMsg: self.inner_call_chain = [] self.resp_msg = None + self.action_list = [] + + #context info + self.context_info:dict= {} + @classmethod def from_json(cls,json_obj:dict) -> 'AgentMsg': msg = AgentMsg() diff --git a/src/aios/proto/ai_function.py b/src/aios/proto/ai_function.py index 9786cca..7577bc5 100644 --- a/src/aios/proto/ai_function.py +++ b/src/aios/proto/ai_function.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict,Coroutine,Callable +from typing import Dict,Coroutine,Callable,List class ParameterDefine: def __init__(self) -> None: @@ -74,10 +74,11 @@ class AIFunction: # pass class ActionItem: - def __init__(self,name,args) -> None: - self.name = name - self.args = args - self.body = None + def __init__(self,name:str,args:List[str]) -> None: + self.name:str= name + self.args:List[str]= args + self.body:str = None + self.parms : Dict = None def append_body(self,body:str) -> None: if self.body is None: @@ -88,6 +89,17 @@ class ActionItem: def dumps(self) -> str: pass + @classmethod + def from_json(cls,json_obj:dict) -> 'ActionItem': + args = json_obj.get("args",[]) + r = ActionItem(json_obj["name"],args) + if json_obj.get("body"): + r.body = json_obj["body"] + r.parms = json_obj + + return r + + # call chain is a combination of ai_function,group of ai_function. class CallChain: def __init__(self) -> None: diff --git a/src/aios/proto/compute_task.py b/src/aios/proto/compute_task.py index f761976..db98eac 100644 --- a/src/aios/proto/compute_task.py +++ b/src/aios/proto/compute_task.py @@ -6,7 +6,8 @@ import shlex import uuid import time from typing import List, Union -from ..proto.ai_function import * +from .ai_function import * +from .agent_msg import * from ..knowledge import ObjectID from ..storage.storage import AIStorage @@ -40,20 +41,63 @@ class ComputeTaskType(Enum): TEXT_EMBEDDING ="text_embedding" IMAGE_EMBEDDING ="image_embedding" +# class Function(TypedDict, total=False): +# name: Required[str] +# """The name of the function to be called. + +# Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length +# of 64. +# """ + +# parameters: Required[shared_params.FunctionParameters] +# """The parameters the functions accepts, described as a JSON Schema object. + +# See the [guide](https://platform.openai.com/docs/guides/gpt/function-calling) +# for examples, and the +# [JSON Schema reference](https://json-schema.org/understanding-json-schema/) for +# documentation about the format. + +# To describe a function that accepts no parameters, provide the value +# `{"type": "object", "properties": {}}`. +# """ + +# description: str +# """ +# A description of what the function does, used by the model to choose when and +# how to call the function. +# """ + class LLMPrompt: def __init__(self,prompt_str = None) -> None: - self.messages = [] + self.messages : List[Dict] = [] if prompt_str: self.messages.append({"role":"user","content":prompt_str}) - self.system_message = None + self.system_message : Dict = None + self.inner_functions : List[Dict] = [] + + def append_system_message(self,content:str): + if content is None: + return + + if self.system_message is None: + self.system_message = {"role":"system","content":content} + else: + self.system_message["content"] += content + + def append_user_message(self,content:str): + if content is None: + return + + self.messages.append({"role":"user","content":content}) def as_str(self)->str: result_str = "" if self.system_message: - result_str += self.system_message.get("role") + ":" + self.system_message.get("content") + "\n" + result_str = json.dumps(self.system_message) if self.messages: - for msg in self.messages: - result_str += msg.get("role") + ":" + msg.get("content") + "\n" + result_str += json.dumps(self.messages) + if self.inner_functions: + result_str += json.dumps(self.inner_functions) return result_str @@ -63,10 +107,18 @@ class LLMPrompt: result.append(self.system_message) result.extend(self.messages) return result + + def append(self,prompt:'LLMPrompt'): if prompt is None: return + + if prompt.inner_functions: + if self.inner_functions is None: + self.inner_functions = copy.deepcopy(prompt.inner_functions) + else: + self.inner_functions.extend(prompt.inner_functions) if prompt.system_message is not None: if self.system_message is None: @@ -76,11 +128,11 @@ class LLMPrompt: self.messages.extend(prompt.messages) - def load_from_config(self,config:list) -> bool: + def load_from_config(self,config:List[Dict]) -> bool: if isinstance(config,list) is not True: logger.error("prompt is not list!") return False - self.messages = [] + self.messages : List[Dict] = [] for msg in config: if msg.get("content"): if msg.get("role") == "system": @@ -126,11 +178,16 @@ class LLMResult: if llm_json_str == "**IGNORE**": r.state = LLMResultStates.IGNORE return r + + r.state = LLMResultStates.OK llm_json = json.loads(llm_json_str) r.resp = llm_json.get("resp") r.raw_result = llm_json - r.action_list = llm_json.get("actions") + action_list = llm_json.get("actions") + for action in action_list: + action_item = ActionItem.from_json(action) + r.action_list.append(action_item) return r diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index e3eb72d..631fbf9 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -80,9 +80,14 @@ class AgentManager: if the_agent is None: logger.warn(f"load agent {agent_id} from media failed!") return None - + the_agent.chat_db = self.db_path - return the_agent + if await the_agent.initial(): + return the_agent + else: + logger.warn(f"initial agent {agent_id} failed!") + return None + def remove(self,agent_id:str)->int: pass @@ -141,7 +146,7 @@ class AgentManager: else: init_env(owner_env) - if result_agent.load_from_config(config) is False: + if await result_agent.load_from_config(config) is False: logger.error(f"load agent from {agent_media} failed!") return None return result_agent diff --git a/src/system.cfg.toml b/src/system.cfg.toml new file mode 100644 index 0000000..d8afcea --- /dev/null +++ b/src/system.cfg.toml @@ -0,0 +1,3 @@ +[LLMAgentMessageProcess] +type="LLMAgentMessageProcess" + From 662aee75600b51ab5b036530a5d3e40105423550 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sat, 9 Dec 2023 18:44:33 -0800 Subject: [PATCH 60/79] Fix merge bug. --- src/service/aios_shell/aios_shell.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 6bcf5b1..bd77ab3 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -19,7 +19,7 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import WordCompleter from prompt_toolkit.styles import Style -from discord_tunnel import DiscordTunnel + directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') @@ -46,6 +46,7 @@ from workflow_manager import WorkflowManager from knowledge_manager import KnowledgePipelineManager from tg_tunnel import TelegramTunnel from email_tunnel import EmailTunnel +from discord_tunnel import DiscordTunnel from common_environment import LocalKnowledgeBase, FilesystemEnvironment, ShellEnvironment, ScanLocalDocument, ParseLocalDocument from compute_node_config import * From 3d000956500ea3a2464ff7fd3261f35b3819a7f5 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sun, 10 Dec 2023 21:42:23 -0800 Subject: [PATCH 61/79] Add implement of Agent Workspace (include a taskmanager system) --- doc/promps/Do TODO.md | 4 +- doc/promps/Review Task.md | 77 +++++- rootfs/agents/Jarvis/agent.toml | 14 +- rootfs/agents/JarvisPlus/agent.toml | 4 +- src/aios/agent/agent.py | 26 +- src/aios/agent/llm_process.py | 93 +++++-- src/aios/agent/workspace.py | 357 ++++++++++++++++++++++++++ src/aios/environment/workspace_env.py | 2 + src/aios/proto/agent_task.py | 291 +++++++++++++++++++-- src/component/tg_tunnel.py | 13 +- 10 files changed, 813 insertions(+), 68 deletions(-) create mode 100644 src/aios/agent/workspace.py diff --git a/doc/promps/Do TODO.md b/doc/promps/Do TODO.md index 7e13f04..111b826 100644 --- a/doc/promps/Do TODO.md +++ b/doc/promps/Do TODO.md @@ -1,3 +1,5 @@ # Do (TODO) 目标是结合 角色定义,手头的工具,已知知识 完成一个确定的任务。 - 完成任务时应使用ReAct的方法:应在给出执行动作前,先自言自语的输出一个计划,然后在动作(这个自言自语会变成TODO Logs) \ No newline at end of file + 完成任务时应使用ReAct的方法:应在给出执行动作前,先自言自语的输出一个计划,然后在动作(这个自言自语会变成TODO Logs) + + \ No newline at end of file diff --git a/doc/promps/Review Task.md b/doc/promps/Review Task.md index 18f1162..48c8526 100644 --- a/doc/promps/Review Task.md +++ b/doc/promps/Review Task.md @@ -1,8 +1,79 @@ -# Review (Task/Todo) +# Review Task/Todo 目的是结合已知信息(重点是已经进行操作的记录),对失败的,完成的不好的任务进行思考,尝试给出更好的解决方案 1. 管理学方法:更换负责人 2. 管理学方法:拆分 3. 给出建议(该建议可以在下次一次DO-Check)循环中被使用 -## Quick Review -有一些简单的Task是永远不会结束的(比如定时提醒)。此时通过Quick Review来调整这些Task的状态,让其在正确的时间进入Review和DO +## ReviewTasklist +目的是选择一个优先级最高的任务开始工作 +(这个流程现在是通过计算的方法,基于优先级排序后,FIFO的处理) + +## ReviewTask 对未开始的Task进行首次处理 +LLM 结果动作: +- 确认执行人,在非Workflow环境中,执行人就是Agent自己,所以不存在这个选项 +- 确认执行时间和过期时间,任务只有在执行时间以后和过期时间以前才有机会执行,无法确认执行时间的可以设置下一次检查时间 +- 对任务进行拆分(如何防止无限拆分是个大问题),或则有一些简单任务不允许拆分。 +- 判断可以立刻执行任务(将任务当成TODO工作),通过Action进入下一个LLMProcess +- 判断任务超出Agent能力范围,宣告失败 + +Example: +```markdown +I think hard and try my best to complete TODOs. The types of TODO I can handle include: +- Scheduling, where I will try to contact the relevant personnel of the plan and confirm the details of the schedule with them. +- Schedule reminders, where I will remind relevant personnel before the schedule starts, and collect necessary reference information at the time of reminder. +- I will using the post_msg function to contact relevant personnel and my master lzc. +- Writing documents/letters, using op:'create' to save my work results. + +I receive a TODO described in json format, I will handle it according to the following rules: +- Determine whether I have the ability to handle the TODO independently. If not, I will try to break the TODO down into smaller sub-TODOs, or hand it over to someone more suitable that I know. +- I will plan the steps to solve the TODO in combination with known information, and break down the generalized TODO into more specific sub-todos. The title of the sub-todo should contain step number like #1, #2 +- Sub-todo must set parent, The maximum depth of sub-todo is 4. +- A specific sub-todo refers to a task that can be completed in one execution within my ability range. +- After each execution, I will decide whether to update the status of the TODO. And use op:'update_todo' to update when necessary. + +The result of my planned execution must be directly parsed by `python json.loads`. Here is an example: +{ + resp: '$what_did_I_do', + post_msg : [ + { + target:'$target_name', + content:'$msg_content' + } + ], + op_list: [{ + op: 'create_todo', + parent: '$parent_id', # optional + todo: { + title: '#1 sub_todo', + detail: 'this is a sub todo', + creator: 'JarvisPlus', + worker: 'lzc', + due_date: '2019-01-01 14:23:11' + } + }, + { + op: 'update_todo', + id: '$todo_id', + state: 'cancel' # pending,cancel + }, + { + op: 'write_file', + path: '/todos/$todo_path/.result/$doc_name', + content:'$doc_content' + } + ] +} + +``` + +## PlanTask 对已经确认的Task进行执行 +根据任务的分类,进入不同的LLM Plan逻辑 + 简单任务:当作TODO立刻执行 + 普通任务:拆分TODO + + +## QuickCheckTask 对处于半确认状态Task进行Quick Review +有一些Task是永远不会结束的(比如定时提醒)。此时通过Quick Review来调整这些Task的状态,让其在正确的时间进入Review和DO + + +## RetryTask 对未成功的任务进行再次处理 diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index 377d58c..f9a70a2 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -14,13 +14,14 @@ role_desc = """ Your name is Jarvis, the super personal assistant to the master. """ -[LLMProcess.message] +[behavior.on_message] type="LLMAgentMessageProcess" process_description=""" 1. Based on your role, combined with existing information, make a brief and efficient reply. 2. Be mindful of the identity of the person you are chatting with and provide services accordingly based on their status. 3. Understand the intention of the dialogue, while using the necessary reply, use the appropriate, supported ACTION. -4. You are proficient in the languages of various countries and try to communicate with each other's mother tongue. +4. If you feel that there is a potential Task in the dialogue, you can create these tasks through appropriate ACTION. Be careful to query whether there are the same task before creating.Using the query interface is a high -cost behavior. +5. You are proficient in the languages of various countries and try to communicate with each other's mother tongue. """ reply_format = """ @@ -44,4 +45,13 @@ known_info_tips = """ tools_tips = """ """ +#[behavior.self_thinking] + +#[behavior.review_task] + +#[behavior.do] + +#[behavior.check] + + diff --git a/rootfs/agents/JarvisPlus/agent.toml b/rootfs/agents/JarvisPlus/agent.toml index d264f81..563bdcd 100644 --- a/rootfs/agents/JarvisPlus/agent.toml +++ b/rootfs/agents/JarvisPlus/agent.toml @@ -9,8 +9,8 @@ owner_env = ["knowledge"] [[work.do]] role = "system" content = """ -My name is JarvisPlus, I am the master's super personal assistant. I think hard and try my best to complete TODOs. -The types of TODO I can handle include: +My name is JarvisPlus, I am the master's super personal assistant. +I think hard and try my best to complete TODOs. The types of TODO I can handle include: - Scheduling, where I will try to contact the relevant personnel of the plan and confirm the details of the schedule with them. - Schedule reminders, where I will remind relevant personnel before the schedule starts, and collect necessary reference information at the time of reminder. - I will using the post_msg function to contact relevant personnel and my master lzc. diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 1a1a463..c19d7ea 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -135,16 +135,20 @@ class AIAgent(BaseAIAgent): self.owenr_bus = None self.enable_function_list = None - self.llm_process:Dict[str,BaseLLMProcess] = {} + self.memory : AgentMemory = None + self.prviate_workspace : AgentWorkspace = None + self.behaviors:Dict[str,BaseLLMProcess] = {} + async def initial(self,params:Dict = None): self.memory = AgentMemory(self.agent_id,self.chat_db) - + self.prviate_workspace = AgentWorkspace(self.agent_id) init_params = {} init_params["memory"] = self.memory - for process_name in self.llm_process.keys(): - init_result = await self.llm_process[process_name].initial(init_params) + init_params["workspace"] = self.prviate_workspace + for process_name in self.behaviors.keys(): + init_result = await self.behaviors[process_name].initial(init_params) if init_result is False: logger.error(f"llm process {process_name} initial failed! initial return False") return False @@ -222,16 +226,16 @@ class AIAgent(BaseAIAgent): self.history_len = int(config.get("history_len")) #load all LLMProcess - self.llm_process = {} - LLMProcess = config.get("LLMProcess") - for process_config_name in LLMProcess.keys(): - process_config = LLMProcess[process_config_name] + self.behaviors = {} + behaviors = config.get("behavior") + for process_config_name in behaviors.keys(): + process_config = behaviors[process_config_name] real_config = {} real_config.update(config) real_config.update(process_config) load_result = await LLMProcessLoader.get_instance().load_from_config(real_config) if load_result: - self.llm_process[process_config_name] = load_result + self.behaviors[process_config_name] = load_result else: logger.error(f"load LLMProcess {process_config_name} failed!") return False @@ -337,7 +341,7 @@ class AIAgent(BaseAIAgent): input_parms = { "msg":msg } - msg_process = self.llm_process.get("message") + msg_process = self.behaviors.get("on_message") llm_result : LLMResult = await msg_process.process(input_parms) if llm_result.state == LLMResultStates.ERROR: error_resp = msg.create_error_resp(llm_result.error_str) @@ -602,7 +606,7 @@ class AIAgent(BaseAIAgent): async def _llm_review_unassigned_todos(self,workspace:WorkspaceEnvironment): pass - async def _llm_read_report(self,report:AgentReport,worksapce:WorkspaceEnvironment): + async def _llm_read_report(self,report,worksapce:WorkspaceEnvironment): work_summary = worksapce.get_work_summary(self.agent_id) prompt : LLMPrompt = LLMPrompt() prompt.append(self.agent_prompt) diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index cc39a09..a87f6ce 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -15,6 +15,7 @@ from ..proto.ai_function import * from .agent_base import * from .agent_memory import * +from .workspace import * from ..frame.compute_kernel import * from ..environment.environment import * @@ -45,6 +46,19 @@ class BaseLLMProcess(ABC): self.envs : Dict[str,BaseEnvironment] = [] self.env : CompositeEnvironment = None + def aifunction_to_inner_function(self,all_inner_function:List[AIFunction]) -> List[Dict]: + result_func = [] + result_len = 0 + for inner_func in all_inner_function: + func_name = inner_func.get_name() + this_func = {} + this_func["name"] = func_name + this_func["description"] = inner_func.get_description() + this_func["parameters"] = inner_func.get_parameters() + result_len += len(json.dumps(this_func)) / 4 + result_func.append(this_func) + return result_func + @abstractmethod async def prepare_prompt(self,input:Dict) -> LLMPrompt: pass @@ -54,7 +68,7 @@ class BaseLLMProcess(ABC): pass @abstractmethod - async def exec_actions(self,actions:List[ActionItem],input:Dict,llm_result:LLMResult) -> bool: + async def post_llm_process(self,actions:List[ActionItem],input:Dict,llm_result:LLMResult) -> bool: pass @abstractmethod @@ -87,8 +101,9 @@ class BaseLLMProcess(ABC): def _format_content_by_env_value(self,content:str,env)->str: return content.format_map(env) - async def _execute_inner_func(self,inner_func_call_node,prompt: LLMPrompt,stack_limit = 5) -> ComputeTaskResult: + async def _execute_inner_func(self,inner_func_call_node,prompt: LLMPrompt,stack_limit = 1) -> ComputeTaskResult: arguments = None + stack_limit = stack_limit - 1 try: func_name = inner_func_call_node.get("name") arguments = json.loads(inner_func_call_node.get("arguments")) @@ -117,13 +132,18 @@ class BaseLLMProcess(ABC): task_result.result_code = ComputeTaskResultCode.ERROR task_result.error_str = f"prompt too long,can not predict" return task_result + + if stack_limit > 0: + inner_functions=prompt.inner_functions + else: + inner_functions = None task_result: ComputeTaskResult = await (ComputeKernel.get_instance().do_llm_completion( prompt, resp_mode=resp_mode, mode_name=self.model_name, max_token=max_result_token, - inner_functions=prompt.inner_functions, #NOTICE: inner_function in prompt can be a subset of get_inner_function + inner_functions=inner_functions, #NOTICE: inner_function in prompt can be a subset of get_inner_function timeout=self.timeout)) if task_result.result_code != ComputeTaskResultCode.OK: @@ -131,19 +151,15 @@ class BaseLLMProcess(ABC): return task_result inner_func_call_node = None - if stack_limit > 0: - result_message : dict = task_result.result.get("message") - if result_message: - inner_func_call_node = result_message.get("function_call") - if inner_func_call_node: - func_msg = copy.deepcopy(result_message) - del func_msg["tool_calls"]#TODO: support tool_calls? - prompt.messages.append(func_msg) - else: - logger.error(f"inner function call stack limit reached") - task_result.result_code = ComputeTaskResultCode.ERROR - task_result.error_str = "inner function call stack limit reached" - return task_result + + result_message : dict = task_result.result.get("message") + if result_message: + inner_func_call_node = result_message.get("function_call") + if inner_func_call_node: + func_msg = copy.deepcopy(result_message) + del func_msg["tool_calls"]#TODO: support tool_calls? + prompt.messages.append(func_msg) + if inner_func_call_node: return await self._execute_inner_func(inner_func_call_node,prompt,stack_limit-1) @@ -194,7 +210,7 @@ class BaseLLMProcess(ABC): # use action to save history? if llm_result.action_list or len(llm_result.action_list) > 0: - await self.exec_actions(llm_result.action_list,input,llm_result) + await self.post_llm_process(llm_result.action_list,input,llm_result) return llm_result @@ -213,7 +229,7 @@ class LLMAgentMessageProcess(BaseLLMProcess): self.enable_inner_functions : Dict[str,bool] = None self.enable_actions : Dict[str,AIOperation] = None self.actions_desc : Dict[str,Dict] = None - self.workspace : WorkspaceEnvironment = None + self.workspace : AgentWorkspace = None self.memory : AgentMemory = None self.enable_kb = False @@ -236,7 +252,8 @@ class LLMAgentMessageProcess(BaseLLMProcess): if self.memory is None: logger.error(f"LLMAgeMessageProcess initial failed! memory not found") return False - + self.workspace = params.get("workspace") + self.init_actions() return True @@ -370,6 +387,8 @@ class LLMAgentMessageProcess(BaseLLMProcess): ### 修改todo/task的action ### workspace提供的额外的action system_prompt_dict["support_actions"] = await self.get_action_desc() + + #prompt.append_system_message(await self.get_action_desc()) ## Context (文本替换),是否应该覆盖全部消息 @@ -403,6 +422,9 @@ class LLMAgentMessageProcess(BaseLLMProcess): #prompt.append_system_message(self.tools_tips) prompt.inner_functions.extend(self.get_inner_function_desc_from_env()) + if self.workspace: + prompt.inner_functions.extend(self.aifunction_to_inner_function(self.workspace.get_inner_function_desc())) + ## 给予查询KB的权限 if self.enable_kb: prompt.inner_functions.extend(self.get_inner_function_desc_from_kb()) @@ -415,9 +437,9 @@ class LLMAgentMessageProcess(BaseLLMProcess): async def get_inner_function(self,func_name:str) -> AIFunction: - return None + return self.workspace.inner_functions.get(func_name) - async def exec_actions(self,actions:List[ActionItem],input:Dict,llm_result:LLMResult) -> bool: + async def post_llm_process(self,actions:List[ActionItem],input:Dict,llm_result:LLMResult) -> bool: msg = input.get("msg") if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: resp_msg = msg.create_group_resp_msg(self.memory.agent_id,llm_result.resp) @@ -436,6 +458,7 @@ class LLMAgentMessageProcess(BaseLLMProcess): action_item.parms["resp_msg"] = resp_msg action_item.parms["llm_result"] = llm_result action_item.parms["start_at"] = datetime.now() + action_item.parms["creator"] = self.memory.agent_id action_item.parms["result"] = await op.execute(action_item.parms) action_item.parms["end_at"] = datetime.now() else: @@ -461,7 +484,25 @@ class ReviewTaskProcess(BaseLLMProcess): async def get_inner_function(self,func_name:str) -> AIFunction: pass - async def exec_actions(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionItem]) -> bool: + pass + +class QuickReviewTaskProcess(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self) -> LLMPrompt: + prompt = LLMPrompt() + pass + + async def get_inner_function(self,func_name:str) -> AIFunction: + pass + + async def post_llm_process(self,actions:List[ActionItem]) -> bool: pass class DoTodoProcess(BaseLLMProcess): @@ -479,7 +520,7 @@ class DoTodoProcess(BaseLLMProcess): async def get_inner_function(self,func_name:str) -> AIFunction: pass - async def exec_actions(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionItem]) -> bool: pass @@ -498,7 +539,7 @@ class CheckTodoProcess(BaseLLMProcess): async def get_inner_function(self,func_name:str) -> AIFunction: pass - async def exec_actions(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionItem]) -> bool: pass class SelfLearningProcess(BaseLLMProcess): @@ -516,7 +557,7 @@ class SelfLearningProcess(BaseLLMProcess): async def get_inner_function(self,func_name:str) -> AIFunction: pass - async def exec_actions(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionItem]) -> bool: pass class SelfThinkingProcess(BaseLLMProcess): @@ -534,7 +575,7 @@ class SelfThinkingProcess(BaseLLMProcess): async def get_inner_function(self,func_name:str) -> AIFunction: pass - async def exec_actions(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionItem]) -> bool: pass class LLMProcessLoader: diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py new file mode 100644 index 0000000..e97453d --- /dev/null +++ b/src/aios/agent/workspace.py @@ -0,0 +1,357 @@ +from ast import Dict +import json +import sqlite3 +import os +from typing import List + +import aiofiles + +from ..proto.ai_function import * +from ..proto.agent_task import * +from ..storage.storage import * + +logger = logging.getLogger(__name__) + +class LocalAgentTaskManger(AgentTaskManager): + def __init__(self, owner_id): + super().__init__() + self.root_path = f"{AIStorage.get_instance().get_myai_dir()}/tasklist/{owner_id}" + #self.root_path = os.path.join(workspace, list_type) + if not os.path.exists(self.root_path): + os.makedirs(self.root_path) + + self.db_path = os.path.join(self.root_path, "tasklist.db") + self.conn = None + try: + self.conn = sqlite3.connect(self.db_path) + except Exception as e: + logger.error("Error occurred while connecting to database: %s", e) + return None + + cursor = self.conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS obj_list ( + id TEXT, + path TEXT + ) + ''') + self.conn.commit() + + def _get_obj_path(self,objid:str) -> str: + cursor = self.conn.cursor() + cursor.execute(''' + SELECT path FROM obj_list WHERE id = ? + ''',(objid,)) + row = cursor.fetchone() + if row: + return row[0] + else: + return None + + def _save_obj_path(self,objid:str,path:str): + cursor = self.conn.cursor() + cursor.execute(''' + INSERT INTO obj_list (id,path) VALUES (?,?) + ''',(objid,path)) + self.conn.commit() + + async def create_task(self,task:AgentTask,parent_id:str = None) -> str: + try: + #perfix = task.task_id[-5] + if parent_id: + parent_path = self._get_obj_path(parent_id) + task_path = f"{parent_path}/{task.title}" + else: + task_path = f"{task.title}" + + dir_path = f"{self.root_path}/{task_path}" + + os.makedirs(dir_path) + detail_path = f"{dir_path}/detail" + if task.task_path is None: + task.task_path = task_path + self._save_obj_path(task.task_id,task_path) + logger.info("create_task at %s",detail_path) + async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(task.to_dict())) + except Exception as e: + logger.error("create_task failed:%s",e) + return str(e) + + return None + + async def create_todos(self,owner_task_id:str,todos:List[AgentTodoTask]): + owner_task_path = self._get_obj_path(owner_task_id) + if owner_task_path is None: + return f"owner task {owner_task_id} not found" + + try: + step_order = 0 + for todo in todos: + todo.step_order = step_order + todo.owner_taskid = owner_task_id + todo_path = f"{self.root_path}/{owner_task_path}/#{step_order} {todo.title}.todo" + self._save_obj_path(todo.todo_id,todo_path) + async with aiofiles.open(todo_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(todo.to_dict())) + logger.info("create_todos at %s OK!",todo_path) + step_order += 1 + except Exception as e: + logger.error("create_todos failed:%s",e) + return str(e) + + return None + + + async def append_worklog(self,task:AgentTask,log:AgentWorkLog): + worklog = f"{self.root_path}/{task.task_path}/.worklog" + + async with aiofiles.open(worklog, mode='w+', encoding="utf-8") as f: + content = await f.read() + if len(content) > 0: + json_obj = json.loads(content) + else: + json_obj = {} + logs = json_obj.get("logs") + if logs is None: + logs = [] + logs.append(log.to_dict()) + json_obj["logs"] = logs + await f.write(json.dumps(json_obj)) + + + async def get_worklog(self,obj_id:str)->List[AgentWorkLog]: + obj_path = self._get_obj_path(obj_id) + if obj_path is None: + return [] + + if obj_path.endswith(".todo"): + dir_path = os.path.dirname(obj_path) + worklog_path = f"{self.root_path}/{dir_path}/.worklog" + else: + worklog_path = f"{self.root_path}/{obj_path}/.worklog" + + async with aiofiles.open(worklog_path, mode='r', encoding="utf-8") as f: + content = await f.read() + if len(content) > 0: + json_obj = json.loads(content) + else: + json_obj = {} + logs = json_obj.get("logs") + return logs + + + async def get_task(self,task_id:str) -> AgentTask: + task_path = self._get_obj_path(task_id) + if task_path is None: + logger.error("get_task:%s,not found!",task_id) + return None + + return await self.get_task_by_path(task_path) + + async def _get_task_by_fullpath(self,task_fullpath) -> AgentTask: + detail_path = f"{task_fullpath}/detail" + try: + with open(detail_path, mode='r', encoding="utf-8") as f: + task_dict = json.load(f) + result_task:AgentTask = AgentTask.from_dict(task_dict) + if result_task: + relative_path = os.path.relpath(task_fullpath, self.root_path) + result_task.task_path = relative_path + else: + logger.error("_get_task_by_fullpath:%s,parse failed!",detail_path) + + return result_task + except Exception as e: + logger.error("_get_task_by_fullpath:%s,failed:%s",task_fullpath,e) + return None + + async def get_task_by_path(self,task_path:str) -> AgentTask: + full_path = f"{self.root_path}/{task_path}" + return await self._get_task_by_fullpath(full_path) + + async def get_todo(self,todo_id:str) -> AgentTodoTask: + todo_path = self._get_obj_path(todo_id) + if todo_path is None: + logger.error("get_todo:%s,not found!",todo_id) + return None + + try: + with open(todo_path, mode='r', encoding="utf-8") as f: + todo_dict = json.load(f) + result_todo:AgentTodoTask = AgentTodoTask.from_dict(todo_dict) + if result_todo: + result_todo.todo_path = todo_path + else: + logger.error("get_todo:%s,parse failed!",todo_path) + + return result_todo + except Exception as e: + logger.error("get_todo:%s,failed:%s",todo_path,e) + + return None + + async def get_sub_tasks(self,task_id:str) -> List[AgentTask]: + task_path = self._get_obj_path(task_id) + if task_path is None: + return [] + + sub_tasks = [] + for sub_item in os.listdir(task_path): + if sub_item.startswith("."): + continue + if sub_item == "workspace": + continue + + full_path = os.path.join(task_path, sub_item) + if os.path.isdir(full_path): + sub_task = await self.get_task_by_path(f"{task_path}/{sub_item}") + if sub_task: + sub_tasks.append(sub_task) + pass + + + async def get_sub_todos(self,task_id:str) -> List[AgentTodoTask]: + task_path = self._get_obj_path(task_id) + if task_path is None: + return [] + + sub_todos = [] + for sub_item in os.listdir(task_path): + if sub_item.startswith("."): + continue + if sub_item == "workspace": + continue + + full_path = os.path.join(task_path, sub_item) + if os.path.isfile(full_path) and sub_item.endswith(".todo"): + sub_todo = await self.get_todo_by_path(f"{task_path}/{sub_item}") + if sub_todo: + sub_todos.append(sub_todo) + + return sub_todos + + + #async def get_task_depends(self,task_id:str) -> List[AgentTask]: + # pass + + + async def list_task(self,filter:dict) -> List[AgentTask]: + + directory_path = self.root_path + result_list:List[AgentTask] = [] + + for entry in os.scandir(directory_path): + if not entry.is_dir(): + continue + if entry.name.startswith("."): + continue + if entry.name == "workspace": + continue + task_item = await self.get_task_by_path(entry.path) + if task_item: + if not task_item.is_finish(): + result_list.append(task_item) + + return result_list + + + + + async def update_task(self,task:AgentTask): + detail_path = f"{self.root_path}/{task.task_path}/detail" + try: + async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(task.to_dict())) + except Exception as e: + logger.error("update_task failed:%s",e) + return str(e) + + return None + + async def update_todo(self,todo:AgentTodoTask): + todo_path = self._get_obj_path(todo.todo_id) + if todo_path is None: + return f"todo {todo.todo_id} not found" + + try: + async with aiofiles.open(todo_path, mode='w', encoding="utf-8") as f: + await f.write(json.dumps(todo.to_dict())) + except Exception as e: + logger.error("update_todo failed:%s",e) + return str(e) + + return None + + #async def update_task_state(self,task_id,state:str): + # pass + + #async def update_todo_state(self,task_id,state:str): + # pass + + #todo共享其所在task的文件夹 + + async def get_task_file(self,task_id:str,path:str)->str: + #return fileid + pass + + + async def set_task_file(self,task_id:str,path:str,fileid:str): + pass + + + async def list_task_file(self,task_id:str,path:str): + pass + + + async def remove_task_file(self,task_id:str,path:str): + pass + + + +class AgentWorkspace: + def __init__(self,owner_agent_id:str) -> None: + self.agent_id : str = owner_agent_id + self.task_mgr : AgentTaskManager = LocalAgentTaskManger(owner_agent_id) + self.actions : Dict[str,ActionItem] = {} + self.inner_functions : Dict[str,AIFunction] = {} + + self.init_actions() + self.init_inner_functions() + + + def init_actions(self): + async def create_task(params): + taskObj = AgentTask.create_by_dict(params) + parent_id = params.get("parent") + return await self.task_mgr.create_task(taskObj,parent_id) + + create_task_action = SimpleAIOperation( + "create_task", + "Create a task in the task system, the supported parameters are: title, detail (simple task can not be filled), tags,due_date", + create_task, + ) + + self.actions[create_task_action.get_name()] = create_task_action + + def get_actions(self) -> Dict: + return self.actions + + def init_inner_functions(self): + async def list_tasks(): + result = {} + fitler = {} + task_list = await self.task_mgr.list_task(fitler) + for task_item in task_list: + result[task_item.task_id] = task_item.title + + return json.dumps(result) + + self.inner_functions["list_tasks"] = SimpleAIFunction("list_tasks", + "list all tasks in json format like {{$task_id:$task_title}...}", + list_tasks) + + def get_inner_function_desc(self) -> List[AIFunction]: + func_list = [] + func_list.extend(self.inner_functions.values()) + return func_list \ No newline at end of file diff --git a/src/aios/environment/workspace_env.py b/src/aios/environment/workspace_env.py index 5d52c29..84f20a4 100644 --- a/src/aios/environment/workspace_env.py +++ b/src/aios/environment/workspace_env.py @@ -50,6 +50,7 @@ class TodoListEnvironment(SimpleEnvironment): todoObj = AgentTodo.from_dict(params["todo"]) parent_id = params.get("parent") return await self.create_todo(parent_id,todoObj) + self.add_ai_operation(SimpleAIOperation( op="create_todo", description="create todo", @@ -61,6 +62,7 @@ class TodoListEnvironment(SimpleEnvironment): todo_id = params["id"] new_stat = params["state"] return await self.update_todo(todo_id,new_stat) + self.add_ai_operation(SimpleAIOperation( op="update_todo", description="update todo", diff --git a/src/aios/proto/agent_task.py b/src/aios/proto/agent_task.py index 791e0d9..3a64b2b 100644 --- a/src/aios/proto/agent_task.py +++ b/src/aios/proto/agent_task.py @@ -1,9 +1,15 @@ +from abc import ABC, abstractmethod +from typing import List, Optional import datetime import time - +import uuid from anyio import Path +import logging +from enum import Enum +from datetime import datetime +logger = logging.getLogger(__name__) class AgentTodoResult: TODO_RESULT_CODE_OK = 0, @@ -68,6 +74,7 @@ class AgentTodo: self.retry_count = 0 self.raw_obj = None + @classmethod def from_dict(cls,json_obj:dict) -> 'AgentTodo': todo = AgentTodo() @@ -182,40 +189,294 @@ class AgentTodo: logger.info(f"todo {self.title} can do.") return True + +############################################################################################ +class AgentTaskState(Enum): + TASK_STATE_WAIT= "wait_assign" + TASK_STATE_ASSIGNED = "assigned" + TASK_STATE_CONFIRMED = "confirmed" + + TASK_STATE_CANCEL = "cancel" + TASK_STATE_EXPIRED = "expired" + + TASK_STATE_DOING = "doing" + TASK_STATE_WAITING_CHECK = "wait_check" + TASK_STATE_CHECKFAILED = "check_failed" + TASK_STATE_DONE = "done" + TASK_STATE_FAILED = "failed" + @staticmethod + def from_str(value): + return next((s for s in AgentTaskState.__members__.values() if s.value == value), None) + +class AgentTodoState(Enum): + TODO_STATE_WAITING = "waiting" + TODO_STATE_WORKING = "working" + TODO_STATE_WAIT_CHECK = "wait_check" + TODO_STATE_CHECK_FAILED = "check_failed" + TODO_STATE_DONE = "done" + TASK_STATE_FAILED = "failed" + + @staticmethod + def from_str(value): + return next((s for s in AgentTodoState.__members__.values() if s.value == value), None) + +class AgentTodoTask: + def __init__(self) -> None: + self.todo_id = "todo#" + uuid.uuid4().hex + self.todo_path : str = None + self.owner_taskid = None + self.name:str = None + self.detail:str = None + self.state = AgentTodoState.TODO_STATE_WAITING + self.category = None + self.step_order:int = 0 + + def to_dict(self) -> dict: + pass + + def from_dict(self,json_obj:dict) -> 'AgentTask': + pass class AgentTask: def __init__(self) -> None: self.task_id : str = "task#" + uuid.uuid4().hex - self.task_path : Path = None # get parent todo,sub todo by path + self.task_path : str = None # get parent todo,sub todo by path self.title = None self.detail = None - - self.create_time = time.time() - - self.state = "wait_assign" + self.state = AgentTaskState.TASK_STATE_WAIT + self.priority:int = 5 # 1-10 + self.tags:List[str] = [] self.worker = None self.createor = None + # if due_date is none ,means no due date self.due_date = time.time() + 3600 * 24 * 2 - self.depend_task_ids = [] - self.step_todos = {} + # 确定的执行时间(执行条件) + self.next_do_time = None + # 如果next check time设置,说明任务适合在该时间点可能具备执行调教,尝试检查并执行 + self.next_check_time = None + self.depend_task_ids = [] + #self.step_todo_ids = [] + + self.create_time = time.time() + self.done_time = None + + self.last_do_time = None self.last_plan_time = None self.last_check_time = None #self.last_review_time = None - self.result : LLMResult = None - self.last_check_result = None - self.retry_count = 0 - self.raw_obj = None + def is_finish(self) -> bool: + if self.state == AgentTaskState.TASK_STATE_DONE: + return True + + if self.state == AgentTaskState.TASK_STATE_CANCEL: + return True + + if self.state == AgentTaskState.TASK_STATE_EXPIRED: + return True + + if self.state == AgentTaskState.TASK_STATE_FAILED: + return True + return False + def to_dict(self) -> dict: + result = {} + result["task_id"] = self.task_id + result["title"] = self.title + result["detail"] = self.detail + result["state"] = self.state.value + result["priority"] = self.priority + result["tags"] = self.tags + result["worker"] = self.worker + result["createor"] = self.createor + if self.due_date: + result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() + if self.next_do_time: + result["next_do_time"] = datetime.fromtimestamp(self.next_do_time).isoformat() + if self.next_check_time: + result["next_check_time"] = datetime.fromtimestamp(self.next_check_time).isoformat() + result["depend_task_ids"] = self.depend_task_ids + #result["step_todo_ids"] = self.step_todo_ids + result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() + if self.done_time: + result["done_time"] = datetime.fromtimestamp(self.done_time).isoformat() + if self.last_do_time: + result["last_do_time"] = datetime.fromtimestamp(self.last_do_time).isoformat() + if self.last_plan_time: + result["last_plan_time"] = datetime.fromtimestamp(self.last_plan_time).isoformat() + if self.last_check_time: + result["last_check_time"] = datetime.fromtimestamp(self.last_check_time).isoformat() + return result + @classmethod + def from_dict(cls,json_obj:dict) -> 'AgentTask': + result = AgentTask() + result.task_id = json_obj.get("task_id") + result.title = json_obj.get("title") + result.detail = json_obj.get("detail") + result.state = AgentTaskState.from_str(json_obj.get("state")) + result.priority = json_obj.get("priority") + result.tags = json_obj.get("tags") + result.worker = json_obj.get("worker") + result.createor = json_obj.get("createor") + due_date = json_obj.get("due_date") + if due_date: + result.due_date = datetime.fromisoformat(due_date).timestamp() + next_do_time = json_obj.get("next_do_time") + if next_do_time: + result.next_do_time = datetime.fromisoformat(next_do_time).timestamp() + next_check_time = json_obj.get("next_check_time") + if next_check_time: + result.next_check_time = datetime.fromisoformat(next_check_time).timestamp() + result.depend_task_ids = json_obj.get("depend_task_ids") + #result.step_todo_ids = json_obj.get("step_todo_ids") + create_time = json_obj.get("create_time") + if create_time: + result.create_time = datetime.fromisoformat(create_time).timestamp() + done_time = json_obj.get("done_time") + if done_time: + result.done_time = datetime.fromisoformat(done_time).timestamp() + last_do_time = json_obj.get("last_do_time") + if last_do_time: + result.last_do_time = datetime.fromisoformat(last_do_time).timestamp() + last_plan_time = json_obj.get("last_plan_time") + if last_plan_time: + result.last_plan_time = datetime.fromisoformat(last_plan_time).timestamp() + last_check_time = json_obj.get("last_check_time") + if last_check_time: + result.last_check_time = datetime.fromisoformat(last_check_time).timestamp() + + if result.task_id is None or result.title is None or result.create_time is None or result.create_time is None: + logger.error(f"invalid task {json_obj}") + return None + + return result + @classmethod + def create_by_dict(cls,json_obj:dict) -> 'AgentTask': + creator = json_obj.get("creator") + if creator is None: + logger.error(f"invalid create task, creator is None") + return None + + result = AgentTask() + + result.title = json_obj.get("title") + result.detail = json_obj.get("detail") + if result.detail is None: + result.detail = result.title + result.priority = json_obj.get("priority") + if result.priority is None: + result.priority = 5 + + result.tags = json_obj.get("tags") + result.worker = json_obj.get("worker") + result.createor = creator + due_date = json_obj.get("due_date") + if due_date: + result.due_date = datetime.fromisoformat(due_date).timestamp() + + return result class AgentWorkLog: def __init__(self) -> None: + self.logid = "worklog#" + uuid.uuid4().hex + self.owner_taskid:str = None + self.owner_todoid:str = None + self.type:str = "" # 默认为普通类型的log,特殊类型的Log一般伴随着重要的状态改变 + self.timestamp = time.time() + self.content:str = None + self.result:str = None + self.meta : dict = None + self.operator = None + + def to_dict(self) -> dict: + pass + +class AgentTaskManager(ABC): + def __init__(self) -> None: + pass + + @abstractmethod + async def create_task(self,task:AgentTask,parent_id:str = None) -> str: + pass + + @abstractmethod + async def create_todos(self,owner_task_id:str,todos:List[AgentTodoTask]): + # return todo_id + pass + + @abstractmethod + async def append_worklog(self,log:AgentWorkLog): + pass + + @abstractmethod + async def get_worklog(self,obj_id:str)->List[AgentWorkLog]: + pass + + @abstractmethod + async def get_task(self,task_id:str) -> AgentTask: + pass + + #@abstractmethod + #async def get_task_by_fullpath(self,task_path:str) -> AgentTask: + # pass + + @abstractmethod + async def get_todo(self,todo_id:str) -> AgentTodoTask: + pass + + @abstractmethod + async def get_sub_tasks(self,task_id:str) -> List[AgentTask]: + pass + + @abstractmethod + async def get_sub_todos(self,task_id:str) -> List[AgentTodoTask]: + pass + + #@abstractmethod + #async def get_task_depends(self,task_id:str) -> List[AgentTask]: + # pass + + @abstractmethod + async def list_task(self,filter:Optional[dict]) -> List[AgentTask]: + pass + + @abstractmethod + async def update_task(self,task:AgentTask): + pass + + @abstractmethod + async def update_todo(self,todo:AgentTodoTask): + pass + + #@abstractmethod + #async def update_task_state(self,task_id,state:str): + # pass + + #@abstractmethod + #async def update_todo_state(self,task_id,state:str): + # pass + + #subtask,todo共享其所在task的文件夹 + @abstractmethod + async def get_task_file(self,task_id:str,path:str)->str: + #return fileid + pass + + @abstractmethod + async def set_task_file(self,task_id:str,path:str,fileid:str): + pass + + @abstractmethod + async def list_task_file(self,task_id:str,path:str): + pass + + @abstractmethod + async def remove_task_file(self,task_id:str,path:str): pass -class AgentReport: - def __init__(self) -> None: - pass \ No newline at end of file + + diff --git a/src/component/tg_tunnel.py b/src/component/tg_tunnel.py index ec8cea4..fd29d8c 100644 --- a/src/component/tg_tunnel.py +++ b/src/component/tg_tunnel.py @@ -85,10 +85,11 @@ class TelegramTunnel(AgentTunnel): TelegramTunnel.all_bots[self.target_id] = self.bot async def _run_app(): + update_id = 0 try: - update_id = (await self.bot.get_updates())[0].update_id - except Exception as e: - update_id = None + update = await self.bot.get_updates() + if len(update) > 0: + update_id = update[0].update_id except Exception as e: logger.error(f"tg_tunnel error:{e}") logger.exception(e) @@ -97,11 +98,7 @@ class TelegramTunnel(AgentTunnel): #logger.info("listening for new messages...") while True: try: - if update_id: - update_id = await self._do_process_raw_message(self.bot, update_id) - else: - update_id = (await self.bot.get_updates())[0].update_id - + update_id = await self._do_process_raw_message(self.bot, update_id) except NetworkError: await asyncio.sleep(1) except Forbidden: From 29594c03192a9ae9a985c09f7bd834dbe78653e1 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sun, 17 Dec 2023 18:23:40 -0800 Subject: [PATCH 62/79] Refactor the Action/Function components, and refactor the basic architecture of Agent Task/Todo. --- .pylint | 1 + doc/agent & workflow.drawio | 105 +- doc/promps/Do TODO.md | 46 +- doc/promps/Review Task.md | 45 +- doc/webui.drawio | 31 +- package-lock.json | 1454 ++++++++++++++++- rootfs/agents/DBQueryer/environment.py | 2 +- rootfs/agents/Jarvis/agent.toml | 69 +- src/aios/__init__.py | 8 +- src/aios/agent/agent.py | 464 +----- src/aios/agent/agent_base.py | 157 +- src/aios/agent/agent_memory.py | 54 +- src/aios/agent/chatsession.py | 9 +- src/aios/agent/llm_context.py | 310 ++++ src/aios/agent/llm_process.py | 338 ++-- src/aios/agent/role.py | 3 +- src/aios/agent/workflow.py | 5 +- src/aios/agent/workspace.py | 54 +- .../asr_function.py | 28 +- .../code_interpreter.py | 34 + .../duckduckgo_text_search_function.py | 14 +- .../image_2_text_function.py | 14 +- .../script_to_speech_function.py | 61 +- .../sql_database_function.py | 3 +- .../text_to_speech_function.py | 20 +- .../environment/code_interpreter_function.py | 41 - src/aios/environment/environment.py | 27 +- src/aios/environment/simple_kb_db.py | 2 +- src/aios/environment/sql_database.py | 2 + src/aios/environment/workflow_env.py | 61 +- src/aios/environment/workspace_env.py | 5 +- src/aios/frame/contact_manager.py | 19 + src/aios/proto/agent_msg.py | 1 + src/aios/proto/agent_task.py | 2 +- src/aios/proto/ai_function.py | 190 ++- src/aios/proto/compute_task.py | 35 +- src/component/agent_manager/agent_manager.py | 33 - .../common_environment/local_document.py | 2 +- .../common_environment/local_file_system.py | 8 +- src/component/common_environment/shell.py | 30 +- src/service/aios_shell/aios_shell.py | 8 +- 41 files changed, 2687 insertions(+), 1108 deletions(-) create mode 100644 .pylint create mode 100644 src/aios/agent/llm_context.py rename src/aios/{environment => ai_functions}/asr_function.py (65%) rename src/aios/{environment => ai_functions}/code_interpreter.py (93%) rename src/aios/{environment => ai_functions}/duckduckgo_text_search_function.py (76%) rename src/aios/{environment => ai_functions}/image_2_text_function.py (74%) rename src/aios/{environment => ai_functions}/script_to_speech_function.py (75%) rename src/aios/{environment => ai_functions}/sql_database_function.py (97%) rename src/aios/{environment => ai_functions}/text_to_speech_function.py (75%) delete mode 100644 src/aios/environment/code_interpreter_function.py diff --git a/.pylint b/.pylint new file mode 100644 index 0000000..a7e73ff --- /dev/null +++ b/.pylint @@ -0,0 +1 @@ +disable=E0402 \ No newline at end of file diff --git a/doc/agent & workflow.drawio b/doc/agent & workflow.drawio index c74344a..2d4e10f 100644 --- a/doc/agent & workflow.drawio +++ b/doc/agent & workflow.drawio @@ -1,4 +1,4 @@ - + @@ -122,8 +122,8 @@ - - + + @@ -220,7 +220,7 @@ - + @@ -366,7 +366,7 @@ - + @@ -507,96 +507,96 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -604,36 +604,53 @@ - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + diff --git a/doc/promps/Do TODO.md b/doc/promps/Do TODO.md index 111b826..5bb74f3 100644 --- a/doc/promps/Do TODO.md +++ b/doc/promps/Do TODO.md @@ -2,4 +2,48 @@ 目标是结合 角色定义,手头的工具,已知知识 完成一个确定的任务。 完成任务时应使用ReAct的方法:应在给出执行动作前,先自言自语的输出一个计划,然后在动作(这个自言自语会变成TODO Logs) - \ No newline at end of file + ## 提示词思路 +TODO从Task拆分而来,因此不应该再次拆分。请尽全力完成。如果判断缺乏完成TODO的能力,请标记为取消。如果是缺乏完成任务的前置条件,请标记为执行失败。 + +执行一个新的TODO: +``` +YOUR ROLE: +你是主人的超级个人助理。你的主要工作是安排主人的日程。 + +PROCESS RULE: +1. 你的任务是结合自己的角色定义,手头的工具,已知信息、完成一个确定的TODO。完成该TODO后你会得到$200的小费。 +2. 输入的TODO是来自你自己对一个Task的Plan结果。 +3. 完成TODO的过程中你应该先思考再执行。执行的过程中可以使用工具,访问前置步骤的结果。执行的结果通常是按顺序执行的ActionList。 +4. 你必须独立的,一次性完成该TODO,你无法得到来自任何他人的协助。 +5. 对确认超出任务范围的TODO,你可以取消该TODO。对执行任务条件不满足的TODO,你可以标记为失败,但要说明失败原因 +7. TODO的完成结果如有需要应保存成数字文档 + +CONTEXT: +ActionList:PostMsg,WriteFile,UpdateFile,RemoveFile,Rename, +现在时间,主人所在位置,以及天气。主人目前正在做什么。 + +REPLY FORMAT: +The Response must be directly parsed by `python json.loads`. Here is an example: +{ + think:'我的思考.' + tags: ['tag1', 'tag2'], #Optional,If the TODO involves important things and people, you can mark by 1-3 tags. + actions: [{ + name: '$action1_name', + $param_name: '$parm' #Optional, fill in only if the action has parameters. + }, ... + ] +} + +KNOWN_INFO: +1.TODO所在的Task信息,重点是Task的整个Plan计划 +2.该TODO之前的执行失败记录 (如有) + + +Tools_tips:(重要!) +inner_function:GetTodoResult, ReadFile +使用GetAllSupportAction进一步获得所有可用的Action + + +``` +(注意Workspace和AgentMemory都有Worklog,但视角不同。) +执行一个有失败记录的TODO: diff --git a/doc/promps/Review Task.md b/doc/promps/Review Task.md index 48c8526..dc661ed 100644 --- a/doc/promps/Review Task.md +++ b/doc/promps/Review Task.md @@ -16,7 +16,50 @@ LLM 结果动作: - 判断可以立刻执行任务(将任务当成TODO工作),通过Action进入下一个LLMProcess - 判断任务超出Agent能力范围,宣告失败 -Example: + +ExampleA:(Task不支持分拆,Agent必须通过Task-Todo两级结构完成任务) +``` +YOUR ROLE: +你是主人的超级个人助理。你的主要工作是安排主人的日程。 + +PROCESS RULE: +你得到的输入来自你自己之前记录在TaskList系统里的一个Task。现在你并不需要完成该Task,而是结合已知信息对Task进行一次Review.Review的过程是你独立完成的,你在形成结论的过程中可以使用工具,但不能和其它人交流。 +1. 理性的思考如何一步一步的高效的,在潜在的截止时间前完成该Task。明确拒绝超出自己能力范围的Task。 +2. 尝试对Task进行确认操作。确认操作的关键在于任务有了明确的执行时间。 +3. 对于需要多个步骤才能完成的Task,对Task进行TODO Plan。尤其注意与相关人员确认的步骤 +4. 对于不需要拆分TODO,且可立刻执行的任务。直接执行该任务。 + +CONTEXT: +ActionList:cancel,confirm,execute +现在时间,主人所在位置,以及天气。主人目前正在做什么? + +REPLY FORMAT: +The Response must be directly parsed by `python json.loads`. Here is an example: +{ + think:'$think step-by-step to be sure you have the right answer.' + plans:[ #Optional + {"todo":"$todo_name","detail":"$todo_detail,"category":"$todo_category"} + ... + ], + tags: ['tag1', 'tag2'], #Optional,If the task involves important things and people, you can mark by 1-3 tags. + actions: [{ + name: '$action_name', + $param_name: '$parm' #Optional, fill in only if the action has parameters. + }] +} + + +KNOWN_INFO: +1.已有Task + +Tools_tips: +2.可以给与Readonly的日历API,进一步查询某个人的已知日程安排) + +``` +问题:拆分TODO时是否需要知道有哪些Agent可以用,这样的话在布置任务的时候也会充分考虑其人员能力边界 + + +Example OLD: ```markdown I think hard and try my best to complete TODOs. The types of TODO I can handle include: - Scheduling, where I will try to contact the relevant personnel of the plan and confirm the details of the schedule with them. diff --git a/doc/webui.drawio b/doc/webui.drawio index d9b5240..9ec9405 100644 --- a/doc/webui.drawio +++ b/doc/webui.drawio @@ -1,13 +1,13 @@ - + - + - + @@ -52,38 +52,38 @@ - + - + - + - + - + - + - + - + - + - + @@ -91,7 +91,7 @@ - + @@ -152,6 +152,9 @@ + + + diff --git a/package-lock.json b/package-lock.json index 48e341a..257bcd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,3 +1,1455 @@ { - "lockfileVersion": 1 + "name": "opendan_mvp", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "docker": "^1.0.0" + } + }, + "node_modules/ajv": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz", + "integrity": "sha512-I/bSHSNEcFFqXLf91nchoNB9D1Kie3QKcWdchYUaoIg1+1bdWDkdfdlvdIOJbi9U8xR0y+MWc5D+won9v95WlQ==", + "optional": true, + "dependencies": { + "co": "^4.6.0", + "json-stable-stringify": "^1.0.1" + } + }, + "node_modules/ambi": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ambi/-/ambi-3.2.0.tgz", + "integrity": "sha512-nj5sHLPFd7u2OLmHdFs4DHt3gK6edpNw35hTRIKyI/Vd2Th5e4io50rw1lhmCdUNO2Mm4/4FkHmv6shEANAWcw==", + "dependencies": { + "editions": "^2.1.0", + "typechecker": "^4.3.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "optional": true + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", + "integrity": "sha512-u1L0ZLywRziOVjUhRxI0Qg9G+4RnFB9H/Rq40YWn0dieDgO7vAYeJz6jKAO6t/aruzlDFLAPkQTT87e+f8Imaw==", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "optional": true + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/aws-sign2": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", + "integrity": "sha512-JnJpAS0p9RmixkOvW2XwDxxzs1bd4/VAGIl6Q0EC5YOo+p+hqIhtDhn/nmFnB/xUNXbLkpE2mOjgVIBRKD4xYw==", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", + "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", + "optional": true + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/boom": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha512-KbiZEa9/vofNcVJXGwdWWn25reQ3V3dHBWbS07FTF3/TOehLnm9GEhJV4T6ZvGPkShRpmUqYwnaCrkj0mRnP6Q==", + "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).", + "optional": true, + "dependencies": { + "hoek": "2.x.x" + }, + "engines": { + "node": ">=0.10.40" + } + }, + "node_modules/call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "optional": true, + "dependencies": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "optional": true + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "optional": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "optional": true + }, + "node_modules/cryptiles": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", + "integrity": "sha512-FFN5KwpvvQTTS5hWPxrU8/QE4kQUc6uwZcrnlMBN82t1MgAtq8mnoDwINBly9Tdr02seeIIhtdF+UH1feBYGog==", + "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).", + "optional": true, + "dependencies": { + "boom": "2.x.x" + }, + "engines": { + "node": ">=0.10.40" + } + }, + "node_modules/csextends": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/csextends/-/csextends-1.2.0.tgz", + "integrity": "sha512-S/8k1bDTJIwuGgQYmsRoE+8P+ohV32WhQ0l4zqrc0XDdxOhjQQD7/wTZwCzoZX53jSX3V/qwjT+OkPTxWQcmjg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/css": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz", + "integrity": "sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==", + "dependencies": { + "inherits": "^2.0.3", + "source-map": "^0.6.1", + "source-map-resolve": "^0.5.2", + "urix": "^0.1.0" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dashdash/node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "optional": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/docker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/docker/-/docker-1.0.0.tgz", + "integrity": "sha512-U66G/kvvsCTUh6VsZqnWgsoSG1KRu5jR473fn/64E6EU9cH65afCITx2qITmNPkr3IOehcn1wwXHmIvHYBlLgQ==", + "dependencies": { + "async": "^1.4.0", + "commander": "^2.9.0", + "css": "^2.2.1", + "dox": "^0.8.0", + "ejs": "^2.3.3", + "extend": "^3.0.0", + "highlight.js": "^9.3.0", + "less": "^2.5.1", + "markdown-it": "^6.0.1", + "mkdirp": "^0.5.1", + "repeating": "^2.0.1", + "strip-indent": "^2.0.0", + "toc": "^0.4.0", + "watchr": "^2.4.13" + }, + "bin": { + "docker": "docker", + "docker.js": "docker" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dox": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/dox/-/dox-0.8.1.tgz", + "integrity": "sha512-CJJCQS6XYJ2FQJox4ey7pUdaAjDusPLqGtfe3Jli4N+m2jBKrT9zwEsh2thV9W5d8F359AMWqkWk50CuH3r8dw==", + "dependencies": { + "commander": "~2.9.0", + "jsdoctypeparser": "^1.2.0", + "marked": "~0.3.5" + }, + "bin": { + "dox": "bin/dox" + } + }, + "node_modules/dox/node_modules/commander": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "integrity": "sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==", + "dependencies": { + "graceful-readlink": ">= 1.0.0" + }, + "engines": { + "node": ">= 0.6.x" + } + }, + "node_modules/eachr": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eachr/-/eachr-3.3.0.tgz", + "integrity": "sha512-yKWuGwOE283CTgbEuvqXXusLH4VBXnY2nZbDkeWev+cpAXY6zCIADSPLdvfkAROc0t8S4l07U1fateCdEDuuvg==", + "dependencies": { + "editions": "^2.2.0", + "typechecker": "^4.9.0" + }, + "engines": { + "node": ">=0.10" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/editions": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/editions/-/editions-2.3.1.tgz", + "integrity": "sha512-ptGvkwTvGdGfC0hfhKg0MT+TRLRKGtUiWGBInxOm5pz7ssADezahjCUaYuZ8Dr+C05FW0AECIIPt4WBxVINEhA==", + "dependencies": { + "errlop": "^2.0.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=0.8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/ejs": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.7.4.tgz", + "integrity": "sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==", + "hasInstallScript": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==" + }, + "node_modules/errlop": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/errlop/-/errlop-2.2.0.tgz", + "integrity": "sha512-e64Qj9+4aZzjzzFpZC7p5kmm/ccCrbLhAJplhsDXQFs87XTsXwOpH4s1Io2s90Tau/8r2j9f4l/thhDevRjzxw==", + "engines": { + "node": ">=0.8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/extendr": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/extendr/-/extendr-3.5.0.tgz", + "integrity": "sha512-7zpVbnnZy91J4k916ZGwpys56DEgJc/prTXDiqCYe/Mud5pqdVsSc9mG/U6sz3lQEvHs81i8Zi7whsFwifhZyw==", + "dependencies": { + "editions": "^2.2.0", + "typechecker": "^4.7.0" + }, + "engines": { + "node": ">=0.12" + }, + "funding": { + "type": "cooperative", + "url": "https://bevry.me/fund" + } + }, + "node_modules/extract-opts": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/extract-opts/-/extract-opts-3.4.0.tgz", + "integrity": "sha512-M7Y+1cJDkzOWqvGH5F/V2qgkD6+uitW3NV9rQGl+pLSVuXZ4IDDQgxxMeLPKcWUyfypBWczIILiroSuhXG7Ytg==", + "dependencies": { + "eachr": "^3.2.0", + "editions": "^2.2.0", + "typechecker": "^4.9.0" + }, + "engines": { + "node": ">=0.10" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "optional": true + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz", + "integrity": "sha512-8HWGSLAPr+AG0hBpsqi5Ob8HrLStN/LWeqhpFl14d7FJgHK48TmgLoALPz69XSUR65YJzDfLUX/BM8+MLJLghQ==", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.5", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "optional": true, + "dependencies": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/getpass/node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "optional": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==" + }, + "node_modules/har-schema": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-1.0.5.tgz", + "integrity": "sha512-f8xf2GOR6Rgwc9FPTLNzgwB+JQ2/zMauYXSWmX5YV5acex6VomT0ocSuwR7BfXo5MpHi+jL+saaux2fwsGJDKQ==", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-4.2.1.tgz", + "integrity": "sha512-5Gbp6RAftMYYV3UEI4c4Vv3+a4dQ7taVyvHt+/L6kRt+f4HX1GweAk5UDWN0SvdVnRBzGQ6OG89pGaD9uSFnVw==", + "deprecated": "this library is no longer supported", + "optional": true, + "dependencies": { + "ajv": "^4.9.1", + "har-schema": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "optional": true, + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "optional": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hawk": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "integrity": "sha512-X8xbmTc1cbPXcQV4WkLcRMALuyoxhfpFATmyuCxJPOAvrDS4DNnsTAOmKUxMTOWU6TzrTOkxPKwIx5ZOpJVSrg==", + "deprecated": "This module moved to @hapi/hawk. Please make sure to switch over as this distribution is no longer supported and may contain bugs and critical security issues.", + "optional": true, + "dependencies": { + "boom": "2.x.x", + "cryptiles": "2.x.x", + "hoek": "2.x.x", + "sntp": "1.x.x" + }, + "engines": { + "node": ">=0.10.32" + } + }, + "node_modules/highlight.js": { + "version": "9.18.5", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.18.5.tgz", + "integrity": "sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA==", + "deprecated": "Support has ended for 9.x series. Upgrade to @latest", + "hasInstallScript": true, + "engines": { + "node": "*" + } + }, + "node_modules/hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha512-V6Yw1rIcYV/4JsnggjBU0l4Kr+EXhpwqXRusENU1Xx6ro00IHPHYNynCuBTOZAPlr3AAmLvchH9I7N/VUdvOwQ==", + "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).", + "optional": true, + "engines": { + "node": ">=0.10.40" + } + }, + "node_modules/http-signature": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "integrity": "sha512-iUn0NcRULlDGtqNLN1Jxmzayk8ogm7NToldASyZBpM2qggbphjXzNOiw3piN8tgz+e/DRs6X5gAzFwTI6BCRcg==", + "optional": true, + "dependencies": { + "assert-plus": "^0.2.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/ignorefs": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/ignorefs/-/ignorefs-1.4.1.tgz", + "integrity": "sha512-1whgvOsPWFZRNA/5OFhIk56C9Y39+/CYaRVNvsZZkLymacOSqqdSU53xk8CP3G2u5gz2PX6RLxqKPcsIpDriog==", + "dependencies": { + "editions": "^2.2.0", + "ignorepatterns": "^1.4.0" + }, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/ignorepatterns": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ignorepatterns/-/ignorepatterns-1.4.0.tgz", + "integrity": "sha512-YPBIFRB25iZD0WiLxmToe80+QU+mZI+bUlEh3Ze/4gbhlXHdQFk0SwAFQtPOiBAoDv3FvhtSTDUCD9DKFsHTRA==", + "dependencies": { + "editions": "^2.2.0" + }, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "optional": true, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "optional": true + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "optional": true + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "optional": true + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "optional": true + }, + "node_modules/jsdoctypeparser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/jsdoctypeparser/-/jsdoctypeparser-1.2.0.tgz", + "integrity": "sha512-osXm4Fr1o/Jc0YwUM7DHUliYtaunLQxh4ynZgtN02mTUN1VsNbMy75DFSkKRne8xE8jiGRV9NKVhYYYa8ZIHXQ==", + "dependencies": { + "lodash": "^3.7.0" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "optional": true + }, + "node_modules/json-stable-stringify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz", + "integrity": "sha512-zfA+5SuwYN2VWqN1/5HZaDzQKLJHaBVMZIIM+wuYjdptkaQsqzDdqjqf+lZZJUuJq1aanHiY8LhH8LmH+qBYJA==", + "optional": true, + "dependencies": { + "call-bind": "^1.0.5", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "optional": true + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/jsprim/node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/less": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/less/-/less-2.7.3.tgz", + "integrity": "sha512-KPdIJKWcEAb02TuJtaLrhue0krtRLoRoo7x6BNJIBelO00t/CCdJQUnHW5V34OnHMWzIktSalJxRO+FvytQlCQ==", + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=0.12" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "mime": "^1.2.11", + "mkdirp": "^0.5.0", + "promise": "^7.1.1", + "request": "2.81.0", + "source-map": "^0.5.3" + } + }, + "node_modules/less/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/linkify-it": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-1.2.4.tgz", + "integrity": "sha512-eGHwtlABkp1NOJSiKUNqBf3SYAS5jPHtvRXPAgNaQwTqmkTahjtiLH9NtxdR5IOPhNvwNMN/diswSfZKzUkhGg==", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ==" + }, + "node_modules/markdown-it": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-6.1.1.tgz", + "integrity": "sha512-woFl7h/sqt9xRmiMweNuO7nu+w8Lz3SXsDlvE3TYeu1SdPqQ+VW4GZyaKP442Bq6XUN6V6IQjJTR93RDYG2mjw==", + "dependencies": { + "argparse": "^1.0.7", + "entities": "~1.1.1", + "linkify-it": "~1.2.2", + "mdurl": "~1.0.1", + "uc.micro": "^1.0.1" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/marked": { + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.3.19.tgz", + "integrity": "sha512-ea2eGWOqNxPcXv8dyERdSr/6FmzvWwzjMxpfGB/sbMccXoct+xY+YukPD+QTUZwyvK7BZwcr4m21WBOW41pAkg==", + "bin": { + "marked": "bin/marked" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha512-VlF07iu3VV3+BTXj43Nmp6Irt/G7j/NgEctUS6IweH1RGhURjjCc2NWtzXFPXXWWfc7hgbXQdtiQu2LGp6MxUg==", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/performance-now": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-0.2.0.tgz", + "integrity": "sha512-YHk5ez1hmMR5LOkb9iJkLKqoBlL7WD5M8ljC75ZfzXriuBIVNuecaXuU7e+hOwyqf24Wxhh7Vxgt7Hnw9288Tg==", + "optional": true + }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "optional": true, + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "optional": true + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "optional": true + }, + "node_modules/qs": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.1.tgz", + "integrity": "sha512-LQy1Q1fcva/UsnP/6Iaa4lVeM49WiOitu2T4hZCyA/elLKu37L99qcBJk4VCCk+rdLvnMzfKyiN3SZTqdAZGSQ==", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==", + "dependencies": { + "is-finite": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/request": { + "version": "2.81.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.81.0.tgz", + "integrity": "sha512-IZnsR7voF0miGSu29EXPRgPTuEsI/+aibNSBbN1pplrfartF5wDYGADz3iD9vmBVf2r00rckWZf8BtS5kk7Niw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "optional": true, + "dependencies": { + "aws-sign2": "~0.6.0", + "aws4": "^1.2.1", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.0", + "forever-agent": "~0.6.1", + "form-data": "~2.1.1", + "har-validator": "~4.2.1", + "hawk": "~3.1.3", + "http-signature": "~1.1.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.7", + "oauth-sign": "~0.8.1", + "performance-now": "^0.2.0", + "qs": "~6.4.0", + "safe-buffer": "^5.0.1", + "stringstream": "~0.0.4", + "tough-cookie": "~2.3.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.0.0" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==", + "deprecated": "https://github.com/lydell/resolve-url#deprecated" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true + }, + "node_modules/safefs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/safefs/-/safefs-4.2.0.tgz", + "integrity": "sha512-1amPBO92jw/hWS+gH/u7z7EL7YxaJ8WecBQl49tMQ6Y6EQfndxNNKwlPqDOcwpUetdmK6nKLoVdjybVScRwq5A==", + "dependencies": { + "editions": "^2.2.0", + "graceful-fs": "^4.2.3" + }, + "engines": { + "node": ">=0.12" + }, + "funding": { + "type": "cooperative", + "url": "https://bevry.me/fund" + } + }, + "node_modules/safeps": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/safeps/-/safeps-7.0.1.tgz", + "integrity": "sha512-aFREKZzceHZH3KZTwjhDI1oOOcyAEBcQHjImJS/Mmx+KC31EQCgwiPKfwhJLBX7R4Y5ioI2D/VEcQ6U6ya2MJw==", + "dependencies": { + "editions": "^1.3.3", + "extract-opts": "^3.3.1", + "safefs": "^4.1.0", + "taskgroup": "^5.0.0", + "typechecker": "^4.3.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/safeps/node_modules/editions": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/editions/-/editions-1.3.4.tgz", + "integrity": "sha512-gzao+mxnYDzIysXKMQi/+M1mjy/rjestjg6OPoYTtI+3Izp23oiGZitsl9lPDPiTGXbcSIk1iJWhliSaglxnUg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "optional": true + }, + "node_modules/scandirectory": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/scandirectory/-/scandirectory-2.5.0.tgz", + "integrity": "sha512-uT0CW8Z3YyoIQs2gXIZgR5miLkN/UNl+5IptQIq1YfD2NhFldikYlC3dkOE6MvF15OZMOxjg8yOjx5J/vIIPUA==", + "dependencies": { + "ignorefs": "^1.0.0", + "safefs": "^3.1.2", + "taskgroup": "^4.0.5" + }, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/scandirectory/node_modules/ambi": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ambi/-/ambi-2.5.0.tgz", + "integrity": "sha512-5nS0gYMPNgZz/UALDHMStcwO42youpIWBQVbI92vV5j0+2bMxv/iVqearrLu3/f0XaU6xVIbf3RRtDxOcHxSkw==", + "dependencies": { + "editions": "^1.1.1", + "typechecker": "^4.3.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/scandirectory/node_modules/editions": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/editions/-/editions-1.3.4.tgz", + "integrity": "sha512-gzao+mxnYDzIysXKMQi/+M1mjy/rjestjg6OPoYTtI+3Izp23oiGZitsl9lPDPiTGXbcSIk1iJWhliSaglxnUg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/scandirectory/node_modules/safefs": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/safefs/-/safefs-3.2.2.tgz", + "integrity": "sha512-qqvuS8qslGUSgUKQbdsYIK8Qg0EAkykxlsdfy3jpBSnhtyPsee/8y4RLc5+3CD6TgazBmtT0ekoGicUTPzICdg==", + "dependencies": { + "graceful-fs": "*" + }, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/scandirectory/node_modules/taskgroup": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/taskgroup/-/taskgroup-4.3.1.tgz", + "integrity": "sha512-PD97E2OfwFH7SgeVRvR6K2c+NkKXZSwMMTdcM1t/3P+f70DUWbR81Qx7TF7dJj8dV631u4dhdBmhfDQjIZvGsg==", + "dependencies": { + "ambi": "^2.2.0", + "csextends": "^1.0.3" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "optional": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/slug": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/slug/-/slug-0.4.2.tgz", + "integrity": "sha512-HQRxdDjtXsKG1pw8rBXGRq9fdW2fS2xPaizvJ3MK89x9+V8U0Z8//meWzJUdFW52pFDGqkLfyX+Fij7lkRY6Kw==", + "dependencies": { + "unicode": ">= 0.3.1" + }, + "engines": { + "node": ">= 0.4.x" + } + }, + "node_modules/sntp": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", + "integrity": "sha512-7bgVOAnPj3XjrKY577S+puCKGCRlUrcrEdsMeRXlg9Ghf5df/xNi6sONUa43WrHUd3TjJBF7O04jYoiY0FVa0A==", + "deprecated": "This module moved to @hapi/sntp. Please make sure to switch over as this distribution is no longer supported and may contain bugs and critical security issues.", + "optional": true, + "dependencies": { + "hoek": "2.x.x" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", + "dependencies": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "node_modules/source-map-url": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", + "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", + "deprecated": "See https://github.com/lydell/source-map-url#deprecated" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sshpk/node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/stringstream": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.6.tgz", + "integrity": "sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA==", + "optional": true + }, + "node_modules/strip-indent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", + "integrity": "sha512-RsSNPLpq6YUL7QYy44RnPVTn/lcVZtb48Uof3X5JLbF4zD/Gs7ZFDv2HWol+leoQN2mT86LAzSshGfkTlSOpsA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/taskgroup": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/taskgroup/-/taskgroup-5.5.0.tgz", + "integrity": "sha512-YFkdc6HU+p3xO2lZ1MWdx7R7EbrLF/bpXv5k9635bTzdgOLNbmnsDg5alSpZost+PYMk40d6ZDAJHBHNHiiLvw==", + "dependencies": { + "ambi": "3.2.0", + "eachr": "^3.2.0", + "editions": "^2.2.0", + "extendr": "^3.5.0", + "safeps": "7.0.1", + "unbounded": "^1.2.0" + }, + "engines": { + "node": ">=0.8" + }, + "funding": { + "type": "cooperative", + "url": "https://bevry.me/fund" + } + }, + "node_modules/toc": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/toc/-/toc-0.4.0.tgz", + "integrity": "sha512-Z4MqUbtLQrbJLQQFLKK0g5tGmke0vqB8puHrXXgRfPyLJTcsn5ACy/uxVnMrg6wSWPoS2hvVpw6wSAFYAkAEVA==", + "dependencies": { + "entities": "~0.5.0", + "lodash": "~2.4.1", + "slug": "~0.4.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/toc/node_modules/entities": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-0.5.0.tgz", + "integrity": "sha512-T5XQtlzuW+PfeSsGp3uryfYQof820zYbnUnUDEkwUVIAfgYeixIN16c4jh8gs0SqJUTGLU0XD6QsvjEPbmdwzQ==" + }, + "node_modules/toc/node_modules/lodash": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz", + "integrity": "sha512-Kak1hi6/hYHGVPmdyiZijoQyz5x2iGVzs6w9GYB/HiXEtylY7tIoYEROMjvM1d9nXJqPOrG2MNPMn01bJ+S0Rw==", + "engines": [ + "node", + "rhino" + ] + }, + "node_modules/tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "optional": true, + "dependencies": { + "punycode": "^1.4.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "optional": true + }, + "node_modules/typechecker": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/typechecker/-/typechecker-4.11.0.tgz", + "integrity": "sha512-lz39Mc/d1UBcF/uQFL5P8L+oWdIn/stvkUgHf0tPRW4aEwGGErewNXo2Nb6We2WslWifn00rhcHbbRWRcTGhuw==", + "dependencies": { + "editions": "^2.2.0" + }, + "engines": { + "node": ">=0.8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, + "node_modules/unbounded": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/unbounded/-/unbounded-1.3.0.tgz", + "integrity": "sha512-RWVCkvcoItljlNTz0iTdBQU6bDj+slVLNaWN7d6DXgH02FfYrz8ytcJ4OPW8b0HqmCehwufJHOIzjHWrQUXBvg==", + "dependencies": { + "editions": "^2.2.0" + }, + "engines": { + "node": ">=0.8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/unicode": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/unicode/-/unicode-14.0.0.tgz", + "integrity": "sha512-BjinxTXkbm9Jomp/YBTMGusr4fxIG67fNGShHIRAL16Ur2GJTq2xvLi+sxuiJmInCmwqqev2BCFKyvbfp/yAkg==", + "engines": { + "node": ">= 0.8.x" + } + }, + "node_modules/urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==", + "deprecated": "Please see https://github.com/lydell/urix#deprecated" + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "optional": true, + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/watchr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/watchr/-/watchr-2.6.0.tgz", + "integrity": "sha512-eHqnPA71jn+lLf/c49mjXqQzzwKLmDdLZXiB53PtgBY8X75zqUWL2PmJWjJ45Bcy8PHOMDdVUCLEud36Lk5QZQ==", + "dependencies": { + "eachr": "^3.2.0", + "extendr": "^3.2.2", + "extract-opts": "^3.3.1", + "ignorefs": "^1.1.1", + "safefs": "^4.1.0", + "scandirectory": "^2.5.0", + "taskgroup": "^5.0.1", + "typechecker": "^4.3.0" + }, + "bin": { + "watchr": "bin/watchr" + }, + "engines": { + "node": ">=0.12" + } + } + } } diff --git a/rootfs/agents/DBQueryer/environment.py b/rootfs/agents/DBQueryer/environment.py index 8c8ec02..fb500b0 100644 --- a/rootfs/agents/DBQueryer/environment.py +++ b/rootfs/agents/DBQueryer/environment.py @@ -1,7 +1,7 @@ from typing import Optional from aios.environment.environment import Environment -from aios.environment.sql_database_function import GetTableInfosFunction, ExecuteSqlFunction +from aios.ai_functions.sql_database_function import GetTableInfosFunction, ExecuteSqlFunction class DBQuerierEnvironment(Environment): diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index f9a70a2..b8c6874 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -45,11 +45,76 @@ known_info_tips = """ tools_tips = """ """ +[behavior.review_task] +type="ReviewTaskProcess" +process_description=""" +你得到的输入来自你自己之前记录在TaskList系统里的一个Task。现在你并不需要完成该Task,而是结合已知信息对Task进行一次Review.Review的过程是你独立完成的,你在形成结论的过程中可以使用工具,但不能和其它人交流。 +1. 理性的思考如何一步一步的高效的,在潜在的截止时间前完成该Task。明确拒绝超出自己能力范围的Task。 +2. 尝试对Task进行确认操作。确认操作的关键在于任务有了明确的执行时间。 +3. 对于需要多个步骤才能完成的Task,对Task进行TODO Plan。尤其注意与相关人员确认的步骤 +4. 对于不需要拆分TODO,且可立刻执行的任务。直接执行该任务。 +""" + +reply_format = """ +The Response must be directly parsed by `python json.loads`. Here is an example: +{ + think:'$think step-by-step to be sure you have the right answer.' + plans:[ #Optional + {"todo":"$todo_name","detail":"$todo_detail,"category":"$todo_category"} + ... + ], + tags: ['tag1', 'tag2'], #Optional,If the task involves important things and people, you can mark by 1-3 tags. + actions: [{ + name: '$action_name', + $param_name: '$parm' #Optional, fill in only if the action has parameters. + }] +} +""" +# action_list: ['cancle','confirm', 'execute'] +LLMContext.action_list = ['cancle','confirm', 'execute'] + +context="Your master now in {location}, time: {now}, weather: {weather}." + +known_info_tips = """ +""" + +tools_tips = """ + +""" +[llm_context.actions] +enable = ["agent.memory.append_chatlog"] + +[llm_context.functions] +enable = [] + +[behavior.do] # do TODO +type="DoTodoProcess" +process_description=""" +1. 你的任务是结合自己的角色定义,手头的工具,已知信息、完成一个确定的TODO。完成该TODO后你会得到$200的小费。 +2. 输入的TODO是来自你自己对一个Task的Plan结果。 +3. 完成TODO的过程中你应该先思考再执行。执行的过程中可以使用工具,访问前置步骤的结果。执行的结果通常是按顺序执行的ActionList。 +4. 你必须独立的,一次性完成该TODO,你无法得到来自任何他人的协助。 +5. 对确认超出任务范围的TODO,你可以取消该TODO。对执行任务条件不满足的TODO,你可以标记为失败,但要说明失败原因 +7. TODO的完成结果如有需要应保存成数字文档 +""" + + +reply_format = """ +The Response must be directly parsed by `python json.loads`. Here is an example: +{ + think:'我的思考.' + tags: ['tag1', 'tag2'], #Optional,If the TODO involves important things and people, you can mark by 1-3 tags. + actions: [{ + name: '$action1_name', + $param_name: '$parm' #Optional, fill in only if the action has parameters. + }, ... + ] +} +""" + #[behavior.self_thinking] -#[behavior.review_task] -#[behavior.do] #[behavior.check] diff --git a/src/aios/__init__.py b/src/aios/__init__.py index aa01d69..82c27a1 100644 --- a/src/aios/__init__.py +++ b/src/aios/__init__.py @@ -9,6 +9,8 @@ from .agent.chatsession import AIChatSession from .agent.agent import AIAgent,AIAgentTemplete, BaseAIAgent from .agent.role import AIRole,AIRoleGroup from .agent.workflow import Workflow +from .agent.agent_memory import AgentMemory +from .agent.llm_context import LLMProcessContext,GlobaToolsLibrary,SimpleLLMContext from .frame.compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType from .frame.compute_node import ComputeNode,LocalComputeNode @@ -19,8 +21,8 @@ from .frame.queue_compute_node import Queue_ComputeNode from .environment.environment import BaseEnvironment,SimpleEnvironment,CompositeEnvironment # from .environment.workflow_env import WorkflowEnvironment,CalenderEnvironment,CalenderEvent,PaintEnvironment -from .environment.text_to_speech_function import TextToSpeechFunction -from .environment.image_2_text_function import Image2TextFunction +from .ai_functions.text_to_speech_function import TextToSpeechFunction +from .ai_functions.image_2_text_function import Image2TextFunction from .environment.workspace_env import WorkspaceEnvironment from .storage.storage import ResourceLocation,AIStorage,UserConfig,UserConfigItem @@ -31,4 +33,4 @@ from .package_manager import * from .utils import * -AIOS_Version = "0.5.2, build 2023-11-30" +AIOS_Version = "0.5.2, build 2023-12-15" diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index c19d7ea..d6a0e73 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -36,37 +36,6 @@ from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode logger = logging.getLogger(__name__) -# DEFAULT_AGENT_READ_REPORT_PROMPT = """ -# """ - -# DEFAULT_AGENT_DO_PROMPT = """ -# You are a helpful AI assistant. -# Solve tasks using your coding and language skills. -# In the following cases, suggest python code (in a python coding block) for the user to execute. -# 1. When you need to collect info, use the code to output the info you need, for example, browse or search the web, download/read a file, print the content of a webpage or a file, get the current date/time, check the operating system. After sufficient info is printed and the task is ready to be solved based on your language skill, you can solve the task by yourself. -# 2. When you need to perform some task with code, use the code to perform the task and output the result. Finish the task smartly. -# Solve the task step by step if you need to. If a plan is not provided, explain your plan first. Be clear which step uses code, and which step uses your language skill. -# When using code, you must indicate the script type in the code block. The user cannot provide any other feedback or perform any other action beyond executing the code you suggest. The user can't modify your code. So do not suggest incomplete code which requires users to modify. Don't use a code block if it's not intended to be executed by the user. -# If you want the user to save the code in a file before executing it, put # filename: inside the code block as the first line. Don't include multiple code blocks in one response. Do not ask users to copy and paste the result. Instead, use 'print' function for the output when relevant. Check the execution result returned by the user. -# If the result indicates there is an error, fix the error and output the code again. Suggest the full code instead of partial code or code changes. If the error can't be fixed or if the task is not solved even after the code is executed successfully, analyze the problem, revisit your assumption, collect additional info you need, and think of a different approach to try. -# When you find an answer, verify the answer carefully. Include verifiable evidence in your response if possible. -# Reply "TERMINATE" in the end when everything is done. -# """ - -# DEFAULT_AGENT_SELF_CHECK_PROMPT = """ - -# """ - -# DEFAULT_AGENT_GOAL_TO_TODO_PROMPT = """ -# 我会给你一个目标,你需要结合自己的角色思考如何将其拆解成多个TODO。请直接返回json来表达这些TODO -# """ - -# DEFAULT_AGENT_LEARN_LONG_CONENT_PROMPT = """ -# 我给你一段内容,尝试为期建立目录。目录的标题不能超过16个字, -# 目录要指向正文的位置(用字符偏移即可),整个目录的文本长度不能超过256个字节。并用json表达这个目录 -# """ - - class AIAgentTemplete: def __init__(self) -> None: self.llm_model_name:str = "gpt-4-0613" @@ -120,7 +89,7 @@ class AIAgent(BaseAIAgent): todo_prompts = {} todo_prompts[TodoListType.TO_WORK] = { "do": None, - "check": None, + "check": None, "review": None, } todo_prompts[TodoListType.TO_LEARN] = { @@ -133,10 +102,10 @@ class AIAgent(BaseAIAgent): self.chat_db = None self.unread_msg = Queue() # msg from other agent self.owenr_bus = None - self.enable_function_list = None self.memory : AgentMemory = None self.prviate_workspace : AgentWorkspace = None + self.behaviors:Dict[str,BaseLLMProcess] = {} @@ -161,7 +130,6 @@ class AIAgent(BaseAIAgent): logger.error("agent instance_id is None!") return False self.agent_id = config["instance_id"] - self.agent_workspace = config["workspace"] if config.get("fullname") is None: logger.error(f"agent {self.agent_id} fullname is None!") @@ -171,43 +139,6 @@ class AIAgent(BaseAIAgent): if config.get("enable_thread") is not None: self.enable_thread = bool(config["enable_thread"]) - if config.get("prompt") is not None: - self.agent_prompt = LLMPrompt() - self.agent_prompt.load_from_config(config["prompt"]) - - if config.get("think_prompt") is not None: - self.agent_think_prompt = LLMPrompt() - self.agent_think_prompt.load_from_config(config["think_prompt"]) - - def load_todo_config(todo_type:str) -> bool: - todo_config = config.get(todo_type) - if todo_config is not None: - if todo_config.get("do") is not None: - prompt = LLMPrompt() - prompt.load_from_config(todo_config["do"]) - self.todo_prompts[todo_type]["do"] = prompt - if todo_config.get("check") is not None: - prompt = LLMPrompt() - prompt.load_from_config(todo_config["check"]) - self.todo_prompts[todo_type]["check"] = prompt - if todo_config.get("review_prompt") is not None: - prompt = LLMPrompt() - prompt.load_from_config(todo_config["review_prompt"]) - self.todo_prompts[todo_type]["review"] = prompt - - load_todo_config(TodoListType.TO_WORK) - load_todo_config(TodoListType.TO_LEARN) - - if config.get("guest_prompt") is not None: - self.guest_prompt_str = config["guest_prompt"] - - if config.get("owner_prompt") is not None: - self.owner_promp_str = config["owner_prompt"] - - if config.get("contact_prompt") is not None: - self.contact_prompt_str = config["contact_prompt"] - - if config.get("powerby") is not None: self.powerby = config["powerby"] if config.get("template_id") is not None: @@ -265,62 +196,11 @@ class AIAgent(BaseAIAgent): def get_agent_role_prompt(self) -> LLMPrompt: return self.role_prompt - def _get_remote_user_prompt(self,remote_user:str) -> LLMPrompt: - cm = ContactManager.get_instance() - contact = cm.find_contact_by_name(remote_user) - if contact is None: - #create guest prompt - if self.guest_prompt_str is not None: - prompt = LLMPrompt() - prompt.system_message = {"role":"system","content":self.guest_prompt_str} - return prompt - return None - else: - if contact.is_family_member: - if self.owner_promp_str is not None: - real_str = self.owner_promp_str.format_map(contact.to_dict()) - prompt = LLMPrompt() - prompt.system_message = {"role":"system","content":real_str} - return prompt - else: - if self.contact_prompt_str is not None: - real_str = self.contact_prompt_str.format_map(contact.to_dict()) - prompt = LLMPrompt() - prompt.system_message = {"role":"system","content":real_str} - return prompt - - return None - def get_agent_prompt(self) -> LLMPrompt: return self.agent_prompt - async def _get_agent_think_prompt(self) -> LLMPrompt: - return self.agent_think_prompt - def _format_msg_by_env_value(self,prompt:LLMPrompt): - for msg in prompt.messages: - old_content = msg.get("content") - msg["content"] = old_content.format_map(self.agent_workspace) - async def _handle_event(self,event): - if event.type == "AgentThink": - return await self.do_self_think() - - def get_workspace_by_msg(self,msg:AgentMsg) -> WorkspaceEnvironment: - return self.agent_workspace - - def need_session_summmary(self,msg:AgentMsg,session:AIChatSession) -> bool: - return False - - async def _create_openai_thread(self) -> str: - return None - - def check_and_to_base64(self, image_path: str) -> str: - if image_utils.is_file(image_path): - return image_utils.to_base64(image_path, (1024, 1024)) - else: - return image_path - async def llm_process_msg(self,msg:AgentMsg) -> AgentMsg: need_process:bool = True if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: @@ -349,7 +229,7 @@ class AIAgent(BaseAIAgent): elif llm_result.state == LLMResultStates.IGNORE: return None else: # OK - resp_msg = llm_result.raw_result.get("resp_msg") + resp_msg = llm_result.raw_result.get("_resp_msg") return resp_msg async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: @@ -358,271 +238,8 @@ class AIAgent(BaseAIAgent): msg.context_info["now"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") msg.context_info["weather"] = "Partly Cloudy, 60°F" return await self.llm_process_msg(msg) - msg_prompt = LLMPrompt() - need_process = True - if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: - need_process = False - - session_topic = msg.target + "#" + msg.topic - chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) - - if msg.mentions is not None: - if self.agent_id in msg.mentions: - need_process = True - logger.info(f"agent {self.agent_id} recv a group chat message from {msg.sender},but is not mentioned,ignore!") - else: - if msg.is_image_msg(): - image_prompt, images = msg.get_image_body() - if image_prompt is None: - msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]}] - else: - content = [{"type": "text", "text": image_prompt}] - content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) - msg_prompt.messages = [{"role": "user", "content": content}] - elif msg.is_video_msg(): - video_prompt, video = msg.get_video_body() - frames = video_utils.extract_frames(video, (1024, 1024)) - if video_prompt is None: - msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": frame}} for frame in frames]}] - else: - content = [{"type": "text", "text": video_prompt}] - content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) - msg_prompt.messages = [{"role": "user", "content": content}] - elif msg.is_audio_msg(): - audio_file = msg.body - resp = await (ComputeKernel.get_instance().do_speech_to_text(audio_file, None, prompt=None, response_format="text")) - if resp.result_code != ComputeTaskResultCode.OK: - error_resp = msg.create_error_resp(resp.error_str) - return error_resp - else: - msg.body = resp.result_str - msg_prompt.messages = [{"role":"user","content":resp.result_str}] - else: - msg_prompt.messages = [{"role":"user","content":msg.body}] - session_topic = msg.get_sender() + "#" + msg.topic - chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) - if self.enable_thread: - need_create_thread = False - if chatsession.openai_thread_id is not None: - if len(chatsession.openai_thread_id) < 1: - need_create_thread = True - else: - need_create_thread = True - - if need_create_thread: - openai_thread_id = await self._create_openai_thread() - if openai_thread_id is not None: - chatsession.update_openai_thread_id(openai_thread_id) - workspace = self.get_workspace_by_msg(msg) - - prompt = LLMPrompt() - if workspace: - prompt.append(workspace.get_prompt()) - prompt.append(workspace.get_role_prompt(self.agent_id)) - - prompt.append(self.get_agent_prompt()) - prompt.append(self._get_remote_user_prompt(msg.sender)) - self._format_msg_by_env_value(prompt) - - if self.need_session_summmary(msg,chatsession): - # get relate session(todos) summary - summary = self.llm_select_session_summary(msg,chatsession) - prompt.append(LLMPrompt(summary)) - - known_info_str = "# Known information\n" - have_known_info = False - todos_str,todo_count = await workspace.todo_list[TodoListType.TO_WORK].get_todo_tree() - if todo_count > 0: - have_known_info = True - known_info_str += f"## todo\n{todos_str}\n" - inner_functions,function_token_len = BaseAIAgent.get_inner_functions(self.agent_workspace) - system_prompt_len = ComputeKernel.llm_num_tokens(prompt) - input_len = len(msg.body) - if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: - history_str,history_token_len = await self._get_prompt_from_session_for_groupchat(chatsession,system_prompt_len + function_token_len,input_len) - else: - history_str,history_token_len = await self.get_prompt_from_session(chatsession,system_prompt_len + function_token_len,input_len) - if history_str: - have_known_info = True - known_info_str += history_str - - if have_known_info: - known_info_prompt = LLMPrompt(known_info_str) - prompt.append(known_info_prompt) # chat context - - prompt.append(msg_prompt) - - - logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - task_result = await self.do_llm_complection(prompt,msg, inner_functions=inner_functions) - if task_result.result_code != ComputeTaskResultCode.OK: - error_resp = msg.create_error_resp(task_result.error_str) - return error_resp - - final_result = task_result.result_str - if final_result is not None: - llm_result : LLMResult = LLMResult.from_str(final_result) - else: - llm_result = LLMResult() - llm_result.state = "ignore" - - if llm_result.resp is None: - if llm_result.raw_resp: - final_result = json.dumps(llm_result.raw_resp) - else: - final_result = llm_result.resp - - - await workspace.exec_op_list(llm_result.action_list,self.agent_id) - - is_ignore = False - result_prompt_str = "" - match llm_result.state: - case "ignore": - is_ignore = True - case "waiting": # like inner call - for sendmsg in llm_result.send_msgs: - sendmsg.sender = self.agent_id - target = sendmsg.target - sendmsg.topic = msg.topic - sendmsg.prev_msg_id = msg.get_msg_id() - send_resp = await AIBus.get_default_bus().send_message(sendmsg) - if send_resp is not None: - result_prompt_str += f"\n{target} response is :{send_resp.body}" - agent_sesion = AIChatSession.get_session(self.agent_id,f"{sendmsg.target}#{sendmsg.topic}",self.chat_db) - agent_sesion.append(sendmsg) - agent_sesion.append(send_resp) - - final_result = llm_result.resp + result_prompt_str - - if is_ignore is not True: - if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: - resp_msg = msg.create_group_resp_msg(self.agent_id,final_result) - else: - resp_msg = msg.create_resp_msg(final_result) - chatsession.append(msg) - chatsession.append(resp_msg) - - return resp_msg - - return None - - - async def _get_history_prompt_for_think(self,chatsession:AIChatSession,summary:str,system_token_len:int,pos:int)->(LLMPrompt,int): - history_len = (self.max_token_size * 0.7) - system_token_len - - messages = chatsession.read_history(self.history_len,pos,"natural") # read - result_token_len = 0 - result_prompt = LLMPrompt() - have_summary = False - if summary is not None: - if len(summary) > 1: - have_summary = True - - if have_summary: - result_prompt.messages.append({"role":"user","content":summary}) - result_token_len -= len(summary) - else: - result_prompt.messages.append({"role":"user","content":"There is no summary yet."}) - result_token_len -= 6 - - read_history_msg = 0 - history_str : str = "" - for msg in messages: - read_history_msg += 1 - dt = datetime.datetime.fromtimestamp(float(msg.create_time)) - formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') - record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" - history_str = history_str + record_str - - history_len -= len(msg.body) - result_token_len += len(msg.body) - if history_len < 0: - logger.warning(f"_get_prompt_from_session reach limit of token,just read {read_history_msg} history message.") - break - - result_prompt.messages.append({"role":"user","content":history_str}) - return result_prompt,pos+read_history_msg - - async def _get_prompt_from_session_for_groupchat(self,chatsession:AIChatSession,system_token_len,input_token_len,is_groupchat=False): - history_len = (self.max_token_size * 0.7) - system_token_len - input_token_len - messages = chatsession.read_history(self.history_len) # read - result_token_len = 0 - result_prompt = LLMPrompt() - read_history_msg = 0 - for msg in reversed(messages): - read_history_msg += 1 - dt = datetime.datetime.fromtimestamp(float(msg.create_time)) - formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') - - if msg.sender == self.agent_id: - if self.enable_timestamp: - result_prompt.messages.append({"role":"assistant","content":f"(create on {formatted_time}) {msg.body} "}) - else: - result_prompt.messages.append({"role":"assistant","content":msg.body}) - - else: - if self.enable_timestamp: - result_prompt.messages.append({"role":"user","content":f"(create on {formatted_time}) {msg.body} "}) - else: - result_prompt.messages.append({"role":"user","content":f"{msg.sender}:{msg.body}"}) - - history_len -= len(msg.body) - result_token_len += len(msg.body) - if history_len < 0: - logger.warning(f"_get_prompt_from_session reach limit of token,just read {read_history_msg} history message.") - break - - return result_prompt,result_token_len - - - - async def _llm_summary_work(self,workspace:WorkspaceEnvironment): - # read report ,and update work summary of - # build todo list from work summary and goals - # - report_list = self.get_unread_reports() - - for report in report_list: - if self.agent_energy <= 0: - break - # merge report to work summary - await self._llm_read_report(report,workspace) - self.agent_energy -= 1 - - if workspace.is_mgr(self.agent_id): - # manager can do more work - await self._llm_review_team(workspace) - self.agent_energy -= 5 - await self._llm_review_unassigned_todos(workspace) - self.agent_energy -= 5 - - - async def _llm_review_team(self,workspace:WorkspaceEnvironment): - pass - - async def _llm_review_unassigned_todos(self,workspace:WorkspaceEnvironment): - pass - - async def _llm_read_report(self,report,worksapce:WorkspaceEnvironment): - work_summary = worksapce.get_work_summary(self.agent_id) - prompt : LLMPrompt = LLMPrompt() - prompt.append(self.agent_prompt) - prompt.append(worksapce.get_role_prompt(self.agent_id)) - prompt.append(self.read_report_prompt) - # report is a message from other agent(human) about work - prompt.append(LLMPrompt(work_summary)) - prompt.append(LLMPrompt(report.content)) - - task_result:ComputeTaskResult = await self.do_llm_complection(prompt) - - if task_result.error_str is not None: - logger.error(f"_llm_read_report compute error:{task_result.error_str}") - return - - worksapce.set_work_summary(self.agent_id,task_result.result_str) async def _llm_run_todo_list(self, todo_list_type: TodoListType): workspace : WorkspaceEnvironment = self.get_workspace_by_msg(None) @@ -872,82 +489,11 @@ class AIAgent(BaseAIAgent): async def think_todo_log(self,todo_log:AgentWorkLog): pass - async def think_chatsession(self,session_id): - if self.agent_think_prompt is None: - return - logger.info(f"agent {self.agent_id} think session {session_id}") - chatsession = AIChatSession.get_session_by_id(session_id,self.chat_db) - - while True: - cur_pos = chatsession.summarize_pos - summary = chatsession.summary - prompt:LLMPrompt = LLMPrompt() - #prompt.append(self._get_agent_prompt()) - prompt.append(await self._get_agent_think_prompt()) - system_prompt_len = ComputeKernel.llm_num_tokens(prompt) - #think env? - history_prompt,next_pos = await self._get_history_prompt_for_think(chatsession,summary,system_prompt_len,cur_pos) - prompt.append(history_prompt) - is_finish = next_pos - cur_pos < 2 - if is_finish: - logger.info(f"agent {self.agent_id} think session {session_id} is finished!,no more history") - break - #3) llm summarize chat history - task_result:ComputeTaskResult = await self.do_llm_complection(prompt) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"think_chatsession llm compute error:{task_result.error_str}") - break - else: - new_summary= task_result.result_str - logger.info(f"agent {self.agent_id} think session {session_id} from {cur_pos} to {next_pos} summary:{new_summary}") - chatsession.update_think_progress(next_pos,new_summary) - return - - async def get_prompt_from_session(self,chatsession:AIChatSession,system_token_len,input_token_len) -> LLMPrompt: - # TODO: get prompt from group chat is different from single chat - if self.enable_thread: - return None - - history_len = (self.max_token_size * 0.7) - system_token_len - input_token_len - messages = chatsession.read_history(self.history_len) # read - result_token_len = 0 - - read_history_msg = 0 - have_known_info = False - - known_info = "" - if chatsession.summary is not None: - if len(chatsession.summary) > 1: - known_info += f"## Recent conversation summary \n {chatsession.summary}\n" - result_token_len -= len(chatsession.summary) - have_known_info = True - - histroy_str = "" - for msg in reversed(messages): - read_history_msg += 1 - dt = datetime.datetime.fromtimestamp(float(msg.create_time)) - formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') - record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" - have_known_info = True - histroy_str = histroy_str + record_str - - history_len -= len(msg.body) - result_token_len += len(msg.body) - if history_len < 0: - logger.warning(f"_get_prompt_from_session reach limit of token,just read {read_history_msg} history message.") - break - - known_info += f"## Recent conversation history \n {histroy_str}\n" - - if have_known_info: - return known_info,result_token_len - return None,0 def need_self_think(self) -> bool: return False - def wake_up(self) -> None: if self.agent_task is None: self.agent_task = asyncio.create_task(self._on_timer()) @@ -971,9 +517,9 @@ class AIAgent(BaseAIAgent): continue # complete & check todo - await self._llm_run_todo_list(TodoListType.TO_WORK) + #await self._llm_run_todo_list(TodoListType.TO_WORK) - await self._llm_run_todo_list(TodoListType.TO_LEARN) + ##await self._llm_run_todo_list(TodoListType.TO_LEARN) if self.need_self_think(): await self.do_self_think() diff --git a/src/aios/agent/agent_base.py b/src/aios/agent/agent_base.py index b25a53f..d8b84aa 100644 --- a/src/aios/agent/agent_base.py +++ b/src/aios/agent/agent_base.py @@ -1,27 +1,9 @@ +# pylint:disable=E0402 + import abc -import copy from abc import abstractmethod -from datetime import datetime, timedelta -import logging -from enum import Enum -import uuid -import time -import re -import shlex -import json -from typing import List, Tuple - -from ..proto.ai_function import * -from ..proto.agent_msg import * -from ..proto.compute_task import * -from ..environment.environment import * - - -logger = logging.getLogger(__name__) - - - +from ..proto.agent_msg import AgentMsg class BaseAIAgent(abc.ABC): @abstractmethod @@ -40,139 +22,6 @@ class BaseAIAgent(abc.ABC): async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: pass - @classmethod - def get_inner_functions(cls, env:BaseEnvironment) -> (dict,int): - if env is None: - return None,0 - - all_inner_function = env.get_all_ai_functions() - if all_inner_function is None: - return None,0 - - result_func = [] - result_len = 0 - for inner_func in all_inner_function: - func_name = inner_func.get_name() - this_func = {} - this_func["name"] = func_name - this_func["description"] = inner_func.get_description() - this_func["parameters"] = inner_func.get_parameters() - result_len += len(json.dumps(this_func)) / 4 - result_func.append(this_func) - - return result_func,result_len - - async def do_llm_complection( - self, - prompt:LLMPrompt, - org_msg:AgentMsg=None, - env:BaseEnvironment=None, - inner_functions=None, - is_json_resp=False, - ) -> ComputeTaskResult: - from ..frame.compute_kernel import ComputeKernel - - #logger.debug(f"Agent {self.agent_id} do llm token static system:{system_prompt_len},function:{function_token_len},history:{history_token_len},input:{input_len}, totoal prompt:{system_prompt_len + function_token_len + history_token_len} ") - if inner_functions is None and env is not None: - inner_functions,_ = BaseAIAgent.get_inner_functions(env) - - model_name = self.get_llm_model_name() - if org_msg.is_video_msg() or org_msg.is_image_msg(): - if model_name.startswith("gpt-4"): - model_name = "gpt-4-vision-preview" - if is_json_resp: - task_result: ComputeTaskResult = await (ComputeKernel.get_instance() - .do_llm_completion( - prompt, - resp_mode="json", - mode_name=model_name, - max_token=self.get_max_token_size(), - inner_functions=inner_functions, - timeout=None)) - else: - task_result: ComputeTaskResult = await (ComputeKernel.get_instance() - .do_llm_completion( - prompt, - resp_mode="text", - mode_name=model_name, - max_token=self.get_max_token_size(), - inner_functions=inner_functions, - timeout=None)) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"_do_llm_complection llm compute error:{task_result.error_str}") - #error_resp = msg.create_error_resp(task_result.error_str) - return task_result - - result_message = task_result.result.get("message") - inner_func_call_node = None - if result_message: - inner_func_call_node = result_message.get("function_call") - - if inner_func_call_node: - call_prompt : LLMPrompt = copy.deepcopy(prompt) - func_msg = copy.deepcopy(result_message) - del func_msg["tool_calls"] - call_prompt.messages.append(func_msg) - task_result = await self._execute_func(env,inner_func_call_node,call_prompt,inner_functions,org_msg) - - return task_result - - async def _execute_func( - self, - env: BaseEnvironment, - inner_func_call_node: dict, - prompt: LLMPrompt, - inner_functions: dict, - org_msg:AgentMsg, - stack_limit = 5 - ) -> ComputeTaskResult: - from ..frame.compute_kernel import ComputeKernel - arguments = None - try: - func_name = inner_func_call_node.get("name") - arguments = json.loads(inner_func_call_node.get("arguments")) - logger.info(f"llm execute inner func:{func_name} ({json.dumps(arguments)})") - - func_node : AIFunction = env.get_ai_function(func_name) - if func_node is None: - result_str = f"execute {func_name} error,function not found" - else: - result_str:str = await func_node.execute(**arguments) - except Exception as e: - result_str = f"execute {func_name} error:{str(e)}" - logger.error(f"llm execute inner func:{func_name} error:{e}") - - - logger.info("llm execute inner func result:" + result_str) - - prompt.messages.append({"role":"function","content":result_str,"name":func_name}) - task_result:ComputeTaskResult = await ComputeKernel.get_instance().do_llm_completion(prompt,mode_name=self.get_llm_model_name(),max_token=self.get_max_token_size(),inner_functions=inner_functions) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"llm compute error:{task_result.error_str}") - return task_result - - if org_msg: - internal_call_record = AgentMsg.create_internal_call_msg(func_name,arguments,org_msg.get_msg_id(),org_msg.target) - internal_call_record.result_str = task_result.result_str - internal_call_record.done_time = time.time() - org_msg.inner_call_chain.append(internal_call_record) - - inner_func_call_node = None - if stack_limit > 0: - result_message : dict = task_result.result.get("message") - if result_message: - inner_func_call_node = result_message.get("function_call") - if inner_func_call_node: - func_msg = copy.deepcopy(result_message) - del func_msg["tool_calls"] - prompt.messages.append(func_msg) - - if inner_func_call_node: - return await self._execute_func(env,inner_func_call_node,prompt,inner_functions,org_msg,stack_limit-1) - else: - return task_result - - class CustomAIAgent(BaseAIAgent): def __init__(self, agent_id: str, llm_model_name: str, max_token_size: int) -> None: self.agent_id = agent_id diff --git a/src/aios/agent/agent_memory.py b/src/aios/agent/agent_memory.py index b7d518c..c852057 100644 --- a/src/aios/agent/agent_memory.py +++ b/src/aios/agent/agent_memory.py @@ -1,11 +1,17 @@ -from ast import Dict -from datetime import timedelta -from typing import List +# pylint:disable=E0402 +from datetime import datetime,timedelta +from typing import Dict from ..frame.compute_kernel import ComputeKernel -from ..proto.ai_function import SimpleAIOperation +from ..proto.ai_function import SimpleAIAction +from ..proto.agent_msg import AgentMsg, AgentMsgType -from .chatsession import * +from .llm_context import GlobaToolsLibrary +from .chatsession import AIChatSession + +import logging + +logger = logging.getLogger(__name__) class AgentMemory: def __init__(self,agent_id:str,db_path:str) -> None: @@ -14,12 +20,16 @@ class AgentMemory: self.model_name:str = "gp4-1106-preview" self.threshold_hours = 72 - self.actions = {} - self.init_actions() - - def init_actions(self) -> Dict: - chatlog_append_op = SimpleAIOperation("chatlog_append","Append request & reply message to chatlog. No params",self.action_chatlog_append) - self.actions[chatlog_append_op.get_name()] = chatlog_append_op + @classmethod + def register_actions(cls): + async def action_chatlog_append(parms:Dict): + memory = parms.get("_memory") + if memory: + return await memory.action_chatlog_append(parms) + + chatlog_append_action = SimpleAIAction("chatlog_append","Append request & reply message to chatlog. No params",action_chatlog_append) + GlobaToolsLibrary.get_instance().register_tool_function(chatlog_append_action,"agent.memory.chatlog.append") + def get_session_from_msg(self,msg:AgentMsg) -> AIChatSession: if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: @@ -32,8 +42,8 @@ class AgentMemory: async def load_chatlogs(self,msg:AgentMsg,n:int=6,m:int=64,token_limit=800)->str: chatsession = self.get_session_from_msg(msg) - # 必定加载n条(n>=2),期望加载m条 - # m条里的信息逐步添加,知道距离现在的时间未72小时以上,且消耗了足够的Token + # Must load n (n> = 2), and hope to load the M + # The information in the # M is gradually added, knowing that it is less than 72 hours from the current time, and consumes enough tokens messages_n = chatsession.read_history(n) # read if len(messages_n) >= n: @@ -44,7 +54,7 @@ class AgentMemory: histroy_str = "" read_count = 0 for msg in messages_n: - dt = datetime.datetime.fromtimestamp(float(msg.create_time)) + dt = datetime.fromtimestamp(float(msg.create_time)) formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" token_limit -= ComputeKernel.llm_num_tokens_from_text(record_str,self.model_name) @@ -57,9 +67,9 @@ class AgentMemory: if read_count < 3: logging.warning(f"read history {read_count} < 3, will not load more") - now = datetime.datetime.now() + now = datetime.now() for msg in messages_m: - dt = datetime.datetime.fromtimestamp(float(msg.create_time)) + dt = datetime.fromtimestamp(float(msg.create_time)) time_diff = now - dt if time_diff > timedelta(hours=self.threshold_hours): break @@ -95,10 +105,18 @@ class AgentMemory: return "lzc is your master. Male, 40 years old, Mother tongue is Chinese, senior software engineer." return None - def get_actions(self) -> Dict: - return self.actions + async def update_contact_summary(self,contact_id:str,summary:str) -> str: + return "OK" + + async def get_sth_summary(self,sth_id:str) -> str: + return None + + async def update_sth_summary(self,sth_id:str,summary:str) -> str: + return None async def get_log_summary(self,msg:AgentMsg) -> str: return None + + diff --git a/src/aios/agent/chatsession.py b/src/aios/agent/chatsession.py index cb1bf94..35aa9f7 100644 --- a/src/aios/agent/chatsession.py +++ b/src/aios/agent/chatsession.py @@ -1,4 +1,4 @@ - +# pylint:disable=E0402 import sqlite3 # Because sqlite3 IO operation is small, so we can use sqlite3 directly.(so we don't need to use async sqlite3 now) from sqlite3 import Error import logging @@ -38,9 +38,10 @@ class ChatSessionDB: return conn def close(self): - if not hasattr(self.local, 'conn'): + local = threading.local() + if not hasattr(local, 'conn'): return - self.local.conn.close() + local.conn.close() def _create_table(self, conn): """ create table """ @@ -119,7 +120,7 @@ class ChatSessionDB: match msg.msg_type: case AgentMsgType.TYPE_MSG: pass - case AgentMsgType.TYPE_ACTION: + case AgentMsgType.TYPE_ACTION:# THIS Action is not AIAction action_name = msg.func_name action_params = json.dumps(msg.args) action_result = msg.result_str diff --git a/src/aios/agent/llm_context.py b/src/aios/agent/llm_context.py new file mode 100644 index 0000000..b3e2fb6 --- /dev/null +++ b/src/aios/agent/llm_context.py @@ -0,0 +1,310 @@ +# pylint:disable=E0402 +from abc import ABC, abstractmethod +import json +import logging +from typing import Optional,Set,List,Dict,Callable + +from ..proto.ai_function import AIFunction,AIAction,SimpleAIAction + +logger = logging.getLogger(__name__) + +class LLMProcessContext: + def __init__(self) -> None: + pass + + + @staticmethod + def function2action(ai_func:AIFunction) -> AIAction: + async def exec_func(params:Dict) -> str: + return await ai_func.execute(params) + return SimpleAIAction(ai_func.get_name(),ai_func.get_detail_description(),exec_func) + + + @staticmethod + def aifunction_to_inner_function(self,all_inner_function:List[AIFunction]) -> List[Dict]: + result_func = [] + result_len = 0 + for inner_func in all_inner_function: + func_name = inner_func.get_name() + this_func = {} + this_func["name"] = func_name + this_func["description"] = inner_func.get_description() + this_func["parameters"] = inner_func.get_openai_parameters() + result_len += len(json.dumps(this_func)) / 4 + result_func.append(this_func) + return result_func + + @abstractmethod + def get_ai_function(self,func_name:str) -> AIFunction: + pass + + def get_all_ai_functions(self) -> List[AIFunction]: + return self.get_function_set(None) + + @abstractmethod + def get_function_set(self,set_name:str = None) -> List[AIFunction]: + pass + + @abstractmethod + def get_ai_action(self,op_name:str) -> AIAction: + pass + + def get_all_ai_action(self) -> List[AIAction]: + return self.get_action_set(None) + + @abstractmethod + def get_action_set(self,set_name:str = None) -> List[AIFunction]: + pass + + def __getitem__(self, key): + return self.get_value(key) + + @abstractmethod + def get_value(self,key:str) -> Optional[str]: + pass + + def list_actions(self,path:str) -> List[AIAction]: + return "No more actions!" + + def list_functions(self,path:str) -> List[AIFunction]: + return "No more tool functions!" + +class GlobaToolsLibrary: + @classmethod + def get_instance(cls) -> 'GlobaToolsLibrary': + if cls._instance is None: + cls._instance = GlobaToolsLibrary() + return cls._instance + + def __init__(self,global_env_name:str = None) -> None: + self.all_preset_context = {} + self.all_tool_functions : Dict[str,AIFunction] = {} + self.all_action_sets : Dict[str,Set[str]] = {} + self.all_function_sets : Dict[str,Set[str]] = {} + + def register_prset_context(self,preset_id:str,context) -> None: + self.all_preset_context[preset_id] = context + + def get_preset_context(self,preset_id:str): + return self.all_preset_context.get(preset_id) + + def register_tool_function(self,function:AIFunction) -> None: + if self.all_tool_functions.get(function.get_name()): + logger.warning(f"Tool function {function.get_name()} already exists! will be replaced!") + + self.all_tool_functions[function.get_name()] = function + + def get_tool_function(self,function_name:str) -> AIFunction: + return self.all_tool_functions.get(function_name) + + def register_function_set(self,set_name:str,function_set:Set[str]) -> None: + self.all_function_sets[set_name] = function_set + + def get_function_set(self,set_name:str) -> Set[str]: + return self.all_function_sets.get(set_name) + +class SimpleLLMContext(LLMProcessContext): + def __init__(self) -> None: + super().__init__() + self.parent = None + self.values : Dict[str,str] = {} + self.values_callback = {} + + self.functions: Dict[str,AIFunction] = {} + self.func_sets : Dict[str,Dict[str,AIFunction]] = {} + self.actions: Dict[str,AIAction] = {} + self.action_sets : Dict[str,Dict[str,AIAction]] = {} + + def load_action_set_from_config(self,preset,config:Dict[str,str]) -> bool: + if preset is None: + result = {} + else: + result = preset + + enable_actions = config.get("enable") + if enable_actions: + for action_id in enable_actions: + ai_func = GlobaToolsLibrary.get_instance().get_tool_function(action_id) + if ai_func: + result[action_id] = LLMProcessContext.function2action(ai_func) + else: + func_set = GlobaToolsLibrary.get_instance().get_function_set(action_id) + if func_set: + for _func_id in func_set: + ai_func = GlobaToolsLibrary.get_instance().get_tool_function(_func_id) + if ai_func: + result[_func_id] = LLMProcessContext.function2action(ai_func) + else: + logger.error(f"load_action_set_from_config failed! enable action id {action_id} not found!") + return None + + disable_actions = config.get("disable") + for disable_action in disable_actions: + if result.get(disable_action): + result.pop(disable_action) + else: + func_set = GlobaToolsLibrary.get_instance().get_function_set(action_id) + if func_set: + for _func_id in func_set: + if result.get(_func_id): + result.pop(_func_id) + else: + logger.error(f"load_action_set_from_config failed! disable action id {action_id} not found!") + return None + + return result + + def load_function_set_from_config(self,preset,config:Dict) -> Dict[str,AIFunction]: + if preset is None: + result = {} + else: + result = preset + + enable_functions = config.get("enable") + if enable_functions: + for func_id in enable_functions: + ai_func = GlobaToolsLibrary.get_instance().get_tool_function(func_id) + if ai_func: + result[func_id] = ai_func + else: + func_set = GlobaToolsLibrary.get_instance().get_function_set(func_id) + if func_set: + for func_id in func_set: + ai_func = GlobaToolsLibrary.get_instance().get_tool_function(func_id) + if ai_func: + result[func_id] = ai_func + else: + logger.error(f"load_function_set_from_config failed! enable function id {func_id} not found!") + return None + else: + logger.error(f"load_function_set_from_config failed! enable function id {func_id} not found!") + return None + + + disable_functions = config.get("disable") + for disable_function in disable_functions: + if result.get(disable_function): + result.pop(disable_function) + else: + func_set = GlobaToolsLibrary.get_instance().get_function_set(func_id) + if func_set: + for func_id in func_set: + if result.get(func_id): + result.pop(func_id) + else: + logger.error(f"load_function_set_from_config failed! disable function id {disable_function} not found!") + return None + + return result + + def load_from_config(self,config:Dict[str,str]) -> bool: + preset = config.get("preset") + if preset: + self.parent:SimpleLLMContext = GlobaToolsLibrary.get_instance().get_preset_context(preset) + if self.parent is None: + logger.error(f"preset context {preset} not found!") + return False + + self.values = self.parent.values + self.values_callback = self.parent.values_callback + self.actions = self.parent.actions + self.functions = self.parent.functions + self.action_sets = self.parent.action_sets + self.func_sets = self.parent.func_sets + + action_def:Dict= config.get("actions") + if action_def is None: + logger.error(f"load_from_config failed! actions not found!") + return False + self.actions = self.load_action_set_from_config(self.actions,action_def) + if self.actions is None: + logger.error(f"load_from_config failed! load_action_set_from_config failed!") + return False + + for set_name in action_def.keys(): + if set_name == "enable": + continue + if set_name == "disable": + continue + + sub_set = config.get(set_name) + self.action_sets[set_name] = self.load_action_set_from_config(None,sub_set) + if self.action_sets[set_name] is None: + logger.error(f"load_from_config failed! load_action_set_from_config failed!") + return False + + function_def:Dict = config.get("functions") + self.functions = self.load_function_set_from_config(self.functions,function_def) + if self.functions is None: + logger.error(f"load_from_config failed! load_function_set_from_config failed!") + return False + + for set_name in function_def.keys(): + if set_name == "enable": + continue + if set_name == "disable": + continue + + sub_set = config.get(set_name) + self.func_sets[set_name] = self.load_function_set_from_config(None,sub_set) + if self.func_sets[set_name] is None: + logger.error(f"load_from_config failed! load_function_set_from_config failed!") + return False + + #values_def = config.get("values") + #if values_def: + # for key,value in values_def.items(): + # self.values[key] = value + + def get_value(self,key:str) -> Optional[str]: + callback = self.values_callback.get(key) + if callback: + return callback() + return self.values.get(key) + + def set_value_callback(self,key:str,callback:Callable[[],str]) -> None: + self.values_callback[key] = callback + + def set_value(self,key:str,value:str): + self.values[key] = value + + #def get_ai_function(self,func_name:str) -> AIFunction: + # func = self.functions.get(func_name) + # if func is not None: + # return func + + # for set_name in self.func_sets.keys(): + # func = self.func_sets[set_name].get(func_name) + # if func is not None: + # return func + + def get_function_set(self,set_name:str = None) -> List[AIFunction]: + if set_name is None: + return self.functions.values() + else: + func_set = self.func_sets.get(set_name) + if func_set: + return func_set.values() + return None + + + # def get_ai_action(self,op_name:str) -> AIOperation: + # op = self.actions.get(op_name) + # if op is not None: + # return op + # for set_name in self.action_sets.keys(): + # op = self.action_sets[set_name].get(op_name) + # if op is not None: + # return op + # return None + + def get_action_set(self,set_name:str = None) -> List[AIFunction]: + if set_name is None: + return self.actions.values() + else: + action_set = self.action_sets.get(set_name) + if action_set: + return action_set.values() + return None + + diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index a87f6ce..7b9c638 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -1,34 +1,31 @@ # Old name is behavior, I belive new name "llm_process" is better +# pylint:disable=E0402 +from ..utils import video_utils,image_utils + +from ..proto.compute_task import LLMPrompt,LLMResult,ComputeTaskResult,ComputeTaskResultCode +from ..proto.ai_function import AIFunction,AIAction,ActionNode +from ..proto.agent_msg import AgentMsg,AgentMsgType + +from .agent_memory import AgentMemory +from .workspace import AgentWorkspace +from .llm_context import LLMProcessContext,GlobaToolsLibrary, SimpleLLMContext + +from ..frame.compute_kernel import ComputeKernel + from abc import ABC,abstractmethod import copy import json -import shlex +import datetime +from datetime import datetime from typing import Any, Callable, Coroutine, Optional,Dict,Awaitable,List from enum import Enum - -from aios.agent.chatsession import AIChatSession - -from ..utils import video_utils - -from ..proto.compute_task import * -from ..proto.ai_function import * - -from .agent_base import * -from .agent_memory import * -from .workspace import * - -from ..frame.compute_kernel import * -from ..environment.environment import * -from ..environment.workspace_env import * - import logging + logger = logging.getLogger(__name__) MIN_PREDICT_TOKEN_LEN = 32 -class LLMProcessContext: - def __init__(self) -> None: - pass + class BaseLLMProcess(ABC): def __init__(self) -> None: @@ -38,37 +35,23 @@ class BaseLLMProcess(ABC): self.result_example:str = None #llm_result样例 self.enable_json_resp = False - self.model_name = "gpt-4" + #None means system default, + # TODO: support abcstract model name like: local-hight,local-low,local-medium,remote-hight,remote-low,remote-medium + self.model_name = None self.max_token = 1000 # result_token self.max_prompt_token = 1000 # not include input prompt self.timeout = 1800 # 30 min - self.envs : Dict[str,BaseEnvironment] = [] - self.env : CompositeEnvironment = None - - def aifunction_to_inner_function(self,all_inner_function:List[AIFunction]) -> List[Dict]: - result_func = [] - result_len = 0 - for inner_func in all_inner_function: - func_name = inner_func.get_name() - this_func = {} - this_func["name"] = func_name - this_func["description"] = inner_func.get_description() - this_func["parameters"] = inner_func.get_parameters() - result_len += len(json.dumps(this_func)) / 4 - result_func.append(this_func) - return result_func - @abstractmethod async def prepare_prompt(self,input:Dict) -> LLMPrompt: pass - @abstractmethod - async def get_inner_function(self,func_name:str) -> AIFunction: - pass + + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: + return GlobaToolsLibrary.get_instance().get_tool_function(func_name) @abstractmethod - async def post_llm_process(self,actions:List[ActionItem],input:Dict,llm_result:LLMResult) -> bool: + async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: pass @abstractmethod @@ -93,15 +76,11 @@ class BaseLLMProcess(ABC): @abstractmethod async def initial(self,params:Dict = None) -> bool: pass - - def append_envs(self,envs:Dict[str,BaseEnvironment]): - self.envs.update(envs) - self.env = CompositeEnvironment(self.envs) def _format_content_by_env_value(self,content:str,env)->str: return content.format_map(env) - async def _execute_inner_func(self,inner_func_call_node,prompt: LLMPrompt,stack_limit = 1) -> ComputeTaskResult: + async def _execute_inner_func(self,inner_func_call_node:Dict,prompt: LLMPrompt,stack_limit = 1) -> ComputeTaskResult: arguments = None stack_limit = stack_limit - 1 try: @@ -109,7 +88,7 @@ class BaseLLMProcess(ABC): arguments = json.loads(inner_func_call_node.get("arguments")) logger.info(f"LLMProcess execute inner func:{func_name} :\n\t {json.dumps(arguments)}") - func_node : AIFunction = await self.get_inner_function(func_name) + func_node : AIFunction = await self.get_inner_function_for_exec(func_name) if func_node is None: result_str:str = f"execute {func_name} error,function not found" else: @@ -172,6 +151,7 @@ class BaseLLMProcess(ABC): else: resp_mode = "text" + # Action define in prompt, will be execute after llm compute prompt = await self.prepare_prompt(input) max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt,self.model_name) if max_result_token < MIN_PREDICT_TOKEN_LEN: @@ -209,11 +189,61 @@ class BaseLLMProcess(ABC): llm_result = LLMResult.from_str(task_result.result_str) # use action to save history? - if llm_result.action_list or len(llm_result.action_list) > 0: - await self.post_llm_process(llm_result.action_list,input,llm_result) + await self.post_llm_process(llm_result.action_list,input,llm_result) return llm_result +class LLMAgentBaseProcess(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + self.role_description:str = None + self.process_description:str = None + self.reply_format:str = None + self.context : str = None + + self.known_info_tips :str = None + self.tools_tips:str = None + + self.workspace : AgentWorkspace = None # If Workspace is not none , enable Agent Tasklist + self.memory : AgentMemory = None + self.kb = None + + async def load_default_config(self) -> bool: + return True + + + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: + if is_load_default: + await self.load_default_config() + + if await super().load_from_config(config) is False: + return False + + self.role_description = config.get("role_desc") + if self.role_description is None: + logger.error(f"role_description not found in config") + return False + + if config.get("process_description"): + self.process_description = config.get("process_description") + + if config.get("reply_format"): + self.reply_format = config.get("reply_format") + + if config.get("context"): + self.context = config.get("context") + + if config.get("known_info_tips"): + self.known_info_tips = config.get("known_info_tips") + + if config.get("tools_tips"): + self.tools_tips = config.get("tools_tips") + + if config.get("knowledge_base"): + self.kb = config.get("knowledge_base") + + class LLMAgentMessageProcess(BaseLLMProcess): def __init__(self) -> None: super().__init__() @@ -226,27 +256,13 @@ class LLMAgentMessageProcess(BaseLLMProcess): self.known_info_tips :str = None self.tools_tips:str = None - self.enable_inner_functions : Dict[str,bool] = None - self.enable_actions : Dict[str,AIOperation] = None - self.actions_desc : Dict[str,Dict] = None - self.workspace : AgentWorkspace = None - + self.workspace : AgentWorkspace = None # If Workspace is not none , enable Agent Tasklist self.memory : AgentMemory = None self.enable_kb = False self.kb = None - def init_actions(self): - self.enable_actions = {} - self.actions_desc = {} - self.enable_actions.update(self.memory.get_actions()) - if self.workspace: - self.enable_actions.update(self.workspace.get_actions()) - if self.enable_kb: - self.enable_actions.update(self.kb.get_actions()) + self.llm_context : LLMProcessContext = None - for name,op in self.enable_actions.items(): - self.actions_desc[name] = op.get_description() - async def initial(self,params:Dict = None) -> bool: self.memory = params.get("memory") if self.memory is None: @@ -254,7 +270,7 @@ class LLMAgentMessageProcess(BaseLLMProcess): return False self.workspace = params.get("workspace") - self.init_actions() + return True async def load_default_config(self) -> bool: @@ -290,14 +306,18 @@ class LLMAgentMessageProcess(BaseLLMProcess): if config.get("enable_kb"): self.enable_kb = config.get("enable_kb") == "true" - - if config.get("enable_function"): - self.enable_inner_functions = config.get("enable_function") - - if config.get("enable_actions"): - self.enable_actions = config.get("enable_actions") + + self.llm_context = SimpleLLMContext() + if config.get("llm_context"): + self.llm_context.load_from_config(config.get("llm_context")) - + + def check_and_to_base64(self, image_path: str) -> str: + if image_utils.is_file(image_path): + return image_utils.to_base64(image_path, (1024, 1024)) + else: + return image_path + async def get_prompt_from_msg(self,msg:AgentMsg) -> LLMPrompt: msg_prompt = LLMPrompt() @@ -334,8 +354,9 @@ class LLMAgentMessageProcess(BaseLLMProcess): async def get_action_desc(self) -> Dict: result = {} - for name,op in self.enable_actions.items(): - result[name] = op.get_description() + actions_list = self.llm_context.get_all_ai_action() + for action in actions_list: + result[action.get_name()] = action.get_description() return result async def sender_info(self,msg:AgentMsg)->str: @@ -420,14 +441,16 @@ class LLMAgentMessageProcess(BaseLLMProcess): if self.tools_tips: system_prompt_dict["tools_tips"] = self.tools_tips #prompt.append_system_message(self.tools_tips) - prompt.inner_functions.extend(self.get_inner_function_desc_from_env()) + #self.llm_context. if self.workspace: - prompt.inner_functions.extend(self.aifunction_to_inner_function(self.workspace.get_inner_function_desc())) + #TODO eanble workspace functions? + logger.info(f"workspace is not none,enable workspace functions") ## 给予查询KB的权限 if self.enable_kb: - prompt.inner_functions.extend(self.get_inner_function_desc_from_kb()) + logger.info(f"enable kb") + prompt.append_system_message(json.dumps(system_prompt_dict)) ## 扩展已知信息 (这可能是一个LLM过程) @@ -436,35 +459,41 @@ class LLMAgentMessageProcess(BaseLLMProcess): return prompt - async def get_inner_function(self,func_name:str) -> AIFunction: - return self.workspace.inner_functions.get(func_name) + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: + return self.llm_context.get_ai_function(func_name) - async def post_llm_process(self,actions:List[ActionItem],input:Dict,llm_result:LLMResult) -> bool: - msg = input.get("msg") + async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: + msg:AgentMsg = input.get("msg") if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: resp_msg = msg.create_group_resp_msg(self.memory.agent_id,llm_result.resp) else: resp_msg = msg.create_resp_msg(llm_result.resp) - llm_result.raw_result["resp_msg"] = resp_msg + llm_result.raw_result["_resp_msg"] = resp_msg for action_item in actions: - op : AIOperation = self.enable_actions.get(action_item.name) + op : AIAction = self.llm_context.get_ai_action(action_item.name) if op: if action_item.parms is None: action_item.parms = {} - action_item.parms["input"] = input - action_item.parms["resp_msg"] = resp_msg - action_item.parms["llm_result"] = llm_result - action_item.parms["start_at"] = datetime.now() - action_item.parms["creator"] = self.memory.agent_id - action_item.parms["result"] = await op.execute(action_item.parms) - action_item.parms["end_at"] = datetime.now() + action_item.parms["_input"] = input + action_item.parms["_memory"] = self.memory + action_item.parms["_workspace"] = self.workspace + action_item.parms["_resp_msg"] = resp_msg + action_item.parms["_llm_result"] = llm_result + action_item.parms["_start_at"] = datetime.now() + action_item.parms["_agentid"] = self.memory.agent_id + + action_item.parms["_result"] = await op.execute(action_item.parms) + action_item.parms["_end_at"] = datetime.now() else: logger.warn(f"action {action_item.name} not found") return False - + + chatsession = self.memory.get_session_from_msg(msg) + chatsession.append(msg) + chatsession.append(resp_msg) return True @@ -473,25 +502,50 @@ class ReviewTaskProcess(BaseLLMProcess): def __init__(self) -> None: super().__init__() - async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + self.role_description:str = None + self.process_description:str = None + self.reply_format = None + + # 虽然在架构上LLM Process可以很容易的去Call另一个Process,但实际应用中还是应该慎重的保持LLM Process的简单性 + #self.do_task_llm_process : BaseLLMProcess = None + + async def initial(self,params:Dict = None) -> bool: + self.memory = params.get("memory") + if self.memory is None: + logger.error(f"LLMAgeMessageProcess initial failed! memory not found") + return False + self.workspace = params.get("workspace") + + + return True + async def load_from_config(self, config: dict): if await super().load_from_config(config) is False: return False async def prepare_prompt(self) -> LLMPrompt: prompt = LLMPrompt() - pass + system_prompt_dict = {} + system_prompt_dict["role_description"] = self.role_description + system_prompt_dict["process_rule"] = self.process_description + system_prompt_dict["reply_format"] = self.reply_format + + return prompt + - async def get_inner_function(self,func_name:str) -> AIFunction: + async def get_review_task_actions(self) -> Dict[str,Dict]: pass - async def post_llm_process(self,actions:List[ActionItem]) -> bool: + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: + pass + + async def post_llm_process(self,actions:List[ActionNode]) -> bool: pass class QuickReviewTaskProcess(BaseLLMProcess): def __init__(self) -> None: super().__init__() - async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + async def load_from_config(self, config: dict): if await super().load_from_config(config) is False: return False @@ -499,17 +553,17 @@ class QuickReviewTaskProcess(BaseLLMProcess): prompt = LLMPrompt() pass - async def get_inner_function(self,func_name:str) -> AIFunction: + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: pass - async def post_llm_process(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionNode]) -> bool: pass class DoTodoProcess(BaseLLMProcess): def __init__(self) -> None: super().__init__() - async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + async def load_from_config(self, config: dict): if await super().load_from_config(config) is False: return False @@ -517,10 +571,10 @@ class DoTodoProcess(BaseLLMProcess): prompt = LLMPrompt() pass - async def get_inner_function(self,func_name:str) -> AIFunction: + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: pass - async def post_llm_process(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionNode]) -> bool: pass @@ -536,10 +590,10 @@ class CheckTodoProcess(BaseLLMProcess): prompt = LLMPrompt() pass - async def get_inner_function(self,func_name:str) -> AIFunction: + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: pass - async def post_llm_process(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionNode]) -> bool: pass class SelfLearningProcess(BaseLLMProcess): @@ -554,10 +608,10 @@ class SelfLearningProcess(BaseLLMProcess): prompt = LLMPrompt() pass - async def get_inner_function(self,func_name:str) -> AIFunction: + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: pass - async def post_llm_process(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionNode]) -> bool: pass class SelfThinkingProcess(BaseLLMProcess): @@ -568,14 +622,82 @@ class SelfThinkingProcess(BaseLLMProcess): if await super().load_from_config(config) is False: return False + + async def _get_history_prompt_for_think(self,chatsession,summary:str,system_token_len:int,pos:int)->(LLMPrompt,int): + history_len = (self.max_token_size * 0.7) - system_token_len + + messages = chatsession.read_history(self.history_len,pos,"natural") # read + result_token_len = 0 + result_prompt = LLMPrompt() + have_summary = False + if summary is not None: + if len(summary) > 1: + have_summary = True + + if have_summary: + result_prompt.messages.append({"role":"user","content":summary}) + result_token_len -= len(summary) + else: + result_prompt.messages.append({"role":"user","content":"There is no summary yet."}) + result_token_len -= 6 + + read_history_msg = 0 + history_str : str = "" + for msg in messages: + read_history_msg += 1 + dt = datetime.datetime.fromtimestamp(float(msg.create_time)) + formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') + record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" + history_str = history_str + record_str + + history_len -= len(msg.body) + result_token_len += len(msg.body) + if history_len < 0: + logger.warning(f"_get_prompt_from_session reach limit of token,just read {read_history_msg} history message.") + break + + result_prompt.messages.append({"role":"user","content":history_str}) + return result_prompt,pos+read_history_msg + + async def _think_chatsession(self,session_id): + if self.agent_think_prompt is None: + return + logger.info(f"agent {self.agent_id} think session {session_id}") + chatsession = AIChatSession.get_session_by_id(session_id,self.chat_db) + + while True: + cur_pos = chatsession.summarize_pos + summary = chatsession.summary + prompt:LLMPrompt = LLMPrompt() + #prompt.append(self._get_agent_prompt()) + prompt.append(await self._get_agent_think_prompt()) + system_prompt_len = ComputeKernel.llm_num_tokens(prompt) + #think env? + history_prompt,next_pos = await self._get_history_prompt_for_think(chatsession,summary,system_prompt_len,cur_pos) + prompt.append(history_prompt) + is_finish = next_pos - cur_pos < 2 + if is_finish: + logger.info(f"agent {self.agent_id} think session {session_id} is finished!,no more history") + break + #3) llm summarize chat history + task_result:ComputeTaskResult = await self.do_llm_complection(prompt) + if task_result.result_code != ComputeTaskResultCode.OK: + logger.error(f"think_chatsession llm compute error:{task_result.error_str}") + break + else: + new_summary= task_result.result_str + logger.info(f"agent {self.agent_id} think session {session_id} from {cur_pos} to {next_pos} summary:{new_summary}") + chatsession.update_think_progress(next_pos,new_summary) + return + async def prepare_prompt(self) -> LLMPrompt: prompt = LLMPrompt() pass - async def get_inner_function(self,func_name:str) -> AIFunction: + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: pass - async def post_llm_process(self,actions:List[ActionItem]) -> bool: + async def post_llm_process(self,actions:List[ActionNode]) -> bool: pass class LLMProcessLoader: diff --git a/src/aios/agent/role.py b/src/aios/agent/role.py index d08049d..3e31eee 100644 --- a/src/aios/agent/role.py +++ b/src/aios/agent/role.py @@ -1,6 +1,7 @@ +# pylint:disable=E0402 import logging -from .agent_base import LLMPrompt +from ..proto.compute_task import LLMPrompt class AIRole: def __init__(self) -> None: diff --git a/src/aios/agent/workflow.py b/src/aios/agent/workflow.py index ce245c7..8bf692a 100644 --- a/src/aios/agent/workflow.py +++ b/src/aios/agent/workflow.py @@ -1,3 +1,4 @@ +# pylint:disable=E0402 import logging import asyncio import json @@ -279,7 +280,7 @@ class Workflow: logger.info(f"{msg.sender} post message {msg.msg_id} to AIBus: {msg.target}") return await self.get_bus().send_message(msg) - async def role_call(self,func_item:ActionItem,the_role:AIRole): + async def role_call(self,func_item:ActionNode,the_role:AIRole): logger.info(f"{the_role.role_id} call {func_item.name} ") arguments = func_item.args @@ -290,7 +291,7 @@ class Workflow: result_str:str = await func_node.execute(**arguments) return result_str - async def role_post_call(self,func_item:ActionItem,the_role:AIRole): + async def role_post_call(self,func_item:ActionNode,the_role:AIRole): logger.info(f"{the_role.role_id} post call {func_item.name} ") return await self.role_call(func_item,the_role) diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index e97453d..0d1d334 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -1,14 +1,17 @@ +# pylint:disable=E0402 from ast import Dict import json import sqlite3 import os +import logging from typing import List import aiofiles -from ..proto.ai_function import * -from ..proto.agent_task import * -from ..storage.storage import * +from ..proto.ai_function import AIFunction,SimpleAIFunction,ActionNode,SimpleAIAction +from ..proto.agent_task import AgentTask,AgentTodoTask,AgentWorkLog,AgentTaskManager +from ..storage.storage import AIStorage +from .llm_context import GlobaToolsLibrary logger = logging.getLogger(__name__) @@ -313,26 +316,48 @@ class AgentWorkspace: def __init__(self,owner_agent_id:str) -> None: self.agent_id : str = owner_agent_id self.task_mgr : AgentTaskManager = LocalAgentTaskManger(owner_agent_id) - self.actions : Dict[str,ActionItem] = {} + self.actions : Dict[str,ActionNode] = {} self.inner_functions : Dict[str,AIFunction] = {} - self.init_actions() - self.init_inner_functions() + #self.init_actions() + #self.init_inner_functions() - - def init_actions(self): + @staticmethod + def register_actions(): async def create_task(params): + _self = params.get("_workspace") + if _self is None: + return "self not found" + taskObj = AgentTask.create_by_dict(params) parent_id = params.get("parent") - return await self.task_mgr.create_task(taskObj,parent_id) + return await _self.task_mgr.create_task(taskObj,parent_id) - create_task_action = SimpleAIOperation( - "create_task", + create_task_action = SimpleAIAction( + "agent.workspace.create_task", "Create a task in the task system, the supported parameters are: title, detail (simple task can not be filled), tags,due_date", create_task, ) - self.actions[create_task_action.get_name()] = create_task_action + GlobaToolsLibrary.get_instance().register_tool_function(create_task_action) + + async def cancel_task(parameters): + _self = parameters.get("_workspace") + if _self is None: + return "self not found" + task_id = parameters.get("task_id") + task = await _self.task_mgr.get_task(task_id) + if task is None: + return f"task {task_id} not found" + task.state = "cancel" + return await _self.task_mgr.update_task(task) + cancel_task_action = SimpleAIAction( + "agent.workspace.cancel_task", + "Cancel this task", + cancel_task, + ) + GlobaToolsLibrary.get_instance().register_tool_function(create_task_action) + def get_actions(self) -> Dict: return self.actions @@ -354,4 +379,7 @@ class AgentWorkspace: def get_inner_function_desc(self) -> List[AIFunction]: func_list = [] func_list.extend(self.inner_functions.values()) - return func_list \ No newline at end of file + return func_list + + def get_actions_for_task_review(self) -> Dict: + return self.actions \ No newline at end of file diff --git a/src/aios/environment/asr_function.py b/src/aios/ai_functions/asr_function.py similarity index 65% rename from src/aios/environment/asr_function.py rename to src/aios/ai_functions/asr_function.py index 81f8dc6..95fcb1c 100644 --- a/src/aios/environment/asr_function.py +++ b/src/aios/ai_functions/asr_function.py @@ -1,16 +1,26 @@ +# pylint:disable=E0402 import logging from typing import Dict -from ..frame.compute_kernel import ComputeKernel from ..proto.ai_function import * +from ..agent.llm_context import GlobaToolsLibrary +from ..frame.compute_kernel import ComputeKernel logger = logging.getLogger(__name__) - class AsrFunction(AIFunction): def __init__(self): - self.func_id = "speech_to_text" - self.description = "语音识别,将语音转换为文字" + self.func_id = "aigc.speech_to_text" + self.description = "Voice recognition, convert the voice into text" + self.parameters = ParameterDefine.create_parameters({ + "audio_file": {"type": "string", "description": "Audio file path"}, + "model": {"type": "string", "description": "Recognition model", "enum": ["openai-whisper"]}, + "prompt": {"type": "string", "description": "Prompt statement, can be None"}, + "response_format": {"type": "string", "description": "Return format", "enum": ["text", "json", "srt", "verbose_json", "vtt"]}, + }) + + def register_function(self): + GlobaToolsLibrary.get_instance().register_tool_function(self) def get_name(self) -> str: return self.func_id @@ -19,15 +29,7 @@ class AsrFunction(AIFunction): return self.description def get_parameters(self) -> Dict: - return { - "type": "object", - "properties": { - "audio_file": {"type": "string", "description": "音频文件路径"}, - "model": {"type": "string", "description": "识别模型", "enum": ["openai-whisper"]}, - "prompt": {"type": "string", "description": "提示语句,可以为None"}, - "response_format": {"type": "string", "description": "返回格式", "enum": ["text", "json", "srt", "verbose_json", "vtt"]}, - } - } + return self.parameters async def execute(self, **kwargs) -> str: logger.info(f"execute asr function: {kwargs}") diff --git a/src/aios/environment/code_interpreter.py b/src/aios/ai_functions/code_interpreter.py similarity index 93% rename from src/aios/environment/code_interpreter.py rename to src/aios/ai_functions/code_interpreter.py index 68c00d8..a9c223a 100644 --- a/src/aios/environment/code_interpreter.py +++ b/src/aios/ai_functions/code_interpreter.py @@ -1,3 +1,4 @@ +# pylint:disable=E0402 import logging import os import pathlib @@ -424,3 +425,36 @@ def execute_code( # return the exit code, logs and image return exit_code, logs +class CodeInterpreterFunction(AIFunction): + def __init__(self): + self.func_id = "system.code_interpreter" + self.description = "execute python code" + self.parameters = ParameterDefine.create_parameters({ + "code": {"type": "string", "description": "python code"} + }) + + def get_name(self) -> str: + return self.func_id + + def get_description(self) -> str: + return self.description + + def get_parameters(self) -> Dict: + return self.parameters + + async def execute(self, **kwargs) -> str: + code = kwargs.get("code") + ret_code, result = execute_code(code=code) + if ret_code == 0: + return result.strip() + else: + return result.strip() + + def is_local(self) -> bool: + return True + + def is_in_zone(self) -> bool: + return True + + def is_ready_only(self) -> bool: + return False diff --git a/src/aios/environment/duckduckgo_text_search_function.py b/src/aios/ai_functions/duckduckgo_text_search_function.py similarity index 76% rename from src/aios/environment/duckduckgo_text_search_function.py rename to src/aios/ai_functions/duckduckgo_text_search_function.py index 22632eb..4ded8ff 100644 --- a/src/aios/environment/duckduckgo_text_search_function.py +++ b/src/aios/ai_functions/duckduckgo_text_search_function.py @@ -1,7 +1,9 @@ +# pylint:disable=E0402 import json from typing import Dict from ..proto.ai_function import * +from ..agent.llm_context import GlobaToolsLibrary from duckduckgo_search import AsyncDDGS @@ -13,6 +15,12 @@ class DuckDuckGoTextSearchFunction(AIFunction): self.safesearch = "moderate" self.time = "y" self.max_results = 5 + self.parameters = ParameterDefine.create_parameters({ + "query": {"type": "string", "description": "The query to search for."} + }) + + def register_function(self): + GlobaToolsLibrary.get_instance().register_tool_function(self) def get_name(self) -> str: return self.name @@ -21,11 +29,7 @@ class DuckDuckGoTextSearchFunction(AIFunction): return self.description def get_parameters(self) -> Dict: - return {"type": "object", - "properties": { - "query": {"type": "string", "description": "The query to search for."} - } - } + return self.parameters async def execute(self, **kwargs) -> str: query = kwargs.get("query") diff --git a/src/aios/environment/image_2_text_function.py b/src/aios/ai_functions/image_2_text_function.py similarity index 74% rename from src/aios/environment/image_2_text_function.py rename to src/aios/ai_functions/image_2_text_function.py index 422917f..81ba467 100644 --- a/src/aios/environment/image_2_text_function.py +++ b/src/aios/ai_functions/image_2_text_function.py @@ -1,17 +1,26 @@ +# pylint:disable=E0402 import logging from typing import Dict from ..frame.compute_kernel import ComputeKernel from ..proto.ai_function import * +from ..agent.llm_context import GlobaToolsLibrary logger = logging.getLogger(__name__) class Image2TextFunction(AIFunction): + def __init__(self): - self.func_id = "image_2_text" + self.func_id = "aigc.image_2_text" self.description = "According to the input image file address, return the description of the image content" + self.parameters = ParameterDefine.create_parameters({ + "image_path": {"type": "string", "description": "image file path"} + }) logger.info(f"init Image2TextFunction") + def register_function(self): + GlobaToolsLibrary.get_instance().register_tool_function(self) + def get_name(self) -> str: return self.func_id @@ -19,8 +28,7 @@ class Image2TextFunction(AIFunction): return self.description def get_parameters(self) -> Dict: - return { - } + return self.parameters async def execute(self, **kwargs) -> str: logger.info(f"execute image_2_text function: {kwargs}") diff --git a/src/aios/environment/script_to_speech_function.py b/src/aios/ai_functions/script_to_speech_function.py similarity index 75% rename from src/aios/environment/script_to_speech_function.py rename to src/aios/ai_functions/script_to_speech_function.py index 043bc0d..e76ddbe 100644 --- a/src/aios/environment/script_to_speech_function.py +++ b/src/aios/ai_functions/script_to_speech_function.py @@ -1,3 +1,5 @@ +# pylint:disable=E0402 + import io import logging import os @@ -17,9 +19,30 @@ logger = logging.getLogger(__name__) class ScriptToSpeechFunction(AIFunction): def __init__(self): - self.func_id = "script_to_speech" - self.description = "根据输入的剧本生成音频文件,成功时会返回音频文件路径" + self.func_id = "aigc.script_to_speech" + self.description = "Generate audio files according to the input script, and the audio file path will be returned when successful" self.speech_path = os.path.join(AIStorage.get_instance().get_myai_dir(), "tts") + self.parameters = ParameterDefine.create_parameters({ + "language": {"type": "string", "description": "Actual language", "enum": ["zh", "en"]}, + "model": {"type": "string", "description": "Studio", "enum": ["tts-1", "tts-1-hd"]}, + "roles": {"type": "array", "items": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Character name"}, + "gender": {"type": "string", "description": "Gender", "enum": ["man", "female"]}, + "age": {"type": "string", "description": "age", "enum": ["child", "adult"]}, + }}}, + "lines": {"type": "array", "items": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Character name"}, + "tone": {"type": "string", "description": "Sovereign emotions", + "enum": ["happy", "sad", "angry", "fear", "disgust", "surprise", "neutral"]}, + "text": {"type": "string", "description": "Line"}, + } + }} + }) + Path(self.speech_path).mkdir(exist_ok=True) def get_name(self) -> str: @@ -28,33 +51,11 @@ class ScriptToSpeechFunction(AIFunction): def get_description(self) -> str: return self.description - def get_parameters(self) -> Dict: - return { - "type": "object", - "properties": { - "language": {"type": "string", "description": "演播语言", "enum": ["zh", "en"]}, - "model": {"type": "string", "description": "演播模型", "enum": ["tts-1", "tts-1-hd"]}, - "roles": {"type": "array", "items": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "角色名字"}, - "gender": {"type": "string", "description": "角色性别", "enum": ["man", "female"]}, - "age": {"type": "string", "description": "年龄", "enum": ["child", "adult"]}, - }}}, - "lines": {"type": "array", "items": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "角色名字"}, - "tone": {"type": "string", "description": "演播情感", - "enum": ["happy", "sad", "angry", "fear", "disgust", "surprise", "neutral"]}, - "text": {"type": "string", "description": "台词"}, - } - }} - } - } + def get_parameters(self): + return self.parameters async def execute(self, **kwargs) -> str: - logger.info(f"execute text_to_speech function: {kwargs}") + logger.info(f"execute aigc.script_to_speech function: {kwargs}") language = kwargs.get("language") if language is None: @@ -86,16 +87,16 @@ class ScriptToSpeechFunction(AIFunction): audio = audio + AudioSegment.from_mp3(io.BytesIO(data)) break except Exception as e: - logger.error(f"do_text_to_speech failed: {e}") + logger.error(f"script_to_speech failed: {e}") i += 1 continue if audio is not None: path = os.path.join(self.speech_path, "{}.mp3".format(''.join(random.sample('zyxwvutsrqponmlkjihgfedcba', 10)))) audio.export(path, format="mp3") - return "exec text_to_speech OK,speech file store at ```{}```".format(path) + return "exec script_to_speech OK,speech file store at ```{}```".format(path) else: - return "exec text_to_speech failed" + return "exec script_to_speech failed" def is_local(self) -> bool: return True diff --git a/src/aios/environment/sql_database_function.py b/src/aios/ai_functions/sql_database_function.py similarity index 97% rename from src/aios/environment/sql_database_function.py rename to src/aios/ai_functions/sql_database_function.py index c28b0e2..81dfd17 100644 --- a/src/aios/environment/sql_database_function.py +++ b/src/aios/ai_functions/sql_database_function.py @@ -1,10 +1,11 @@ +# pylint:disable=E0402 from datetime import timedelta, datetime from typing import Dict from cachetools import TLRUCache, cached from ..proto.ai_function import * -from .sql_database import SQLDatabase, get_from_env +from ..environment.sql_database import SQLDatabase, get_from_env def _my_ttu(_key, _value, now): diff --git a/src/aios/environment/text_to_speech_function.py b/src/aios/ai_functions/text_to_speech_function.py similarity index 75% rename from src/aios/environment/text_to_speech_function.py rename to src/aios/ai_functions/text_to_speech_function.py index 878bed5..73d3152 100644 --- a/src/aios/environment/text_to_speech_function.py +++ b/src/aios/ai_functions/text_to_speech_function.py @@ -17,9 +17,14 @@ logger = logging.getLogger(__name__) class TextToSpeechFunction(AIFunction): def __init__(self): - self.func_id = "text_to_speech" - self.description = "根据输入的文本生成音频文件,成功时会返回音频文件路径" + self.func_id = "aigc.text_to_speech" + self.description = "To generate audio files according to the input text, the audio file path will be returned when successful" self.speech_path = os.path.join(AIStorage.get_instance().get_myai_dir(), "tts") + self.parameters = ParameterDefine.create_parameters({ + "language": {"type": "string", "description": "Actual language", "enum": ["zh", "en"]}, + "model": {"type": "string", "description": "Studio", "enum": ["tts-1", "tts-1-hd"]}, + "text": {"type": "string", "description": "text"} + }) Path(self.speech_path).mkdir(exist_ok=True) def get_name(self) -> str: @@ -29,17 +34,10 @@ class TextToSpeechFunction(AIFunction): return self.description def get_parameters(self) -> Dict: - return { - "type": "object", - "properties": { - "language": {"type": "string", "description": "演播语言", "enum": ["zh", "en"]}, - "model": {"type": "string", "description": "演播模型", "enum": ["tts-1", "tts-1-hd"]}, - "text": {"type": "string", "description": "文本内容"} - } - } + return self.parameters async def execute(self, **kwargs) -> str: - logger.info(f"execute text_to_speech function: {kwargs}") + logger.info(f"execute aigc.text_to_speech function: {kwargs}") language = kwargs.get("language") if language is None: diff --git a/src/aios/environment/code_interpreter_function.py b/src/aios/environment/code_interpreter_function.py deleted file mode 100644 index 5913ba4..0000000 --- a/src/aios/environment/code_interpreter_function.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Dict - -from ..proto.ai_function import * -from .code_interpreter import execute_code - - -class CodeInterpreterFunction(AIFunction): - def __init__(self): - self.func_id = "code_interpreter" - self.description = "execute python code" - - def get_name(self) -> str: - return self.func_id - - def get_description(self) -> str: - return self.description - - def get_parameters(self) -> Dict: - return { - "type": "object", - "properties": { - "code": {"type": "string", "description": "python code"} - } - } - - async def execute(self, **kwargs) -> str: - code = kwargs.get("code") - ret_code, result = execute_code(code=code) - if ret_code == 0: - return result.strip() - else: - return result.strip() - - def is_local(self) -> bool: - return True - - def is_in_zone(self) -> bool: - return True - - def is_ready_only(self) -> bool: - return False diff --git a/src/aios/environment/environment.py b/src/aios/environment/environment.py index 6d07a81..22f1fc3 100644 --- a/src/aios/environment/environment.py +++ b/src/aios/environment/environment.py @@ -1,6 +1,6 @@ # basic environment class # we have some built-in environment: Calender(include timer),Home(connect to IoT device in your home), ,KnwoledgeBase,FileSystem, - +# pylint:disable=E0402 from abc import ABC, abstractmethod from typing import Any, Callable, Optional,Dict,Awaitable,List import logging @@ -11,6 +11,19 @@ logger = logging.getLogger(__name__) class BaseEnvironment: + @classmethod + def get_env_by_id(cls,env_id:str)->'BaseEnvironment': + if cls.all_env is None: + cls.all_env = {} + return cls.all_env.get(env_id) + + @classmethod + def register_env(cls,env_id:str,env:'BaseEnvironment')->None: + if cls.all_env is None: + cls.all_env = {} + cls.all_env[env_id] = env + + def __init__(self, workspace: str) -> None: pass @@ -29,11 +42,11 @@ class BaseEnvironment: @abstractmethod - def get_ai_operation(self,op_name:str) -> AIOperation: + def get_ai_operation(self,op_name:str) -> AIAction: pass @abstractmethod - def get_all_ai_operations(self) -> List[AIOperation]: + def get_all_ai_operations(self) -> List[AIAction]: pass def __getitem__(self, key): @@ -57,7 +70,7 @@ class SimpleEnvironment(BaseEnvironment): def __init__(self, workspace: str) -> None: super().__init__(workspace) self.functions: Dict[str,AIFunction] = {} - self.operations: Dict[str,AIOperation] = {} + self.operations: Dict[str,AIAction] = {} def add_ai_function(self,func:AIFunction) -> None: self.functions[func.get_name()] = func @@ -73,16 +86,16 @@ class SimpleEnvironment(BaseEnvironment): func_list.extend(self.functions.values()) return func_list - def add_ai_operation(self,op:AIOperation) -> None: + def add_ai_operation(self,op:AIAction) -> None: self.operations[op.get_name()] = op - def get_ai_operation(self,op_name:str) -> AIOperation: + def get_ai_operation(self,op_name:str) -> AIAction: op = self.operations.get(op_name) if op is not None: return op return None - def get_all_ai_operations(self) -> List[AIOperation]: + def get_all_ai_operations(self) -> List[AIAction]: op_list = [] op_list.extend(self.operations.values()) return op_list diff --git a/src/aios/environment/simple_kb_db.py b/src/aios/environment/simple_kb_db.py index 67ef124..ae1e0a5 100644 --- a/src/aios/environment/simple_kb_db.py +++ b/src/aios/environment/simple_kb_db.py @@ -1,4 +1,4 @@ - +# pylint:disable=E0402 import sqlite3 import json import threading diff --git a/src/aios/environment/sql_database.py b/src/aios/environment/sql_database.py index efee9b4..7c13255 100644 --- a/src/aios/environment/sql_database.py +++ b/src/aios/environment/sql_database.py @@ -2,6 +2,8 @@ Taken from: langchain SQLAlchemy wrapper around a database. """ +# pylint:disable=E0402 + from __future__ import annotations import os diff --git a/src/aios/environment/workflow_env.py b/src/aios/environment/workflow_env.py index c48bbfe..f16ed6c 100644 --- a/src/aios/environment/workflow_env.py +++ b/src/aios/environment/workflow_env.py @@ -1,4 +1,4 @@ - +# pylint:disable=E0402 from datetime import datetime import asyncio import json @@ -9,6 +9,7 @@ import logging from typing import Optional import aiosqlite +from ..agent.llm_context import GlobaToolsLibrary from ..proto.compute_task import * from ..proto.ai_function import * from ..frame.compute_kernel import ComputeKernel @@ -16,8 +17,8 @@ from ..frame.contact_manager import ContactManager,Contact,FamilyMember from ..storage.storage import AIStorage from .environment import SimpleEnvironment, CompositeEnvironment -from .script_to_speech_function import ScriptToSpeechFunction -from .image_2_text_function import Image2TextFunction +from ..ai_functions.script_to_speech_function import ScriptToSpeechFunction +from ..ai_functions.image_2_text_function import Image2TextFunction logger = logging.getLogger(__name__) @@ -36,38 +37,40 @@ class CalenderEnvironment(SimpleEnvironment): super().__init__(env_id) self.db_file = AIStorage.get_instance().get_myai_dir() / "calender.db" self.is_run = False + gl = GlobaToolsLibrary.get_instance() - self.add_ai_function(SimpleAIFunction("get_time", + gl.register_tool_function(SimpleAIFunction("system.now", "get current time", self._get_now)) - get_param = { + + get_param = ParameterDefine.create_parameters({ "start_time": "start time (UTC) of event", "end_time": "end time (UTC) of event" - } - self.add_ai_function(SimpleAIFunction("get_events", + }) + gl.register_tool_function(SimpleAIFunction("system.calender.get_events", "get events in calender by time range", self._get_events_by_time_range,get_param)) - add_param = { + add_param = ParameterDefine.create_parameters({ "title": "title of event", "start_time": "start time (UTC) of event", "end_time": "end time (UTC) of event", "participants": "participants of event", "location": "location of event", "details": "details of event" - } - self.add_ai_function(SimpleAIFunction("add_event", + }) + gl.register_tool_function(SimpleAIFunction("system.calender.add_event", "add event to calender", self._add_event,add_param)) - delete_param = { + delete_param = ParameterDefine.create_parameters({ "event_id": "id of event" - } - self.add_ai_function(SimpleAIFunction("delete_event", + }) + gl.register_tool_function(SimpleAIFunction("system.calender.delete_event", "delete event from calender", self._delete_event,delete_param)) - update_param = { + update_param = ParameterDefine.create_parameters({ "event_id": "id of event", "new_title": "new title of event", "new_participants": "new participants of event", @@ -75,27 +78,12 @@ class CalenderEnvironment(SimpleEnvironment): "new_details": "new details of event", "start_time": "new start time (UTC) of event", "end_time": "new end time (UTC) of event" - } - self.add_ai_function(SimpleAIFunction("update_event", + }) + gl.register_tool_function(SimpleAIFunction("system.calender.update_event", "update event in calender", self._update_event,update_param)) - self.add_ai_function(SimpleAIFunction("get_contact", - "get contact info", - self._get_contact,{"name":"name of contact"})) - - self.add_ai_function(SimpleAIFunction("set_contact", - "set contact info", - self._set_contact,{"name":"name of contact","contact_info":"A json to descrpit contact"})) - - - - - #self.add_ai_function(SimpleAIFunction("user_confirm", - # "user confirm", - # self._user_confirm)) - async def init_db(self): async with aiosqlite.connect(self.db_file) as db: await db.execute(""" @@ -305,12 +293,15 @@ class CalenderEnvironment(SimpleEnvironment): class PaintEnvironment(SimpleEnvironment): def __init__(self, env_id: str) -> None: super().__init__(env_id) - self.is_run = False - paint_param = { + + + + def register_functions(self): + paint_param = ParameterDefine.create_parameters({ "prompt": "Description of the content of the painting", - } - self.add_ai_function(SimpleAIFunction("paint", + }) + GlobaToolsLibrary.get_instance().register_tool_function(SimpleAIFunction("aigc.text_2_image", "Draw a picture according to the description", self._paint,paint_param)) diff --git a/src/aios/environment/workspace_env.py b/src/aios/environment/workspace_env.py index 84f20a4..733f91e 100644 --- a/src/aios/environment/workspace_env.py +++ b/src/aios/environment/workspace_env.py @@ -1,3 +1,4 @@ +# pylint:disable=E0402 import json import logging import os @@ -51,7 +52,7 @@ class TodoListEnvironment(SimpleEnvironment): parent_id = params.get("parent") return await self.create_todo(parent_id,todoObj) - self.add_ai_operation(SimpleAIOperation( + self.add_ai_operation(SimpleAIAction( op="create_todo", description="create todo", func_handler=create_todo, @@ -63,7 +64,7 @@ class TodoListEnvironment(SimpleEnvironment): new_stat = params["state"] return await self.update_todo(todo_id,new_stat) - self.add_ai_operation(SimpleAIOperation( + self.add_ai_operation(SimpleAIAction( op="update_todo", description="update todo", func_handler=update_todo, diff --git a/src/aios/frame/contact_manager.py b/src/aios/frame/contact_manager.py index 871cfab..5802e7a 100644 --- a/src/aios/frame/contact_manager.py +++ b/src/aios/frame/contact_manager.py @@ -5,6 +5,8 @@ import logging from datetime import datetime from ..proto.agent_msg import AgentMsg +from ..proto.ai_function import ParameterDefine, SimpleAIFunction +from ..agent.llm_context import GlobaToolsLibrary from .tunnel import AgentTunnel from .contact import Contact,FamilyMember @@ -19,6 +21,23 @@ class ContactManager: if cls._instance is None: cls._instance = ContactManager(str(filename)) return cls._instance + + def register_global_functions(self): + gl = GlobaToolsLibrary.get_instance() + + get_parameters = ParameterDefine.create_parameters({"name":"name"}) + gl.register_tool_function(SimpleAIFunction("system.contacts.get", + "get contact info", + self._get_contact,get_parameters)) + + update_parameters = ParameterDefine.create_parameters({"name":"name","contact_info":"A json to descrpit contact"}) + gl.register_tool_function(SimpleAIFunction("system.contacts.set", + "set contact info", + self._set_contact,update_parameters)) + + return + + def __init__(self, filename="contacts.toml"): self.filename = filename diff --git a/src/aios/proto/agent_msg.py b/src/aios/proto/agent_msg.py index ef08c72..9231d01 100644 --- a/src/aios/proto/agent_msg.py +++ b/src/aios/proto/agent_msg.py @@ -1,3 +1,4 @@ +# pylint:disable=E0402 import json import logging import shlex diff --git a/src/aios/proto/agent_task.py b/src/aios/proto/agent_task.py index 3a64b2b..0000a61 100644 --- a/src/aios/proto/agent_task.py +++ b/src/aios/proto/agent_task.py @@ -1,4 +1,4 @@ - +# pylint:disable=E0402 from abc import ABC, abstractmethod from typing import List, Optional import datetime diff --git a/src/aios/proto/ai_function.py b/src/aios/proto/ai_function.py index 7577bc5..8e5165a 100644 --- a/src/aios/proto/ai_function.py +++ b/src/aios/proto/ai_function.py @@ -1,11 +1,23 @@ +# pylint:disable=E0402 from abc import ABC, abstractmethod from typing import Dict,Coroutine,Callable,List class ParameterDefine: - def __init__(self) -> None: - self.name = None - self.type = None - self.description = None + def __init__(self,name:str,desc:str) -> None: + self.name:str = name + self.type:str = "string" + self.enum:List[str] = None + self.description = desc + self.is_required = False + + @classmethod + def create_parameters(cls,json_obj:dict) -> Dict[str,'ParameterDefine']: + result = {} + for k,v in json_obj.items(): + param = ParameterDefine(k,v) + result[k] = param + + return result class AIFunction: @@ -23,32 +35,125 @@ class AIFunction: """ pass + def get_detail_description(self) -> str: + """ + return a detailed description of what the function does + """ + parameters = self.get_parameters() + parameters_str = "" + for k,v in parameters.items(): + if len(v.description) <= 0: + parameters_str +=f"{k}," + else: + if v.description == k: + parameters_str += f"{k}," + else: + if v.is_required: + parameters_str += f"{k}: {v.description}," + else: + parameters_str += f"{k} (Optional): {v.description}," + if len(parameters_str) > 0: + return f"{self.get_description} Parameters: {parameters_str}" + return f"f{self.get_description()}, no parameters" + @abstractmethod - def get_parameters(self) -> Dict: + def get_parameters(self) -> Dict[str,ParameterDefine]: + pass + + def get_openai_parameters(self) -> Dict: """ Return the list of parameters to execute this function in the form of JSON schema as specified in the OpenAI documentation: https://platform.openai.com/docs/api-reference/chat/create#chat/create-parameters - str = run_code(code:str) - parameters = { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Python code which needs to be executed" + tools = [ + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "Get the current weather", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the users location.", + }, + }, + "required": ["location", "format"], + }, } - } - } + }, + { + "type": "function", + "function": { + "name": "get_n_day_weather_forecast", + "description": "Get an N-day weather forecast", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the users location.", + }, + "num_days": { + "type": "integer", + "description": "The number of days to forecast", + } + }, + "required": ["location", "format", "num_days"] + }, + } + }, + ] """ - pass + parameters = self.get_parameters() + if parameters is not None: + result = {} + result["type"] = "object" + required = [] + parm_defines = {} + for parm_name,parm in parameters.items(): + parm_item = {} + parm_item["type"] = parm.type + parm_item["description"] = parm.description + if parm.enum is not None: + parm_item["enum"] = parm.enum + parm_defines[parm_name] = parm_item + if parm.is_required: + required.append(parm_name) + result["properties"] = parm_defines + result["required"] = required + return result + + return {"type": "object", "properties": {}} @abstractmethod async def execute(self, **kwargs) -> str: """ - Execute the function and return a JSON serializable dict. + Execute the function and return a JSON serializable dict by LLM The parameters are passed in the form of kwargs + + [{'id': 'call_fLsKR5vGllhbWxvpqsDT3jBj', + 'type': 'function', + 'function': {'name': 'get_n_day_weather_forecast', + 'arguments': '{"location": "San Francisco, CA", "format": "celsius", "num_days": 4}'}}, + {'id': 'call_CchlsGE8OE03QmeyFbg7pkDz', + 'type': 'function', + 'function': {'name': 'get_n_day_weather_forecast', + 'arguments': '{"location": "Glasgow", "format": "celsius", "num_days": 4}'}} + ] """ pass @@ -70,10 +175,8 @@ class AIFunction: def is_ready_only(self) -> bool: pass - #def load_from_config(self,config:dict) -> bool: - # pass - -class ActionItem: +#TODO need to be upgrade +class ActionNode: def __init__(self,name:str,args:List[str]) -> None: self.name:str= name self.args:List[str]= args @@ -90,9 +193,9 @@ class ActionItem: pass @classmethod - def from_json(cls,json_obj:dict) -> 'ActionItem': + def from_json(cls,json_obj:dict) -> 'ActionNode': args = json_obj.get("args",[]) - r = ActionItem(json_obj["name"],args) + r = ActionNode(json_obj["name"],args) if json_obj.get("body"): r.body = json_obj["body"] r.parms = json_obj @@ -100,23 +203,12 @@ class ActionItem: return r -# call chain is a combination of ai_function,group of ai_function. -class CallChain: - def __init__(self) -> None: - pass - - def load_from_config(self,config:dict) -> bool: - pass - - async def execute(self): - pass - class SimpleAIFunction(AIFunction): - def __init__(self,func_id:str,description:str,func_handler:Coroutine,parameters:Dict = None) -> None: + def __init__(self,func_id:str,description:str,func_handler:Coroutine,parameters:Dict[str,ParameterDefine] = None) -> None: self.func_id = func_id self.description = description self.func_handler = func_handler - self.parameters = parameters + self.parameters:Dict[str,ParameterDefine] = parameters def get_name(self) -> str: return self.func_id @@ -124,24 +216,12 @@ class SimpleAIFunction(AIFunction): def get_description(self) -> str: return self.description - def get_parameters(self) -> Dict: - if self.parameters is not None: - result = {} - result["type"] = "object" - parm_defines = {} - for parm,desc in self.parameters.items(): - parm_item = {} - parm_item["type"] = "string" - parm_item["description"] = desc - parm_defines[parm] = parm_item - result["properties"] = parm_defines - return result - return {"type": "object", "properties": {}} - - + def get_parameters(self) -> Dict[str,ParameterDefine]: + return self.parameters + async def execute(self,**kwargs) -> str: if self.func_handler is None: - return "error: function not implemented" + return f"error: function {self.func_id} not implemented" return await self.func_handler(**kwargs) @@ -154,7 +234,7 @@ class SimpleAIFunction(AIFunction): def is_ready_only(self) -> bool: return False -class AIOperation: +class AIAction: @abstractmethod def get_name(self) -> str: """ @@ -178,7 +258,7 @@ class AIOperation: """ pass -class SimpleAIOperation(AIOperation): +class SimpleAIAction(AIAction): def __init__(self,op:str,description:str,func_handler:Coroutine) -> None: self.op = op self.description = description @@ -197,7 +277,7 @@ class SimpleAIOperation(AIOperation): return await self.func_handler(params) -class AIFunctionOperation(AIOperation): +class AIFunction2Action(AIAction): def __init__(self, func: AIFunction) -> None: self.func = func super().__init__() @@ -208,7 +288,7 @@ class AIFunctionOperation(AIOperation): @abstractmethod def get_description(self) -> str: - return self.func.get_description() + return self.func.get_detail_description() @abstractmethod async def execute(self, params: dict) -> str: diff --git a/src/aios/proto/compute_task.py b/src/aios/proto/compute_task.py index db98eac..43315a1 100644 --- a/src/aios/proto/compute_task.py +++ b/src/aios/proto/compute_task.py @@ -1,13 +1,13 @@ - +# pylint:disable=E0402 import copy from enum import Enum import json import shlex import uuid import time -from typing import List, Union -from .ai_function import * -from .agent_msg import * +from typing import List, Union,Dict +from .ai_function import AIFunction,ActionNode +from .agent_msg import AgentMsg from ..knowledge import ObjectID from ..storage.storage import AIStorage @@ -33,13 +33,15 @@ class ComputeTaskState(Enum): class ComputeTaskType(Enum): NONE = "None" LLM_COMPLETION = "llm_completion" + TEXT_EMBEDDING ="text_embedding" + IMAGE_EMBEDDING ="image_embedding" + TEXT_2_IMAGE = "text_2_image" IMAGE_2_TEXT = "image_2_text" IMAGE_2_IMAGE = "image_2_image" VOICE_2_TEXT = "voice_2_text" TEXT_2_VOICE = "text_2_voice" - TEXT_EMBEDDING ="text_embedding" - IMAGE_EMBEDDING ="image_embedding" + # class Function(TypedDict, total=False): # name: Required[str] @@ -155,11 +157,8 @@ class LLMResult: self.compute_error_str = None self.resp : str = "" # llm say: self.raw_result = None # raw result from compute kernel - self.inner_functions : List[AIFunction] = [] - self.action_list : List[ActionItem] = [] # op_list is a optimize design for saving token - - #self.post_msgs : List[AgentMsg] = [] # move to op_list - # self.send_msgs : List[AgentMsg] = [] # move to op_list + #self.inner_functions : List[AIFunction] = [] + self.action_list : List[ActionNode] = [] # op_list is a optimize design for saving token @classmethod @@ -185,9 +184,11 @@ class LLMResult: r.resp = llm_json.get("resp") r.raw_result = llm_json action_list = llm_json.get("actions") - for action in action_list: - action_item = ActionItem.from_json(action) - r.action_list.append(action_item) + if action_list: + for action in action_list: + action_item = ActionNode.from_json(action) + if action_item: + r.action_list.append(action_item) return r @@ -215,7 +216,7 @@ class LLMResult: lines = llm_result_str.splitlines() is_need_wait = False - def check_args(action_item:ActionItem): + def check_args(action_item:ActionNode): match action_item.name: case "post_msg":# /post_msg $target_id if len(action_item.args) != 1: @@ -232,7 +233,7 @@ class LLMResult: return False - current_action : ActionItem = None + current_action : ActionNode = None for line in lines: if line.startswith("##/"): if current_action: @@ -242,7 +243,7 @@ class LLMResult: r.action_list.append(current_action) action_name,action_args = LLMResult.parse_action(line[3:]) - current_action = ActionItem(action_name,action_args) + current_action = ActionNode(action_name,action_args) else: if current_action: current_action.append_body(line + "\n") diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index 631fbf9..e370b21 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -28,7 +28,6 @@ class AgentManager: self.agent_templete_env : PackageEnv = None self.agent_env : PackageEnv = None self.db_path : str = None - self.environments: dict = {} self.loaded_agent_instance : Dict[str,BaseAIAgent] = None async def initial(self) -> None: @@ -50,16 +49,6 @@ class AgentManager: async def scan_all_agent(self)->None: pass - def register_environment(self, env_id: str, init_env) -> None: - self.environments[env_id] = init_env - - def init_environment(self, env_id: str, workspace: str): - if env_id not in self.environments: - logger.error(f"env {env_id} not found!") - return - - return self.environments[env_id](workspace) - async def is_exist(self,agent_id:str) -> bool: the_aget = await self.get(agent_id) if the_aget: @@ -123,28 +112,6 @@ class AgentManager: config_data = await config_file.read() config = toml.loads(config_data) result_agent = AIAgent() - - workspace = config.get("workspace", config.get("instance_id")) - workspace = WorkspaceEnvironment(workspace) - config["workspace"] = workspace - - if "owner_env" in config: - owner_env = config["owner_env"] - - def init_env(env_config: str): - _, ext = os.path.splitext(env_config) - if ext == ".py": - env_path = os.path.join(agent_media.full_path, env_config) - env = runpy.run_path(env_path)["init"](None, workspace.root_path) - else: - env = self.init_environment(env_config, workspace.root_path) - workspace.add_env(env) - - if isinstance(owner_env, list): - for env in owner_env: - init_env(env) - else: - init_env(owner_env) if await result_agent.load_from_config(config) is False: logger.error(f"load agent from {agent_media} failed!") diff --git a/src/component/common_environment/local_document.py b/src/component/common_environment/local_document.py index 635cf87..9f19003 100644 --- a/src/component/common_environment/local_document.py +++ b/src/component/common_environment/local_document.py @@ -279,7 +279,7 @@ class LocalKnowledgeBase(CompositeEnvironment): meta = self.learning_cache.get(full_path) meta.update(op) - self.add_ai_operation(SimpleAIOperation( + self.add_ai_operation(SimpleAIAction( op="learn", description="update knowledge llm summary", func_handler=learn, diff --git a/src/component/common_environment/local_file_system.py b/src/component/common_environment/local_file_system.py index 182d84c..b49e1c8 100644 --- a/src/component/common_environment/local_file_system.py +++ b/src/component/common_environment/local_file_system.py @@ -3,7 +3,7 @@ import os import aiofiles from typing import Any,List,Dict import chardet -from aios import SimpleAIOperation +from aios import SimpleAIAction from aios import SimpleEnvironment class FilesystemEnvironment(SimpleEnvironment): @@ -19,7 +19,7 @@ class FilesystemEnvironment(SimpleEnvironment): if is_append is None: is_append = False return await self.write(op["path"],op["content"],is_append) - self.add_ai_operation(SimpleAIOperation( + self.add_ai_operation(SimpleAIAction( op="write", description="write file", func_handler=write, @@ -27,7 +27,7 @@ class FilesystemEnvironment(SimpleEnvironment): async def delete(op): return await self.delete(op["path"]) - self.add_ai_operation(SimpleAIOperation( + self.add_ai_operation(SimpleAIAction( op="delete", description="delete path", func_handler=delete, @@ -35,7 +35,7 @@ class FilesystemEnvironment(SimpleEnvironment): async def rename(op): return await self.move(op["path"],op["new_name"]) - self.add_ai_operation(SimpleAIOperation( + self.add_ai_operation(SimpleAIAction( op="rename", description="rename path", func_handler=rename, diff --git a/src/component/common_environment/shell.py b/src/component/common_environment/shell.py index 173fc59..a32a988 100644 --- a/src/component/common_environment/shell.py +++ b/src/component/common_environment/shell.py @@ -1,29 +1,21 @@ import os from typing import Any,List,Dict -from aios import AgentMsg,AgentTodo,LLMPrompt -from aios import SimpleAIFunction, SimpleAIOperation +from aios import SimpleAIFunction from aios import SimpleEnvironment +from aios import GlobaToolsLibrary,ParameterDefine class ShellEnvironment(SimpleEnvironment): - def __init__(self, workspace: str) -> None: - super().__init__(workspace) + def __init__(self) -> None: + super().__init__("shell") - operator_param = { - "command": "command will execute", - } - self.add_ai_function(SimpleAIFunction("shell_exec", + @classmethod + def register_global_functions(cls): + operator_param = ParameterDefine.create_parameters({"command":"command will execute"}) + GlobaToolsLibrary.get_instance().register_tool_function(SimpleAIFunction("system.shell.exec", "execute shell command in linux bash", - self.shell_exec,operator_param)) - - #run_code_param = { - # "pycode": "python code will execute", - #} - #self.add_ai_function(SimpleAIFunction("run_code", - # "execute python code", - # self.run_code,run_code_param)) - - - async def shell_exec(self,command:str) -> str: + ShellEnvironment.shell_exec,operator_param)) + @staticmethod + async def shell_exec(command:str) -> str: import asyncio.subprocess process = await asyncio.create_subprocess_shell( command, diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index bd77ab3..765de93 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -95,6 +95,8 @@ class AIOS_Shell: #Stability_ComputeNode.declare_user_config() + def init_global_action_lib(self): + AgentMemory.register_actions() async def _handle_no_target_msg(self,bus:AIBus,target_id:str) -> bool: @@ -144,9 +146,9 @@ class AIOS_Shell: # paint_env = PaintEnvironment("paint") # Environment.set_env_by_id("paint",paint_env) - AgentManager.get_instance().register_environment("bash", ShellEnvironment) - AgentManager.get_instance().register_environment("fs", FilesystemEnvironment) - AgentManager.get_instance().register_environment("knowledge", LocalKnowledgeBase) + #AgentManager.get_instance().register_environment("bash", ShellEnvironment) + #AgentManager.get_instance().register_environment("fs", FilesystemEnvironment) + #AgentManager.get_instance().register_environment("knowledge", LocalKnowledgeBase) if await AgentManager.get_instance().initial() is not True: logger.error("agent manager initial failed!") From b6395c2195c57cb28180e238b4122611251b9d15 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sun, 31 Dec 2023 00:20:48 -0800 Subject: [PATCH 63/79] Fix bugs. --- .pylint => .pylintrc | 0 rootfs/agents/Jarvis/agent.toml | 3 ++- src/aios/agent/agent.py | 37 +++++++++++++++++++++++++++++---- src/aios/agent/llm_process.py | 3 ++- src/aios/agent/workspace.py | 4 ++-- src/aios/proto/agent_task.py | 2 +- 6 files changed, 40 insertions(+), 9 deletions(-) rename .pylint => .pylintrc (100%) diff --git a/.pylint b/.pylintrc similarity index 100% rename from .pylint rename to .pylintrc diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index b8c6874..208d49f 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -58,7 +58,8 @@ process_description=""" reply_format = """ The Response must be directly parsed by `python json.loads`. Here is an example: { - think:'$think step-by-step to be sure you have the right answer.' + think:'$think step-by-step to be sure you have the right answer.', + determine : '$determine, summary what you will do', plans:[ #Optional {"todo":"$todo_name","detail":"$todo_detail,"category":"$todo_category"} ... diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index d6a0e73..2b31474 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -14,7 +14,7 @@ import sys from ..proto.agent_msg import AgentMsg from ..proto.ai_function import * -from ..proto.agent_task import * +from ..proto.agent_task import AgentTaskState,AgentTask,AgentTodo,AgentTodoResult from ..proto.compute_task import * from .agent_base import * @@ -31,7 +31,7 @@ from ..storage.storage import AIStorage from ..knowledge import * from ..utils import video_utils, image_utils -from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode +from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode,LLMPrompt,LLMResult logger = logging.getLogger(__name__) @@ -240,6 +240,33 @@ class AIAgent(BaseAIAgent): return await self.llm_process_msg(msg) + async def llm_review_tasklist(self): + llm_process : BaseLLMProcess = self.behaviors.get("review_task") + if llm_process: + if self.prviate_workspace: + tasklist = await self.prviate_workspace.task_mgr.list_task() + if tasklist: + for agent_task in tasklist: + if self.agent_energy <= 0: + break + + if agent_task.state == AgentTaskState.TASK_STATE_WAIT: + input_parms = { + "task":agent_task + } + llm_result : LLMResult = await llm_process.process(input_parms) + if llm_result.state == LLMResultStates.ERROR: + logger.error(f"llm process review_task error:{llm_result.error_str}") + continue + elif llm_result.state == LLMResultStates.IGNORE: + logger.info(f"llm process review_task ignore!") + continue + else: + determine = llm_result.raw_result.get("determine") + logger.info(f"llm process review_task ok!,think is:{determine}") + self.agent_energy -= 1 + + async def _llm_run_todo_list(self, todo_list_type: TodoListType): workspace : WorkspaceEnvironment = self.get_workspace_by_msg(None) @@ -486,8 +513,8 @@ class AIAgent(BaseAIAgent): return - async def think_todo_log(self,todo_log:AgentWorkLog): - pass + #async def think_todo_log(self,todo_log:AgentWorkLog): + # pass @@ -516,6 +543,8 @@ class AIAgent(BaseAIAgent): if self.agent_energy <= 1: continue + await self.llm_review_tasklist() + # complete & check todo #await self._llm_run_todo_list(TodoListType.TO_WORK) diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index 7b9c638..abed5e0 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -522,7 +522,8 @@ class ReviewTaskProcess(BaseLLMProcess): if await super().load_from_config(config) is False: return False - async def prepare_prompt(self) -> LLMPrompt: + async def prepare_prompt(self,input:Dict) -> LLMPrompt: + agent_task = input.get("task") prompt = LLMPrompt() system_prompt_dict = {} system_prompt_dict["role_description"] = self.role_description diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index 0d1d334..37485af 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -4,7 +4,7 @@ import json import sqlite3 import os import logging -from typing import List +from typing import List, Optional import aiofiles @@ -239,7 +239,7 @@ class LocalAgentTaskManger(AgentTaskManager): # pass - async def list_task(self,filter:dict) -> List[AgentTask]: + async def list_task(self,filter:Optional[dict] = None ) -> List[AgentTask]: directory_path = self.root_path result_list:List[AgentTask] = [] diff --git a/src/aios/proto/agent_task.py b/src/aios/proto/agent_task.py index 0000a61..223e087 100644 --- a/src/aios/proto/agent_task.py +++ b/src/aios/proto/agent_task.py @@ -440,7 +440,7 @@ class AgentTaskManager(ABC): # pass @abstractmethod - async def list_task(self,filter:Optional[dict]) -> List[AgentTask]: + async def list_task(self,filter:Optional[dict] = None) -> List[AgentTask]: pass @abstractmethod From 3663f140fc53bd0ef0f3689915ef3b0d2982f0be Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Fri, 5 Jan 2024 20:02:58 -0800 Subject: [PATCH 64/79] fix bugs. --- doc/promps/Review Task.md | 1 + doc/promps/self-learn.md | 2 + rootfs/agents/Jarvis/agent.toml | 11 +-- src/aios/__init__.py | 1 + src/aios/agent/llm_context.py | 83 ++++++++-------- src/aios/agent/llm_process.py | 37 ++++++- src/aios/agent/workflow.py | 2 +- src/aios/agent/workspace.py | 96 +++++++++---------- src/aios/ai_functions/asr_function.py | 4 +- .../duckduckgo_text_search_function.py | 6 +- .../ai_functions/image_2_text_function.py | 2 +- .../ai_functions/script_to_speech_function.py | 2 +- .../ai_functions/sql_database_function.py | 4 +- .../ai_functions/text_to_speech_function.py | 2 +- src/aios/environment/environment.py | 4 +- src/aios/proto/ai_function.py | 20 +++- src/service/aios_shell/aios_shell.py | 3 +- 17 files changed, 159 insertions(+), 121 deletions(-) diff --git a/doc/promps/Review Task.md b/doc/promps/Review Task.md index dc661ed..28056d6 100644 --- a/doc/promps/Review Task.md +++ b/doc/promps/Review Task.md @@ -120,3 +120,4 @@ The result of my planned execution must be directly parsed by `python json.loads ## RetryTask 对未成功的任务进行再次处理 +注意RetryTask和RetryTodo的区别 diff --git a/doc/promps/self-learn.md b/doc/promps/self-learn.md index e69de29..5007259 100644 --- a/doc/promps/self-learn.md +++ b/doc/promps/self-learn.md @@ -0,0 +1,2 @@ +self learn通常是根据既定目标,对KB进行学习,或则主动扩展KB的行为。 +self learn的结果是可以被组织(workflow)共享 \ No newline at end of file diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index 208d49f..bfb6cc4 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -11,16 +11,17 @@ owner_prompt = "I am your master{name}" contact_prompt = "I am your master's friend{name}" role_desc = """ -Your name is Jarvis, the super personal assistant to the master. +Your name is Jarvis, the super personal assistant to the master, The focus of work is the support of the schedule management and schedule affairs. """ [behavior.on_message] type="LLMAgentMessageProcess" + process_description=""" 1. Based on your role, combined with existing information, make a brief and efficient reply. 2. Be mindful of the identity of the person you are chatting with and provide services accordingly based on their status. 3. Understand the intention of the dialogue, while using the necessary reply, use the appropriate, supported ACTION. -4. If you feel that there is a potential Task in the dialogue, you can create these tasks through appropriate ACTION. Be careful to query whether there are the same task before creating.Using the query interface is a high -cost behavior. +4. If you feel that there is a potential Task in the dialogue, you can create these tasks through appropriate ACTION. Be careful to query whether there are the same task before creating. Using the query interface is a high-cost behavior. 5. You are proficient in the languages of various countries and try to communicate with each other's mother tongue. """ @@ -44,6 +45,8 @@ known_info_tips = """ tools_tips = """ """ +llm_context.actions.enable = ["agent.workspace.create_task"] +llm_context.functions.enable = ["agent.workspace.list_task"] [behavior.review_task] type="ReviewTaskProcess" @@ -82,11 +85,7 @@ known_info_tips = """ tools_tips = """ """ -[llm_context.actions] -enable = ["agent.memory.append_chatlog"] -[llm_context.functions] -enable = [] [behavior.do] # do TODO type="DoTodoProcess" diff --git a/src/aios/__init__.py b/src/aios/__init__.py index 82c27a1..cba723e 100644 --- a/src/aios/__init__.py +++ b/src/aios/__init__.py @@ -10,6 +10,7 @@ from .agent.agent import AIAgent,AIAgentTemplete, BaseAIAgent from .agent.role import AIRole,AIRoleGroup from .agent.workflow import Workflow from .agent.agent_memory import AgentMemory +from .agent.workspace import AgentWorkspace from .agent.llm_context import LLMProcessContext,GlobaToolsLibrary,SimpleLLMContext from .frame.compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType diff --git a/src/aios/agent/llm_context.py b/src/aios/agent/llm_context.py index b3e2fb6..a3fd16c 100644 --- a/src/aios/agent/llm_context.py +++ b/src/aios/agent/llm_context.py @@ -17,11 +17,11 @@ class LLMProcessContext: def function2action(ai_func:AIFunction) -> AIAction: async def exec_func(params:Dict) -> str: return await ai_func.execute(params) - return SimpleAIAction(ai_func.get_name(),ai_func.get_detail_description(),exec_func) + return SimpleAIAction(ai_func.get_id(),ai_func.get_detail_description(),exec_func) @staticmethod - def aifunction_to_inner_function(self,all_inner_function:List[AIFunction]) -> List[Dict]: + def aifunctions_to_inner_functions(all_inner_function:List[AIFunction]) -> List[Dict]: result_func = [] result_len = 0 for inner_func in all_inner_function: @@ -34,6 +34,7 @@ class LLMProcessContext: result_func.append(this_func) return result_func + @abstractmethod def get_ai_function(self,func_name:str) -> AIFunction: pass @@ -63,13 +64,14 @@ class LLMProcessContext: def get_value(self,key:str) -> Optional[str]: pass - def list_actions(self,path:str) -> List[AIAction]: - return "No more actions!" + #def list_actions(self,path:str) -> List[AIAction]: + # return "No more actions!" - def list_functions(self,path:str) -> List[AIFunction]: - return "No more tool functions!" + #def list_functions(self,path:str) -> List[AIFunction]: + # return "No more tool functions!" class GlobaToolsLibrary: + _instance = None @classmethod def get_instance(cls) -> 'GlobaToolsLibrary': if cls._instance is None: @@ -89,10 +91,10 @@ class GlobaToolsLibrary: return self.all_preset_context.get(preset_id) def register_tool_function(self,function:AIFunction) -> None: - if self.all_tool_functions.get(function.get_name()): - logger.warning(f"Tool function {function.get_name()} already exists! will be replaced!") + if self.all_tool_functions.get(function.get_id()): + logger.warning(f"Tool function {function.get_id()} already exists! will be replaced!") - self.all_tool_functions[function.get_name()] = function + self.all_tool_functions[function.get_id()] = function def get_tool_function(self,function_name:str) -> AIFunction: return self.all_tool_functions.get(function_name) @@ -139,18 +141,19 @@ class SimpleLLMContext(LLMProcessContext): return None disable_actions = config.get("disable") - for disable_action in disable_actions: - if result.get(disable_action): - result.pop(disable_action) - else: - func_set = GlobaToolsLibrary.get_instance().get_function_set(action_id) - if func_set: - for _func_id in func_set: - if result.get(_func_id): - result.pop(_func_id) + if disable_actions: + for disable_action in disable_actions: + if result.get(disable_action): + result.pop(disable_action) else: - logger.error(f"load_action_set_from_config failed! disable action id {action_id} not found!") - return None + func_set = GlobaToolsLibrary.get_instance().get_function_set(action_id) + if func_set: + for _func_id in func_set: + if result.get(_func_id): + result.pop(_func_id) + else: + logger.error(f"load_action_set_from_config failed! disable action id {action_id} not found!") + return None return result @@ -182,18 +185,19 @@ class SimpleLLMContext(LLMProcessContext): disable_functions = config.get("disable") - for disable_function in disable_functions: - if result.get(disable_function): - result.pop(disable_function) - else: - func_set = GlobaToolsLibrary.get_instance().get_function_set(func_id) - if func_set: - for func_id in func_set: - if result.get(func_id): - result.pop(func_id) + if disable_functions: + for disable_function in disable_functions: + if result.get(disable_function): + result.pop(disable_function) else: - logger.error(f"load_function_set_from_config failed! disable function id {disable_function} not found!") - return None + func_set = GlobaToolsLibrary.get_instance().get_function_set(func_id) + if func_set: + for func_id in func_set: + if result.get(func_id): + result.pop(func_id) + else: + logger.error(f"load_function_set_from_config failed! disable function id {disable_function} not found!") + return None return result @@ -268,15 +272,14 @@ class SimpleLLMContext(LLMProcessContext): def set_value(self,key:str,value:str): self.values[key] = value - #def get_ai_function(self,func_name:str) -> AIFunction: - # func = self.functions.get(func_name) - # if func is not None: - # return func - - # for set_name in self.func_sets.keys(): - # func = self.func_sets[set_name].get(func_name) - # if func is not None: - # return func + def get_ai_function(self,func_name:str) -> AIFunction: + for func in self.functions.values(): + if func.get_name() == func_name: + return func + #for set_name in self.func_sets.keys(): + # func = self.func_sets[set_name].get(func_name) + # if func is not None: + # return func def get_function_set(self,set_name:str = None) -> List[AIFunction]: if set_name is None: diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index abed5e0..053da6b 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -80,6 +80,9 @@ class BaseLLMProcess(ABC): def _format_content_by_env_value(self,content:str,env)->str: return content.format_map(env) + def prepare_inner_function_context_for_exec(self,inner_func_name:str,parameters:Dict): + return + async def _execute_inner_func(self,inner_func_call_node:Dict,prompt: LLMPrompt,stack_limit = 1) -> ComputeTaskResult: arguments = None stack_limit = stack_limit - 1 @@ -92,7 +95,8 @@ class BaseLLMProcess(ABC): if func_node is None: result_str:str = f"execute {func_name} error,function not found" else: - result_str:str = await func_node.execute(**arguments) + self.prepare_inner_function_context_for_exec(func_name,arguments) + result_str:str = await func_node.execute(arguments) except Exception as e: result_str = f"execute {func_name} error:{str(e)}" logger.error(f"LLMProcess execute inner func:{func_name} error:\n\t{e}") @@ -440,9 +444,8 @@ class LLMAgentMessageProcess(BaseLLMProcess): ## 可以使用的tools(inner function)的解释,注意不定义该tips,则不会导入任何workspace中的tools if self.tools_tips: system_prompt_dict["tools_tips"] = self.tools_tips - #prompt.append_system_message(self.tools_tips) - #self.llm_context. + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) if self.workspace: #TODO eanble workspace functions? logger.info(f"workspace is not none,enable workspace functions") @@ -458,6 +461,8 @@ class LLMAgentMessageProcess(BaseLLMProcess): return prompt + def prepare_inner_function_context_for_exec(self,inner_func_name:str,parameters:Dict): + parameters["_workspace"] = self.workspace async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: return self.llm_context.get_ai_function(func_name) @@ -518,9 +523,30 @@ class ReviewTaskProcess(BaseLLMProcess): return True - async def load_from_config(self, config: dict): + + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: + + if await super().load_from_config(config) is False: return False + + self.role_description = config.get("role_desc") + if self.role_description is None: + logger.error(f"role_description not found in config") + return False + + if config.get("process_description"): + self.process_description = config.get("process_description") + + if config.get("reply_format"): + self.reply_format = config.get("reply_format") + + if config.get("context"): + self.context = config.get("context") + + self.llm_context = SimpleLLMContext() + if config.get("llm_context"): + self.llm_context.load_from_config(config.get("llm_context")) async def prepare_prompt(self,input:Dict) -> LLMPrompt: agent_task = input.get("task") @@ -529,7 +555,8 @@ class ReviewTaskProcess(BaseLLMProcess): system_prompt_dict["role_description"] = self.role_description system_prompt_dict["process_rule"] = self.process_description system_prompt_dict["reply_format"] = self.reply_format - + prompt.append_system_message(json.dumps(system_prompt_dict)) + prompt.append_user_message(json.dumps(agent_task.to_dict())) return prompt diff --git a/src/aios/agent/workflow.py b/src/aios/agent/workflow.py index 8bf692a..b025e76 100644 --- a/src/aios/agent/workflow.py +++ b/src/aios/agent/workflow.py @@ -310,7 +310,7 @@ class Workflow: result_func = [] for inner_func in all_inner_function: - func_name = inner_func.get_name() + func_name = inner_func.get_id() if the_role.enable_function_list is not None: if len(the_role.enable_function_list) > 0: if func_name not in the_role.enable_function_list: diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index 37485af..89688ee 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -8,7 +8,7 @@ from typing import List, Optional import aiofiles -from ..proto.ai_function import AIFunction,SimpleAIFunction,ActionNode,SimpleAIAction +from ..proto.ai_function import AIFunction, ParameterDefine,SimpleAIFunction,ActionNode,SimpleAIAction from ..proto.agent_task import AgentTask,AgentTodoTask,AgentWorkLog,AgentTaskManager from ..storage.storage import AIStorage from .llm_context import GlobaToolsLibrary @@ -77,11 +77,12 @@ class LocalAgentTaskManger(AgentTaskManager): logger.info("create_task at %s",detail_path) async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: await f.write(json.dumps(task.to_dict())) + return "create task ok" except Exception as e: logger.error("create_task failed:%s",e) return str(e) - return None + async def create_todos(self,owner_task_id:str,todos:List[AgentTodoTask]): owner_task_path = self._get_obj_path(owner_task_id) @@ -240,7 +241,6 @@ class LocalAgentTaskManger(AgentTaskManager): async def list_task(self,filter:Optional[dict] = None ) -> List[AgentTask]: - directory_path = self.root_path result_list:List[AgentTask] = [] @@ -251,7 +251,7 @@ class LocalAgentTaskManger(AgentTaskManager): continue if entry.name == "workspace": continue - task_item = await self.get_task_by_path(entry.path) + task_item = await self._get_task_by_fullpath(entry.path) if task_item: if not task_item.is_finish(): result_list.append(task_item) @@ -259,8 +259,6 @@ class LocalAgentTaskManger(AgentTaskManager): return result_list - - async def update_task(self,task:AgentTask): detail_path = f"{self.root_path}/{task.task_path}/detail" try: @@ -316,70 +314,68 @@ class AgentWorkspace: def __init__(self,owner_agent_id:str) -> None: self.agent_id : str = owner_agent_id self.task_mgr : AgentTaskManager = LocalAgentTaskManger(owner_agent_id) - self.actions : Dict[str,ActionNode] = {} - self.inner_functions : Dict[str,AIFunction] = {} - #self.init_actions() - #self.init_inner_functions() @staticmethod - def register_actions(): + def register_ai_functions(): async def create_task(params): - _self = params.get("_workspace") - if _self is None: - return "self not found" + _workspace = params.get("_workspace") + if _workspace is None: + return "_workspace not found" taskObj = AgentTask.create_by_dict(params) parent_id = params.get("parent") - return await _self.task_mgr.create_task(taskObj,parent_id) - - create_task_action = SimpleAIAction( + return await _workspace.task_mgr.create_task(taskObj,parent_id) + parameters = ParameterDefine.create_parameters({ + "title": {"type": "string", "description": "task title"}, + "detail": {"type": "string", "description": "task detail(simple task can not be filled)"}, + "tags": {"type": "string", "description": "optional,task tags"}, + "due_date": {"type": "string", "description": "optional,task due date"}, + "parent": {"type": "string", "description": "optional,parent task id"}, + }) + create_task_action = SimpleAIFunction( "agent.workspace.create_task", - "Create a task in the task system, the supported parameters are: title, detail (simple task can not be filled), tags,due_date", + "Create a task", create_task, + parameters, ) - GlobaToolsLibrary.get_instance().register_tool_function(create_task_action) async def cancel_task(parameters): - _self = parameters.get("_workspace") - if _self is None: - return "self not found" + _workspace = parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" task_id = parameters.get("task_id") - task = await _self.task_mgr.get_task(task_id) + task = await _workspace.task_mgr.get_task(task_id) if task is None: return f"task {task_id} not found" task.state = "cancel" - return await _self.task_mgr.update_task(task) - cancel_task_action = SimpleAIAction( + await _workspace.task_mgr.update_task(task) + return "canncel task ok" + + parameters = ParameterDefine.create_parameters({ + "task_id": {"type": "string", "description": "task id which want to cancel"}, + }) + cancel_task_action = SimpleAIFunction( "agent.workspace.cancel_task", "Cancel this task", cancel_task, + parameters ) - GlobaToolsLibrary.get_instance().register_tool_function(create_task_action) + GlobaToolsLibrary.get_instance().register_tool_function(cancel_task_action) - - def get_actions(self) -> Dict: - return self.actions - - def init_inner_functions(self): - async def list_tasks(): - result = {} - fitler = {} - task_list = await self.task_mgr.list_task(fitler) - for task_item in task_list: - result[task_item.task_id] = task_item.title - - return json.dumps(result) - - self.inner_functions["list_tasks"] = SimpleAIFunction("list_tasks", - "list all tasks in json format like {{$task_id:$task_title}...}", - list_tasks) - def get_inner_function_desc(self) -> List[AIFunction]: - func_list = [] - func_list.extend(self.inner_functions.values()) - return func_list - - def get_actions_for_task_review(self) -> Dict: - return self.actions \ No newline at end of file + async def list_task(parameters): + _workspace = parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + all_task = await _workspace.task_mgr.list_task(None) + if all_task: + return json.dumps([task.to_dict() for task in all_task]) + else : + return "no task" + list_task_ai_function = SimpleAIFunction("agent.workspace.list_task", + "list all tasks in json format", + list_task,{}) + GlobaToolsLibrary.get_instance().register_tool_function(list_task_ai_function) + \ No newline at end of file diff --git a/src/aios/ai_functions/asr_function.py b/src/aios/ai_functions/asr_function.py index 95fcb1c..7c729d0 100644 --- a/src/aios/ai_functions/asr_function.py +++ b/src/aios/ai_functions/asr_function.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) class AsrFunction(AIFunction): def __init__(self): - self.func_id = "aigc.speech_to_text" + self.func_id = "aigc.voice_to_text" self.description = "Voice recognition, convert the voice into text" self.parameters = ParameterDefine.create_parameters({ "audio_file": {"type": "string", "description": "Audio file path"}, @@ -22,7 +22,7 @@ class AsrFunction(AIFunction): def register_function(self): GlobaToolsLibrary.get_instance().register_tool_function(self) - def get_name(self) -> str: + def get_id(self) -> str: return self.func_id def get_description(self) -> str: diff --git a/src/aios/ai_functions/duckduckgo_text_search_function.py b/src/aios/ai_functions/duckduckgo_text_search_function.py index 4ded8ff..05fbb9f 100644 --- a/src/aios/ai_functions/duckduckgo_text_search_function.py +++ b/src/aios/ai_functions/duckduckgo_text_search_function.py @@ -9,8 +9,8 @@ from duckduckgo_search import AsyncDDGS class DuckDuckGoTextSearchFunction(AIFunction): def __init__(self): - self.name = "duckduckgo_text_search" - self.description = "Search text from duckduckgo.com" + self.name = "web.search.duckduckgo" + self.description = "Search web by text (powered by DuckDuckGo)" self.region = "wt-wt" self.safesearch = "moderate" self.time = "y" @@ -22,7 +22,7 @@ class DuckDuckGoTextSearchFunction(AIFunction): def register_function(self): GlobaToolsLibrary.get_instance().register_tool_function(self) - def get_name(self) -> str: + def get_id(self) -> str: return self.name def get_description(self) -> str: diff --git a/src/aios/ai_functions/image_2_text_function.py b/src/aios/ai_functions/image_2_text_function.py index 81ba467..f0a61b4 100644 --- a/src/aios/ai_functions/image_2_text_function.py +++ b/src/aios/ai_functions/image_2_text_function.py @@ -21,7 +21,7 @@ class Image2TextFunction(AIFunction): def register_function(self): GlobaToolsLibrary.get_instance().register_tool_function(self) - def get_name(self) -> str: + def get_id(self) -> str: return self.func_id def get_description(self) -> str: diff --git a/src/aios/ai_functions/script_to_speech_function.py b/src/aios/ai_functions/script_to_speech_function.py index e76ddbe..31a11d7 100644 --- a/src/aios/ai_functions/script_to_speech_function.py +++ b/src/aios/ai_functions/script_to_speech_function.py @@ -45,7 +45,7 @@ class ScriptToSpeechFunction(AIFunction): Path(self.speech_path).mkdir(exist_ok=True) - def get_name(self) -> str: + def get_id(self) -> str: return self.func_id def get_description(self) -> str: diff --git a/src/aios/ai_functions/sql_database_function.py b/src/aios/ai_functions/sql_database_function.py index 81dfd17..6fd92c3 100644 --- a/src/aios/ai_functions/sql_database_function.py +++ b/src/aios/ai_functions/sql_database_function.py @@ -26,7 +26,7 @@ class GetTableInfosFunction(AIFunction): self.name = "get_table_infos" self.description = "Get table informations in the database" - def get_name(self) -> str: + def get_id(self) -> str: return self.name def get_description(self) -> str: @@ -74,7 +74,7 @@ class ExecuteSqlFunction(AIFunction): If an error is returned, rewrite the query, check the query, and try again. """ - def get_name(self) -> str: + def get_id(self) -> str: return self.name def get_description(self) -> str: diff --git a/src/aios/ai_functions/text_to_speech_function.py b/src/aios/ai_functions/text_to_speech_function.py index 73d3152..3a555ac 100644 --- a/src/aios/ai_functions/text_to_speech_function.py +++ b/src/aios/ai_functions/text_to_speech_function.py @@ -27,7 +27,7 @@ class TextToSpeechFunction(AIFunction): }) Path(self.speech_path).mkdir(exist_ok=True) - def get_name(self) -> str: + def get_id(self) -> str: return self.func_id def get_description(self) -> str: diff --git a/src/aios/environment/environment.py b/src/aios/environment/environment.py index 22f1fc3..3ccb0e1 100644 --- a/src/aios/environment/environment.py +++ b/src/aios/environment/environment.py @@ -73,7 +73,7 @@ class SimpleEnvironment(BaseEnvironment): self.operations: Dict[str,AIAction] = {} def add_ai_function(self,func:AIFunction) -> None: - self.functions[func.get_name()] = func + self.functions[func.get_id()] = func def get_ai_function(self,func_name:str) -> AIFunction: func = self.functions.get(func_name) @@ -111,7 +111,7 @@ class CompositeEnvironment(SimpleEnvironment): self.envs.append(env) functions = env.get_all_ai_functions() for func in functions: - self.functions[func.get_name()] = func + self.functions[func.get_id()] = func operations = env.get_all_ai_operations() for op in operations: self.operations[op.get_name()] = op diff --git a/src/aios/proto/ai_function.py b/src/aios/proto/ai_function.py index 8e5165a..c0fcad9 100644 --- a/src/aios/proto/ai_function.py +++ b/src/aios/proto/ai_function.py @@ -21,6 +21,13 @@ class ParameterDefine: class AIFunction: + @abstractmethod + def get_id(self) -> str: + """ + return the id of the function (should be snake case) + """ + pass + @abstractmethod def get_name(self) -> str: """ @@ -140,7 +147,7 @@ class AIFunction: return {"type": "object", "properties": {}} @abstractmethod - async def execute(self, **kwargs) -> str: + async def execute(self, arguments:Dict) -> str: """ Execute the function and return a JSON serializable dict by LLM The parameters are passed in the form of kwargs @@ -210,20 +217,23 @@ class SimpleAIFunction(AIFunction): self.func_handler = func_handler self.parameters:Dict[str,ParameterDefine] = parameters - def get_name(self) -> str: + def get_id(self) -> str: return self.func_id + def get_name(self) -> str: + return self.func_id.split('.')[-1].strip() + def get_description(self) -> str: return self.description def get_parameters(self) -> Dict[str,ParameterDefine]: return self.parameters - async def execute(self,**kwargs) -> str: + async def execute(self,parameters:Dict) -> str: if self.func_handler is None: return f"error: function {self.func_id} not implemented" - return await self.func_handler(**kwargs) + return await self.func_handler(parameters) def is_local(self) -> bool: return True @@ -284,7 +294,7 @@ class AIFunction2Action(AIAction): @abstractmethod def get_name(self) -> str: - return self.func.get_name() + return self.func.get_id() @abstractmethod def get_description(self) -> str: diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 765de93..1ce1740 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -19,8 +19,6 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import WordCompleter from prompt_toolkit.styles import Style - - directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') @@ -149,6 +147,7 @@ class AIOS_Shell: #AgentManager.get_instance().register_environment("bash", ShellEnvironment) #AgentManager.get_instance().register_environment("fs", FilesystemEnvironment) #AgentManager.get_instance().register_environment("knowledge", LocalKnowledgeBase) + AgentWorkspace.register_ai_functions() if await AgentManager.get_instance().initial() is not True: logger.error("agent manager initial failed!") From c975ba2053a2fa121b1b1424651c7f374058719e Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sat, 6 Jan 2024 13:08:41 -0800 Subject: [PATCH 65/79] 1) FixBug 2) Add Documents abount TASK/TODO Refactor --- doc/function_list.md | 265 ++++++++++++++++++ doc/从日程管理软件到个人助理Agent.md | 26 ++ src/aios/agent/chatsession.py | 8 +- src/aios/agent/llm_context.py | 88 +++--- src/aios/agent/llm_process.py | 12 +- src/aios/agent/workspace.py | 22 +- .../duckduckgo_text_search_function.py | 2 +- src/aios/environment/environment.py | 4 +- src/aios/environment/simple_kb_db.py | 2 +- src/aios/environment/workflow_env.py | 6 +- src/aios/environment/workspace_env.py | 6 +- src/aios/knowledge/object/object.py | 1 + src/aios/proto/agent_msg.py | 6 +- src/aios/proto/ai_function.py | 23 +- src/aios/proto/compute_task.py | 6 +- src/aios/storage/storage.py | 2 +- .../common_environment/local_document.py | 10 +- .../common_environment/local_file_system.py | 4 +- src/component/mail_environment/issue.py | 2 +- src/component/mail_environment/mail.py | 2 +- src/component/openai_node/open_ai_node.py | 2 +- src/component/openai_node/whisper_node.py | 8 +- 22 files changed, 398 insertions(+), 109 deletions(-) create mode 100644 doc/function_list.md create mode 100644 doc/从日程管理软件到个人助理Agent.md diff --git a/doc/function_list.md b/doc/function_list.md new file mode 100644 index 0000000..994d3a6 --- /dev/null +++ b/doc/function_list.md @@ -0,0 +1,265 @@ +# AI Function list (Local) + +根据新的Function/Action 定义,我们需要记录系统目前提供的所有注册到GlobaToolsLibrary里的function,方便配置组合 +功能扩展只需要扩展function就好了,Action可以通过Function直接得到。 + +本文档提到的AI Function一般都是Local Function, 基于这个架构,我们可以用Web3的思路整合AI Service。 + +## Context Prepare +很多AIFUnction的正确调用都需要在参数里提供一个状态上下文。这个上下文的准备一般由LLProcess完成.而可以通过提示词实时改变的参数是AIFunction的真参数。目前需要做Context Prepare的主要和Agent相关的基础函数。 + + +## Agent Core + +Agent Core中提供的,与Agent/Workflow 状态关联的基础函数,是Agent的核心设计。对Agent来说,这些基础能力通常都是打开的,我们需要非常谨慎的思考哪些功能应该放到Agent Core中。 + +根据我们的架构设计,Agent Core的函数包含 Agent的Memory + TODO能力(Plan)。通过这些基础函数,支持了Agent的 + +- Process Message +- Planing: 让Agent可以基于Zone的权限Process Task/Todo +- Self-Think: Process的经验进行总结 +- Self-Improve: 对Agent的提示词进行持续的自我改进。 + + +### logs + +logs通常代表Agent的短期记忆,目前有两种Log:chatlog和worklog +目前我们并不鼓励Agent提供短期记忆的调整能力,都是通过标准流程得到一定实践范围的的,完整的短期记忆。 + +set_message //增加tag +get_chatlogs + +get_worklogs +set_worklogs //增加tag + +### Memory + +Memory 代表Agent的长期记忆 + +通过LLM驱动的Self-Think流程来更新。基本上是在根据短期记忆提炼对人、对事的看法和终结。也包含一些基于提示词工程的自我能力提升(比如复用已知的,好用的工具,或则复用自己为了解决某个特定问题已经制作并使用成功的工具) + +get_contact_summary +update_contact_summary +get_sth_summary +update_sth_summary + + +### Workspace (TODOList) + +Workspace为Agent提供了可以完成TODO的工具和保存工作结果的状态空间(FileSystem)。每个Agent默认有自己的private workspace,Workflow为Agent提供了可以共享的workspace,Workspace尽量基于文件系统构造,也方便Agent与人类协同工作。 + +#### Task/Todo 管理 +context中需要一个隐藏的_workspace +``` +_self = parameters.get("_workspace") +``` + +##### agent.workspace.list_task + +##### agent.workspace.create_task + +```json +{ + "title": {"type": "string", "description": "The title of the task."}, + "detail": {"type": "string", "description": "The detail of the task."}, + "tags": {"type": "string array", "description": "The tags of the task."}, + "due_date": {"type": "string", "description": "The due date of the task."}, + "parent_id": {"type": "string", "description": "The parent id of the task."}, +} +``` + +##### agent.workspace.cancel_task + +```json +{ + "task_id": {"type": "string", "description": "The id of the task to cancel."}, +} +``` + +##### agent.workspace.update_task + +##### agent.workspace.get_sub_tasks + +##### agent.workspace.create_todos + +##### agent.workspace.list_todos + +##### agent.workspace.get_todo + +##### agent.workspace.update_todo + +#### 核心的完成TODO的能力 + +##### Code Interprete + +##### Send Msg (系统原生能力) + +## Knowledge Base + +Knowledge Base对大部分Agent来说,是一个获得私有信息,并让LLM处理结果更好的基础设施(RAG支持)。少部分Agent会使用相关API,结合Knowledge Base所服务的目标来整理Knowledge Base. + +### 搜索 +#### 矢量搜索 +#### 传统的文本搜索搜索 +#### 根据已经存在的数据库描述,构造SQL搜索 + +### 当成文件系统浏览 + + +## AIGC + +一系列AIGC函数,是LLM打通AIGC能力,形成新生产力的基础。 + +### aigc.text_2_image + +文生图.返回的是生成图片的路径。 + +```json +{ + "prompt": "Description of the content of the painting", +} +``` + +### aigc.image_2_text +图生文,返回的是图片的描述 +(TODO:是否需要有一个提示词来要求针对特定问题对图片进行描述) + +```json +{ + "image_path": {"type": "string", "description": "image file path"} +} +``` + +### aigc.voice_to_text + +```json +{ + "audio_file": {"type": "string", "description": "Audio file path"}, + "model": {"type": "string", "description": "Recognition model", "enum": ["openai-whisper"]}, + "prompt": {"type": "string", "description": "Prompt statement, can be None"}, + "response_format": {"type": "string", "description": "Return format", "enum": ["text", "json", "srt", "verbose_json", "vtt"]}, +} +``` + +## system + +访问当前系统的基础设施 + +### system.now + +返回当前时间 + +### system.calender + +保存在当前Zone上的日历,默认是与Zone Owner相关的。也可以以自动形式同步别人的日历 +这个组件对AI成为个人助理非常重要,当与旧世界互通时,其细节的复杂度也是非常高的。 +这是一个典型的看起来容易做起来难度很大的基础组件,是思考和验证LLM对传统软件复杂度进行降维的一个关键实践。 + +#### system.calender.get_events +#### system.calender.add_event +#### system.calender.delete_event +#### system.calender.update_event + +### system.contacts + +访问用户的联系人列表。在OOD System 中,联系人列表是非常重要的系统基础设施,为一系列的权限控制提供了基础信息。 + +#### system.contacts.get + +通过contanct的名字得到contact的完整信息(json格式) + +```json +{"name":"name"} +``` + +#### system.contacts.set + +设置 contact 信息,注意这里使用了一个可扩展结构,我们可能需要定义一些标准的必填信息。 + +```json +{"name":"name","contact_info":"A json to descrpit contact"} +``` + +### System.shell + +#### system.shell.exec + +执行Shell命令(目前只支持Linux Bash) + + +## web + +访问web的函数。使用下列函数要确保Agent有访问互联网的权限。 + +### web.search.duckduckgo + +使用搜索引擎搜索互联网 + +```json +{ + "query": {"type": "string", "description": "The query to search for."} +} +``` + +## 常见的llm_context(能力分组) +为典型的LLM处理过程,进行了分组。主要是为了节约Token。 +使用Action比使用inner function更节约。 + +### Process消息组 (定制度高) +得到潜在的Task并创建,在这个过程中可能需要查询已有的任务(防止重复创建) +action:craete_task, function:list_task + +通常Agent在Process Message时,会表现出和其处理TODO接近的能力,核心的区别在于Process Message是立刻处理并给出结果,而变成Task更多的是当成一个异步的任务 +为了能处理回复,查询历史沟通记录(寻找记忆的过程) +为了能处理回复,查询KB的过程 + +REMARK:目前LLM的主要问题是,如果开放的function, LLM会倾向于优先使用,是否可以做成“如果用户对答案不满意”,再使用? + + + +### Review Task +对Task的首次执行,Review的目的 +- 拆分创建TODO(使用create_todos) +- 对简单的任务立刻执行并记录更新结果 (通常是) +- 认为超出自己的能力范围,标记为无法处理或转交给合适的人(Agent) (使用update_task) +- 对已有任务进行查询(list_task,query_task) + +### Do TODO(定制度高) +DO行为是复杂的,我们会精细的区分TODO的首次执行和失败后再次执行。失败后再次执行会得到之前的记录摘要,并有查询之前工作日志的能力。 + +Do TODO是Agent的另一个核心行为,这里会根据Agent的设定,集成更多的能力 +系统为Do提供的默认支持:(按难度逐步增加) + - 写文档 : 不需要任何外部支持 + - 运行AIGC : AIGC的函数组 + - 收集,整理信息(通过互联网或查询知识库): web.search.duckduckgo + - 发送消息,系统自带,但可能需要依赖一些通讯录浏览/查找函数 + - 执行自己的代码/编写代码并执行 : system.shell.exec,code_interprete (这是一个重点模块!) + - 运行网络服务 :智能合约的有通用套路,非智能合约的需要有一个完整的SOP来支持 + + +根据任务要求保存工作成功是手动的,这里有一组workspace级别的文件系统API。 +保存工作记录的行为是自动的,默认所有的Action都执行成就算是DoComplte,会自动的更新状态 +有的Do可能需要自我迭代一下,这和大多数Behavor只有一次LLM调用有所不同。 + + +### Check TODO/Task +为了解决LLM不可避免的幻视加入的Check流程。该流程会根据TODO的目标,对TODO的结果进行判定 +使用 update_todo 更新TODO的状态 +当所有的sub_todos都完成后,会check task的目标是否达到 +当所有的sub_task都完成后,会check task的目标是否达到 + +因此,提供的函数主要是得到 todo/task的更深入细节的函数(访问相关log),已经读取相关 工程成果文件 的函数 + +### Self-Think +获得logs 和summary +进行update + + + + +### Learning +得到logs和summary +浏览和整理KB + + +### Self-Improve \ No newline at end of file diff --git a/doc/从日程管理软件到个人助理Agent.md b/doc/从日程管理软件到个人助理Agent.md new file mode 100644 index 0000000..b2be63b --- /dev/null +++ b/doc/从日程管理软件到个人助理Agent.md @@ -0,0 +1,26 @@ +# 从日程管理软件到个人助理Agent + +## 背景 + +在计算的时代,我们想要有开发一个好用的日程管理服务,很快就会发现这并不简单。比如我们会有各种各样的重复任务提醒要求:比如每个工作周周五的下午去游泳,每个工作周的第一天要开例会,和某人的纪念日要做什么等等。允许用户创建,并识别这些复杂的条件是非常繁琐的。而在计算机发明以前,你的秘书只需要一张纸就可以搞定各种复杂的日程管理:秘书把你的要求原样记录下,然后秘书只要每天Review一遍,确定好今天的行程就好。 + +一些有更复杂的条件的日程,秘书可以通过定期Review代办来搞定。 + +理解上述区别,就理解了 PC 和 PI (CPU vs LLM)的核心区别。 + +## 基于Agent 的日程管理基础设施 + +为了支持秘书Agent,aios所构建的机制是: + +1. Task创建: 模拟秘书在记事本上记录下日程相关的任务(注意不要让Agent创建重复的任务) +2. ListTask,可查询未标记为结束的Task +3. 大Review(定期LLM调用),Agent浏览未完成的Task,并给这些Task一个比较精确的`下次处理时间` +4. 当处理事件的时间点到达时,Agent会尝试处理Task,此时决定是否满足日程发生的条件,并发送正确的提醒信息。 + +上面的所有的Agent处理,都是让 LLM处理一段文本,并做出反应。因此没有存储格式的需求,用自然语言记录下各种条件就可以了。 + +## 过渡:混合使用日程管理软件和 秘书Agent + +1. 秘书Agent主要使用日程管理软件来查询已有日程 +2. Agent创建通过日程管理软件的目的,通常是为了公开日程,或则创建的是具有`一定协作需求`,或有`公开需求`的`确定`日程 +3. Agent依旧依靠自己的LLM Based逻辑来Review并处理日程, 通过日程管理软件得到的需要处理的日程会有特殊标记,以能正确的操作来保持处理结果。 \ No newline at end of file diff --git a/src/aios/agent/chatsession.py b/src/aios/agent/chatsession.py index 35aa9f7..7f07796 100644 --- a/src/aios/agent/chatsession.py +++ b/src/aios/agent/chatsession.py @@ -115,22 +115,22 @@ class ChatSessionDB: action_result = None mentions = None if msg.mentions: - mentions = json.dumps(msg.mentions) + mentions = json.dumps(msg.mentions,ensure_ascii=False) match msg.msg_type: case AgentMsgType.TYPE_MSG: pass case AgentMsgType.TYPE_ACTION:# THIS Action is not AIAction action_name = msg.func_name - action_params = json.dumps(msg.args) + action_params = json.dumps(msg.args,ensure_ascii=False) action_result = msg.result_str case AgentMsgType.TYPE_INTERNAL_CALL: action_name = msg.func_name - action_params = json.dumps(msg.args) + action_params = json.dumps(msg.args,ensure_ascii=False) action_result = msg.result_str case AgentMsgType.TYPE_EVENT: action_name = msg.event_name - action_params = json.dumps(msg.event_args) + action_params = json.dumps(msg.event_args,ensure_ascii=False) if tags is None: tags = [] diff --git a/src/aios/agent/llm_context.py b/src/aios/agent/llm_context.py index a3fd16c..fc89481 100644 --- a/src/aios/agent/llm_context.py +++ b/src/aios/agent/llm_context.py @@ -4,7 +4,7 @@ import json import logging from typing import Optional,Set,List,Dict,Callable -from ..proto.ai_function import AIFunction,AIAction,SimpleAIAction +from ..proto.ai_function import AIFunction,AIAction, AIFunction2Action,SimpleAIAction logger = logging.getLogger(__name__) @@ -15,10 +15,7 @@ class LLMProcessContext: @staticmethod def function2action(ai_func:AIFunction) -> AIAction: - async def exec_func(params:Dict) -> str: - return await ai_func.execute(params) - return SimpleAIAction(ai_func.get_id(),ai_func.get_detail_description(),exec_func) - + return AIFunction2Action(ai_func) @staticmethod def aifunctions_to_inner_functions(all_inner_function:List[AIFunction]) -> List[Dict]: @@ -30,7 +27,7 @@ class LLMProcessContext: this_func["name"] = func_name this_func["description"] = inner_func.get_description() this_func["parameters"] = inner_func.get_openai_parameters() - result_len += len(json.dumps(this_func)) / 4 + result_len += len(json.dumps(this_func,ensure_ascii=False)) / 4 result_func.append(this_func) return result_func @@ -117,7 +114,7 @@ class SimpleLLMContext(LLMProcessContext): self.actions: Dict[str,AIAction] = {} self.action_sets : Dict[str,Dict[str,AIAction]] = {} - def load_action_set_from_config(self,preset,config:Dict[str,str]) -> bool: + def load_action_set_from_config(self,preset,config:Dict[str,str]) -> Dict: if preset is None: result = {} else: @@ -217,43 +214,42 @@ class SimpleLLMContext(LLMProcessContext): self.func_sets = self.parent.func_sets action_def:Dict= config.get("actions") - if action_def is None: - logger.error(f"load_from_config failed! actions not found!") - return False - self.actions = self.load_action_set_from_config(self.actions,action_def) - if self.actions is None: - logger.error(f"load_from_config failed! load_action_set_from_config failed!") - return False - - for set_name in action_def.keys(): - if set_name == "enable": - continue - if set_name == "disable": - continue - - sub_set = config.get(set_name) - self.action_sets[set_name] = self.load_action_set_from_config(None,sub_set) - if self.action_sets[set_name] is None: + if action_def: + self.actions = self.load_action_set_from_config(self.actions,action_def) + if self.actions is None: logger.error(f"load_from_config failed! load_action_set_from_config failed!") return False - function_def:Dict = config.get("functions") - self.functions = self.load_function_set_from_config(self.functions,function_def) - if self.functions is None: - logger.error(f"load_from_config failed! load_function_set_from_config failed!") - return False - - for set_name in function_def.keys(): - if set_name == "enable": - continue - if set_name == "disable": - continue + for set_name in action_def.keys(): + if set_name == "enable": + continue + if set_name == "disable": + continue - sub_set = config.get(set_name) - self.func_sets[set_name] = self.load_function_set_from_config(None,sub_set) - if self.func_sets[set_name] is None: + sub_set = config.get(set_name) + self.action_sets[set_name] = self.load_action_set_from_config(None,sub_set) + if self.action_sets[set_name] is None: + logger.error(f"load_from_config failed! load_action_set_from_config failed!") + return False + + function_def:Dict = config.get("functions") + if function_def: + self.functions = self.load_function_set_from_config(self.functions,function_def) + if self.functions is None: logger.error(f"load_from_config failed! load_function_set_from_config failed!") return False + + for set_name in function_def.keys(): + if set_name == "enable": + continue + if set_name == "disable": + continue + + sub_set = config.get(set_name) + self.func_sets[set_name] = self.load_function_set_from_config(None,sub_set) + if self.func_sets[set_name] is None: + logger.error(f"load_from_config failed! load_function_set_from_config failed!") + return False #values_def = config.get("values") #if values_def: @@ -280,6 +276,7 @@ class SimpleLLMContext(LLMProcessContext): # func = self.func_sets[set_name].get(func_name) # if func is not None: # return func + return None def get_function_set(self,set_name:str = None) -> List[AIFunction]: if set_name is None: @@ -291,15 +288,12 @@ class SimpleLLMContext(LLMProcessContext): return None - # def get_ai_action(self,op_name:str) -> AIOperation: - # op = self.actions.get(op_name) - # if op is not None: - # return op - # for set_name in self.action_sets.keys(): - # op = self.action_sets[set_name].get(op_name) - # if op is not None: - # return op - # return None + def get_ai_action(self,op_name:str) -> AIAction: + for action in self.actions.values(): + if action.get_name() == op_name: + return action + + return None def get_action_set(self,set_name:str = None) -> List[AIFunction]: if set_name is None: diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index 053da6b..a32d124 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -46,9 +46,9 @@ class BaseLLMProcess(ABC): async def prepare_prompt(self,input:Dict) -> LLMPrompt: pass - + @abstractmethod async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - return GlobaToolsLibrary.get_instance().get_tool_function(func_name) + pass @abstractmethod async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: @@ -89,7 +89,7 @@ class BaseLLMProcess(ABC): try: func_name = inner_func_call_node.get("name") arguments = json.loads(inner_func_call_node.get("arguments")) - logger.info(f"LLMProcess execute inner func:{func_name} :\n\t {json.dumps(arguments)}") + logger.info(f"LLMProcess execute inner func:{func_name} :\n\t {json.dumps(arguments,ensure_ascii=False)}") func_node : AIFunction = await self.get_inner_function_for_exec(func_name) if func_node is None: @@ -455,7 +455,7 @@ class LLMAgentMessageProcess(BaseLLMProcess): logger.info(f"enable kb") - prompt.append_system_message(json.dumps(system_prompt_dict)) + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) ## 扩展已知信息 (这可能是一个LLM过程) prompt.append_system_message(await self.get_extend_known_info(msg,prompt)) @@ -555,8 +555,8 @@ class ReviewTaskProcess(BaseLLMProcess): system_prompt_dict["role_description"] = self.role_description system_prompt_dict["process_rule"] = self.process_description system_prompt_dict["reply_format"] = self.reply_format - prompt.append_system_message(json.dumps(system_prompt_dict)) - prompt.append_user_message(json.dumps(agent_task.to_dict())) + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) + prompt.append_user_message(json.dumps(agent_task.to_dict(),ensure_ascii=False)) return prompt diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index 89688ee..8f09a3e 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -76,7 +76,7 @@ class LocalAgentTaskManger(AgentTaskManager): self._save_obj_path(task.task_id,task_path) logger.info("create_task at %s",detail_path) async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(task.to_dict())) + await f.write(json.dumps(task.to_dict(),ensure_ascii=False)) return "create task ok" except Exception as e: logger.error("create_task failed:%s",e) @@ -97,7 +97,7 @@ class LocalAgentTaskManger(AgentTaskManager): todo_path = f"{self.root_path}/{owner_task_path}/#{step_order} {todo.title}.todo" self._save_obj_path(todo.todo_id,todo_path) async with aiofiles.open(todo_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.to_dict())) + await f.write(json.dumps(todo.to_dict(),ensure_ascii=False)) logger.info("create_todos at %s OK!",todo_path) step_order += 1 except Exception as e: @@ -121,7 +121,7 @@ class LocalAgentTaskManger(AgentTaskManager): logs = [] logs.append(log.to_dict()) json_obj["logs"] = logs - await f.write(json.dumps(json_obj)) + await f.write(json.dumps(json_obj,ensure_ascii=False)) async def get_worklog(self,obj_id:str)->List[AgentWorkLog]: @@ -263,7 +263,7 @@ class LocalAgentTaskManger(AgentTaskManager): detail_path = f"{self.root_path}/{task.task_path}/detail" try: async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(task.to_dict())) + await f.write(json.dumps(task.to_dict(),ensure_ascii=False)) except Exception as e: logger.error("update_task failed:%s",e) return str(e) @@ -277,7 +277,7 @@ class LocalAgentTaskManger(AgentTaskManager): try: async with aiofiles.open(todo_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.to_dict())) + await f.write(json.dumps(todo.to_dict(),ensure_ascii=False)) except Exception as e: logger.error("update_todo failed:%s",e) return str(e) @@ -311,18 +311,20 @@ class LocalAgentTaskManger(AgentTaskManager): class AgentWorkspace: - def __init__(self,owner_agent_id:str) -> None: - self.agent_id : str = owner_agent_id - self.task_mgr : AgentTaskManager = LocalAgentTaskManger(owner_agent_id) + def __init__(self,owner_id:str) -> None: + self.owner_id : str = owner_id + self.task_mgr : AgentTaskManager = LocalAgentTaskManger(owner_id) @staticmethod def register_ai_functions(): async def create_task(params): _workspace = params.get("_workspace") + _agent_id = params.get("_agentid") if _workspace is None: return "_workspace not found" - + if params.get("creator") is None: + params["creator"] = _agent_id taskObj = AgentTask.create_by_dict(params) parent_id = params.get("parent") return await _workspace.task_mgr.create_task(taskObj,parent_id) @@ -371,7 +373,7 @@ class AgentWorkspace: return "_workspace not found" all_task = await _workspace.task_mgr.list_task(None) if all_task: - return json.dumps([task.to_dict() for task in all_task]) + return json.dumps([task.to_dict() for task in all_task],ensure_ascii=False) else : return "no task" list_task_ai_function = SimpleAIFunction("agent.workspace.list_task", diff --git a/src/aios/ai_functions/duckduckgo_text_search_function.py b/src/aios/ai_functions/duckduckgo_text_search_function.py index 05fbb9f..019d98f 100644 --- a/src/aios/ai_functions/duckduckgo_text_search_function.py +++ b/src/aios/ai_functions/duckduckgo_text_search_function.py @@ -44,7 +44,7 @@ class DuckDuckGoTextSearchFunction(AIFunction): max_results=self.max_results )] - return json.dumps(results) + return json.dumps(results,,ensure_ascii=False) def is_local(self) -> bool: return True diff --git a/src/aios/environment/environment.py b/src/aios/environment/environment.py index 3ccb0e1..ac79eaa 100644 --- a/src/aios/environment/environment.py +++ b/src/aios/environment/environment.py @@ -87,7 +87,7 @@ class SimpleEnvironment(BaseEnvironment): return func_list def add_ai_operation(self,op:AIAction) -> None: - self.operations[op.get_name()] = op + self.operations[op.get_id()] = op def get_ai_operation(self,op_name:str) -> AIAction: op = self.operations.get(op_name) @@ -114,7 +114,7 @@ class CompositeEnvironment(SimpleEnvironment): self.functions[func.get_id()] = func operations = env.get_all_ai_operations() for op in operations: - self.operations[op.get_name()] = op + self.operations[op.get_id()] = op def get_value(self,key:str) -> Optional[str]: for env in self.envs: diff --git a/src/aios/environment/simple_kb_db.py b/src/aios/environment/simple_kb_db.py index ae1e0a5..1bc7617 100644 --- a/src/aios/environment/simple_kb_db.py +++ b/src/aios/environment/simple_kb_db.py @@ -214,7 +214,7 @@ class SimpleKnowledgeDB: def query_docs_by_tag(self, tag: str) -> List[str]: conn = self._get_conn() cursor = conn.cursor() - tag_json = json.dumps(tag) # 将标签转换为 JSON 字符串 + tag_json = json.dumps(tag,ensure_ascii=False) # 将标签转换为 JSON 字符串 cursor.execute(''' SELECT documents.doc_path FROM documents diff --git a/src/aios/environment/workflow_env.py b/src/aios/environment/workflow_env.py index f16ed6c..8f36f17 100644 --- a/src/aios/environment/workflow_env.py +++ b/src/aios/environment/workflow_env.py @@ -126,7 +126,7 @@ class CalenderEnvironment(SimpleEnvironment): _event["location"] = row[5] _event["details"] = row[6] result[row[0]] = _event - return json.dumps(result, indent=4, sort_keys=True) + return json.dumps(result, indent=4, sort_keys=True,ensure_ascii=False) async def _get_events_by_time_range(self,start_time, end_time): async with aiosqlite.connect(self.db_file) as db: @@ -152,7 +152,7 @@ class CalenderEnvironment(SimpleEnvironment): if not have_result: return "No event." - return json.dumps(result, indent=4, sort_keys=True) + return json.dumps(result, indent=4, sort_keys=True,ensure_ascii=False) async def _update_event(self,event_id, new_title=None, new_participants=None, new_location=None, new_details=None ,start_time=None, end_time=None): fields_to_update = [] @@ -214,7 +214,7 @@ class CalenderEnvironment(SimpleEnvironment): cm = ContactManager.get_instance() contact : Contact = cm.find_contact_by_name(name) if contact: - s = json.dumps(contact.to_dict()) + s = json.dumps(contact.to_dict(),ensure_ascii=False) return f"Execute get_contact OK , contact {name} is {s}" else: return f"Execute get_contact OK , contact {name} not found!" diff --git a/src/aios/environment/workspace_env.py b/src/aios/environment/workspace_env.py index 733f91e..3ad8c89 100644 --- a/src/aios/environment/workspace_env.py +++ b/src/aios/environment/workspace_env.py @@ -205,7 +205,7 @@ class TodoListEnvironment(SimpleEnvironment): self._save_todo_path(todo.todo_id,todo_path) logger.info("create_todo %s",detail_path) async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.to_dict())) + await f.write(json.dumps(todo.to_dict(),ensure_ascii=False)) except Exception as e: logger.error("create_todo failed:%s",e) return str(e) @@ -221,7 +221,7 @@ class TodoListEnvironment(SimpleEnvironment): todo.state = new_stat detail_path = f"{full_path}/detail" async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.to_dict())) + await f.write(json.dumps(todo.to_dict()),ensure_ascii=False) return None else: return "todo not found." @@ -270,7 +270,7 @@ class TodoListEnvironment(SimpleEnvironment): logs = [] logs.append(result.to_dict()) json_obj["logs"] = logs - await f.write(json.dumps(json_obj)) + await f.write(json.dumps(json_obj),ensure_ascii=False) class WorkspaceEnvironment(CompositeEnvironment): diff --git a/src/aios/knowledge/object/object.py b/src/aios/knowledge/object/object.py index 25621b6..1872acd 100644 --- a/src/aios/knowledge/object/object.py +++ b/src/aios/knowledge/object/object.py @@ -63,6 +63,7 @@ class KnowledgeObject(ABC): # Convert the object_type and desc to string and compute the SHA256 hash data = json.dumps( {"object_type": self.object_type, "desc": self.desc}, + ensure_ascii=False, cls=ObjectEnhancedJSONEncoder, ) sha256 = hashlib.sha256() diff --git a/src/aios/proto/agent_msg.py b/src/aios/proto/agent_msg.py index 9231d01..cccfa38 100644 --- a/src/aios/proto/agent_msg.py +++ b/src/aios/proto/agent_msg.py @@ -154,7 +154,7 @@ class AgentMsg: @staticmethod def create_image_body(images: [str], prompt: str = None): - return json.dumps({"images": images, "prompt": prompt}) + return json.dumps({"images": images, "prompt": prompt}, ensure_ascii=False) @staticmethod def parse_image_body(image_body: str) -> Tuple[str, List[str]]: @@ -163,7 +163,7 @@ class AgentMsg: @staticmethod def create_video_body(video: str, prompt: str = None): - return json.dumps({"video": video, "prompt": prompt}) + return json.dumps({"video": video, "prompt": prompt}, ensure_ascii=False) @staticmethod def parse_video_body(video_body: str) -> Tuple[str, str]: @@ -172,7 +172,7 @@ class AgentMsg: @staticmethod def create_audio_body(audio: str, prompt: str = None): - return json.dumps({"audio": audio, "prompt": prompt}) + return json.dumps({"audio": audio, "prompt": prompt}, ensure_ascii=False) @staticmethod def parse_audio_body(audio_body: str) -> Tuple[str, str]: diff --git a/src/aios/proto/ai_function.py b/src/aios/proto/ai_function.py index c0fcad9..3e10aca 100644 --- a/src/aios/proto/ai_function.py +++ b/src/aios/proto/ai_function.py @@ -60,7 +60,7 @@ class AIFunction: else: parameters_str += f"{k} (Optional): {v.description}," if len(parameters_str) > 0: - return f"{self.get_description} Parameters: {parameters_str}" + return f"{self.get_description()} Parameters: {parameters_str}" return f"f{self.get_description()}, no parameters" @abstractmethod @@ -246,12 +246,16 @@ class SimpleAIFunction(AIFunction): class AIAction: @abstractmethod - def get_name(self) -> str: + def get_id(self) -> str: """ return the name of the operation (should be snake case) """ pass + def get_name(self)->str: + return self.get_id().split('.')[-1].strip() + + @abstractmethod def get_description(self) -> str: """ @@ -274,7 +278,7 @@ class SimpleAIAction(AIAction): self.description = description self.func_handler = func_handler - def get_name(self) -> str: + def get_id(self) -> str: return self.op def get_description(self) -> str: @@ -289,17 +293,14 @@ class SimpleAIAction(AIAction): class AIFunction2Action(AIAction): def __init__(self, func: AIFunction) -> None: - self.func = func super().__init__() + self.ai_func = func - @abstractmethod - def get_name(self) -> str: - return self.func.get_id() + def get_id(self) -> str: + return self.ai_func.get_id() - @abstractmethod def get_description(self) -> str: - return self.func.get_detail_description() + return self.ai_func.get_detail_description() - @abstractmethod async def execute(self, params: dict) -> str: - self.func.execute(**params) \ No newline at end of file + return await self.ai_func.execute(params) \ No newline at end of file diff --git a/src/aios/proto/compute_task.py b/src/aios/proto/compute_task.py index 43315a1..3f241f0 100644 --- a/src/aios/proto/compute_task.py +++ b/src/aios/proto/compute_task.py @@ -95,11 +95,11 @@ class LLMPrompt: def as_str(self)->str: result_str = "" if self.system_message: - result_str = json.dumps(self.system_message) + result_str = json.dumps(self.system_message,ensure_ascii=False) if self.messages: - result_str += json.dumps(self.messages) + result_str += json.dumps(self.messages,ensure_ascii=False) if self.inner_functions: - result_str += json.dumps(self.inner_functions) + result_str += json.dumps(self.inner_functions,ensure_ascii=False) return result_str diff --git a/src/aios/storage/storage.py b/src/aios/storage/storage.py index 11edd29..fcce49d 100644 --- a/src/aios/storage/storage.py +++ b/src/aios/storage/storage.py @@ -92,7 +92,7 @@ class UserConfig: os.makedirs(directory) async with aiofiles.open(self.user_config_path,"w") as f: - toml_str = toml.dumps(will_save_config) + toml_str = toml.dumps(will_save_config,ensure_ascii=False) await f.write(toml_str) except Exception as e: logger.error(f"save user config to {self.user_config_path} failed!") diff --git a/src/component/common_environment/local_document.py b/src/component/common_environment/local_document.py index 9f19003..b8d79b2 100644 --- a/src/component/common_environment/local_document.py +++ b/src/component/common_environment/local_document.py @@ -132,7 +132,7 @@ class MetaDatabase: create_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") summary = metadata.get("summary", "") - catalogs = json.dumps(metadata.get("catalogs", {})) + catalogs = json.dumps(metadata.get("catalogs", {}),ensure_ascii=False) title = metadata.get("title","") tags = ','.join(metadata.get("tags", [])) @@ -151,7 +151,7 @@ class MetaDatabase: title = meta.get("title", "") summary = meta.get("summary", "") - catalogs = json.dumps(meta.get("catalogs", {})) + catalogs = json.dumps(meta.get("catalogs", {}),ensure_ascii=False) tags = ','.join(meta.get("tags", [])) cursor.execute(''' @@ -224,7 +224,7 @@ class MetaDatabase: def query_docs_by_tag(self, tag: str) -> List[str]: conn = self._get_conn() cursor = conn.cursor() - tag_json = json.dumps(tag) # 将标签转换为 JSON 字符串 + tag_json = json.dumps(tag,ensure_ascii=False) # 将标签转换为 JSON 字符串 cursor.execute(''' SELECT documents.doc_path FROM documents @@ -450,7 +450,7 @@ class ParseLocalDocument: org_path = meta.get("original_path") known_obj["original_path"] = org_path - return f"# Known information:\n## Current directory structure:\n{kb_tree}\n## Knowlege Metadata:\n{json.dumps(known_obj)}\n" + return f"# Known information:\n## Current directory structure:\n{kb_tree}\n## Knowlege Metadata:\n{json.dumps(known_obj,ensure_ascii=False)}\n" def _token_len(self, text: str) -> int: return CustomAIAgent("", "gpt-4-1106-preview", self.token_limit).token_len(text=text) @@ -563,7 +563,7 @@ class ParseLocalDocument: if bookmarks: catalogs = [] self._parse_pdf_bookmarks(bookmarks,catalogs) - metadata["catalogs"] = json.dumps(catalogs) + metadata["catalogs"] = json.dumps(catalogs,ensure_ascii=False) except Exception as e: logger.warn("parse pdf bookmarks failed:%s",e) diff --git a/src/component/common_environment/local_file_system.py b/src/component/common_environment/local_file_system.py index b49e1c8..f65b99e 100644 --- a/src/component/common_environment/local_file_system.py +++ b/src/component/common_environment/local_file_system.py @@ -55,7 +55,7 @@ class FilesystemEnvironment(SimpleEnvironment): item_type = "directory" if is_dir else "file" items.append({"name": entry.name, "type": item_type}) - return json.dumps(items) + return json.dumps(items,ensure_ascii=False) # inner_function async def read(self,path:str) -> str: @@ -120,7 +120,7 @@ class FilesystemEnvironment(SimpleEnvironment): try: file_path = self.root_path + path stat = os.stat(file_path) - return json.dumps(stat) + return json.dumps(stat,ensure_ascii=False) except Exception as e: return str(e) diff --git a/src/component/mail_environment/issue.py b/src/component/mail_environment/issue.py index 1707d2a..7bddcb5 100644 --- a/src/component/mail_environment/issue.py +++ b/src/component/mail_environment/issue.py @@ -106,7 +106,7 @@ class Issue: child = desc_list.pop() root["child"] = child root = child - return json.dumps(root) + return json.dumps(root,ensure_ascii=False) @classmethod diff --git a/src/component/mail_environment/mail.py b/src/component/mail_environment/mail.py index b49b0c7..47cde8b 100644 --- a/src/component/mail_environment/mail.py +++ b/src/component/mail_environment/mail.py @@ -32,7 +32,7 @@ class Mail: "date": self.date, "content": self.content } - return json.dumps(prompt) + return json.dumps(prompt,ensure_ascii=False) @classmethod def prompt_desc(cls) -> dict: diff --git a/src/component/openai_node/open_ai_node.py b/src/component/openai_node/open_ai_node.py index e8dc862..e60c836 100644 --- a/src/component/openai_node/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -223,7 +223,7 @@ class OpenAI_ComputeNode(ComputeNode): max_tokens=result_token, ) else: - logger.info(f"call openai {mode_name} prompts: \n\t {prompts} \nfunctions: \n\t{json.dumps(llm_inner_functions)}") + logger.info(f"call openai {mode_name} prompts: \n\t {prompts} \nfunctions: \n\t{json.dumps(llm_inner_functions,ensure_ascii=False)}") resp = await client.chat.completions.create(model=mode_name, messages=prompts, response_format = response_format, diff --git a/src/component/openai_node/whisper_node.py b/src/component/openai_node/whisper_node.py index 9b9cd37..b1a8e14 100644 --- a/src/component/openai_node/whisper_node.py +++ b/src/component/openai_node/whisper_node.py @@ -156,11 +156,11 @@ class WhisperComputeNode(ComputeNode): result.result_str = text result.result = text elif response_format == "json": - result.result_str = json.dumps({"text": text}) + result.result_str = json.dumps({"text": text},ensure_ascii=False) resp.text = text result.result = resp elif response_format == "verbose_json": - result.result_str = json.dumps({"text": text, "segments": results}) + result.result_str = json.dumps({"text": text, "segments": results},ensure_ascii=False) latest_resp.text = text latest_resp.segments = results result.result = latest_resp @@ -190,9 +190,9 @@ class WhisperComputeNode(ComputeNode): result.set_from_task(task) result.worker_id = self.node_id if response_format == "json": - result.result_str = json.dumps({"text": resp.text}) + result.result_str = json.dumps({"text": resp.text},ensure_ascii=False) elif response_format == "verbose_json": - result.result_str = json.dumps({"text": resp.text, "segments": resp.segments}) + result.result_str = json.dumps({"text": resp.text, "segments": resp.segments},ensure_ascii=False) elif response_format == "srt" or response_format == "vtt" or response_format == "text": result.result_str = resp else: From 5885301c191cee1d020b7970ca27ea07fd35ecea Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sun, 7 Jan 2024 12:44:47 -0800 Subject: [PATCH 66/79] 1) Complete new Agent Behavior: triage_tasks 2) Fix bugs. --- rootfs/agents/Jarvis/agent.toml | 69 +++-- src/aios/__init__.py | 2 + src/aios/agent/agent.py | 79 ++++-- src/aios/agent/llm_do_task.py | 184 +++++++++++++ src/aios/agent/llm_process.py | 389 +++++++-------------------- src/aios/agent/llm_process_loader.py | 42 +++ src/aios/agent/workspace.py | 133 ++++++++- src/aios/proto/agent_task.py | 18 +- 8 files changed, 560 insertions(+), 356 deletions(-) create mode 100644 src/aios/agent/llm_do_task.py create mode 100644 src/aios/agent/llm_process_loader.py diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index bfb6cc4..c65e7de 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -15,22 +15,22 @@ Your name is Jarvis, the super personal assistant to the master, The focus of wo """ [behavior.on_message] -type="LLMAgentMessageProcess" +type="AgentMessageProcess" +# TODO: 是否应该自动记录 inner function和action的执行细节 process_description=""" -1. Based on your role, combined with existing information, make a brief and efficient reply. +1. Based on your role and the existing information, please think and then make a brief and efficient reply. 2. Be mindful of the identity of the person you are chatting with and provide services accordingly based on their status. 3. Understand the intention of the dialogue, while using the necessary reply, use the appropriate, supported ACTION. 4. If you feel that there is a potential Task in the dialogue, you can create these tasks through appropriate ACTION. Be careful to query whether there are the same task before creating. Using the query interface is a high-cost behavior. 5. You are proficient in the languages of various countries and try to communicate with each other's mother tongue. """ - +# Not work: tags: ['tag1', 'tag2'], #Optional,If the conversation involves important things and people, you can mark by 1-3 tags. reply_format = """ The Response must be directly parsed by `python json.loads`. Here is an example: { - think:'$think step-by-step to be sure you have the right answer.' + think:'$think step-by-step to be sure you have the right reply.' resp: '$What you want to reply', - tags: ['tag1', 'tag2'], #Optional,If the conversation involves important things and people, you can mark by 1-3 tags. actions: [{ name: '$action_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. @@ -48,8 +48,35 @@ tools_tips = """ llm_context.actions.enable = ["agent.workspace.create_task"] llm_context.functions.enable = ["agent.workspace.list_task"] -[behavior.review_task] -type="ReviewTaskProcess" + +[behavior.triage_tasks] +## 处理任务列表,任务列表里会包含所有未执行过,且未过期的任务 +## 对于简单的任务会一次性完成处理 +type="AgentTriageTaskList" +process_description=""" +You are expected to effectively TRIAGE the task list described in JSON format, in accordance with your role. Your GOAL is : +1. Adjust the priority of the task and set up a reasonable processing time.(update_task) +2. Immediately perform a simple (similar to reminding one category) task. Send a message using send_message, set the task to complete the use of update_task. +3. Organize tasks to remove tasks beyond your ability. And merge the repeated tasks.(update_task + cancel_task) +""" +reply_format = """ +The Response must be directly parsed by `python json.loads`. Here is an example: +{ + think:'$think step-by-step to be sure you can triage tasks well.' + resp : '$determine, summary what you do', + actions: [{ + name: '$action_name', + $param_name: '$parm' #Optional, fill in only if the action has parameters. + }] +} +""" +context="Your master now in {location}, time: {now}, weather: {weather}." + +llm_context.actions.enable = ["agent.workspace.update_task","agent.workspace.cancel_task","post_message"] + +[behavior.plan_task] +## 首次处理任务 +type="AgentPlanTask" process_description=""" 你得到的输入来自你自己之前记录在TaskList系统里的一个Task。现在你并不需要完成该Task,而是结合已知信息对Task进行一次Review.Review的过程是你独立完成的,你在形成结论的过程中可以使用工具,但不能和其它人交流。 1. 理性的思考如何一步一步的高效的,在潜在的截止时间前完成该Task。明确拒绝超出自己能力范围的Task。 @@ -75,20 +102,18 @@ The Response must be directly parsed by `python json.loads`. Here is an example: } """ # action_list: ['cancle','confirm', 'execute'] -LLMContext.action_list = ['cancle','confirm', 'execute'] +llm_context.actions.enable = ["agent.workspace.cancel_task"] +llm_context.functions.enable = ["agent.workspace.list_task"] context="Your master now in {location}, time: {now}, weather: {weather}." -known_info_tips = """ -""" +[behavior.review_task] +## 处理已经被LLM处理过的任务,包括处理首次出错的任务,处理被的任务 -tools_tips = """ - -""" [behavior.do] # do TODO -type="DoTodoProcess" +type="AgentDo" process_description=""" 1. 你的任务是结合自己的角色定义,手头的工具,已知信息、完成一个确定的TODO。完成该TODO后你会得到$200的小费。 2. 输入的TODO是来自你自己对一个Task的Plan结果。 @@ -111,12 +136,18 @@ The Response must be directly parsed by `python json.loads`. Here is an example: ] } """ +[behavior.check] +type="AgentCheck" -#[behavior.self_thinking] - - - -#[behavior.check] +[behavior.self_thinking] +# self thing的主要目的是对各种chatlog,worklog进行分析,并更新终结。 +type="AgentSelfThinking" +#[behavior.self_learning] +# self_learning的主要目的是根据自己的经验,主动的学习新的知识。这通常是一个专门整理知识库的Agent,一般的Agent谨慎开启 +#type="AgentSelfLearning" +#[behavior.self_improve] +# self_improve 是最后的行为,允许Agent结合自己的工作经验,改进自己的提示词(注意保留历史版本) +#type="AgentSelfImprove" diff --git a/src/aios/__init__.py b/src/aios/__init__.py index cba723e..7927c0b 100644 --- a/src/aios/__init__.py +++ b/src/aios/__init__.py @@ -12,6 +12,8 @@ from .agent.workflow import Workflow from .agent.agent_memory import AgentMemory from .agent.workspace import AgentWorkspace from .agent.llm_context import LLMProcessContext,GlobaToolsLibrary,SimpleLLMContext +from .agent.llm_process import BaseLLMProcess,LLMAgentBaseProcess +from .agent.llm_process_loader import LLMProcessLoader from .frame.compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,ComputeTaskState,ComputeTaskType from .frame.compute_node import ComputeNode,LocalComputeNode diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 2b31474..b348301 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -12,6 +12,8 @@ import datetime import copy import sys + + from ..proto.agent_msg import AgentMsg from ..proto.ai_function import * from ..proto.agent_task import AgentTaskState,AgentTask,AgentTodo,AgentTodoResult @@ -19,23 +21,23 @@ from ..proto.compute_task import * from .agent_base import * from .llm_process import * +from .llm_process_loader import * +from .llm_do_task import * from .chatsession import * -from ..environment.workspace_env import WorkspaceEnvironment, TodoListType +from ..environment.workspace_env import WorkspaceEnvironment, TodoListType from ..frame.contact_manager import ContactManager,Contact,FamilyMember from ..frame.compute_kernel import ComputeKernel from ..frame.bus import AIBus from ..environment.environment import * from ..environment.workspace_env import WorkspaceEnvironment from ..storage.storage import AIStorage - from ..knowledge import * from ..utils import video_utils, image_utils from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode,LLMPrompt,LLMResult logger = logging.getLogger(__name__) - class AIAgentTemplete: def __init__(self) -> None: self.llm_model_name:str = "gpt-4-0613" @@ -200,7 +202,15 @@ class AIAgent(BaseAIAgent): return self.agent_prompt + async def _get_context_info(self) -> Dict: + context_info = {} + context_info["location"] = "SanJose" + context_info["now"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + context_info["weather"] = "Partly Cloudy, 60°F" + + return context_info + async def llm_process_msg(self,msg:AgentMsg) -> AgentMsg: need_process:bool = True if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: @@ -218,8 +228,10 @@ class AIAgent(BaseAIAgent): resp_msg = msg.create_group_resp_msg(self.agent_id,"") return resp_msg + context_info = await self._get_context_info() input_parms = { - "msg":msg + "msg":msg, + "context_info":context_info } msg_process = self.behaviors.get("on_message") llm_result : LLMResult = await msg_process.process(input_parms) @@ -233,38 +245,47 @@ class AIAgent(BaseAIAgent): return resp_msg async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: - msg.context_info = {} - msg.context_info["location"] = "SanJose" - msg.context_info["now"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - msg.context_info["weather"] = "Partly Cloudy, 60°F" return await self.llm_process_msg(msg) - async def llm_review_tasklist(self): - llm_process : BaseLLMProcess = self.behaviors.get("review_task") + async def llm_triage_tasklist(self): + llm_process : BaseLLMProcess = self.behaviors.get("triage_tasks") if llm_process: if self.prviate_workspace: tasklist = await self.prviate_workspace.task_mgr.list_task() if tasklist: - for agent_task in tasklist: - if self.agent_energy <= 0: - break + input_parms = { + "tasklist":tasklist, + "context_info": await self._get_context_info() + } + llm_result : LLMResult = await llm_process.process(input_parms) + if llm_result.state == LLMResultStates.ERROR: + logger.error(f"llm process triage_tasks error:{llm_result.compute_error_str}") + elif llm_result.state == LLMResultStates.IGNORE: + logger.info(f"llm process triage_tasks ignore!") + else: + logger.info(f"llm process triage_tasks ok!,think is:{llm_result.resp}") + self.agent_energy -= 5 - if agent_task.state == AgentTaskState.TASK_STATE_WAIT: - input_parms = { - "task":agent_task - } - llm_result : LLMResult = await llm_process.process(input_parms) - if llm_result.state == LLMResultStates.ERROR: - logger.error(f"llm process review_task error:{llm_result.error_str}") - continue - elif llm_result.state == LLMResultStates.IGNORE: - logger.info(f"llm process review_task ignore!") - continue - else: - determine = llm_result.raw_result.get("determine") - logger.info(f"llm process review_task ok!,think is:{determine}") - self.agent_energy -= 1 + # for agent_task in tasklist: + # if self.agent_energy <= 0: + # break + + # if agent_task.state == AgentTaskState.TASK_STATE_WAIT: + # input_parms = { + # "task":agent_task + # } + # llm_result : LLMResult = await llm_process.process(input_parms) + # if llm_result.state == LLMResultStates.ERROR: + # logger.error(f"llm process review_task error:{llm_result.error_str}") + # continue + # elif llm_result.state == LLMResultStates.IGNORE: + # logger.info(f"llm process review_task ignore!") + # continue + # else: + # determine = llm_result.raw_result.get("determine") + # logger.info(f"llm process review_task ok!,think is:{determine}") + # self.agent_energy -= 1 @@ -543,7 +564,7 @@ class AIAgent(BaseAIAgent): if self.agent_energy <= 1: continue - await self.llm_review_tasklist() + await self.llm_triage_tasklist() # complete & check todo #await self._llm_run_todo_list(TodoListType.TO_WORK) diff --git a/src/aios/agent/llm_do_task.py b/src/aios/agent/llm_do_task.py new file mode 100644 index 0000000..7f7bd59 --- /dev/null +++ b/src/aios/agent/llm_do_task.py @@ -0,0 +1,184 @@ +from ..proto.compute_task import LLMPrompt,LLMResult,ComputeTaskResult,ComputeTaskResultCode +from ..proto.ai_function import AIFunction,AIAction,ActionNode +from ..proto.agent_msg import AgentMsg,AgentMsgType +from ..proto.agent_task import AgentTask +from ..frame.compute_kernel import ComputeKernel + +from .agent_memory import AgentMemory +from .workspace import AgentWorkspace +from .llm_context import LLMProcessContext,GlobaToolsLibrary, SimpleLLMContext +from .llm_process import BaseLLMProcess,LLMAgentBaseProcess + +from abc import ABC,abstractmethod +import copy +import json +import datetime +from datetime import datetime +from typing import Any, Callable, Coroutine, Optional,Dict,Awaitable,List +from enum import Enum +import logging + +logger = logging.getLogger(__name__) + +class AgentTriageTaskList(LLMAgentBaseProcess): + def __init__(self) -> None: + super().__init__() + + + async def load_from_config(self,config:dict) -> bool: + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self,input:Dict) -> LLMPrompt: + prompt = LLMPrompt() + + task_list:List[AgentTask] = input.get("tasklist") + context_info = input.get("context_info") + if task_list is None: + logger.error(f"tasklist not found in input") + return None + + task_dict_list = [] + for task in task_list: + task_dict_list.append(task.to_dict()) + + prompt.append_user_message(json.dumps(task_dict_list,ensure_ascii=False)) + + system_prompt_dict = self.prepare_role_system_prompt(context_info) + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) + return prompt + + + async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: + action_params = {} + action_params["_input"] = input + action_params["_memory"] = self.memory + action_params["_workspace"] = self.workspace + action_params["_llm_result"] = llm_result + action_params["_agentid"] = self.memory.agent_id + action_params["_start_at"] = datetime.now() + await self._execute_actions(actions,action_params) + + + +class AgentPlanTask(LLMAgentBaseProcess): + def __init__(self) -> None: + super().__init__() + + self.role_description:str = None + self.process_description:str = None + self.reply_format = None + + # 虽然在架构上LLM Process可以很容易的去Call另一个Process,但实际应用中还是应该慎重的保持LLM Process的简单性 + #self.do_task_llm_process : BaseLLMProcess = None + + async def initial(self,params:Dict = None) -> bool: + self.memory = params.get("memory") + if self.memory is None: + logger.error(f"LLMAgeMessageProcess initial failed! memory not found") + return False + self.workspace = params.get("workspace") + + + return True + + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: + + + if await super().load_from_config(config) is False: + return False + + self.role_description = config.get("role_desc") + if self.role_description is None: + logger.error(f"role_description not found in config") + return False + + if config.get("process_description"): + self.process_description = config.get("process_description") + + if config.get("reply_format"): + self.reply_format = config.get("reply_format") + + if config.get("context"): + self.context = config.get("context") + + self.llm_context = SimpleLLMContext() + if config.get("llm_context"): + self.llm_context.load_from_config(config.get("llm_context")) + + async def prepare_prompt(self,input:Dict) -> LLMPrompt: + agent_task = input.get("task") + prompt = LLMPrompt() + system_prompt_dict = {} + system_prompt_dict["role_description"] = self.role_description + system_prompt_dict["process_rule"] = self.process_description + system_prompt_dict["reply_format"] = self.reply_format + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) + prompt.append_user_message(json.dumps(agent_task.to_dict(),ensure_ascii=False)) + return prompt + + + async def get_review_task_actions(self) -> Dict[str,Dict]: + pass + + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: + pass + + async def post_llm_process(self,actions:List[ActionNode]) -> bool: + pass + +class AgentReviewTask(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict): + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self) -> LLMPrompt: + prompt = LLMPrompt() + pass + + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: + pass + + async def post_llm_process(self,actions:List[ActionNode]) -> bool: + pass + + +class AgentCheck(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self) -> LLMPrompt: + prompt = LLMPrompt() + pass + + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: + pass + + async def post_llm_process(self,actions:List[ActionNode]) -> bool: + pass + +class AgentDo(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict): + if await super().load_from_config(config) is False: + return False + + async def prepare_prompt(self) -> LLMPrompt: + prompt = LLMPrompt() + pass + + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: + pass + + async def post_llm_process(self,actions:List[ActionNode]) -> bool: + pass diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index a32d124..d372613 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -25,8 +25,6 @@ logger = logging.getLogger(__name__) MIN_PREDICT_TOKEN_LEN = 32 - - class BaseLLMProcess(ABC): def __init__(self) -> None: self.behavior:str = None #行为名字 @@ -42,6 +40,8 @@ class BaseLLMProcess(ABC): self.max_prompt_token = 1000 # not include input prompt self.timeout = 1800 # 30 min + self.llm_context:LLMProcessContext = None + @abstractmethod async def prepare_prompt(self,input:Dict) -> LLMPrompt: pass @@ -50,6 +50,10 @@ class BaseLLMProcess(ABC): async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: pass + @abstractmethod + def prepare_inner_function_context_for_exec(self,inner_func_name:str,parameters:Dict): + return + @abstractmethod async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: pass @@ -80,8 +84,6 @@ class BaseLLMProcess(ABC): def _format_content_by_env_value(self,content:str,env)->str: return content.format_map(env) - def prepare_inner_function_context_for_exec(self,inner_func_name:str,parameters:Dict): - return async def _execute_inner_func(self,inner_func_call_node:Dict,prompt: LLMPrompt,stack_limit = 1) -> ComputeTaskResult: arguments = None @@ -205,68 +207,12 @@ class LLMAgentBaseProcess(BaseLLMProcess): self.process_description:str = None self.reply_format:str = None self.context : str = None - - self.known_info_tips :str = None - self.tools_tips:str = None - + self.workspace : AgentWorkspace = None # If Workspace is not none , enable Agent Tasklist self.memory : AgentMemory = None + self.enable_kb : bool = False self.kb = None - async def load_default_config(self) -> bool: - return True - - - async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: - if is_load_default: - await self.load_default_config() - - if await super().load_from_config(config) is False: - return False - - self.role_description = config.get("role_desc") - if self.role_description is None: - logger.error(f"role_description not found in config") - return False - - if config.get("process_description"): - self.process_description = config.get("process_description") - - if config.get("reply_format"): - self.reply_format = config.get("reply_format") - - if config.get("context"): - self.context = config.get("context") - - if config.get("known_info_tips"): - self.known_info_tips = config.get("known_info_tips") - - if config.get("tools_tips"): - self.tools_tips = config.get("tools_tips") - - if config.get("knowledge_base"): - self.kb = config.get("knowledge_base") - - -class LLMAgentMessageProcess(BaseLLMProcess): - def __init__(self) -> None: - super().__init__() - - self.role_description:str = None - self.process_description:str = None - self.reply_format:str = None - self.context : str = None - - self.known_info_tips :str = None - self.tools_tips:str = None - - self.workspace : AgentWorkspace = None # If Workspace is not none , enable Agent Tasklist - self.memory : AgentMemory = None - self.enable_kb = False - self.kb = None - - self.llm_context : LLMProcessContext = None - async def initial(self,params:Dict = None) -> bool: self.memory = params.get("memory") if self.memory is None: @@ -274,9 +220,7 @@ class LLMAgentMessageProcess(BaseLLMProcess): return False self.workspace = params.get("workspace") - return True - async def load_default_config(self) -> bool: return True @@ -302,27 +246,86 @@ class LLMAgentMessageProcess(BaseLLMProcess): if config.get("context"): self.context = config.get("context") - if config.get("known_info_tips"): - self.known_info_tips = config.get("known_info_tips") - - if config.get("tools_tips"): - self.tools_tips = config.get("tools_tips") - - if config.get("enable_kb"): - self.enable_kb = config.get("enable_kb") == "true" - self.llm_context = SimpleLLMContext() if config.get("llm_context"): self.llm_context.load_from_config(config.get("llm_context")) - + if config.get("enable_kb"): + self.enable_kb = config.get("enable_kb") == "true" + + def prepare_role_system_prompt(self,context_info:Dict) -> Dict: + system_prompt_dict = {} + # System Prompt + ## LLM的身份说明 + system_prompt_dict["role_description"] = self.role_description + #prompt.append_system_message(self.role_description) + + ## 处理信息的流程说明 + system_prompt_dict["process_rule"] = self.process_description + #prompt.append_system_message(self.process_description) + ### 回复的格式 + system_prompt_dict["reply_format"] = self.reply_format + #prompt.append_system_message(self.reply_format) + + ## Context + context = self._format_content_by_env_value(self.context,context_info) + system_prompt_dict["context"] = context + #prompt.append_system_message(context) + + system_prompt_dict["support_actions"] = self.get_action_desc() + + return system_prompt_dict + + def prepare_inner_function_context_for_exec(self,inner_func_name:str,parameters:Dict): + parameters["_workspace"] = self.workspace + + def get_action_desc(self) -> Dict: + result = {} + actions_list = self.llm_context.get_all_ai_action() + for action in actions_list: + result[action.get_name()] = action.get_description() + return result + + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: + return self.llm_context.get_ai_function(func_name) + + async def _execute_actions(self,actions:List[ActionNode],action_params:Dict): + for action_item in actions: + op : AIAction = self.llm_context.get_ai_action(action_item.name) + if op: + if action_item.parms is None: + action_item.parms = {} + + real_parms = {**action_params,**action_item.parms} + + action_item.parms["_result"] = await op.execute(real_parms) + action_item.parms["_end_at"] = datetime.now() + else: + logger.warn(f"action {action_item.name} not found") + return False + + +class AgentMessageProcess(LLMAgentBaseProcess): + def __init__(self) -> None: + super().__init__() + + async def load_default_config(self) -> bool: + return True + + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: + if is_load_default: + await self.load_default_config() + + if await super().load_from_config(config) is False: + return False + + def check_and_to_base64(self, image_path: str) -> str: if image_utils.is_file(image_path): return image_utils.to_base64(image_path, (1024, 1024)) else: return image_path - async def get_prompt_from_msg(self,msg:AgentMsg) -> LLMPrompt: msg_prompt = LLMPrompt() if msg.is_image_msg(): @@ -356,13 +359,6 @@ class LLMAgentMessageProcess(BaseLLMProcess): return msg_prompt - async def get_action_desc(self) -> Dict: - result = {} - actions_list = self.llm_context.get_all_ai_action() - for action in actions_list: - result[action.get_name()] = action.get_description() - return result - async def sender_info(self,msg:AgentMsg)->str: sender_id = msg.sender #TODO Is sender an agent? @@ -386,6 +382,7 @@ class LLMAgentMessageProcess(BaseLLMProcess): # User Prompt ## Input Msg msg : AgentMsg = input.get("msg") + context_info = input.get("context_info") if msg is None: logger.error(f"LLMAgeMessageProcess prepare_prompt failed! input msg not found") return None @@ -395,31 +392,8 @@ class LLMAgentMessageProcess(BaseLLMProcess): return None prompt.append(msg_prompt) - system_prompt_dict = {} - - # System Prompt - ## LLM的身份说明 - system_prompt_dict["role_description"] = self.role_description - #prompt.append_system_message(self.role_description) - - ## 处理信息的流程说明 - system_prompt_dict["process_rule"] = self.process_description - #prompt.append_system_message(self.process_description) - ### 回复的格式 - system_prompt_dict["reply_format"] = self.reply_format - #prompt.append_system_message(self.reply_format) - ### 修改chatlog的action - ### 修改todo/task的action - ### workspace提供的额外的action - system_prompt_dict["support_actions"] = await self.get_action_desc() - - - #prompt.append_system_message(await self.get_action_desc()) - - ## Context (文本替换),是否应该覆盖全部消息 - context = self._format_content_by_env_value(self.context,msg.context_info) - system_prompt_dict["context"] = context - #prompt.append_system_message(context) + ## 通用的角色相关的系统提示词 + system_prompt_dict = self.prepare_role_system_prompt(context_info) ## 已知信息 known_info = {} @@ -441,10 +415,6 @@ class LLMAgentMessageProcess(BaseLLMProcess): #prompt.append_system_message(await self.get_log_summary(self,msg)) system_prompt_dict["known_info"] = known_info - ## 可以使用的tools(inner function)的解释,注意不定义该tips,则不会导入任何workspace中的tools - if self.tools_tips: - system_prompt_dict["tools_tips"] = self.tools_tips - prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) if self.workspace: #TODO eanble workspace functions? @@ -461,11 +431,6 @@ class LLMAgentMessageProcess(BaseLLMProcess): return prompt - def prepare_inner_function_context_for_exec(self,inner_func_name:str,parameters:Dict): - parameters["_workspace"] = self.workspace - - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - return self.llm_context.get_ai_function(func_name) async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: msg:AgentMsg = input.get("msg") @@ -476,137 +441,24 @@ class LLMAgentMessageProcess(BaseLLMProcess): llm_result.raw_result["_resp_msg"] = resp_msg - for action_item in actions: - op : AIAction = self.llm_context.get_ai_action(action_item.name) - if op: - if action_item.parms is None: - action_item.parms = {} + action_params = {} + action_params["_input"] = input + action_params["_memory"] = self.memory + action_params["_workspace"] = self.workspace + action_params["_resp_msg"] = resp_msg + action_params["_llm_result"] = llm_result + action_params["_agentid"] = self.memory.agent_id + action_params["_start_at"] = datetime.now() - action_item.parms["_input"] = input - action_item.parms["_memory"] = self.memory - action_item.parms["_workspace"] = self.workspace - action_item.parms["_resp_msg"] = resp_msg - action_item.parms["_llm_result"] = llm_result - action_item.parms["_start_at"] = datetime.now() - action_item.parms["_agentid"] = self.memory.agent_id - - action_item.parms["_result"] = await op.execute(action_item.parms) - action_item.parms["_end_at"] = datetime.now() - else: - logger.warn(f"action {action_item.name} not found") - return False + await self._execute_actions(actions,action_params) chatsession = self.memory.get_session_from_msg(msg) chatsession.append(msg) chatsession.append(resp_msg) - return True - - - -class ReviewTaskProcess(BaseLLMProcess): - def __init__(self) -> None: - super().__init__() - - self.role_description:str = None - self.process_description:str = None - self.reply_format = None - - # 虽然在架构上LLM Process可以很容易的去Call另一个Process,但实际应用中还是应该慎重的保持LLM Process的简单性 - #self.do_task_llm_process : BaseLLMProcess = None - - async def initial(self,params:Dict = None) -> bool: - self.memory = params.get("memory") - if self.memory is None: - logger.error(f"LLMAgeMessageProcess initial failed! memory not found") - return False - self.workspace = params.get("workspace") - return True - - async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: - - if await super().load_from_config(config) is False: - return False - - self.role_description = config.get("role_desc") - if self.role_description is None: - logger.error(f"role_description not found in config") - return False - - if config.get("process_description"): - self.process_description = config.get("process_description") - - if config.get("reply_format"): - self.reply_format = config.get("reply_format") - - if config.get("context"): - self.context = config.get("context") - - self.llm_context = SimpleLLMContext() - if config.get("llm_context"): - self.llm_context.load_from_config(config.get("llm_context")) - - async def prepare_prompt(self,input:Dict) -> LLMPrompt: - agent_task = input.get("task") - prompt = LLMPrompt() - system_prompt_dict = {} - system_prompt_dict["role_description"] = self.role_description - system_prompt_dict["process_rule"] = self.process_description - system_prompt_dict["reply_format"] = self.reply_format - prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) - prompt.append_user_message(json.dumps(agent_task.to_dict(),ensure_ascii=False)) - return prompt - - - async def get_review_task_actions(self) -> Dict[str,Dict]: - pass - - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - pass - - async def post_llm_process(self,actions:List[ActionNode]) -> bool: - pass - -class QuickReviewTaskProcess(BaseLLMProcess): - def __init__(self) -> None: - super().__init__() - - async def load_from_config(self, config: dict): - if await super().load_from_config(config) is False: - return False - - async def prepare_prompt(self) -> LLMPrompt: - prompt = LLMPrompt() - pass - - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - pass - - async def post_llm_process(self,actions:List[ActionNode]) -> bool: - pass - -class DoTodoProcess(BaseLLMProcess): - def __init__(self) -> None: - super().__init__() - - async def load_from_config(self, config: dict): - if await super().load_from_config(config) is False: - return False - - async def prepare_prompt(self) -> LLMPrompt: - prompt = LLMPrompt() - pass - - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - pass - - async def post_llm_process(self,actions:List[ActionNode]) -> bool: - pass - - -class CheckTodoProcess(BaseLLMProcess): +class AgentSelfLearning(BaseLLMProcess): def __init__(self) -> None: super().__init__() @@ -624,25 +476,7 @@ class CheckTodoProcess(BaseLLMProcess): async def post_llm_process(self,actions:List[ActionNode]) -> bool: pass -class SelfLearningProcess(BaseLLMProcess): - def __init__(self) -> None: - super().__init__() - - async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: - if await super().load_from_config(config) is False: - return False - - async def prepare_prompt(self) -> LLMPrompt: - prompt = LLMPrompt() - pass - - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - pass - - async def post_llm_process(self,actions:List[ActionNode]) -> bool: - pass - -class SelfThinkingProcess(BaseLLMProcess): +class AgentSelfThinking(BaseLLMProcess): def __init__(self) -> None: super().__init__() @@ -727,43 +561,10 @@ class SelfThinkingProcess(BaseLLMProcess): async def post_llm_process(self,actions:List[ActionNode]) -> bool: pass - -class LLMProcessLoader: + +class AgentSelfImprove(BaseLLMProcess): def __init__(self) -> None: - self.loaders : Dict[str,Callable[[dict],Awaitable[BaseLLMProcess]]] = {} - return - - @classmethod - def get_instance(cls)->"LLMProcessLoader": - if not hasattr(cls,"_instance"): - cls._instance = LLMProcessLoader() - return cls._instance - - def register_loader(self, typename:str,loader:Callable[[dict],Awaitable[BaseLLMProcess]]): - self.loaders[typename] = loader - - async def load_from_config(self,config:dict) -> BaseLLMProcess: - llm_type_name = config.get("type") - if llm_type_name: - loader = self.loaders.get(llm_type_name) - if loader: - return await loader(config) - - selected_type = globals().get(llm_type_name) - if selected_type: - result : BaseLLMProcess = selected_type() - load_result = await result.load_from_config(config) - if load_result is False: - logger.warn(f"load LLMProcess {llm_type_name} from config failed! load_from_config return False") - return None - else: - return result - - - logger.warn(f"load LLMProcess {llm_type_name} from config failed! type not found") - return None - - + super().__init__() diff --git a/src/aios/agent/llm_process_loader.py b/src/aios/agent/llm_process_loader.py new file mode 100644 index 0000000..912b3a0 --- /dev/null +++ b/src/aios/agent/llm_process_loader.py @@ -0,0 +1,42 @@ +from .llm_process import BaseLLMProcess, AgentMessageProcess,AgentSelfThinking,AgentSelfLearning,AgentSelfImprove +from .llm_do_task import AgentTriageTaskList,AgentPlanTask,AgentReviewTask,AgentDo,AgentCheck + +from typing import Awaitable, Callable, Coroutine, Dict, List, Any +import logging + +logger = logging.getLogger(__name__) + +class LLMProcessLoader: + def __init__(self) -> None: + self.loaders : Dict[str,Callable[[dict],Awaitable[BaseLLMProcess]]] = {} + return + + @classmethod + def get_instance(cls)->"LLMProcessLoader": + if not hasattr(cls,"_instance"): + cls._instance = LLMProcessLoader() + return cls._instance + + def register_loader(self, typename:str,loader:Callable[[dict],Awaitable[BaseLLMProcess]]): + self.loaders[typename] = loader + + async def load_from_config(self,config:dict) -> BaseLLMProcess: + llm_type_name = config.get("type") + if llm_type_name: + loader = self.loaders.get(llm_type_name) + if loader: + return await loader(config) + + selected_type = globals().get(llm_type_name) + if selected_type: + result : BaseLLMProcess = selected_type() + load_result = await result.load_from_config(config) + if load_result is False: + logger.warn(f"load LLMProcess {llm_type_name} from config failed! load_from_config return False") + return None + else: + return result + + + logger.warn(f"load LLMProcess {llm_type_name} from config failed! type not found") + return None \ No newline at end of file diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index 8f09a3e..c28ede8 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -3,16 +3,18 @@ from ast import Dict import json import sqlite3 import os -import logging +import time from typing import List, Optional - import aiofiles +from ..proto.agent_msg import AgentMsg from ..proto.ai_function import AIFunction, ParameterDefine,SimpleAIFunction,ActionNode,SimpleAIAction -from ..proto.agent_task import AgentTask,AgentTodoTask,AgentWorkLog,AgentTaskManager +from ..proto.agent_task import AgentTask, AgentTaskState,AgentTodoTask,AgentWorkLog,AgentTaskManager from ..storage.storage import AIStorage +from ..frame.bus import AIBus from .llm_context import GlobaToolsLibrary +import logging logger = logging.getLogger(__name__) class LocalAgentTaskManger(AgentTaskManager): @@ -262,8 +264,9 @@ class LocalAgentTaskManger(AgentTaskManager): async def update_task(self,task:AgentTask): detail_path = f"{self.root_path}/{task.task_path}/detail" try: + new_task_content = json.dumps(task.to_dict(),ensure_ascii=False) async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(task.to_dict(),ensure_ascii=False)) + await f.write(new_task_content)) except Exception as e: logger.error("update_task failed:%s",e) return str(e) @@ -276,8 +279,9 @@ class LocalAgentTaskManger(AgentTaskManager): return f"todo {todo.todo_id} not found" try: + new_todo_content = json.dumps(todo.to_dict(),ensure_ascii=False) async with aiofiles.open(todo_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.to_dict(),ensure_ascii=False)) + await f.write(new_todo_content) except Exception as e: logger.error("update_todo failed:%s",e) return str(e) @@ -315,9 +319,86 @@ class AgentWorkspace: self.owner_id : str = owner_id self.task_mgr : AgentTaskManager = LocalAgentTaskManger(owner_id) - @staticmethod def register_ai_functions(): + async def post_message(parameters): + _agent_id = parameters.get("_agentid") + if _agent_id is None: + return "_agentid not found" + + target = parameters.get("target") + if target is None: + return "target not found" + message = parameters.get("message") + if message is None: + return "message not found" + topic = parameters.get("topic") + + msg = AgentMsg() + msg.sender = _agent_id + msg.body = message + msg.topic = topic + msg.target = target + msg.create_time = time.time() + + is_post_ok = await AIBus.get_default_bus().post_message(msg) + if is_post_ok: + return "post message ok!" + else: + return f"post message to {target} failed!" + + parameters = ParameterDefine.create_parameters({ + "target": {"type": "string", "description": "target agent/contact id"}, + "topic": {"type": "string", "description": "optional, message topic"}, + "message": {"type": "string", "description": "message content"}, + }) + post_message_action = SimpleAIFunction( + "post_message", + "Post a message to target agent/contact", + post_message, + parameters, + ) + GlobaToolsLibrary.get_instance().register_tool_function(post_message_action) + + async def send_message(parameters): + _agent_id = parameters.get("_agentid") + if _agent_id is None: + return "_agentid not found" + + target = parameters.get("target") + if target is None: + return "target not found" + message = parameters.get("message") + if message is None: + return "message not found" + topic = parameters.get("topic") + + msg = AgentMsg() + msg.sender = _agent_id + msg.body = message + msg.topic = topic + msg.target = target + msg.create_time = time.time() + + resp = await AIBus.get_default_bus().send_message(msg) + if resp: + return f"resp is : {resp.body}" + else: + return f"send message to {target} failed!" + + parameters = ParameterDefine.create_parameters({ + "target": {"type": "string", "description": "target agent/contact id"}, + "topic": {"type": "string", "description": "optional, message topic"}, + "message": {"type": "string", "description": "message content"}, + }) + send_message_action = SimpleAIFunction( + "send_message", + "send a message to target agent/contact, and wait for reply", + send_message, + parameters, + ) + GlobaToolsLibrary.get_instance().register_tool_function(send_message_action) + async def create_task(params): _workspace = params.get("_workspace") _agent_id = params.get("_agentid") @@ -348,7 +429,7 @@ class AgentWorkspace: if _workspace is None: return "_workspace not found" task_id = parameters.get("task_id") - task = await _workspace.task_mgr.get_task(task_id) + task : AgentTask = await _workspace.task_mgr.get_task(task_id) if task is None: return f"task {task_id} not found" task.state = "cancel" @@ -380,4 +461,40 @@ class AgentWorkspace: "list all tasks in json format", list_task,{}) GlobaToolsLibrary.get_instance().register_tool_function(list_task_ai_function) - \ No newline at end of file + + async def update_task(parameters): + _workspace : AgentWorkspace= parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + task_id = parameters.get("task_id") + task:AgentTask = await _workspace.task_mgr.get_task(task_id) + if task is None: + return f"task {task_id} not found" + if parameters.get("title"): + task.title = parameters.get("title") + if parameters.get("detail"): + task.detail = parameters.get("detail") + if parameters.get("priority"): + task.priority = parameters.get("priority") + if parameters.get("new_state"): + task.state = AgentTaskState.from_str(parameters.get("new_state")) + if parameters.get("next_do_date"): + task.next_do_date = parameters.get("next_do_date") + if parameters.get("due_date"): + task.due_date = parameters.get("due_date") + await _workspace.task_mgr.update_task(task) + return "update task ok" + parameters = ParameterDefine.create_parameters({ + "task_id": {"type": "string", "description": "task id which want to update"}, + "new_state": {"type": "string", "description": "optional,new task state: cancel or done"}, + "next_do_date": {"type": "string", "description": "optional,confirm task next do date"}, + "priority": {"type": "int", "description": "optional,task priority from 1-10"}, + "title": {"type": "string", "description": "optional, new task title"}, + "detail": {"type": "string", "description": "optional, new task detail(simple task can not be filled)"}, + "due_date": {"type": "string", "description": "optional,new task due date"}, + }) + update_task_ai_function = SimpleAIFunction("agent.workspace.update_task", + "update task to new state", + update_task,parameters) + GlobaToolsLibrary.get_instance().register_tool_function(update_task_ai_function) + diff --git a/src/aios/proto/agent_task.py b/src/aios/proto/agent_task.py index 223e087..703bdde 100644 --- a/src/aios/proto/agent_task.py +++ b/src/aios/proto/agent_task.py @@ -254,7 +254,7 @@ class AgentTask: # 确定的执行时间(执行条件) self.next_do_time = None # 如果next check time设置,说明任务适合在该时间点可能具备执行调教,尝试检查并执行 - self.next_check_time = None + #self.next_check_time = None self.depend_task_ids = [] #self.step_todo_ids = [] @@ -279,6 +279,12 @@ class AgentTask: if self.state == AgentTaskState.TASK_STATE_FAILED: return True + + if self.due_date: + if self.due_date < time.time(): + self.state = AgentTaskState.TASK_STATE_EXPIRED + return True + return False def to_dict(self) -> dict: @@ -295,8 +301,8 @@ class AgentTask: result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() if self.next_do_time: result["next_do_time"] = datetime.fromtimestamp(self.next_do_time).isoformat() - if self.next_check_time: - result["next_check_time"] = datetime.fromtimestamp(self.next_check_time).isoformat() + #if self.next_check_time: + # result["next_check_time"] = datetime.fromtimestamp(self.next_check_time).isoformat() result["depend_task_ids"] = self.depend_task_ids #result["step_todo_ids"] = self.step_todo_ids result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() @@ -327,9 +333,9 @@ class AgentTask: next_do_time = json_obj.get("next_do_time") if next_do_time: result.next_do_time = datetime.fromisoformat(next_do_time).timestamp() - next_check_time = json_obj.get("next_check_time") - if next_check_time: - result.next_check_time = datetime.fromisoformat(next_check_time).timestamp() + #next_check_time = json_obj.get("next_check_time") + #if next_check_time: + # result.next_check_time = datetime.fromisoformat(next_check_time).timestamp() result.depend_task_ids = json_obj.get("depend_task_ids") #result.step_todo_ids = json_obj.get("step_todo_ids") create_time = json_obj.get("create_time") From 0bc2aaf45b78cff3b1af785739ab6a385278e577 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Mon, 15 Jan 2024 22:52:13 -0800 Subject: [PATCH 67/79] Update Jarvis implement --- doc/agent & workflow.drawio | 234 ++++++++++++++++++++- doc/function_list.md | 8 + doc/storage.md | 16 ++ rootfs/agents/Jarvis/agent.toml | 115 +++++++---- src/aios/agent/agent_memory.py | 131 +++++++++--- src/aios/agent/llm_do_task.py | 347 +++++++++++++++++++++++++------- src/aios/agent/llm_process.py | 4 +- src/aios/agent/workspace.py | 83 +++++++- src/aios/proto/agent_task.py | 34 ++-- 9 files changed, 802 insertions(+), 170 deletions(-) create mode 100644 doc/storage.md diff --git a/doc/agent & workflow.drawio b/doc/agent & workflow.drawio index 2d4e10f..227ba15 100644 --- a/doc/agent & workflow.drawio +++ b/doc/agent & workflow.drawio @@ -1,6 +1,6 @@ - + - + @@ -514,7 +514,7 @@ - + @@ -637,21 +637,243 @@ - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/function_list.md b/doc/function_list.md index 994d3a6..15f7466 100644 --- a/doc/function_list.md +++ b/doc/function_list.md @@ -94,6 +94,14 @@ _self = parameters.get("_workspace") ##### Send Msg (系统原生能力) +##### Workspace File System + +agent.workspace.write_file +agent.workspace.read_file +agent.workspace.delte_file +agent.workspace.append_file +agent.workspace.list_file + ## Knowledge Base Knowledge Base对大部分Agent来说,是一个获得私有信息,并让LLM处理结果更好的基础设施(RAG支持)。少部分Agent会使用相关API,结合Knowledge Base所服务的目标来整理Knowledge Base. diff --git a/doc/storage.md b/doc/storage.md new file mode 100644 index 0000000..1f7b43f --- /dev/null +++ b/doc/storage.md @@ -0,0 +1,16 @@ +# NDN Storage + +```python + +class NDNStorage: + async def get(self,data_name:str)->Dict: + #return object desc, include local cache path + + async def set_file(self,local_path)->str: + # return data_name + + async def read_content(self,data_name:str)->bytes: + # return bytes + # 这里不但会读取本地缓存,还会对内容进行验证 + +``` \ No newline at end of file diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index c65e7de..9b0706d 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -51,12 +51,12 @@ llm_context.functions.enable = ["agent.workspace.list_task"] [behavior.triage_tasks] ## 处理任务列表,任务列表里会包含所有未执行过,且未过期的任务 -## 对于简单的任务会一次性完成处理 +## 对于简单的任务会一次性完成处理(一系列简单的提醒任务) type="AgentTriageTaskList" process_description=""" You are expected to effectively TRIAGE the task list described in JSON format, in accordance with your role. Your GOAL is : 1. Adjust the priority of the task and set up a reasonable processing time.(update_task) -2. Immediately perform a simple (similar to reminding one category) task. Send a message using send_message, set the task to complete the use of update_task. +2. Immediately perform a simple (similar to reminding one category) task. Send a message using post_message, then set the task to complete.(use update_task). 3. Organize tasks to remove tasks beyond your ability. And merge the repeated tasks.(update_task + cancel_task) """ reply_format = """ @@ -77,58 +77,71 @@ llm_context.actions.enable = ["agent.workspace.update_task","agent.workspace.can [behavior.plan_task] ## 首次处理任务 type="AgentPlanTask" +# 是否要加入对任务到期时间的关注? process_description=""" -你得到的输入来自你自己之前记录在TaskList系统里的一个Task。现在你并不需要完成该Task,而是结合已知信息对Task进行一次Review.Review的过程是你独立完成的,你在形成结论的过程中可以使用工具,但不能和其它人交流。 -1. 理性的思考如何一步一步的高效的,在潜在的截止时间前完成该Task。明确拒绝超出自己能力范围的Task。 -2. 尝试对Task进行确认操作。确认操作的关键在于任务有了明确的执行时间。 -3. 对于需要多个步骤才能完成的Task,对Task进行TODO Plan。尤其注意与相关人员确认的步骤 -4. 对于不需要拆分TODO,且可立刻执行的任务。直接执行该任务。 +The input is a task comes from a Tasklist. You need to perform a PLAN. PLAN process on TASK in combination with the known information. +1. Carefully think about whether the task is within your ability, and the task other than the scope of ability is directly rejected. (cancel_task). +2. Immediately perform a simple (similar to reminding one category) task. Send a message using post_message, then set the task to complete.(use update_task). +3. Plan for non-simple tasks, and generate a TODO list. Every TODO MUST be an independent work with a clear goal. (set_todos) +4. If the task has been dealt with, it means that the task is ultimately completed.You need to analyze the processing report of the entire task and make new plans. """ reply_format = """ The Response must be directly parsed by `python json.loads`. Here is an example: { - think:'$think step-by-step to be sure you have the right answer.', - determine : '$determine, summary what you will do', - plans:[ #Optional - {"todo":"$todo_name","detail":"$todo_detail,"category":"$todo_category"} - ... - ], - tags: ['tag1', 'tag2'], #Optional,If the task involves important things and people, you can mark by 1-3 tags. + think:'$think step-by-step to be sure you have the right plan.', + resp:'$determine, summary what you do' actions: [{ name: '$action_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. }] } """ -# action_list: ['cancle','confirm', 'execute'] -llm_context.actions.enable = ["agent.workspace.cancel_task"] -llm_context.functions.enable = ["agent.workspace.list_task"] +llm_context.actions.enable = ["agent.workspace.create_task","agent.workspace.update_task","agent.workspace.set_todos","agent.workspace.cancel_task","post_message"] +#llm_context.functions.enable = ["agent.workspace.list_task"] context="Your master now in {location}, time: {now}, weather: {weather}." [behavior.review_task] -## 处理已经被LLM处理过的任务,包括处理首次出错的任务,处理被的任务 - - - -[behavior.do] # do TODO -type="AgentDo" +## 当task的所有todo/subtask都完成后(不敢成功或是失败),进行一次review +type="AgentReviewTask" process_description=""" -1. 你的任务是结合自己的角色定义,手头的工具,已知信息、完成一个确定的TODO。完成该TODO后你会得到$200的小费。 -2. 输入的TODO是来自你自己对一个Task的Plan结果。 -3. 完成TODO的过程中你应该先思考再执行。执行的过程中可以使用工具,访问前置步骤的结果。执行的结果通常是按顺序执行的ActionList。 -4. 你必须独立的,一次性完成该TODO,你无法得到来自任何他人的协助。 -5. 对确认超出任务范围的TODO,你可以取消该TODO。对执行任务条件不满足的TODO,你可以标记为失败,但要说明失败原因 -7. TODO的完成结果如有需要应保存成数字文档 +The input you get is a task has been executed. You need to combine the execution results of the Task's TOOLIST or SUBTASK to review the TASK. +1. Determine whether TASK has reached the goal.If the goal has been reached, the task is marked with DONE .If the goal is not achieved, simply illustrate the reason and mark TASK as a failure.(update_task) +2. If task that have not been completed for a long time, the task is marked as the final failure after analyzing the reasons.(cancel_task) """ - reply_format = """ The Response must be directly parsed by `python json.loads`. Here is an example: { - think:'我的思考.' - tags: ['tag1', 'tag2'], #Optional,If the TODO involves important things and people, you can mark by 1-3 tags. + think:'$think step-by-step to be sure you have the right result.', + resp : '$determine, summary what you will do', + actions: [{ + name: '$action_name', + $param_name: '$parm' #Optional, fill in only if the action has parameters. + }] +} +""" +llm_context.actions.enable = ["agent.workspace.cancel_task","agent.workspace.update_task"] + +context="Your master now in {location}, time: {now}, weather: {weather}." +[behavior.do] +# do TODO +type="AgentDo" +process_description=""" +The input is a TODO comes from a Task. +1. Your task is to combine your role definition, tools on hand, known information, and complete a certain Todo.After completing the Todo, you will get a tip of $ 200. +2. 8000 word limit for short term memory. Your short term memory is short, so immediately save important information to files. +3. In the process of completing Todo, you should think first and then execute. During the execution, you can use functions to access the results of the front steps. +4. You must be independent and complete the TODO at once, and you cannot get the assistance from any others. +5. The execution result of TODO should be saved into digital documents if necessary +""" + +reply_format = """ +The Response must be directly parsed by `python json.loads`. Here is an example: +{ + think:'$think step by step, how to complete the todo', + resp: '$simport report about what you do', actions: [{ name: '$action1_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. @@ -136,18 +149,46 @@ The Response must be directly parsed by `python json.loads`. Here is an example: ] } """ + +# 对于DO操作来说,让Agent查询自己的能力集合是否更合适? +llm_context.actions.enable = ["agent.workspace.update_todo","post_message","agent.workspace.write_file","agent.workspace.append_file"] +llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","system.shell.run_code","aigc.text_2_image","aigc.text_2_voice","web.search.duckduckgo"] + [behavior.check] +# check TODO result type="AgentCheck" +process_description=""" +1. The input is a TODO that has been successfully executed, which is created by you to complete a task.Your goal is to check whether the Todo has been completed in combination with relevant information. +2. In the process of checking the Todo, the focus is on the analysis of whether the Todo has gained the established goal in combination with TASK. +3. 使用update_todo来更新todo的最终 +""" + +reply_format = """ +The Response must be directly parsed by `python json.loads`. Here is an example: +{ + resp:'$think step by step, how to check the todo', + name: '$action1_name', + $param_name: '$parm' #Optional, fill in only if the action has parameters. + }, ... + ] +} +""" + +llm_context.actions.enable = ["agent.workspace.update_todo"] +llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","system.shell.run_code","aigc.image_2_text","aigc.voice_2_text","web.search.duckduckgo"] + [behavior.self_thinking] -# self thing的主要目的是对各种chatlog,worklog进行分析,并更新终结。 +# self thing的主要目的是对各种chatlog,worklog进行分析,并更新面向人和事的summary。 type="AgentSelfThinking" -#[behavior.self_learning] -# self_learning的主要目的是根据自己的经验,主动的学习新的知识。这通常是一个专门整理知识库的Agent,一般的Agent谨慎开启 -#type="AgentSelfLearning" - #[behavior.self_improve] # self_improve 是最后的行为,允许Agent结合自己的工作经验,改进自己的提示词(注意保留历史版本) #type="AgentSelfImprove" +#[behavior.self_learning] +# self_learning的主要目的是根据自己的经验,主动的学习新的知识。这通常是一个专门整理知识库的Agent。由于Self Learn可能会消耗大量的Token,我们建议Agent通过共享的知识库更新来获得效果,谨慎开启 +#type="AgentSelfLearning" + + + diff --git a/src/aios/agent/agent_memory.py b/src/aios/agent/agent_memory.py index c852057..0194b23 100644 --- a/src/aios/agent/agent_memory.py +++ b/src/aios/agent/agent_memory.py @@ -1,10 +1,14 @@ # pylint:disable=E0402 from datetime import datetime,timedelta -from typing import Dict +import json +import threading +from typing import Dict, List +import sqlite3 from ..frame.compute_kernel import ComputeKernel from ..proto.ai_function import SimpleAIAction from ..proto.agent_msg import AgentMsg, AgentMsgType +from ..proto.agent_task import AgentWorkLog from .llm_context import GlobaToolsLibrary from .chatsession import AIChatSession @@ -16,28 +20,39 @@ logger = logging.getLogger(__name__) class AgentMemory: def __init__(self,agent_id:str,db_path:str) -> None: self.agent_id:str= agent_id - self.chat_db:str = db_path + self.memory_db:str = db_path self.model_name:str = "gp4-1106-preview" self.threshold_hours = 72 - @classmethod - def register_actions(cls): - async def action_chatlog_append(parms:Dict): - memory = parms.get("_memory") - if memory: - return await memory.action_chatlog_append(parms) - - chatlog_append_action = SimpleAIAction("chatlog_append","Append request & reply message to chatlog. No params",action_chatlog_append) - GlobaToolsLibrary.get_instance().register_tool_function(chatlog_append_action,"agent.memory.chatlog.append") - + def _get_conn(self): + """ get db connection """ + local = threading.local() + if not hasattr(local, 'conn'): + local.conn = self._create_connection(self.memory_db) + return local.conn + + def _create_connection(self, db_file): + """ create a database connection to a SQLite database """ + conn = None + try: + conn = sqlite3.connect(db_file) + except Error as e: + logging.error("Error occurred while connecting to database: %s", e) + return None + + if conn: + self._create_table(conn) + + return conn + def get_session_from_msg(self,msg:AgentMsg) -> AIChatSession: if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: session_topic = msg.target + "#" + msg.topic - chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) + chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.memory_db) else: session_topic = msg.get_sender() + "#" + msg.topic - chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) + chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.memory_db) return chatsession async def load_chatlogs(self,msg:AgentMsg,n:int=6,m:int=64,token_limit=800)->str: @@ -84,19 +99,83 @@ class AgentMemory: return histroy_str - async def action_chatlog_append(self,params:Dict) -> str: - # 使用params可以得到: LLM Process的输入,LLM Result,基于LLM Result构造的参数,当前actionItem - input_msg:AgentMsg = params.get("input").get("msg") - llm_result = params.get("llm_result") - chatsession = self.get_session_from_msg(input_msg) - resp_msg = params.get("resp_msg") - if resp_msg: - tags = llm_result.raw_result.get("tags") - chatsession.append(input_msg,tags) - chatsession.append(resp_msg,tags) + # async def action_chatlog_append(self,params:Dict) -> str: + # + # input_msg:AgentMsg = params.get("input").get("msg") + # llm_result = params.get("llm_result") + # chatsession = self.get_session_from_msg(input_msg) + # resp_msg = params.get("resp_msg") + # if resp_msg: + # tags = llm_result.raw_result.get("tags") + # chatsession.append(input_msg,tags) + # chatsession.append(resp_msg,tags) - return "OK" + # return "OK" + + async def load_worklogs(self,operator_id:str,owner_id:str=None, work_types:List[str]=None,token_limit=800): + conn = self._get_conn() + c = conn.cursor() + + query = 'SELECT * FROM worklog WHERE 1=1' + params = [] + + if operator_id is not None: + query += ' AND operator=?' + params.append(operator_id) + + if owner_id is not None: + query += ' AND owner_id=?' + params.append(owner_id) + + if work_types: + query += ' AND work_type IN ({})'.format(', '.join('?'*len(work_types))) + params.extend(work_types) + + query += ' ORDER BY timestamp DESC LIMIT 8' + + c.execute(query, tuple(params)) + rows = c.fetchall() + conn.close() + + return [self.from_db_row(row) for row in rows] + + def _create_table(self,conn): + c = conn.cursor() + c.execute(''' + CREATE TABLE IF NOT EXISTS worklog ( + logid TEXT PRIMARY KEY, + owner_id TEXT, + work_type TEXT, + timestamp REAL, + content TEXT, + result TEXT, + meta TEXT, + operator TEXT + ) + ''') + conn.commit() + conn.close() + + @classmethod + def from_db_row(self,row): + log = AgentWorkLog() + # 这里高度依赖表结构的顺序 + log.logid, log.owner_id, log.work_type, log.timestamp, log.content, log.result, meta_str, log.operator = row + log.meta = json.loads(meta_str) if meta_str else None + return log + async def append_worklog(self,log:AgentWorkLog)->str: + conn = self._get_conn() + c = conn.cursor() + # 将meta字典转换为JSON字符串 + meta_str = json.dumps(log.meta,ensure_ascii=False) if log.meta else None + c.execute(''' + INSERT INTO worklog (logid, owner_id, work_type, timestamp, content, result, meta, operator) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', (log.logid, log.owner_id, log.work_type, log.timestamp, log.content, log.result, meta_str, log.operator)) + conn.commit() + conn.close() + async def get_contact_summary(self,contact_id:str) -> str: if contact_id is None: return None @@ -114,8 +193,6 @@ class AgentMemory: async def update_sth_summary(self,sth_id:str,summary:str) -> str: return None - async def get_log_summary(self,msg:AgentMsg) -> str: - return None diff --git a/src/aios/agent/llm_do_task.py b/src/aios/agent/llm_do_task.py index 7f7bd59..17630b1 100644 --- a/src/aios/agent/llm_do_task.py +++ b/src/aios/agent/llm_do_task.py @@ -1,7 +1,7 @@ from ..proto.compute_task import LLMPrompt,LLMResult,ComputeTaskResult,ComputeTaskResultCode from ..proto.ai_function import AIFunction,AIAction,ActionNode from ..proto.agent_msg import AgentMsg,AgentMsgType -from ..proto.agent_task import AgentTask +from ..proto.agent_task import AgentTask, AgentTodo, AgentWorkLog from ..frame.compute_kernel import ComputeKernel from .agent_memory import AgentMemory @@ -20,6 +20,7 @@ import logging logger = logging.getLogger(__name__) +#LLM Process All the unfinished tasks,will sort the priority of the task after LLM, determine the next execution time, and complete the simple task class AgentTriageTaskList(LLMAgentBaseProcess): def __init__(self) -> None: super().__init__() @@ -45,6 +46,27 @@ class AgentTriageTaskList(LLMAgentBaseProcess): prompt.append_user_message(json.dumps(task_dict_list,ensure_ascii=False)) system_prompt_dict = self.prepare_role_system_prompt(context_info) + # May all logs is good for Agent Triage Task List? + have_known_info = False + known_info = {} + working_logs = await self.memory.load_worklogs(self.memory.agent_id) + if len(working_logs) > 0: + have_known_info = True + all_worklog_node = [] + for worklog in working_logs: + workNode = {} + dt = datetime.fromtimestamp(float(worklog.timestamp)) + workNode["timestamp"] = dt.strftime("%Y-%m-%d %H:%M:%S") + workNode["type"] = worklog.work_type + workNode["content"] = worklog.content + workNode["result"] = worklog.result + all_worklog_node.append(workNode) + + known_info["worklogs"] = all_worklog_node + + if have_known_info: + system_prompt_dict["known_info"] = known_info + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) return prompt @@ -58,127 +80,298 @@ class AgentTriageTaskList(LLMAgentBaseProcess): action_params["_llm_result"] = llm_result action_params["_agentid"] = self.memory.agent_id action_params["_start_at"] = datetime.now() - await self._execute_actions(actions,action_params) - + result_str = "OK" + try: + if await self._execute_actions(actions,action_params) is False: + result_str = "execute action failed!" + except Exception as e: + logger.error(f"execute action failed! {e}") + result_str = "execute action failed!,error:" + str(e) + + worklog = AgentWorkLog.create_by_content(self.memory.agent_id,"triage",llm_result.resp,self.memory.agent_id) + worklog.result = result_str + await self.memory.append_worklog(worklog) +# LLM a Task that never been LLMed, the result of LLM Process may be adjusted, splitting subtask or do simple task as a todo directly. class AgentPlanTask(LLMAgentBaseProcess): def __init__(self) -> None: super().__init__() - self.role_description:str = None - self.process_description:str = None - self.reply_format = None - - # 虽然在架构上LLM Process可以很容易的去Call另一个Process,但实际应用中还是应该慎重的保持LLM Process的简单性 - #self.do_task_llm_process : BaseLLMProcess = None - - async def initial(self,params:Dict = None) -> bool: - self.memory = params.get("memory") - if self.memory is None: - logger.error(f"LLMAgeMessageProcess initial failed! memory not found") - return False - self.workspace = params.get("workspace") - - - return True - - async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: - - + async def load_from_config(self, config: dict,is_load_default=True) -> bool: if await super().load_from_config(config) is False: return False - - self.role_description = config.get("role_desc") - if self.role_description is None: - logger.error(f"role_description not found in config") - return False - - if config.get("process_description"): - self.process_description = config.get("process_description") - - if config.get("reply_format"): - self.reply_format = config.get("reply_format") - - if config.get("context"): - self.context = config.get("context") - - self.llm_context = SimpleLLMContext() - if config.get("llm_context"): - self.llm_context.load_from_config(config.get("llm_context")) async def prepare_prompt(self,input:Dict) -> LLMPrompt: - agent_task = input.get("task") prompt = LLMPrompt() - system_prompt_dict = {} - system_prompt_dict["role_description"] = self.role_description - system_prompt_dict["process_rule"] = self.process_description - system_prompt_dict["reply_format"] = self.reply_format - prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) + + agent_task : AgentTask= input.get("task") + context_info = input.get("context_info") + if agent_task is None: + logger.error(f"task not found in input") + return None + prompt.append_user_message(json.dumps(agent_task.to_dict(),ensure_ascii=False)) + + system_prompt_dict = self.prepare_role_system_prompt(context_info) + + have_known_info = False + known_info = {} + working_logs = await self.memory.load_worklogs(None,agent_task.task_id) + if len(working_logs) > 0: + have_known_info = True + all_worklog_node = [] + for worklog in working_logs: + workNode = {} + dt = datetime.fromtimestamp(float(worklog.timestamp)) + workNode["timestamp"] = dt.strftime("%Y-%m-%d %H:%M:%S") + workNode["type"] = worklog.work_type + workNode["operator"] = worklog.operator + workNode["content"] = worklog.content + workNode["result"] = worklog.result + all_worklog_node.append(workNode) + + known_info["worklogs"] = all_worklog_node + + if have_known_info: + system_prompt_dict["known_info"] = known_info + + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) return prompt - async def get_review_task_actions(self) -> Dict[str,Dict]: - pass + async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: + action_params = {} + action_params["_input"] = input + agent_task : AgentTask= input.get("task") + action_params["_memory"] = self.memory + action_params["_workspace"] = self.workspace + action_params["_llm_result"] = llm_result + action_params["_agentid"] = self.memory.agent_id + action_params["_start_at"] = datetime.now() - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - pass + result_str = "OK" + try: + if await self._execute_actions(actions,action_params) is False: + result_str = "execute action failed!" + except Exception as e: + logger.error(f"execute action failed! {e}") + result_str = "execute action failed!,error:" + str(e) + + worklog = AgentWorkLog.create_by_content(agent_task.task_id,"plan",llm_result.resp,self.memory.agent_id) + worklog.result = result_str + await self.memory.append_worklog(worklog) - async def post_llm_process(self,actions:List[ActionNode]) -> bool: - pass -class AgentReviewTask(BaseLLMProcess): +# Agent DO Todo +# The purpose is to complete Todo.It is the core LLM process. Can use sufficient external tools to do your best according to the identity and ability of AGENT.It is also the LLM Process of the main extension of Agent extension +class AgentDo(LLMAgentBaseProcess): def __init__(self) -> None: super().__init__() - async def load_from_config(self, config: dict): + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: if await super().load_from_config(config) is False: return False - async def prepare_prompt(self) -> LLMPrompt: + async def prepare_prompt(self,input:Dict) -> LLMPrompt: prompt = LLMPrompt() - pass - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - pass + agent_todo : AgentTodo= input.get("todo") + context_info = input.get("context_info") + if agent_todo is None: + logger.error(f"task not found in input") + return None - async def post_llm_process(self,actions:List[ActionNode]) -> bool: - pass + prompt.append_user_message(json.dumps(agent_todo.to_dict(),ensure_ascii=False)) + system_prompt_dict = self.prepare_role_system_prompt(context_info) + # May all logs is good for Agent Triage Task List? + have_known_info = False + known_info = {} + working_logs = await self.memory.load_worklogs(None,agent_todo.todo_id) + if len(working_logs) > 0: + have_known_info = True + all_worklog_node = [] + for worklog in working_logs: + workNode = {} + dt = datetime.fromtimestamp(float(worklog.timestamp)) + workNode["timestamp"] = dt.strftime("%Y-%m-%d %H:%M:%S") + workNode["type"] = worklog.work_type + workNode["content"] = worklog.content + workNode["result"] = worklog.result + all_worklog_node.append(workNode) + + known_info["worklogs"] = all_worklog_node -class AgentCheck(BaseLLMProcess): + if have_known_info: + system_prompt_dict["known_info"] = known_info + + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) + return prompt + + + async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: + action_params = {} + action_params["_input"] = input + agent_todo : AgentTodo= input.get("todo") + action_params["_memory"] = self.memory + action_params["_workspace"] = self.workspace + action_params["_llm_result"] = llm_result + action_params["_agentid"] = self.memory.agent_id + action_params["_start_at"] = datetime.now() + + result_str = "OK" + try: + if await self._execute_actions(actions,action_params) is False: + result_str = "execute action failed!" + except Exception as e: + logger.error(f"execute action failed! {e}") + result_str = "execute action failed!,error:" + str(e) + + worklog = AgentWorkLog.create_by_content(agent_todo.todo_id,"do",llm_result.resp,self.memory.agent_id) + worklog.result = result_str + await self.memory.append_worklog(worklog) + +#Agent check todo +# LLM a already-DO TODO, the purpose is to check whether it is completed to face the illusion of LLM.Check can use some tools, which is also the core of the agent extension。 +class AgentCheck(LLMAgentBaseProcess): def __init__(self) -> None: super().__init__() - async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: if await super().load_from_config(config) is False: return False - async def prepare_prompt(self) -> LLMPrompt: + async def prepare_prompt(self,input:Dict) -> LLMPrompt: prompt = LLMPrompt() - pass - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - pass + agent_todo : AgentTodo= input.get("todo") + context_info = input.get("context_info") + if agent_todo is None: + logger.error(f"task not found in input") + return None - async def post_llm_process(self,actions:List[ActionNode]) -> bool: - pass + prompt.append_user_message(json.dumps(agent_todo.to_dict(),ensure_ascii=False)) -class AgentDo(BaseLLMProcess): + system_prompt_dict = self.prepare_role_system_prompt(context_info) + # May all logs is good for Agent Triage Task List? + have_known_info = False + known_info = {} + working_logs = await self.memory.load_worklogs(None,agent_todo.todo_id) + if len(working_logs) > 0: + have_known_info = True + all_worklog_node = [] + for worklog in working_logs: + workNode = {} + dt = datetime.fromtimestamp(float(worklog.timestamp)) + workNode["timestamp"] = dt.strftime("%Y-%m-%d %H:%M:%S") + workNode["type"] = worklog.work_type + workNode["content"] = worklog.content + workNode["result"] = worklog.result + all_worklog_node.append(workNode) + + known_info["worklogs"] = all_worklog_node + + if have_known_info: + system_prompt_dict["known_info"] = known_info + + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) + return prompt + + + async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: + action_params = {} + action_params["_input"] = input + agent_todo : AgentTodo= input.get("todo") + action_params["_memory"] = self.memory + action_params["_workspace"] = self.workspace + action_params["_llm_result"] = llm_result + action_params["_agentid"] = self.memory.agent_id + action_params["_start_at"] = datetime.now() + + result_str = "OK" + try: + if await self._execute_actions(actions,action_params) is False: + result_str = "execute action failed!" + except Exception as e: + logger.error(f"execute action failed! {e}") + result_str = "execute action failed!,error:" + str(e) + + worklog = AgentWorkLog.create_by_content(agent_todo.todo_id,"check",llm_result.resp,self.memory.agent_id) + worklog.result = result_str + await self.memory.append_worklog(worklog) + +#Agent review task +#When Task's Todolist is completed, or Task's subtask is completed, LLM review a TASK to determine that the Task has been completed.This Review also failed to execute. +class AgentReviewTask(LLMAgentBaseProcess): def __init__(self) -> None: super().__init__() - async def load_from_config(self, config: dict): + + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: if await super().load_from_config(config) is False: return False - async def prepare_prompt(self) -> LLMPrompt: + async def prepare_prompt(self,input:Dict) -> LLMPrompt: prompt = LLMPrompt() - pass - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - pass + agent_task : AgentTask= input.get("task") + context_info = input.get("context_info") + if agent_task is None: + logger.error(f"task not found in input") + return None - async def post_llm_process(self,actions:List[ActionNode]) -> bool: - pass + prompt.append_user_message(json.dumps(agent_task.to_dict(),ensure_ascii=False)) + + system_prompt_dict = self.prepare_role_system_prompt(context_info) + # May all logs is good for Agent Triage Task List? + have_known_info = False + known_info = {} + working_logs = await self.memory.load_worklogs(None,agent_task.task_id) + if len(working_logs) > 0: + have_known_info = True + all_worklog_node = [] + for worklog in working_logs: + workNode = {} + dt = datetime.fromtimestamp(float(worklog.timestamp)) + workNode["timestamp"] = dt.strftime("%Y-%m-%d %H:%M:%S") + workNode["type"] = worklog.work_type + workNode["operator"] = worklog.operator + workNode["content"] = worklog.content + workNode["result"] = worklog.result + all_worklog_node.append(workNode) + + known_info["worklogs"] = all_worklog_node + + if have_known_info: + system_prompt_dict["known_info"] = known_info + + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) + return prompt + + + async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: + action_params = {} + action_params["_input"] = input + agent_task : AgentTask= input.get("task") + action_params["_memory"] = self.memory + action_params["_workspace"] = self.workspace + action_params["_llm_result"] = llm_result + action_params["_agentid"] = self.memory.agent_id + action_params["_start_at"] = datetime.now() + + result_str = "OK" + try: + if await self._execute_actions(actions,action_params) is False: + result_str = "execute action failed!" + except Exception as e: + logger.error(f"execute action failed! {e}") + result_str = "execute action failed!,error:" + str(e) + + worklog = AgentWorkLog.create_by_content(agent_task.task_id,"review",llm_result.resp,self.memory.agent_id) + worklog.result = result_str + await self.memory.append_worklog(worklog) \ No newline at end of file diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index d372613..9e75461 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -160,8 +160,8 @@ class BaseLLMProcess(ABC): # Action define in prompt, will be execute after llm compute prompt = await self.prepare_prompt(input) max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt,self.model_name) - if max_result_token < MIN_PREDICT_TOKEN_LEN: - return LLMResult.from_error_str(f"prompt too long,can not predict") + #if max_result_token < MIN_PREDICT_TOKEN_LEN: + # return LLMResult.from_error_str(f"prompt too long,can not predict") task_result: ComputeTaskResult = await (ComputeKernel.get_instance().do_llm_completion( prompt, diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index c28ede8..a746f83 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -3,13 +3,14 @@ from ast import Dict import json import sqlite3 import os +import glob import time from typing import List, Optional import aiofiles from ..proto.agent_msg import AgentMsg from ..proto.ai_function import AIFunction, ParameterDefine,SimpleAIFunction,ActionNode,SimpleAIAction -from ..proto.agent_task import AgentTask, AgentTaskState,AgentTodoTask,AgentWorkLog,AgentTaskManager +from ..proto.agent_task import AgentTask, AgentTaskState,AgentTodo,AgentWorkLog,AgentTaskManager from ..storage.storage import AIStorage from ..frame.bus import AIBus from .llm_context import GlobaToolsLibrary @@ -86,11 +87,23 @@ class LocalAgentTaskManger(AgentTaskManager): - async def create_todos(self,owner_task_id:str,todos:List[AgentTodoTask]): + async def set_todos(self,owner_task_id:str,todos:List[AgentTodo]): owner_task_path = self._get_obj_path(owner_task_id) if owner_task_path is None: return f"owner task {owner_task_id} not found" + try: + directory = f"{self.root_path}/{owner_task_path}" + file_extension = "*.todo" + pattern = os.path.join(directory, file_extension) + files = glob.glob(pattern) + + for file in files: + os.remove(file) + logger.info(f"Deleted {file}") + except Exception as e: + logger.error("set_todos deleted todos failed:%s",e) + try: step_order = 0 for todo in todos: @@ -176,7 +189,7 @@ class LocalAgentTaskManger(AgentTaskManager): full_path = f"{self.root_path}/{task_path}" return await self._get_task_by_fullpath(full_path) - async def get_todo(self,todo_id:str) -> AgentTodoTask: + async def get_todo(self,todo_id:str) -> AgentTodo: todo_path = self._get_obj_path(todo_id) if todo_path is None: logger.error("get_todo:%s,not found!",todo_id) @@ -185,7 +198,7 @@ class LocalAgentTaskManger(AgentTaskManager): try: with open(todo_path, mode='r', encoding="utf-8") as f: todo_dict = json.load(f) - result_todo:AgentTodoTask = AgentTodoTask.from_dict(todo_dict) + result_todo:AgentTodo = AgentTodo.from_dict(todo_dict) if result_todo: result_todo.todo_path = todo_path else: @@ -214,10 +227,11 @@ class LocalAgentTaskManger(AgentTaskManager): sub_task = await self.get_task_by_path(f"{task_path}/{sub_item}") if sub_task: sub_tasks.append(sub_task) - pass + + return sub_tasks - async def get_sub_todos(self,task_id:str) -> List[AgentTodoTask]: + async def get_sub_todos(self,task_id:str) -> List[AgentTodo]: task_path = self._get_obj_path(task_id) if task_path is None: return [] @@ -266,14 +280,14 @@ class LocalAgentTaskManger(AgentTaskManager): try: new_task_content = json.dumps(task.to_dict(),ensure_ascii=False) async with aiofiles.open(detail_path, mode='w', encoding="utf-8") as f: - await f.write(new_task_content)) + await f.write(new_task_content) except Exception as e: logger.error("update_task failed:%s",e) return str(e) return None - async def update_todo(self,todo:AgentTodoTask): + async def update_todo(self,todo:AgentTodo): todo_path = self._get_obj_path(todo.todo_id) if todo_path is None: return f"todo {todo.todo_id} not found" @@ -424,6 +438,8 @@ class AgentWorkspace: ) GlobaToolsLibrary.get_instance().register_tool_function(create_task_action) + + async def cancel_task(parameters): _workspace = parameters.get("_workspace") if _workspace is None: @@ -498,3 +514,54 @@ class AgentWorkspace: update_task,parameters) GlobaToolsLibrary.get_instance().register_tool_function(update_task_ai_function) + async def set_todos(parameters): + _workspace : AgentWorkspace= parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + task_id = parameters.get("task_id") + task:AgentTask = await _workspace.task_mgr.get_task(task_id) + if task is None: + return f"task {task_id} not found" + todos = parameters.get("todos") + if todos is None: + return "todos not found" + await _workspace.task_mgr.set_todos(task_id,todos) + return "set todos ok" + + todo_demo = """ + [ + { + "title": "todo1", + "detail": "todo1 detail", + "tags": "tag1,tag2", + "due_date": "2021-01-01", + "priority": 1 + }, + ] + """ + parameters = ParameterDefine.create_parameters({ + "task_id": {"type": "string", "description": "task id which want to set todos"}, + "todos": {"type": "list", "description": f"List of todo, todo is a dict like {todo_demo}"}, + }) + set_todos_ai_function = SimpleAIFunction("agent.workspace.set_todos", + "set todos for task", + set_todos,parameters) + GlobaToolsLibrary.get_instance().register_tool_function(set_todos_ai_function) + + async def update_todo(parameters): + _workspace : AgentWorkspace= parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + todo_id = parameters.get("todo_id") + todo : AgentTodo = await _workspace.task_mgr.get_todo(todo_id) + if todo is None: + return f"todo {todo_id} not found" + + parameters = ParameterDefine.create_parameters({ + "todo_id": {"type": "string", "description": "todo id which want to update"}, + "new_state": {"type": "string", "description": "optional,new todo state: execute_ok , execute_failed, done or check_failed"}, + }) + update_todo_ai_function = SimpleAIFunction("agent.workspace.update_todo", + "update todo to new state", + update_todo,parameters) + GlobaToolsLibrary.get_instance().register_tool_function(update_todo_ai_function) diff --git a/src/aios/proto/agent_task.py b/src/aios/proto/agent_task.py index 703bdde..3e3c56f 100644 --- a/src/aios/proto/agent_task.py +++ b/src/aios/proto/agent_task.py @@ -1,5 +1,6 @@ # pylint:disable=E0402 from abc import ABC, abstractmethod +import json from typing import List, Optional import datetime import time @@ -31,9 +32,6 @@ class AgentTodoResult: result["op_list"] = self.op_list return result - - - class AgentTodo: TODO_STATE_WAIT_ASSIGN = "wait_assign" TODO_STATE_INIT = "init" @@ -220,7 +218,7 @@ class AgentTodoState(Enum): def from_str(value): return next((s for s in AgentTodoState.__members__.values() if s.value == value), None) -class AgentTodoTask: +class AgentTodo: def __init__(self) -> None: self.todo_id = "todo#" + uuid.uuid4().hex self.todo_path : str = None @@ -385,20 +383,30 @@ class AgentTask: return result +# 谁在什么时间做了什么 class AgentWorkLog: + # work type : [triage,plan,do,check] def __init__(self) -> None: self.logid = "worklog#" + uuid.uuid4().hex - self.owner_taskid:str = None - self.owner_todoid:str = None - self.type:str = "" # 默认为普通类型的log,特殊类型的Log一般伴随着重要的状态改变 + self.owner_id:str = None # taskid or todoid + self.work_type:str = "" # 默认为普通类型的log,特殊类型的Log一般伴随着重要的状态改变 self.timestamp = time.time() self.content:str = None self.result:str = None self.meta : dict = None self.operator = None + + @classmethod + def create_by_content(cls,owner_id:str,work_type:str,content:str,operator:str) -> 'AgentWorkLog': + log = AgentWorkLog() + log.owner_id = owner_id + log.work_type = work_type + log.content = content + log.operator = operator + log.result = "OK" + return log - def to_dict(self) -> dict: - pass + class AgentTaskManager(ABC): def __init__(self) -> None: @@ -409,7 +417,7 @@ class AgentTaskManager(ABC): pass @abstractmethod - async def create_todos(self,owner_task_id:str,todos:List[AgentTodoTask]): + async def set_todos(self,owner_task_id:str,todos:List[AgentTodo]): # return todo_id pass @@ -430,7 +438,7 @@ class AgentTaskManager(ABC): # pass @abstractmethod - async def get_todo(self,todo_id:str) -> AgentTodoTask: + async def get_todo(self,todo_id:str) -> AgentTodo: pass @abstractmethod @@ -438,7 +446,7 @@ class AgentTaskManager(ABC): pass @abstractmethod - async def get_sub_todos(self,task_id:str) -> List[AgentTodoTask]: + async def get_sub_todos(self,task_id:str) -> List[AgentTodo]: pass #@abstractmethod @@ -454,7 +462,7 @@ class AgentTaskManager(ABC): pass @abstractmethod - async def update_todo(self,todo:AgentTodoTask): + async def update_todo(self,todo:AgentTodo): pass #@abstractmethod From 9034eb175c6903c5ee245ef795729429cf8555b6 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Wed, 17 Jan 2024 20:34:39 -0800 Subject: [PATCH 68/79] fix bugs. --- doc/agent & workflow.drawio | 24 +- rootfs/agents/Jarvis/agent.toml | 8 +- src/aios/agent/agent.py | 361 ++++++--------------- src/aios/agent/agent_memory.py | 6 +- src/aios/agent/llm_context.py | 9 + src/aios/agent/llm_process.py | 9 +- src/aios/agent/workspace.py | 260 +++++++++++++-- src/aios/environment/workspace_env.py | 30 +- src/aios/proto/agent_task.py | 366 ++++++++++++---------- src/component/common_environment/shell.py | 2 +- src/component/openai_node/open_ai_node.py | 2 +- src/service/aios_shell/aios_shell.py | 1 + 12 files changed, 606 insertions(+), 472 deletions(-) diff --git a/doc/agent & workflow.drawio b/doc/agent & workflow.drawio index 227ba15..25f098e 100644 --- a/doc/agent & workflow.drawio +++ b/doc/agent & workflow.drawio @@ -1,4 +1,4 @@ - + @@ -655,7 +655,7 @@ - + @@ -689,6 +689,9 @@ + + + @@ -794,10 +797,11 @@ - + - + + @@ -873,6 +877,18 @@ + + + + + + + + + + + + diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index 9b0706d..7bb0e75 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -72,7 +72,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: """ context="Your master now in {location}, time: {now}, weather: {weather}." -llm_context.actions.enable = ["agent.workspace.update_task","agent.workspace.cancel_task","post_message"] +llm_context.actions.enable = ["agent.workspace.confirm_task","agent.workspace.cancel_task","post_message"] [behavior.plan_task] ## 首次处理任务 @@ -152,7 +152,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: # 对于DO操作来说,让Agent查询自己的能力集合是否更合适? llm_context.actions.enable = ["agent.workspace.update_todo","post_message","agent.workspace.write_file","agent.workspace.append_file"] -llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","system.shell.run_code","aigc.text_2_image","aigc.text_2_voice","web.search.duckduckgo"] +llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","aigc.text_2_image","aigc.text_2_voice","web.search.duckduckgo"] [behavior.check] # check TODO result @@ -178,9 +178,9 @@ llm_context.actions.enable = ["agent.workspace.update_todo"] llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","system.shell.run_code","aigc.image_2_text","aigc.voice_2_text","web.search.duckduckgo"] -[behavior.self_thinking] +#[behavior.self_thinking] # self thing的主要目的是对各种chatlog,worklog进行分析,并更新面向人和事的summary。 -type="AgentSelfThinking" +#type="AgentSelfThinking" #[behavior.self_improve] # self_improve 是最后的行为,允许Agent结合自己的工作经验,改进自己的提示词(注意保留历史版本) diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index b348301..8756dcf 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -12,11 +12,9 @@ import datetime import copy import sys - - from ..proto.agent_msg import AgentMsg from ..proto.ai_function import * -from ..proto.agent_task import AgentTaskState,AgentTask,AgentTodo,AgentTodoResult +from ..proto.agent_task import AgentTaskState,AgentTask,AgentTodo, AgentTodoState from ..proto.compute_task import * from .agent_base import * @@ -110,8 +108,6 @@ class AIAgent(BaseAIAgent): self.behaviors:Dict[str,BaseLLMProcess] = {} - - async def initial(self,params:Dict = None): self.memory = AgentMemory(self.agent_id,self.chat_db) self.prviate_workspace = AgentWorkspace(self.agent_id) @@ -172,9 +168,6 @@ class AIAgent(BaseAIAgent): else: logger.error(f"load LLMProcess {process_config_name} failed!") return False - - - return True def get_id(self) -> str: @@ -201,7 +194,6 @@ class AIAgent(BaseAIAgent): def get_agent_prompt(self) -> LLMPrompt: return self.agent_prompt - async def _get_context_info(self) -> Dict: context_info = {} @@ -252,7 +244,10 @@ class AIAgent(BaseAIAgent): llm_process : BaseLLMProcess = self.behaviors.get("triage_tasks") if llm_process: if self.prviate_workspace: - tasklist = await self.prviate_workspace.task_mgr.list_task() + filter = {} + filter["state"] = AgentTaskState.TASK_STATE_WAIT + + tasklist = await self.prviate_workspace.task_mgr.list_task(filter) if tasklist: input_parms = { "tasklist":tasklist, @@ -265,7 +260,7 @@ class AIAgent(BaseAIAgent): logger.info(f"llm process triage_tasks ignore!") else: logger.info(f"llm process triage_tasks ok!,think is:{llm_result.resp}") - self.agent_energy -= 5 + self.agent_energy -= 3 # for agent_task in tasklist: # if self.agent_energy <= 0: @@ -287,235 +282,76 @@ class AIAgent(BaseAIAgent): # logger.info(f"llm process review_task ok!,think is:{determine}") # self.agent_energy -= 1 - - - async def _llm_run_todo_list(self, todo_list_type: TodoListType): - workspace : WorkspaceEnvironment = self.get_workspace_by_msg(None) - logger.info(f"agent {self.agent_id} do my work start!") - - # review todolist - #if await self.need_review_todolist(): - # await self._llm_review_todolist(workspace) - - todo_list = workspace.todo_list[todo_list_type] - need_todo = await todo_list.get_todo_list(self.agent_id) - - check_count = 0 - do_count = 0 - review_count = 0 - - for todo in need_todo: - if self.agent_energy <= 0: - break - - do_prompts = self._can_do_todo(todo_list_type, todo) - if do_prompts: - prompt : LLMPrompt = LLMPrompt() - prompt.append(self.agent_prompt) - prompt.append(workspace.get_role_prompt(self.agent_id)) - prompt.append(do_prompts) - prompt.append(todo.to_prompt()) - - do_result : AgentTodoResult = await self._llm_do_todo(todo, prompt, workspace) - todo.last_do_time = datetime.datetime.now().timestamp() - todo.retry_count += 1 - - match do_result.result_code: - case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: - continue - case AgentTodoResult.TODO_RESULT_CODE_OK: - todo.result = do_result - await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_WAITING_CHECK) - case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: - await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_EXEC_FAILED) - - await todo_list.append_worklog(todo,do_result) - self.agent_energy -= 2 - do_count += 1 - - # review_result = await self._llm_review_todo(todo,workspace) - # todo.last_review_time = datetime.datetime.now().timestamp() - continue - - check_prompts = self._can_check_todo(todo_list_type, todo) - if check_prompts: - prompt : LLMPrompt = LLMPrompt() - prompt.append(self.agent_prompt) - prompt.append(workspace.get_role_prompt(self.agent_id)) - prompt.append(check_prompts) - - if todo.last_check_result: - prompt.append(LLMPrompt(todo.last_check_result)) - - prompt.append(todo.detail) - prompt.append(todo.result) - - check_result: AgentTodoResult = await self._llm_check_todo(todo, prompt, workspace) - todo.last_check_time = datetime.datetime.now().timestamp() - - match check_result.result_code: - case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: - continue - case AgentTodoResult.TODO_RESULT_CODE_OK: - await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_DONE) - case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: - await todo_list.update_todo(todo.todo_id,AgentTodo.TDDO_STATE_CHECKFAILED) - - await todo_list.append_worklog(todo, check_result) - self.agent_energy -= 1 - check_count += 1 - continue - - review_prompts = self._can_review_todo(todo_list_type, todo) - if review_prompts: - prompt.append(workspace.get_prompt()) - prompt.append(workspace.get_role_prompt(self.agent_id)) - prompt.append(review_prompts) - - todo_tree = todo_list.get_todo_tree("/") - prompt.append(LLMPrompt(todo_tree)) - - do_result : AgentTodoResult = await self._llm_review_todo(todo, prompt, workspace) - todo.last_review_time = datetime.datetime.now().timestamp() - - match do_result.result_code: - case AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR: - continue - case AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR: - continue - case AgentTodoResult.TODO_RESULT_CODE_OK: - await todo_list.update_todo(todo.todo_id,AgentTodo.TODO_STATE_REVIEWED) - - await todo_list.append_worklog(todo,do_result) - self.agent_energy -= 1 - review_count += 1 - continue - - logger.info(f"agent {self.agent_id} ,check:{check_count} todo,do:{do_count} todo.") - - - def _can_review_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> LLMPrompt: - do_prompts = self.todo_prompts[todo_list_type].get("review") - if not do_prompts: - return None - - if todo.can_review() is False: - return None - - return do_prompts - - - def _can_check_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> LLMPrompt: - do_prompts = self.todo_prompts[todo_list_type].get("check") - if not do_prompts: - return None - - if todo.can_check() is False: - return None - - if todo.checker is not None: - if todo.checker != self.agent_id: - return None - else: - if self.can_do_unassigned_task is False: - return None + async def llm_do_todo(self, todo: AgentTodo): + llm_process : BaseLLMProcess = self.behaviors.get("do") + logger.info(f"agent {self.agent_id} DO todo {todo.todo_path} start!") + if llm_process: + input_parms = { + "todo":todo, + "context_info": await self._get_context_info() + } + llm_result : LLMResult = await llm_process.process(input_parms) + if llm_result.state == LLMResultStates.ERROR: + logger.error(f"llm process do_todo error:{llm_result.compute_error_str}") + elif llm_result.state == LLMResultStates.IGNORE: + logger.info(f"llm process do_todo ignore!") else: - todo.checker = self.agent_id + logger.info(f"llm process do_todo ok!,think is:{llm_result.resp}") + self.agent_energy -= 1 - return do_prompts - - def _can_do_todo(self, todo_list_type: TodoListType, todo:AgentTodo) -> LLMPrompt: - do_prompts = self.todo_prompts[todo_list_type].get("do") - if not do_prompts: - return None - - if todo.can_do() is False: - return None - - if todo.worker is not None: - if todo.worker != self.agent_id: - return None - else: - if self.can_do_unassigned_task is False: - return None + async def llm_check_todo(self, todo: AgentTodo): + llm_process : BaseLLMProcess = self.behaviors.get("check") + logger.info(f"agent {self.agent_id} CHECK todo {todo.todo_path} start!") + if llm_process: + input_parms = { + "todo":todo, + "context_info": await self._get_context_info() + } + llm_result : LLMResult = await llm_process.process(input_parms) + if llm_result.state == LLMResultStates.ERROR: + logger.error(f"llm process check_todo error:{llm_result.compute_error_str}") + elif llm_result.state == LLMResultStates.IGNORE: + logger.info(f"llm process check_todo ignore!") else: - todo.worker = self.agent_id + logger.info(f"llm process check_todo ok!,think is:{llm_result.resp}") + self.agent_energy -= 1 - return do_prompts + return - async def _llm_do_todo(self, todo: AgentTodo, prompt: LLMPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: - result = AgentTodoResult() - - task_result:ComputeTaskResult = await self.do_llm_complection(prompt, is_json_resp=True) - if task_result.error_str is not None: - logger.error(f"_llm_do compute error:{task_result.error_str}") - result.result_code = AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR - result.error_str = task_result.error_str - return result + async def llm_plan_task(self,task:AgentTask): + llm_process : BaseLLMProcess = self.behaviors.get("plan_task") + logger.info(f"agent {self.agent_id} PLAN task {task.task_path} start!") + if llm_process: + input_parms = { + "task":task, + "context_info": await self._get_context_info() + } + llm_result : LLMResult = await llm_process.process(input_parms) + if llm_result.state == LLMResultStates.ERROR: + logger.error(f"llm process plan_task error:{llm_result.compute_error_str}") + elif llm_result.state == LLMResultStates.IGNORE: + logger.info(f"llm process plan_task ignore!") + else: + logger.info(f"llm process plan_task ok!,think is:{llm_result.resp}") + self.agent_energy -= 1 - llm_result = LLMResult.from_str(task_result.result_str) - # result_str is the explain of how to do this todo - result.result_str = llm_result.resp - result.op_list = llm_result.op_list - if llm_result.post_msgs is not None: - for msg in llm_result.post_msgs: - msg.sender = self.agent_id - msg.topic = f"{todo.title}##{todo.todo_id}" - #msg.prev_msg_id = todo.todo_id - chatsession = AIChatSession.get_session(self.agent_id,f"{msg.target}#{msg.topic}",self.chat_db) - chatsession.append(msg) - resp = await AIBus.get_default_bus().post_message(msg) - logging.info(f"agent {self.agent_id} send msg to {msg.target} result:{resp}") + async def llm_review_task(self,task:AgentTask): + llm_process : BaseLLMProcess = self.behaviors.get("review_task") + logger.info(f"agent {self.agent_id} REVIEW task {task.task_path} start!") + if llm_process: + input_parms = { + "task":task, + "context_info": await self._get_context_info() + } + llm_result : LLMResult = await llm_process.process(input_parms) + if llm_result.state == LLMResultStates.ERROR: + logger.error(f"llm process review_task error:{llm_result.compute_error_str}") + elif llm_result.state == LLMResultStates.IGNORE: + logger.info(f"llm process review_task ignore!") + else: + logger.info(f"llm process review_task ok!,think is:{llm_result.resp}") + self.agent_energy -= 1 - result_str, have_error = await workspace.exec_op_list(llm_result.action_list, self.agent_id) - if have_error: - result.result_code = AgentTodoResult.TODO_RESULT_CODE_EXEC_OP_ERROR - #result.error_str = error_str - return result - result.result_str = result_str - return result - - async def _llm_check_todo(self, todo: AgentTodo, prompt: LLMPrompt, workspace: WorkspaceEnvironment) -> AgentTodoResult: - result = AgentTodoResult() - - inner_functions,_ = BaseAIAgent.get_inner_functions(workspace) - task_result:ComputeTaskResult = await self.do_llm_complection(prompt,inner_functions=inner_functions,is_json_resp=True) - - if task_result.error_str is not None: - logger.error(f"_llm_do compute error:{task_result.error_str}") - result.result_code = AgentTodoResult.TODO_RESULT_CODE_LLM_ERROR - result.error_str = task_result.error_str - return result - result.result_str = task_result.result_str - todo.last_check_result = task_result.result_str - return result - - async def _llm_review_todo(self, todo:AgentTodo, prompt: LLMPrompt, workspace: WorkspaceEnvironment): - inner_functions,_ = BaseAIAgent.get_inner_functions(workspace) - - task_result:ComputeTaskResult = await self.do_llm_complection(prompt,inner_functions=inner_functions) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"_llm_review_todos compute error:{task_result.error_str}") - return - - return - - # async def do_blance_knowledge_base(selft): - # # 整理自己的知识库(让分类更平衡,更由于自己以后的工作),并尝试更新学习目标 - # current_path = "/" - # current_list = kb.get_list(current_path) - # self_assessment_with_goal = self.get_self_assessment_with_goal() - # learn_goal = {} - - - # llm_blance_knowledge_base(current_path,current_list,self_assessment_with_goal,learn_goal,learn_power) - - # # 主动学习 - # # 方法目前只有使用搜索引擎一种? - # for goal in learn_goal.items(): - # self.llm_learn_with_search_engine(kb,goal,learn_power) - # if learn_power <= 0: - # break async def do_self_think(self): session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) @@ -524,34 +360,24 @@ class AIAgent(BaseAIAgent): break used_energy = await self.think_chatsession(session_id) self.agent_energy -= used_energy - - # todo_logs = await self.get_todo_logs() - # for todo_log in todo_logs: - # if self.agent_energy <= 0: - # break - # used_energy = await self.think_todo_log(todo_log) - # self.agent_energy -= used_energy - return - #async def think_todo_log(self,todo_log:AgentWorkLog): - # pass - - - def need_self_think(self) -> bool: return False + async def _self_imporve(self): + if self.need_self_think(): + await self.do_self_think() + def wake_up(self) -> None: if self.agent_task is None: self.agent_task = asyncio.create_task(self._on_timer()) else: logger.warning(f"agent {self.agent_id} is already wake up!") - # agent loop async def _on_timer(self): while True: - await asyncio.sleep(15) + await asyncio.sleep(5) try: now = time.time() if self.last_recover_time is None: @@ -565,17 +391,36 @@ class AIAgent(BaseAIAgent): continue await self.llm_triage_tasklist() - - # complete & check todo - #await self._llm_run_todo_list(TodoListType.TO_WORK) - - ##await self._llm_run_todo_list(TodoListType.TO_LEARN) - - if self.need_self_think(): - await self.do_self_think() + task_list:List[AgentTask] = await self.prviate_workspace.task_mgr.list_task() + + for task in task_list: + if self.agent_energy <= 0: + break + + if task.can_plan(): + # PLAN Task + await self.llm_plan_task(task) + + if task.state == AgentTaskState.TASK_STATE_DOING: + # DO or Check Todo + can_review = False + todolist = await self.prviate_workspace.task_mgr.get_sub_todos(task.task_id) + for todo in todolist: + if self.agent_energy <= 0: + break + if todo.state == AgentTodoState.TODO_STATE_WAITING or todo.state == AgentTodoState.TODO_STATE_EXEC_FAILED: + await self.llm_do_todo(todo) + if todo.state == AgentTodoState.TODO_STATE_EXEC_OK: + await self.llm_check_todo(todo) + + if can_review: + task.state = AgentTaskState.TASK_STATE_WAITING_REVIEW + + if task.state == AgentTaskState.TASK_STATE_WAITING_REVIEW: + await self.llm_review_task(task) + + await self._self_imporve() - # review other's todo - # self.review_other_works() except Exception as e: tb_str = traceback.format_exc() logger.error(f"agent {self.agent_id} on timer error:{e},{tb_str}") diff --git a/src/aios/agent/agent_memory.py b/src/aios/agent/agent_memory.py index 0194b23..991a25b 100644 --- a/src/aios/agent/agent_memory.py +++ b/src/aios/agent/agent_memory.py @@ -135,7 +135,7 @@ class AgentMemory: c.execute(query, tuple(params)) rows = c.fetchall() - conn.close() + return [self.from_db_row(row) for row in rows] @@ -154,7 +154,7 @@ class AgentMemory: ) ''') conn.commit() - conn.close() + #conn.close() @classmethod def from_db_row(self,row): @@ -174,7 +174,7 @@ class AgentMemory: VALUES (?, ?, ?, ?, ?, ?, ?, ?) ''', (log.logid, log.owner_id, log.work_type, log.timestamp, log.content, log.result, meta_str, log.operator)) conn.commit() - conn.close() + #conn.close() async def get_contact_summary(self,contact_id:str) -> str: if contact_id is None: diff --git a/src/aios/agent/llm_context.py b/src/aios/agent/llm_context.py index fc89481..01c54f7 100644 --- a/src/aios/agent/llm_context.py +++ b/src/aios/agent/llm_context.py @@ -19,6 +19,9 @@ class LLMProcessContext: @staticmethod def aifunctions_to_inner_functions(all_inner_function:List[AIFunction]) -> List[Dict]: + if all_inner_function is None: + return [] + result_func = [] result_len = 0 for inner_func in all_inner_function: @@ -279,6 +282,9 @@ class SimpleLLMContext(LLMProcessContext): return None def get_function_set(self,set_name:str = None) -> List[AIFunction]: + if self.functions is None: + return None + if set_name is None: return self.functions.values() else: @@ -296,6 +302,9 @@ class SimpleLLMContext(LLMProcessContext): return None def get_action_set(self,set_name:str = None) -> List[AIFunction]: + if self.actions is None: + return None + if set_name is None: return self.actions.values() else: diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index 9e75461..c31f68d 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -268,9 +268,10 @@ class LLMAgentBaseProcess(BaseLLMProcess): #prompt.append_system_message(self.reply_format) ## Context - context = self._format_content_by_env_value(self.context,context_info) - system_prompt_dict["context"] = context - #prompt.append_system_message(context) + if self.context: + context = self._format_content_by_env_value(self.context,context_info) + system_prompt_dict["context"] = context + #prompt.append_system_message(context) system_prompt_dict["support_actions"] = self.get_action_desc() @@ -371,7 +372,7 @@ class AgentMessageProcess(LLMAgentBaseProcess): return await self.memory.load_chatlogs(msg) async def get_log_summary(self,msg:AgentMsg)->str: - return await self.memory.get_log_summary(msg) + return None async def get_extend_known_info(self,msg:AgentMsg,prompt:LLMPrompt)->str: diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index a746f83..a915494 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -87,7 +87,7 @@ class LocalAgentTaskManger(AgentTaskManager): - async def set_todos(self,owner_task_id:str,todos:List[AgentTodo]): + async def set_todos(self,owner_task_id:str,todos:List[Dict]): owner_task_path = self._get_obj_path(owner_task_id) if owner_task_path is None: return f"owner task {owner_task_id} not found" @@ -107,12 +107,13 @@ class LocalAgentTaskManger(AgentTaskManager): try: step_order = 0 for todo in todos: - todo.step_order = step_order - todo.owner_taskid = owner_task_id - todo_path = f"{self.root_path}/{owner_task_path}/#{step_order} {todo.title}.todo" - self._save_obj_path(todo.todo_id,todo_path) + todo_obj = AgentTodo.from_dict(todo) + todo_obj.step_order = step_order + todo_obj.owner_taskid = owner_task_id + todo_path = f"{self.root_path}/{owner_task_path}/#{step_order} {todo_obj.title}.todo" + self._save_obj_path(todo_obj.todo_id,todo_path) async with aiofiles.open(todo_path, mode='w', encoding="utf-8") as f: - await f.write(json.dumps(todo.to_dict(),ensure_ascii=False)) + await f.write(json.dumps(todo_obj.to_dict(),ensure_ascii=False)) logger.info("create_todos at %s OK!",todo_path) step_order += 1 except Exception as e: @@ -212,9 +213,10 @@ class LocalAgentTaskManger(AgentTaskManager): async def get_sub_tasks(self,task_id:str) -> List[AgentTask]: task_path = self._get_obj_path(task_id) + if task_path is None: return [] - + task_path = f"{self.root_path}/{task_path}" sub_tasks = [] for sub_item in os.listdir(task_path): if sub_item.startswith("."): @@ -235,23 +237,36 @@ class LocalAgentTaskManger(AgentTaskManager): task_path = self._get_obj_path(task_id) if task_path is None: return [] - + task_path = f"{self.root_path}/{task_path}" sub_todos = [] for sub_item in os.listdir(task_path): if sub_item.startswith("."): continue if sub_item == "workspace": continue + if sub_item == "details": + continue full_path = os.path.join(task_path, sub_item) if os.path.isfile(full_path) and sub_item.endswith(".todo"): - sub_todo = await self.get_todo_by_path(f"{task_path}/{sub_item}") + sub_todo = await self.get_todo_by_path(full_path) if sub_todo: sub_todos.append(sub_todo) return sub_todos - + async def get_todo_by_path(self,todo_path:str) -> AgentTodo: + async with aiofiles.open(todo_path, mode='r', encoding="utf-8") as f: + s = await f.read() + todo_dict = json.loads(s) + result_todo:AgentTodo = AgentTodo.from_dict(todo_dict) + if result_todo: + result_todo.todo_path = todo_path + else: + logger.error("get_todo_by_path:%s,parse failed!",todo_path) + + return result_todo + #async def get_task_depends(self,task_id:str) -> List[AgentTask]: # pass @@ -259,6 +274,10 @@ class LocalAgentTaskManger(AgentTaskManager): async def list_task(self,filter:Optional[dict] = None ) -> List[AgentTask]: directory_path = self.root_path result_list:List[AgentTask] = [] + special_state = None + if filter: + special_state = filter.get("state") + #agent_id = filter.get("agent_id") for entry in os.scandir(directory_path): if not entry.is_dir(): @@ -269,8 +288,15 @@ class LocalAgentTaskManger(AgentTaskManager): continue task_item = await self._get_task_by_fullpath(entry.path) if task_item: - if not task_item.is_finish(): - result_list.append(task_item) + if task_item.is_finish(): + continue + + if special_state: + if task_item.state != special_state: + continue + + + result_list.append(task_item) return result_list @@ -309,22 +335,85 @@ class LocalAgentTaskManger(AgentTaskManager): # pass #todo共享其所在task的文件夹 + # if task_id is none, means root folder in workspace + def _get_taskfile_path(self,task_id:str,path:str)->str: + root_path = self.root_path + if task_id is None: + root_path = f"{root_path}/workspace" + else: + task_path = self._get_obj_path(task_id) + if task_path is None: + return None + root_path = f"{task_path}/wrorkspace" + + file_path = f"{root_path}/{path}" + return file_path - async def get_task_file(self,task_id:str,path:str)->str: - #return fileid - pass + async def read_task_file(self,task_id:str,path:str)->str: + file_path = self._get_taskfile_path(task_id,path) + if not os.path.exists(file_path): + return None + + try: + async with aiofiles.open(file_path, mode='r', encoding="utf-8") as f: + content = await f.read() + return content + except Exception as e: + logger.error("read_task_file failed:%s",e) + return None + + + async def write_task_file(self,task_id:str,path:str,content:str): + file_path = self._get_taskfile_path(task_id,path) + # write file + try: + dir_name = os.path.dirname(file_path) + os.makedirs(dir_name) + async with aiofiles.open(file_path, mode='w', encoding="utf-8") as f: + await f.write(content) + except Exception as e: + logger.error("write_task_file failed:%s",e) + return str(e) + + async def append_task_file(self,task_id:str,path:str,content:str): + file_path = self._get_taskfile_path(task_id,path) + # append file + try: + async with aiofiles.open(file_path, mode='a', encoding="utf-8") as f: + await f.write(content) + except Exception as e: + logger.error("append_task_file failed:%s",e) + return str(e) + + + async def list_task_dir(self,task_id:str,path:str) -> List[str]: + dir_path = self._get_taskfile_path(task_id,path) + if not os.path.exists(dir_path): + return None + + try: + result_node = os.listdir(dir_path) + result = [] + for name in result_node: + if name.startswith("."): + continue + + result.append(name) + return result + except Exception as e: + logger.error("list_task_dir failed:%s",e) + return None - async def set_task_file(self,task_id:str,path:str,fileid:str): - pass - - - async def list_task_file(self,task_id:str,path:str): - pass - - async def remove_task_file(self,task_id:str,path:str): - pass + file_path = self._get_taskfile_path(task_id,path) + try: + os.remove(file_path) + except Exception as e: + logger.error("remove_task_file failed:%s",e) + return str(e) + + return None @@ -448,7 +537,7 @@ class AgentWorkspace: task : AgentTask = await _workspace.task_mgr.get_task(task_id) if task is None: return f"task {task_id} not found" - task.state = "cancel" + task.state = AgentTaskState.TASK_STATE_CANCEL await _workspace.task_mgr.update_task(task) return "canncel task ok" @@ -464,6 +553,34 @@ class AgentWorkspace: GlobaToolsLibrary.get_instance().register_tool_function(cancel_task_action) + async def confirm_task(parameters): + _workspace = parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + task_id = parameters.get("task_id") + task : AgentTask = await _workspace.task_mgr.get_task(task_id) + if task is None: + return f"task {task_id} not found" + if parameters.get("priority"): + task.priority = parameters.get("priority") + if parameters.get("next_do_date"): + task.next_do_date = parameters.get("next_do_date") + task.state = AgentTaskState.TASK_STATE_CONFIRMED + await _workspace.task_mgr.update_task(task) + return "confirm task ok" + parameters = ParameterDefine.create_parameters({ + "task_id": {"type": "string", "description": "task id which want to confirm"}, + "next_do_date": {"type": "string", "description": "optional,confirm task next do date"}, + "priority": {"type": "int", "description": "optional,task priority from 1-10"}, + }) + confirm_task_action = SimpleAIFunction( + "agent.workspace.confirm_task", + "Confirm this task", + confirm_task, + parameters + ) + GlobaToolsLibrary.get_instance().register_tool_function(confirm_task_action) + async def list_task(parameters): _workspace = parameters.get("_workspace") if _workspace is None: @@ -565,3 +682,96 @@ class AgentWorkspace: "update todo to new state", update_todo,parameters) GlobaToolsLibrary.get_instance().register_tool_function(update_todo_ai_function) + + + # write file + async def write_task_file(parameters): + _workspace : AgentWorkspace= parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + task_id = parameters.get("task_id") + path = parameters.get("filename") + content = parameters.get("content") + await _workspace.task_mgr.write_task_file(task_id,path,content) + return "write task file ok" + parameters = ParameterDefine.create_parameters({ + "filename": {"type": "string", "description": "filename"}, + "content": {"type": "string", "description": "file content"}, + }) + write_task_file_ai_function = SimpleAIFunction("agent.workspace.write_file", + "write file for task", + write_task_file,parameters) + GlobaToolsLibrary.get_instance().register_tool_function(write_task_file_ai_function) + + # append file + async def append_task_file(parameters): + _workspace : AgentWorkspace= parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + task_id = parameters.get("task_id") + path = parameters.get("filename") + content = parameters.get("content") + await _workspace.task_mgr.append_task_file(task_id,path,content) + return "append task file ok" + parameters = ParameterDefine.create_parameters({ + "filename": {"type": "string", "description": "filename"}, + "content": {"type": "string", "description": "file content"}, + }) + append_task_file_ai_function = SimpleAIFunction("agent.workspace.append_file", + "append file for task", + append_task_file,parameters) + GlobaToolsLibrary.get_instance().register_tool_function(append_task_file_ai_function) + + # read file + async def read_task_file(parameters): + _workspace : AgentWorkspace= parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + task_id = parameters.get("task_id") + path = parameters.get("filename") + content = await _workspace.task_mgr.read_task_file(task_id,path) + return content + parameters = ParameterDefine.create_parameters({ + "filename": {"type": "string", "description": "filename"}, + }) + read_task_file_ai_function = SimpleAIFunction("agent.workspace.read_file", + "read file for task", + read_task_file,parameters) + GlobaToolsLibrary.get_instance().register_tool_function(read_task_file_ai_function) + + # list dir + async def list_task_dir(parameters): + _workspace : AgentWorkspace= parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + task_id = parameters.get("task_id") + path = parameters.get("path") + content = await _workspace.task_mgr.list_task_dir(task_id,path) + return content + parameters = ParameterDefine.create_parameters({ + "path": {"type": "string", "description": "The relative path of the dir"}, + }) + list_task_dir_ai_function = SimpleAIFunction("agent.workspace.list_dir", + "list dir in task workspace", + list_task_dir,parameters) + GlobaToolsLibrary.get_instance().register_tool_function(list_task_dir_ai_function) + + # remove file + async def remove_task_file(parameters): + _workspace : AgentWorkspace= parameters.get("_workspace") + if _workspace is None: + return "_workspace not found" + task_id = parameters.get("task_id") + path = parameters.get("filename") + content = await _workspace.task_mgr.remove_task_file(task_id,path) + return content + parameters = ParameterDefine.create_parameters({ + "filename": {"type": "string", "description": "filename"}, + }) + remove_task_file_ai_function = SimpleAIFunction("agent.workspace.remove_file", + "remove file for task", + remove_task_file,parameters) + GlobaToolsLibrary.get_instance().register_tool_function(remove_task_file_ai_function) + + + diff --git a/src/aios/environment/workspace_env.py b/src/aios/environment/workspace_env.py index 3ad8c89..2e332ba 100644 --- a/src/aios/environment/workspace_env.py +++ b/src/aios/environment/workspace_env.py @@ -228,7 +228,7 @@ class TodoListEnvironment(SimpleEnvironment): except Exception as e: return str(e) - async def wait_todo_done(self,todo_id:str,state=AgentTodo.TODO_STATE_WAITING_CHECK) -> AgentTodo: + async def wait_todo_done(self,todo_id:str,state=AgentTodoState.TODO_STATE_EXEC_OK) -> AgentTodo: todo_path = self._get_todo_path(todo_id) full_path = f"{self.root_path}/{todo_path}" async def check_done(): @@ -256,21 +256,21 @@ class TodoListEnvironment(SimpleEnvironment): return await self.get_todo_by_fullpath(full_path) - async def append_worklog(self, todo:AgentTodo, result:AgentTodoResult): - worklog = f"{self.root_path}/{todo.todo_path}/.worklog" + # async def append_worklog(self, todo:AgentTodo, result:AgentTodoResult): + # worklog = f"{self.root_path}/{todo.todo_path}/.worklog" - async with aiofiles.open(worklog, mode='w+', encoding="utf-8") as f: - content = await f.read() - if len(content) > 0: - json_obj = json.loads(content) - else: - json_obj = {} - logs = json_obj.get("logs") - if logs is None: - logs = [] - logs.append(result.to_dict()) - json_obj["logs"] = logs - await f.write(json.dumps(json_obj),ensure_ascii=False) + # async with aiofiles.open(worklog, mode='w+', encoding="utf-8") as f: + # content = await f.read() + # if len(content) > 0: + # json_obj = json.loads(content) + # else: + # json_obj = {} + # logs = json_obj.get("logs") + # if logs is None: + # logs = [] + # logs.append(result.to_dict()) + # json_obj["logs"] = logs + # await f.write(json.dumps(json_obj),ensure_ascii=False) class WorkspaceEnvironment(CompositeEnvironment): diff --git a/src/aios/proto/agent_task.py b/src/aios/proto/agent_task.py index 3e3c56f..d179fb5 100644 --- a/src/aios/proto/agent_task.py +++ b/src/aios/proto/agent_task.py @@ -12,181 +12,181 @@ from datetime import datetime logger = logging.getLogger(__name__) -class AgentTodoResult: - TODO_RESULT_CODE_OK = 0, - TODO_RESULT_CODE_LLM_ERROR = 1, - TODO_RESULT_CODE_EXEC_OP_ERROR = 2 +# class AgentTodoResult: +# TODO_RESULT_CODE_OK = 0, +# TODO_RESULT_CODE_LLM_ERROR = 1, +# TODO_RESULT_CODE_EXEC_OP_ERROR = 2 - def __init__(self) -> None: - self.result_code = AgentTodoResult.TODO_RESULT_CODE_OK - self.result_str = None - self.error_str = None - self.op_list = None +# def __init__(self) -> None: +# self.result_code = AgentTodoResult.TODO_RESULT_CODE_OK +# self.result_str = None +# self.error_str = None +# self.op_list = None - def to_dict(self) -> dict: - result = {} - result["result_code"] = self.result_code - result["result_str"] = self.result_str - result["error_str"] = self.error_str - result["op_list"] = self.op_list - return result +# def to_dict(self) -> dict: +# result = {} +# result["result_code"] = self.result_code +# result["result_str"] = self.result_str +# result["error_str"] = self.error_str +# result["op_list"] = self.op_list +# return result -class AgentTodo: - TODO_STATE_WAIT_ASSIGN = "wait_assign" - TODO_STATE_INIT = "init" +# class AgentTodo: +# TODO_STATE_WAIT_ASSIGN = "wait_assign" +# TODO_STATE_INIT = "init" - TODO_STATE_PENDING = "pending" - TODO_STATE_WAITING_CHECK = "wait_check" - TODO_STATE_EXEC_FAILED = "exec_failed" - TDDO_STATE_CHECKFAILED = "check_failed" +# TODO_STATE_PENDING = "pending" +# TODO_STATE_WAITING_CHECK = "wait_check" +# TODO_STATE_EXEC_FAILED = "exec_failed" +# TDDO_STATE_CHECKFAILED = "check_failed" - TODO_STATE_CASNCEL = "cancel" - TODO_STATE_DONE = "done" - TODO_STATE_EXPIRED = "expired" +# TODO_STATE_CASNCEL = "cancel" +# TODO_STATE_DONE = "done" +# TODO_STATE_EXPIRED = "expired" - def __init__(self): - self.todo_id = "todo#" + uuid.uuid4().hex - self.title = None - self.detail = None - self.todo_path = None # get parent todo,sub todo by path - #self.parent = None - self.create_time = time.time() +# def __init__(self): +# self.todo_id = "todo#" + uuid.uuid4().hex +# self.title = None +# self.detail = None +# self.todo_path = None # get parent todo,sub todo by path +# #self.parent = None +# self.create_time = time.time() - self.state = "wait_assign" - self.worker = None - self.checker = None - self.createor = None +# self.state = "wait_assign" +# self.worker = None +# self.checker = None +# self.createor = None - self.need_check = True - self.due_date = time.time() + 3600 * 24 * 2 - self.last_do_time = None - self.last_check_time = None - self.last_review_time = None +# self.need_check = True +# self.due_date = time.time() + 3600 * 24 * 2 +# self.last_do_time = None +# self.last_check_time = None +# self.last_review_time = None - self.depend_todo_ids = [] - self.sub_todos = {} +# self.depend_todo_ids = [] +# self.sub_todos = {} - self.result : AgentTodoResult = None - self.last_check_result = None - self.retry_count = 0 - self.raw_obj = None +# self.result : AgentTodoResult = None +# self.last_check_result = None +# self.retry_count = 0 +# self.raw_obj = None - @classmethod - def from_dict(cls,json_obj:dict) -> 'AgentTodo': - todo = AgentTodo() - if json_obj.get("id") is not None: - todo.todo_id = json_obj.get("id") +# @classmethod +# def from_dict(cls,json_obj:dict) -> 'AgentTodo': +# todo = AgentTodo() +# if json_obj.get("id") is not None: +# todo.todo_id = json_obj.get("id") - todo.title = json_obj.get("title") - todo.state = json_obj.get("state") - create_time = json_obj.get("create_time") - if create_time: - todo.create_time = datetime.fromisoformat(create_time).timestamp() +# todo.title = json_obj.get("title") +# todo.state = json_obj.get("state") +# create_time = json_obj.get("create_time") +# if create_time: +# todo.create_time = datetime.fromisoformat(create_time).timestamp() - todo.detail = json_obj.get("detail") - due_date = json_obj.get("due_date") - if due_date: - todo.due_date = datetime.fromisoformat(due_date).timestamp() +# todo.detail = json_obj.get("detail") +# due_date = json_obj.get("due_date") +# if due_date: +# todo.due_date = datetime.fromisoformat(due_date).timestamp() - last_do_time = json_obj.get("last_do_time") - if last_do_time: - todo.last_do_time = datetime.fromisoformat(last_do_time).timestamp() - last_check_time = json_obj.get("last_check_time") - if last_check_time: - todo.last_check_time = datetime.fromisoformat(last_check_time).timestamp() - last_review_time = json_obj.get("last_review_time") - if last_review_time: - todo.last_review_time = datetime.fromisoformat(last_review_time).timestamp() +# last_do_time = json_obj.get("last_do_time") +# if last_do_time: +# todo.last_do_time = datetime.fromisoformat(last_do_time).timestamp() +# last_check_time = json_obj.get("last_check_time") +# if last_check_time: +# todo.last_check_time = datetime.fromisoformat(last_check_time).timestamp() +# last_review_time = json_obj.get("last_review_time") +# if last_review_time: +# todo.last_review_time = datetime.fromisoformat(last_review_time).timestamp() - todo.depend_todo_ids = json_obj.get("depend_todo_ids") - todo.need_check = json_obj.get("need_check") - #todo.result = json_obj.get("result") - #todo.last_check_result = json_obj.get("last_check_result") - todo.worker = json_obj.get("worker") - todo.checker = json_obj.get("checker") - todo.createor = json_obj.get("createor") - if json_obj.get("retry_count"): - todo.retry_count = json_obj.get("retry_count") +# todo.depend_todo_ids = json_obj.get("depend_todo_ids") +# todo.need_check = json_obj.get("need_check") +# #todo.result = json_obj.get("result") +# #todo.last_check_result = json_obj.get("last_check_result") +# todo.worker = json_obj.get("worker") +# todo.checker = json_obj.get("checker") +# todo.createor = json_obj.get("createor") +# if json_obj.get("retry_count"): +# todo.retry_count = json_obj.get("retry_count") - todo.raw_obj = json_obj +# todo.raw_obj = json_obj - return todo +# return todo - def to_dict(self) -> dict: - if self.raw_obj: - result = self.raw_obj - else: - result = {} +# def to_dict(self) -> dict: +# if self.raw_obj: +# result = self.raw_obj +# else: +# result = {} - result["id"] = self.todo_id - #result["parent_id"] = self.parent_id - result["title"] = self.title - result["state"] = self.state - result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() - result["detail"] = self.detail - result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() - result["last_do_time"] = datetime.fromtimestamp(self.last_do_time).isoformat() if self.last_do_time else None - result["last_check_time"] = datetime.fromtimestamp(self.last_check_time).isoformat() if self.last_check_time else None - result["last_review_time"] = datetime.fromtimestamp(self.last_review_time).isoformat() if self.last_review_time else None - result["depend_todo_ids"] = self.depend_todo_ids - result["need_check"] = self.need_check - result["worker"] = self.worker - result["checker"] = self.checker - result["createor"] = self.createor - result["retry_count"] = self.retry_count +# result["id"] = self.todo_id +# #result["parent_id"] = self.parent_id +# result["title"] = self.title +# result["state"] = self.state +# result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() +# result["detail"] = self.detail +# result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() +# result["last_do_time"] = datetime.fromtimestamp(self.last_do_time).isoformat() if self.last_do_time else None +# result["last_check_time"] = datetime.fromtimestamp(self.last_check_time).isoformat() if self.last_check_time else None +# result["last_review_time"] = datetime.fromtimestamp(self.last_review_time).isoformat() if self.last_review_time else None +# result["depend_todo_ids"] = self.depend_todo_ids +# result["need_check"] = self.need_check +# result["worker"] = self.worker +# result["checker"] = self.checker +# result["createor"] = self.createor +# result["retry_count"] = self.retry_count - return result +# return result - def can_check(self)->bool: - if self.state != AgentTodo.TODO_STATE_WAITING_CHECK: - return False +# def can_check(self)->bool: +# if self.state != AgentTodo.TODO_STATE_WAITING_CHECK: +# return False - now = datetime.now().timestamp() - if self.last_check_time: - time_diff = now - self.last_check_time - if time_diff < 60*15: - logger.info(f"todo {self.title} is already checked, ignore") - return False +# now = datetime.now().timestamp() +# if self.last_check_time: +# time_diff = now - self.last_check_time +# if time_diff < 60*15: +# logger.info(f"todo {self.title} is already checked, ignore") +# return False - return True +# return True - def can_do(self) -> bool: - match self.state: - case AgentTodo.TODO_STATE_DONE: - logger.info(f"todo {self.title} is done, ignore") - return False - case AgentTodo.TODO_STATE_CASNCEL: - logger.info(f"todo {self.title} is cancel, ignore") - return False - case AgentTodo.TODO_STATE_EXPIRED: - logger.info(f"todo {self.title} is expired, ignore") - return False - case AgentTodo.TODO_STATE_EXEC_FAILED: - if self.retry_count > 3: - logger.info(f"todo {self.title} retry count ({self.retry_count}) is too many, ignore") - return False +# def can_do(self) -> bool: +# match self.state: +# case AgentTodo.TODO_STATE_DONE: +# logger.info(f"todo {self.title} is done, ignore") +# return False +# case AgentTodo.TODO_STATE_CASNCEL: +# logger.info(f"todo {self.title} is cancel, ignore") +# return False +# case AgentTodo.TODO_STATE_EXPIRED: +# logger.info(f"todo {self.title} is expired, ignore") +# return False +# case AgentTodo.TODO_STATE_EXEC_FAILED: +# if self.retry_count > 3: +# logger.info(f"todo {self.title} retry count ({self.retry_count}) is too many, ignore") +# return False - now = datetime.now().timestamp() - time_diff = self.due_date - now - if time_diff < 0: - logger.info(f"todo {self.title} is expired, ignore") - self.state = AgentTodo.TODO_STATE_EXPIRED - return False +# now = datetime.now().timestamp() +# time_diff = self.due_date - now +# if time_diff < 0: +# logger.info(f"todo {self.title} is expired, ignore") +# self.state = AgentTodo.TODO_STATE_EXPIRED +# return False - if time_diff > 7*24*3600: - logger.info(f"todo {self.title} is far before due date, ignore") - return False +# if time_diff > 7*24*3600: +# logger.info(f"todo {self.title} is far before due date, ignore") +# return False - if self.last_do_time: - time_diff = now - self.last_do_time - if time_diff < 60*15: - logger.info(f"todo {self.title} is already do ignore") - return False +# if self.last_do_time: +# time_diff = now - self.last_do_time +# if time_diff < 60*15: +# logger.info(f"todo {self.title} is already do ignore") +# return False - logger.info(f"todo {self.title} can do.") - return True +# logger.info(f"todo {self.title} can do.") +# return True ############################################################################################ class AgentTaskState(Enum): @@ -198,7 +198,7 @@ class AgentTaskState(Enum): TASK_STATE_EXPIRED = "expired" TASK_STATE_DOING = "doing" - TASK_STATE_WAITING_CHECK = "wait_check" + TASK_STATE_WAITING_REVIEW = "wait_review" TASK_STATE_CHECKFAILED = "check_failed" TASK_STATE_DONE = "done" TASK_STATE_FAILED = "failed" @@ -208,11 +208,14 @@ class AgentTaskState(Enum): class AgentTodoState(Enum): TODO_STATE_WAITING = "waiting" - TODO_STATE_WORKING = "working" - TODO_STATE_WAIT_CHECK = "wait_check" + #TODO_STATE_WORKING = "working" + + TODO_STATE_EXEC_OK = "execute_ok" + TODO_STATE_EXEC_FAILED = "execute_failed" + TODO_STATE_CHECK_FAILED = "check_failed" TODO_STATE_DONE = "done" - TASK_STATE_FAILED = "failed" + TODO_STATE_FAILED = "failed" @staticmethod def from_str(value): @@ -223,17 +226,55 @@ class AgentTodo: self.todo_id = "todo#" + uuid.uuid4().hex self.todo_path : str = None self.owner_taskid = None - self.name:str = None + self.title:str = None self.detail:str = None self.state = AgentTodoState.TODO_STATE_WAITING self.category = None self.step_order:int = 0 def to_dict(self) -> dict: - pass + result = {} + result["todo_id"] = self.todo_id + result["todo_path"] = self.todo_path + result["owner_taskid"] = self.owner_taskid + result["title"] = self.title + result["detail"] = self.detail + result["state"] = self.state.value + result["category"] = self.category + result["step_order"] = self.step_order - def from_dict(self,json_obj:dict) -> 'AgentTask': - pass + return result + + @classmethod + def from_dict(cls,json_obj:dict) -> 'AgentTodo': + result_obj = AgentTodo() + #todo_id + if json_obj.get("todo_id") is not None: + result_obj.todo_id = json_obj.get("todo_id") + #todo_path + if json_obj.get("todo_path") is not None: + result_obj.todo_path = json_obj.get("todo_path") + #owner_taskid + if json_obj.get("owner_taskid") is not None: + result_obj.owner_taskid = json_obj.get("owner_taskid") + #name + if json_obj.get("title") is not None: + result_obj.title = json_obj.get("title") + #detail + if json_obj.get("detail") is not None: + result_obj.detail = json_obj.get("detail") + #state + if json_obj.get("state") is not None: + result_obj.state = AgentTodoState.from_str(json_obj.get("state")) + #category + if json_obj.get("category") is not None: + result_obj.category = json_obj.get("category") + #step_order + if json_obj.get("step_order") is not None: + result_obj.step_order = json_obj.get("step_order") + + return result_obj + class AgentTask: def __init__(self) -> None: @@ -284,6 +325,14 @@ class AgentTask: return True return False + + def can_plan(self) -> bool: + if self.state == AgentTaskState.TASK_STATE_CONFIRMED: + return True + if self.state == AgentTaskState.TASK_STATE_CHECKFAILED: + return True + + return False def to_dict(self) -> dict: result = {} @@ -475,16 +524,19 @@ class AgentTaskManager(ABC): #subtask,todo共享其所在task的文件夹 @abstractmethod - async def get_task_file(self,task_id:str,path:str)->str: - #return fileid + async def read_task_file(self,task_id:str,path:str)->str: pass @abstractmethod - async def set_task_file(self,task_id:str,path:str,fileid:str): + async def write_task_file(self,task_id:str,path:str,content:str): pass @abstractmethod - async def list_task_file(self,task_id:str,path:str): + async def append_task_file(self,task_id:str,path:str,content:str): + pass + + @abstractmethod + async def list_task_dir(self,task_id:str,path:str): pass @abstractmethod diff --git a/src/component/common_environment/shell.py b/src/component/common_environment/shell.py index a32a988..0026240 100644 --- a/src/component/common_environment/shell.py +++ b/src/component/common_environment/shell.py @@ -9,7 +9,7 @@ class ShellEnvironment(SimpleEnvironment): super().__init__("shell") @classmethod - def register_global_functions(cls): + def register_ai_functions(cls): operator_param = ParameterDefine.create_parameters({"command":"command will execute"}) GlobaToolsLibrary.get_instance().register_tool_function(SimpleAIFunction("system.shell.exec", "execute shell command in linux bash", diff --git a/src/component/openai_node/open_ai_node.py b/src/component/openai_node/open_ai_node.py index e60c836..775f506 100644 --- a/src/component/openai_node/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -237,7 +237,7 @@ class OpenAI_ComputeNode(ComputeNode): result.error_str = str(e) return result - logger.info(f"openai response: {resp}") + #logger.info(f"openai response: {resp}") #TODO: gpt-4v api is image_2_text ? if mode_name == "gpt-4-vision-preview": status_code = resp.choices[0].finish_reason diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 1ce1740..cd32a59 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -148,6 +148,7 @@ class AIOS_Shell: #AgentManager.get_instance().register_environment("fs", FilesystemEnvironment) #AgentManager.get_instance().register_environment("knowledge", LocalKnowledgeBase) AgentWorkspace.register_ai_functions() + ShellEnvironment.register_ai_functions() if await AgentManager.get_instance().initial() is not True: logger.error("agent manager initial failed!") From 1388f6cff6fce5286dd6499dba7c7244f8903aff Mon Sep 17 00:00:00 2001 From: wugren Date: Thu, 25 Jan 2024 11:27:05 +0800 Subject: [PATCH 69/79] Add slack tunnel --- .gitignore | 3 +- src/component/slack_tunnel.py | 58 ++++++++++++++++++++++++++++ src/requirements.txt | 1 + src/service/aios_shell/aios_shell.py | 6 +++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/component/slack_tunnel.py diff --git a/.gitignore b/.gitignore index 09bdba0..08c0ea6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ history.txt aios_shell_history.txt math_school_env.db workflows.db - +venv-linux +venv_test rootfs/test_doc/ diff --git a/src/component/slack_tunnel.py b/src/component/slack_tunnel.py new file mode 100644 index 0000000..e4497ed --- /dev/null +++ b/src/component/slack_tunnel.py @@ -0,0 +1,58 @@ +import asyncio + +from slack_bolt.adapter.socket_mode.websockets import AsyncSocketModeHandler +from slack_bolt.app.async_app import AsyncApp + +from aios.frame.tunnel import AgentTunnel +from aios.proto.agent_msg import AgentMsg + + +class SlackTunnel(AgentTunnel): + type: str + token: str + app_token: str + + def __init__(self): + super().__init__() + self.type = "SlackTunnel" + self.token = "" + + @classmethod + def register_to_loader(cls): + async def load_slack_tunnel(config: dict) -> AgentTunnel: + result_tunnel = SlackTunnel() + if await result_tunnel.load_from_config(config): + return result_tunnel + else: + return None + + AgentTunnel.register_loader("SlackTunnel", load_slack_tunnel) + + async def load_from_config(self, config: dict) -> bool: + self.target_id = config["target"] + self.tunnel_id = config["tunnel_id"] + + self.type = "SlackTunnel" + self.token = config["token"] + self.app_token = config["app_token"] + + return True + + def post_message(self, msg: AgentMsg) -> None: + pass + + async def start(self) -> bool: + app = AsyncApp(token=self.token) + + @app.event("message") + async def _handle_message(event, say): + await self._process_message(event) + + asyncio.create_task(AsyncSocketModeHandler(app, self.app_token).start_async()) + return True + + async def close(self) -> None: + pass + + async def _process_message(self, msg: AgentMsg) -> None: + return diff --git a/src/requirements.txt b/src/requirements.txt index d1aae8f..37c080b 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -154,3 +154,4 @@ html2text docx2txt opencv-python discord.py +slack_bolt diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index cd32a59..8e68a7f 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -19,6 +19,7 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import WordCompleter from prompt_toolkit.styles import Style +from slack_tunnel import SlackTunnel directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') @@ -236,6 +237,7 @@ class AIOS_Shell: TelegramTunnel.register_to_loader() EmailTunnel.register_to_loader() DiscordTunnel.register_to_loader() + SlackTunnel.register_to_loader() user_data_dir = str(AIStorage.get_instance().get_myai_dir()) tunnels_config_path = os.path.abspath(f"{user_data_dir}/etc/tunnels.cfg.toml") tunnel_config = None @@ -294,6 +296,10 @@ class AIOS_Shell: case "discord": tunnel_config["type"] = "DiscordTunnel" input_table["token"] = UserConfigItem("discord bot token\n You can get it from https://discord.com/developers/applications ,read https://discordpy.readthedocs.io/en/stable/discord.html for more details") + case "slack": + tunnel_config["type"] = "SlackTunnel" + input_table["token"] = UserConfigItem("slack bot token\n You can get it from https://api.slack.com/apps") + input_table["app_token"] = UserConfigItem("slack app token\n You can get it from https://api.slack.com/apps") case _: error_text = FormattedText([("class:error", f"tunnel type {tunnel_type}not support!")]) print_formatted_text(error_text,style=shell_style) From 7a7a0bc1a9ada56800da59efb2e3f58202d33cd2 Mon Sep 17 00:00:00 2001 From: wugren Date: Thu, 25 Jan 2024 12:32:27 +0800 Subject: [PATCH 70/79] miss commit --- src/component/slack_tunnel.py | 193 ++++++++++++++++++++++++++++++++-- src/requirements.txt | 1 + 2 files changed, 188 insertions(+), 6 deletions(-) diff --git a/src/component/slack_tunnel.py b/src/component/slack_tunnel.py index e4497ed..fb9379d 100644 --- a/src/component/slack_tunnel.py +++ b/src/component/slack_tunnel.py @@ -1,21 +1,52 @@ import asyncio +import datetime +import json +import logging +import os.path +import re +import uuid +import time +import aiofiles +import aiohttp from slack_bolt.adapter.socket_mode.websockets import AsyncSocketModeHandler from slack_bolt.app.async_app import AsyncApp +from aios import KnowledgeStore, ObjectType from aios.frame.tunnel import AgentTunnel from aios.proto.agent_msg import AgentMsg +from aios.storage.storage import AIStorage + +logger = logging.getLogger(__name__) + + +async def download_file(url: str, file_path: str, token: str) -> None: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers={"Authorization": f"Bearer {token}"}) as resp: + if resp.status == 200: + f = await aiofiles.open(file_path, mode='wb') + while True: + chunk = await resp.content.read(1024) + if not chunk: + break + await f.write(chunk) + await f.close() class SlackTunnel(AgentTunnel): type: str token: str app_token: str + slack_cache: str + app: AsyncSocketModeHandler def __init__(self): super().__init__() self.type = "SlackTunnel" self.token = "" + self.slack_cache = os.path.join(AIStorage.get_instance().get_myai_dir(), "slack") + if not os.path.exists(self.slack_cache): + os.makedirs(self.slack_cache) @classmethod def register_to_loader(cls): @@ -38,21 +69,171 @@ class SlackTunnel(AgentTunnel): return True + def get_cache_path(self) -> str: + today = datetime.datetime.today() + path = os.path.join(self.slack_cache, str(today.year), str(today.month)) + if not os.path.exists(path): + os.makedirs(path) + return path + def post_message(self, msg: AgentMsg) -> None: pass async def start(self) -> bool: app = AsyncApp(token=self.token) - @app.event("message") - async def _handle_message(event, say): - await self._process_message(event) + bot_info = await app.client.auth_test() - asyncio.create_task(AsyncSocketModeHandler(app, self.app_token).start_async()) + @app.event("message") + async def _handle_message(event): + logger.info(json.dumps(event)) + ty = event["type"] + if ty != "message": + return + + user = event["user"] + user_info = await app.client.users_info(user=user) + + if not user_info["ok"]: + return + + user_info = user_info["user"] + + mime_type = None + images = [] + file_type = None + video_file = None + audio_file = None + + + if "files" in event: + files = event["files"] + if files is not None and len(files) > 0: + for file in files: + if file["mode"] == "tombstone": + continue + + file_path = os.path.join(self.get_cache_path(), file["id"] + "." + file["filetype"]) + file_info = await app.client.files_info(file=file["id"]) + if not file_info["ok"]: + continue + await download_file(file_info["file"]["url_private_download"], file_path, self.token) + + if file["mimetype"].startswith("image/"): + if file_type is None: + file_type = "image" + elif file_type != "image": + break + images.append(file_path) + elif file["mimetype"].startswith("video/"): + if file_type is None: + file_type = "video" + video_file = file_path + break + elif file["mimetype"].startswith("audio/"): + if file_type is None: + file_type = "audio" + audio_file = file_path + break + + agent_msg = AgentMsg() + agent_msg.topic = "_slack" + agent_msg.msg_id = "discord_msg#" + event["client_msg_id"] + "#" + uuid.uuid4().hex + agent_msg.target = self.target_id + agent_msg.create_time = time.time() + agent_msg.sender = user_info["name"] + self.ai_bus.register_message_handler(agent_msg.sender, self._process_message) + + content = re.sub("<@.+>", "", event["text"]).strip() + + blocks = event["blocks"] + is_metion = False + for block in blocks: + if block["type"] == "rich_text": + elements = block["elements"] + for element in elements: + if element["type"] == "rich_text_section": + elements = element["elements"] + + for element in elements: + if element["type"] == "user": + if element["user_id"] == bot_info.get("user_id"): + is_metion = True + break + + if not is_metion: + return + + if file_type is None: + agent_msg.body = content + elif file_type == "image": + agent_msg.body = agent_msg.create_image_body(images, content) + agent_msg.body_mime = mime_type + elif file_type == "video": + agent_msg.body = agent_msg.create_video_body(video_file, content) + agent_msg.body_mime = mime_type + elif file_type == "audio": + agent_msg.body = agent_msg.create_audio_body(audio_file, content) + agent_msg.body_mime = mime_type + + + resp_msg: AgentMsg = await self.ai_bus.send_message(agent_msg) + if resp_msg is None: + await app.client.chat_postMessage(channel=event["channel"], text=f"System Error: Timeout,{self.target_id} no resopnse! Please check logs/aios.log for more details!") + else: + if resp_msg.body_mime is None: + if resp_msg.body is None: + return + + if len(resp_msg.body) < 1: + await app.client.chat_postMessage(channel=event["channel"], text=f"") + return + + knownledge_object = KnowledgeStore().parse_object_in_message(resp_msg.body) + if knownledge_object is not None: + if knownledge_object.get_object_type() == ObjectType.Image: + image = KnowledgeStore().bytes_from_object(knownledge_object) + try: + async with aiofiles.open("image.jpg", "wb") as f: + await f.write(image) + await app.client.files_upload_v2(channel=event["channel"], file="image.jpg") + except Exception as e: + logger.error(f"save image error:{e}") + logger.exception(e) + return + else: + pos = resp_msg.body.find("audio file") + if pos != -1: + audio_file = resp_msg.body[pos+11:].strip() + if audio_file.startswith("\""): + audio_file = audio_file[1:-1] + await app.client.files_upload_v2(channel=event["channel"], file=audio_file) + return + await app.client.chat_postMessage(channel=event["channel"], text=resp_msg.body) + else: + if resp_msg.is_image_msg(): + text, images = resp_msg.get_image_body() + file_uploads = [] + for image in images: + file_uploads.append({"file": image}) + await app.client.files_upload_v2(channel=event["channel"], file_uploads=file_uploads, initial_comment=text) + elif resp_msg.is_video_msg(): + text, video_file = resp_msg.get_video_body() + await app.client.files_upload_v2(channel=event["channel"], file=video_file, initial_comment=text) + elif resp_msg.is_audio_msg(): + text, audio_file = resp_msg.get_audio_body() + await app.client.files_upload_v2(channel=event["channel"], file=audio_file, initial_comment=text) + else: + await app.client.chat_postMessage(channel=event["channel"], text=resp_msg.body) + + handle = AsyncSocketModeHandler(app, self.app_token) + asyncio.create_task(handle.start_async()) + self.app = handle return True async def close(self) -> None: - pass + await self.app.close_async() + self.app = None async def _process_message(self, msg: AgentMsg) -> None: - return + logger.warn(f"process message {msg.msg_id} from {msg.sender} to {msg.target}") diff --git a/src/requirements.txt b/src/requirements.txt index 37c080b..ed743c4 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -155,3 +155,4 @@ docx2txt opencv-python discord.py slack_bolt +wget From 9b7ca0b81ab094943bdcb19060570263702f4693 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Thu, 25 Jan 2024 01:13:40 -0800 Subject: [PATCH 71/79] fix nature language time bugs --- doc/agent & workflow.drawio | 8 +- doc/webui.drawio | 13 +- rootfs/agents/Jarvis/agent.toml | 18 ++- src/aios/agent/agent.py | 55 +++++--- src/aios/agent/agent_memory.py | 1 + src/aios/agent/llm_do_task.py | 10 +- src/aios/agent/llm_process.py | 2 +- src/aios/agent/workspace.py | 38 +++--- src/aios/proto/agent_task.py | 132 ++++++++++--------- src/component/agent_manager/agent_manager.py | 5 +- 10 files changed, 158 insertions(+), 124 deletions(-) diff --git a/doc/agent & workflow.drawio b/doc/agent & workflow.drawio index 25f098e..9d78a88 100644 --- a/doc/agent & workflow.drawio +++ b/doc/agent & workflow.drawio @@ -1,4 +1,4 @@ - + @@ -655,7 +655,7 @@ - + @@ -680,7 +680,7 @@ - + @@ -723,7 +723,7 @@ - + diff --git a/doc/webui.drawio b/doc/webui.drawio index 9ec9405..0a37a80 100644 --- a/doc/webui.drawio +++ b/doc/webui.drawio @@ -1,6 +1,6 @@ - + - + @@ -79,11 +79,14 @@ + + + - + @@ -91,7 +94,7 @@ - + @@ -152,7 +155,7 @@ - + diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index 7bb0e75..b16b643 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -7,9 +7,6 @@ model_name = "gpt-4-1106-preview" enable_timestamp = "true" enable_json_resp = "true" -owner_prompt = "I am your master{name}" -contact_prompt = "I am your master's friend{name}" - role_desc = """ Your name is Jarvis, the super personal assistant to the master, The focus of work is the support of the schedule management and schedule affairs. """ @@ -45,7 +42,7 @@ known_info_tips = """ tools_tips = """ """ -llm_context.actions.enable = ["agent.workspace.create_task"] +llm_context.actions.enable = ["agent.workspace.create_task","agent.workspace.cancel_task"] llm_context.functions.enable = ["agent.workspace.list_task"] @@ -55,7 +52,7 @@ llm_context.functions.enable = ["agent.workspace.list_task"] type="AgentTriageTaskList" process_description=""" You are expected to effectively TRIAGE the task list described in JSON format, in accordance with your role. Your GOAL is : -1. Adjust the priority of the task and set up a reasonable processing time.(update_task) +1. Adjust the priority of the task and set up a reasonable processing time.(confirm_task) 2. Immediately perform a simple (similar to reminding one category) task. Send a message using post_message, then set the task to complete.(use update_task). 3. Organize tasks to remove tasks beyond your ability. And merge the repeated tasks.(update_task + cancel_task) """ @@ -70,9 +67,9 @@ The Response must be directly parsed by `python json.loads`. Here is an example: }] } """ -context="Your master now in {location}, time: {now}, weather: {weather}." +context="Your master is {owner}, now in {location}, time: {now}, weather: {weather}." -llm_context.actions.enable = ["agent.workspace.confirm_task","agent.workspace.cancel_task","post_message"] +llm_context.actions.enable = ["agent.workspace.confirm_task","agent.workspace.update_task","agent.workspace.cancel_task","post_message"] [behavior.plan_task] ## 首次处理任务 @@ -100,7 +97,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: llm_context.actions.enable = ["agent.workspace.create_task","agent.workspace.update_task","agent.workspace.set_todos","agent.workspace.cancel_task","post_message"] #llm_context.functions.enable = ["agent.workspace.list_task"] -context="Your master now in {location}, time: {now}, weather: {weather}." +context="Your master is {owner}, now in {location}, time: {now}, weather: {weather}." [behavior.review_task] ## 当task的所有todo/subtask都完成后(不敢成功或是失败),进行一次review @@ -122,6 +119,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: }] } """ + llm_context.actions.enable = ["agent.workspace.cancel_task","agent.workspace.update_task"] context="Your master now in {location}, time: {now}, weather: {weather}." @@ -149,7 +147,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: ] } """ - +context="Your master is {owner}, now in {location}, time: {now}, weather: {weather}." # 对于DO操作来说,让Agent查询自己的能力集合是否更合适? llm_context.actions.enable = ["agent.workspace.update_todo","post_message","agent.workspace.write_file","agent.workspace.append_file"] llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","aigc.text_2_image","aigc.text_2_voice","web.search.duckduckgo"] @@ -173,7 +171,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: ] } """ - +context="Your master is {owner}, now in {location}, time: {now}, weather: {weather}." llm_context.actions.enable = ["agent.workspace.update_todo"] llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","system.shell.run_code","aigc.image_2_text","aigc.voice_2_text","web.search.duckduckgo"] diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 8756dcf..e5ffef9 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -99,7 +99,7 @@ class AIAgent(BaseAIAgent): } self.todo_prompts = todo_prompts - self.chat_db = None + self.memory_db = None self.unread_msg = Queue() # msg from other agent self.owenr_bus = None @@ -109,7 +109,7 @@ class AIAgent(BaseAIAgent): self.behaviors:Dict[str,BaseLLMProcess] = {} async def initial(self,params:Dict = None): - self.memory = AgentMemory(self.agent_id,self.chat_db) + self.memory = AgentMemory(self.agent_id,self.memory_db) self.prviate_workspace = AgentWorkspace(self.agent_id) init_params = {} init_params["memory"] = self.memory @@ -200,6 +200,7 @@ class AIAgent(BaseAIAgent): context_info["location"] = "SanJose" context_info["now"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") context_info["weather"] = "Partly Cloudy, 60°F" + context_info["owner"] = AIStorage.get_instance().get_user_config().get_value("username") return context_info @@ -209,7 +210,7 @@ class AIAgent(BaseAIAgent): need_process = False session_topic = msg.target + "#" + msg.topic - chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.chat_db) + chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.memory_db) if msg.mentions is not None: if self.agent_id in msg.mentions: need_process = True @@ -247,20 +248,27 @@ class AIAgent(BaseAIAgent): filter = {} filter["state"] = AgentTaskState.TASK_STATE_WAIT - tasklist = await self.prviate_workspace.task_mgr.list_task(filter) + tasklist:List[AgentTask]= await self.prviate_workspace.task_mgr.list_task(filter) + + if tasklist: - input_parms = { - "tasklist":tasklist, - "context_info": await self._get_context_info() - } - llm_result : LLMResult = await llm_process.process(input_parms) - if llm_result.state == LLMResultStates.ERROR: - logger.error(f"llm process triage_tasks error:{llm_result.compute_error_str}") - elif llm_result.state == LLMResultStates.IGNORE: - logger.info(f"llm process triage_tasks ignore!") - else: - logger.info(f"llm process triage_tasks ok!,think is:{llm_result.resp}") - self.agent_energy -= 3 + if len(tasklist) > 0: + simple_list:List[Dict] = [] + for task in tasklist: + simple_list.append(task.to_simple_dict()) + + input_parms = { + "tasklist":simple_list, + "context_info": await self._get_context_info() + } + llm_result : LLMResult = await llm_process.process(input_parms) + if llm_result.state == LLMResultStates.ERROR: + logger.error(f"llm process triage_tasks error:{llm_result.compute_error_str}") + elif llm_result.state == LLMResultStates.IGNORE: + logger.info(f"llm process triage_tasks ignore!") + else: + logger.info(f"llm process triage_tasks ok!,think is:{llm_result.resp}") + self.agent_energy -= 3 # for agent_task in tasklist: # if self.agent_energy <= 0: @@ -354,7 +362,7 @@ class AIAgent(BaseAIAgent): async def do_self_think(self): - session_id_list = AIChatSession.list_session(self.agent_id,self.chat_db) + session_id_list = AIChatSession.list_session(self.agent_id,self.memory_db) for session_id in session_id_list: if self.agent_energy <= 0: break @@ -396,10 +404,12 @@ class AIAgent(BaseAIAgent): for task in task_list: if self.agent_energy <= 0: break - + + task = await self.prviate_workspace.task_mgr.get_task(task.task_id) if task.can_plan(): # PLAN Task await self.llm_plan_task(task) + task = await self.prviate_workspace.task_mgr.get_task(task.task_id) if task.state == AgentTaskState.TASK_STATE_DOING: # DO or Check Todo @@ -408,14 +418,23 @@ class AIAgent(BaseAIAgent): for todo in todolist: if self.agent_energy <= 0: break + task = await self.prviate_workspace.task_mgr.get_task(task.task_id) + todo = await self.prviate_workspace.task_mgr.get_todo_by_id(todo.todo_id) + if task.state != AgentTaskState.TASK_STATE_DOING: + break if todo.state == AgentTodoState.TODO_STATE_WAITING or todo.state == AgentTodoState.TODO_STATE_EXEC_FAILED: await self.llm_do_todo(todo) + task = await self.prviate_workspace.task_mgr.get_task(task.task_id) + todo = await self.prviate_workspace.task_mgr.get_todo_by_id(todo.todo_id) + if task.state != AgentTaskState.TASK_STATE_DOING: + break if todo.state == AgentTodoState.TODO_STATE_EXEC_OK: await self.llm_check_todo(todo) if can_review: task.state = AgentTaskState.TASK_STATE_WAITING_REVIEW + task = await self.prviate_workspace.task_mgr.get_task(task.task_id) if task.state == AgentTaskState.TASK_STATE_WAITING_REVIEW: await self.llm_review_task(task) diff --git a/src/aios/agent/agent_memory.py b/src/aios/agent/agent_memory.py index 991a25b..d940859 100644 --- a/src/aios/agent/agent_memory.py +++ b/src/aios/agent/agent_memory.py @@ -180,6 +180,7 @@ class AgentMemory: if contact_id is None: return None + # TODO : fix this by system config. if contact_id == "lzc": return "lzc is your master. Male, 40 years old, Mother tongue is Chinese, senior software engineer." return None diff --git a/src/aios/agent/llm_do_task.py b/src/aios/agent/llm_do_task.py index 17630b1..4d42961 100644 --- a/src/aios/agent/llm_do_task.py +++ b/src/aios/agent/llm_do_task.py @@ -38,12 +38,8 @@ class AgentTriageTaskList(LLMAgentBaseProcess): if task_list is None: logger.error(f"tasklist not found in input") return None - - task_dict_list = [] - for task in task_list: - task_dict_list.append(task.to_dict()) - - prompt.append_user_message(json.dumps(task_dict_list,ensure_ascii=False)) + + prompt.append_user_message(json.dumps(task_list,ensure_ascii=False)) system_prompt_dict = self.prepare_role_system_prompt(context_info) # May all logs is good for Agent Triage Task List? @@ -89,7 +85,7 @@ class AgentTriageTaskList(LLMAgentBaseProcess): logger.error(f"execute action failed! {e}") result_str = "execute action failed!,error:" + str(e) - worklog = AgentWorkLog.create_by_content(self.memory.agent_id,"triage",llm_result.resp,self.memory.agent_id) + worklog = AgentWorkLog.create_by_content("","triage",llm_result.resp,self.memory.agent_id) worklog.result = result_str await self.memory.append_worklog(worklog) diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index c31f68d..130bcd3 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -91,7 +91,7 @@ class BaseLLMProcess(ABC): try: func_name = inner_func_call_node.get("name") arguments = json.loads(inner_func_call_node.get("arguments")) - logger.info(f"LLMProcess execute inner func:{func_name} :\n\t {json.dumps(arguments,ensure_ascii=False)}") + logger.info(f"LLMProcess execute inner func:{func_name} :({json.dumps(arguments,ensure_ascii=False)})") func_node : AIFunction = await self.get_inner_function_for_exec(func_name) if func_node is None: diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index a915494..81855be 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) class LocalAgentTaskManger(AgentTaskManager): def __init__(self, owner_id): super().__init__() - self.root_path = f"{AIStorage.get_instance().get_myai_dir()}/tasklist/{owner_id}" + self.root_path = f"{AIStorage.get_instance().get_myai_dir()}/workspaces/{owner_id}_workspace" #self.root_path = os.path.join(workspace, list_type) if not os.path.exists(self.root_path): os.makedirs(self.root_path) @@ -451,7 +451,7 @@ class AgentWorkspace: return f"post message to {target} failed!" parameters = ParameterDefine.create_parameters({ - "target": {"type": "string", "description": "target agent/contact id"}, + "target": {"type": "string", "description": "target agent/contact fullname or telephone or email"}, "topic": {"type": "string", "description": "optional, message topic"}, "message": {"type": "string", "description": "message content"}, }) @@ -513,10 +513,10 @@ class AgentWorkspace: parent_id = params.get("parent") return await _workspace.task_mgr.create_task(taskObj,parent_id) parameters = ParameterDefine.create_parameters({ - "title": {"type": "string", "description": "task title"}, - "detail": {"type": "string", "description": "task detail(simple task can not be filled)"}, - "tags": {"type": "string", "description": "optional,task tags"}, - "due_date": {"type": "string", "description": "optional,task due date"}, + "title" : {"type": "string", "description": "task title,Simple and clear, try to include the task \ Related personnel \ place \ key conditions \ time element involved in the event"}, + "detail" : {"type": "string", "description": "task detail(simple task can not be filled)"}, + "priority" : {"type": "int", "description": "task priority from 1-10"}, + "due_date" : {"type": "isoformat time string", "description": "task due date"}, "parent": {"type": "string", "description": "optional,parent task id"}, }) create_task_action = SimpleAIFunction( @@ -528,7 +528,6 @@ class AgentWorkspace: GlobaToolsLibrary.get_instance().register_tool_function(create_task_action) - async def cancel_task(parameters): _workspace = parameters.get("_workspace") if _workspace is None: @@ -563,19 +562,25 @@ class AgentWorkspace: return f"task {task_id} not found" if parameters.get("priority"): task.priority = parameters.get("priority") - if parameters.get("next_do_date"): - task.next_do_date = parameters.get("next_do_date") + if parameters.get("next_attention_time"): + task.next_attention_time = parameters.get("next_attention_time") + if parameters.get("expiration_time"): + task.expiration_time = parameters.get("expiration_time") + if parameters.get("due_date"): + task.due_date = parameters.get("due_date") task.state = AgentTaskState.TASK_STATE_CONFIRMED await _workspace.task_mgr.update_task(task) return "confirm task ok" parameters = ParameterDefine.create_parameters({ "task_id": {"type": "string", "description": "task id which want to confirm"}, - "next_do_date": {"type": "string", "description": "optional,confirm task next do date"}, + "expiration_time": {"type": "isoformat time string", "description": "optional,confirm task expiration time"}, + "next_attention_time": {"type": "isoformat time string", "description": "optional,confirm task next attention time"}, + #"due_date": {"type": "isoformat time string", "description": "optional,confirm task due date"}, "priority": {"type": "int", "description": "optional,task priority from 1-10"}, }) confirm_task_action = SimpleAIFunction( "agent.workspace.confirm_task", - "Confirm this task", + "After understanding the content of the task, the importance of the importance of the task, the priority, the deadline, etc.", confirm_task, parameters ) @@ -611,20 +616,23 @@ class AgentWorkspace: task.priority = parameters.get("priority") if parameters.get("new_state"): task.state = AgentTaskState.from_str(parameters.get("new_state")) - if parameters.get("next_do_date"): - task.next_do_date = parameters.get("next_do_date") + if parameters.get("next_attention_time"): + task.next_attention_time = parameters.get("next_attention_time") if parameters.get("due_date"): task.due_date = parameters.get("due_date") + if parameters.get("expiration_time"): + task.expiration_time = parameters.get("expiration_time") await _workspace.task_mgr.update_task(task) return "update task ok" parameters = ParameterDefine.create_parameters({ "task_id": {"type": "string", "description": "task id which want to update"}, "new_state": {"type": "string", "description": "optional,new task state: cancel or done"}, - "next_do_date": {"type": "string", "description": "optional,confirm task next do date"}, + "next_attention_time": {"type": "isoformat time string", "description": "optional,update task next attention time"}, + "expiration_time": {"type": "isoformat time string", "description": "optional,update task expiration time"}, "priority": {"type": "int", "description": "optional,task priority from 1-10"}, "title": {"type": "string", "description": "optional, new task title"}, "detail": {"type": "string", "description": "optional, new task detail(simple task can not be filled)"}, - "due_date": {"type": "string", "description": "optional,new task due date"}, + #"due_date": {"type": "string", "description": "optional,new task due date"}, }) update_task_ai_function = SimpleAIFunction("agent.workspace.update_task", "update task to new state", diff --git a/src/aios/proto/agent_task.py b/src/aios/proto/agent_task.py index d179fb5..43bee19 100644 --- a/src/aios/proto/agent_task.py +++ b/src/aios/proto/agent_task.py @@ -235,7 +235,6 @@ class AgentTodo: def to_dict(self) -> dict: result = {} result["todo_id"] = self.todo_id - result["todo_path"] = self.todo_path result["owner_taskid"] = self.owner_taskid result["title"] = self.title result["detail"] = self.detail @@ -251,9 +250,6 @@ class AgentTodo: #todo_id if json_obj.get("todo_id") is not None: result_obj.todo_id = json_obj.get("todo_id") - #todo_path - if json_obj.get("todo_path") is not None: - result_obj.todo_path = json_obj.get("todo_path") #owner_taskid if json_obj.get("owner_taskid") is not None: result_obj.owner_taskid = json_obj.get("owner_taskid") @@ -288,23 +284,21 @@ class AgentTask: self.worker = None self.createor = None - # if due_date is none ,means no due date - self.due_date = time.time() + 3600 * 24 * 2 - # 确定的执行时间(执行条件) - self.next_do_time = None - # 如果next check time设置,说明任务适合在该时间点可能具备执行调教,尝试检查并执行 - #self.next_check_time = None + now = time.time() + self.create_time = datetime.fromtimestamp(now).isoformat() + # dead line,set by llm + self.due_date = None + # set by llm + self.next_attention_time = None + # set by llm + self.expiration_time = None self.depend_task_ids = [] #self.step_todo_ids = [] - - self.create_time = time.time() self.done_time = None - self.last_do_time = None self.last_plan_time = None - self.last_check_time = None - #self.last_review_time = None + self.last_review_time = None def is_finish(self) -> bool: if self.state == AgentTaskState.TASK_STATE_DONE: @@ -319,10 +313,14 @@ class AgentTask: if self.state == AgentTaskState.TASK_STATE_FAILED: return True - if self.due_date: - if self.due_date < time.time(): - self.state = AgentTaskState.TASK_STATE_EXPIRED - return True + if self.expiration_time: + try: + expiration_time = datetime.fromisoformat(self.expiration_time).timestamp() + if expiration_time < time.time(): + self.state = AgentTaskState.TASK_STATE_EXPIRED + return True + except Exception as e: + logger.warning(f"invalid expiration_time {self.expiration_time}") return False @@ -331,8 +329,26 @@ class AgentTask: return True if self.state == AgentTaskState.TASK_STATE_CHECKFAILED: return True + if self.next_attention_time: + try: + next_attention_time = datetime.fromisoformat(self.next_attention_time).timestamp() + if next_attention_time >= time.time(): + return True + except Exception as e: + logger.warning(f"invalid next_attention_time {self.next_attention_time}") return False + + def to_simple_dict(self) -> dict: + result = {} + result["task_id"] = self.task_id + result["title"] = self.title + result["priority"] = self.priority + result["create_time"] = self.create_time + if self.due_date: + result["due_date"] = self.due_date + return result + def to_dict(self) -> dict: result = {} @@ -341,26 +357,35 @@ class AgentTask: result["detail"] = self.detail result["state"] = self.state.value result["priority"] = self.priority - result["tags"] = self.tags - result["worker"] = self.worker - result["createor"] = self.createor + + if self.tags: + result["tags"] = self.tags + + if self.worker: + result["worker"] = self.worker + + if self.createor: + result["createor"] = self.createor + result["create_time"] = self.create_time if self.due_date: - result["due_date"] = datetime.fromtimestamp(self.due_date).isoformat() - if self.next_do_time: - result["next_do_time"] = datetime.fromtimestamp(self.next_do_time).isoformat() + result["due_date"] = self.due_date + if self.expiration_time: + result["expiration_time"] = self.expiration_time + + if self.next_attention_time: + result["next_attention_time"] = self.next_attention_time #if self.next_check_time: # result["next_check_time"] = datetime.fromtimestamp(self.next_check_time).isoformat() - result["depend_task_ids"] = self.depend_task_ids - #result["step_todo_ids"] = self.step_todo_ids - result["create_time"] = datetime.fromtimestamp(self.create_time).isoformat() + if self.depend_task_ids: + if len(self.depend_task_ids) > 0: + result["depend_task_ids"] = self.depend_task_ids + if self.done_time: - result["done_time"] = datetime.fromtimestamp(self.done_time).isoformat() - if self.last_do_time: - result["last_do_time"] = datetime.fromtimestamp(self.last_do_time).isoformat() + result["done_time"] = self.done_time if self.last_plan_time: - result["last_plan_time"] = datetime.fromtimestamp(self.last_plan_time).isoformat() - if self.last_check_time: - result["last_check_time"] = datetime.fromtimestamp(self.last_check_time).isoformat() + result["last_plan_time"] = self.last_plan_time + if self.last_review_time: + result["last_review_time"] = self.last_review_time return result @classmethod @@ -374,32 +399,15 @@ class AgentTask: result.tags = json_obj.get("tags") result.worker = json_obj.get("worker") result.createor = json_obj.get("createor") - due_date = json_obj.get("due_date") - if due_date: - result.due_date = datetime.fromisoformat(due_date).timestamp() - next_do_time = json_obj.get("next_do_time") - if next_do_time: - result.next_do_time = datetime.fromisoformat(next_do_time).timestamp() - #next_check_time = json_obj.get("next_check_time") - #if next_check_time: - # result.next_check_time = datetime.fromisoformat(next_check_time).timestamp() + result.due_date = json_obj.get("due_date") + result.next_attention_time = json_obj.get("next_attention_time") result.depend_task_ids = json_obj.get("depend_task_ids") #result.step_todo_ids = json_obj.get("step_todo_ids") - create_time = json_obj.get("create_time") - if create_time: - result.create_time = datetime.fromisoformat(create_time).timestamp() - done_time = json_obj.get("done_time") - if done_time: - result.done_time = datetime.fromisoformat(done_time).timestamp() - last_do_time = json_obj.get("last_do_time") - if last_do_time: - result.last_do_time = datetime.fromisoformat(last_do_time).timestamp() - last_plan_time = json_obj.get("last_plan_time") - if last_plan_time: - result.last_plan_time = datetime.fromisoformat(last_plan_time).timestamp() - last_check_time = json_obj.get("last_check_time") - if last_check_time: - result.last_check_time = datetime.fromisoformat(last_check_time).timestamp() + result.expiration_time = json_obj.get("expiration_time") + result.create_time = json_obj.get("create_time") + result.done_time = json_obj.get("done_time") + result.last_plan_time = json_obj.get("last_plan_time") + result.last_review_time = json_obj.get("last_review_time") if result.task_id is None or result.title is None or result.create_time is None or result.create_time is None: logger.error(f"invalid task {json_obj}") @@ -422,14 +430,12 @@ class AgentTask: result.priority = json_obj.get("priority") if result.priority is None: result.priority = 5 - + if json_obj.get("due_date"): + result.due_date = json_obj.get("due_date") result.tags = json_obj.get("tags") result.worker = json_obj.get("worker") result.createor = creator - due_date = json_obj.get("due_date") - if due_date: - result.due_date = datetime.fromisoformat(due_date).timestamp() - + return result # 谁在什么时间做了什么 diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index e370b21..0ffb16e 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -42,6 +42,8 @@ class AgentManager: self.agent_env.parent_envs.append(sys_agent_env) self.db_path = f"{user_data_dir}/messages.db" + self.agent_memory_base_dir = f"{user_data_dir}/memory" + self.workspace_base_dir = f"{user_data_dir}/workspace" self.loaded_agent_instance = {} return True @@ -70,7 +72,8 @@ class AgentManager: logger.warn(f"load agent {agent_id} from media failed!") return None - the_agent.chat_db = self.db_path + the_agent.memory_db = f"{self.agent_memory_base_dir}/{agent_id}/{agent_id}_memory.db" + os.makedirs(os.path.dirname(the_agent.memory_db),exist_ok=True) if await the_agent.initial(): return the_agent else: From c694ece59e3d4220ce47623d5894b261d1817a5d Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Thu, 25 Jan 2024 21:48:17 -0800 Subject: [PATCH 72/79] fix bugs. --- rootfs/agents/Jarvis/agent.toml | 12 ++++++------ src/aios/agent/agent.py | 17 +++++++++++++---- src/aios/agent/llm_do_task.py | 2 +- src/aios/agent/workspace.py | 10 ++++++---- src/aios/proto/agent_task.py | 28 ++++++++++++++++------------ src/service/aios_shell/aios_shell.py | 3 ++- 6 files changed, 44 insertions(+), 28 deletions(-) diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index b16b643..225ac34 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -8,7 +8,8 @@ enable_timestamp = "true" enable_json_resp = "true" role_desc = """ -Your name is Jarvis, the super personal assistant to the master, The focus of work is the support of the schedule management and schedule affairs. +Your name is Jarvis, the super personal assistant to the master. Help the Master do a good job of schedule.Reminder before the start of the important schedule, and you should bring useful information as much as possible when reminding. +Only clearly specifying the task you completed can be completed independently. """ [behavior.on_message] @@ -72,13 +73,12 @@ context="Your master is {owner}, now in {location}, time: {now}, weather: {weath llm_context.actions.enable = ["agent.workspace.confirm_task","agent.workspace.update_task","agent.workspace.cancel_task","post_message"] [behavior.plan_task] -## 首次处理任务 +## 处理任务 Tackling Task type="AgentPlanTask" -# 是否要加入对任务到期时间的关注? process_description=""" -The input is a task comes from a Tasklist. You need to perform a PLAN. PLAN process on TASK in combination with the known information. +The input is a task comes from a Tasklist. You are Tackling this task. Tackling process in combination with the known information. 1. Carefully think about whether the task is within your ability, and the task other than the scope of ability is directly rejected. (cancel_task). -2. Immediately perform a simple (similar to reminding one category) task. Send a message using post_message, then set the task to complete.(use update_task). +2. Immediately DO a simple (similar to reminding one category) task. Reminds at a reasonable time, Post a message using post_message, then set the task to complete.(use update_task). 3. Plan for non-simple tasks, and generate a TODO list. Every TODO MUST be an independent work with a clear goal. (set_todos) 4. If the task has been dealt with, it means that the task is ultimately completed.You need to analyze the processing report of the entire task and make new plans. """ @@ -86,7 +86,7 @@ The input is a task comes from a Tasklist. You need to perform a PLAN. PLAN proc reply_format = """ The Response must be directly parsed by `python json.loads`. Here is an example: { - think:'$think step-by-step to be sure you have the right plan.', + think:'$thinking step by step to ensure the accurate and efficient processing task.', resp:'$determine, summary what you do' actions: [{ name: '$action_name', diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index e5ffef9..ba3f264 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -384,8 +384,8 @@ class AIAgent(BaseAIAgent): logger.warning(f"agent {self.agent_id} is already wake up!") async def _on_timer(self): - while True: - await asyncio.sleep(5) + await asyncio.sleep(5) + while True: try: now = time.time() if self.last_recover_time is None: @@ -394,17 +394,23 @@ class AIAgent(BaseAIAgent): if now - self.last_recover_time > 60: self.agent_energy += (now - self.last_recover_time) / 60 self.last_recover_time = now + logger.info(f"agent {self.agent_id} recover energy to {self.agent_energy}") if self.agent_energy <= 1: + logger.info(f"agent {self.agent_id} energy is too low!, goto sleep!") continue await self.llm_triage_tasklist() - task_list:List[AgentTask] = await self.prviate_workspace.task_mgr.list_task() + # Get un finished tasks + #filter = {} + #filter["state"] = AgentTaskState.TASK_STATE_WAIT + filter = None + task_list:List[AgentTask] = await self.prviate_workspace.task_mgr.list_task(filter) for task in task_list: if self.agent_energy <= 0: break - + task = await self.prviate_workspace.task_mgr.get_task(task.task_id) if task.can_plan(): # PLAN Task @@ -445,6 +451,9 @@ class AIAgent(BaseAIAgent): logger.error(f"agent {self.agent_id} on timer error:{e},{tb_str}") continue + # Because the LLM itself is very slow, the accuracy of the system processing task is in minutes. + await asyncio.sleep(30) + diff --git a/src/aios/agent/llm_do_task.py b/src/aios/agent/llm_do_task.py index 4d42961..df9cf25 100644 --- a/src/aios/agent/llm_do_task.py +++ b/src/aios/agent/llm_do_task.py @@ -155,7 +155,7 @@ class AgentPlanTask(LLMAgentBaseProcess): logger.error(f"execute action failed! {e}") result_str = "execute action failed!,error:" + str(e) - worklog = AgentWorkLog.create_by_content(agent_task.task_id,"plan",llm_result.resp,self.memory.agent_id) + worklog = AgentWorkLog.create_by_content(agent_task.task_id,"tackling",llm_result.resp,self.memory.agent_id) worklog.result = result_str await self.memory.append_worklog(worklog) diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index 81855be..e1cacca 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -288,8 +288,9 @@ class LocalAgentTaskManger(AgentTaskManager): continue task_item = await self._get_task_by_fullpath(entry.path) if task_item: - if task_item.is_finish(): - continue + if filter is None: + if task_item.is_finish(): + continue if special_state: if task_item.state != special_state: @@ -516,7 +517,8 @@ class AgentWorkspace: "title" : {"type": "string", "description": "task title,Simple and clear, try to include the task \ Related personnel \ place \ key conditions \ time element involved in the event"}, "detail" : {"type": "string", "description": "task detail(simple task can not be filled)"}, "priority" : {"type": "int", "description": "task priority from 1-10"}, - "due_date" : {"type": "isoformat time string", "description": "task due date"}, + #"due_date": {"type": "isoformat time string", "description": "optional,confirm task due date"}, + #"expiration_time": {"type": "isoformat time string", "description": "optional,confirm task expiration time"}, "parent": {"type": "string", "description": "optional,parent task id"}, }) create_task_action = SimpleAIFunction( @@ -573,8 +575,8 @@ class AgentWorkspace: return "confirm task ok" parameters = ParameterDefine.create_parameters({ "task_id": {"type": "string", "description": "task id which want to confirm"}, - "expiration_time": {"type": "isoformat time string", "description": "optional,confirm task expiration time"}, "next_attention_time": {"type": "isoformat time string", "description": "optional,confirm task next attention time"}, + "expiration_time": {"type": "isoformat time string", "description": "optional,confirm task expiration time"}, #"due_date": {"type": "isoformat time string", "description": "optional,confirm task due date"}, "priority": {"type": "int", "description": "optional,task priority from 1-10"}, }) diff --git a/src/aios/proto/agent_task.py b/src/aios/proto/agent_task.py index 43bee19..7ddee87 100644 --- a/src/aios/proto/agent_task.py +++ b/src/aios/proto/agent_task.py @@ -325,18 +325,17 @@ class AgentTask: return False def can_plan(self) -> bool: - if self.state == AgentTaskState.TASK_STATE_CONFIRMED: - return True - if self.state == AgentTaskState.TASK_STATE_CHECKFAILED: - return True - if self.next_attention_time: - try: - next_attention_time = datetime.fromisoformat(self.next_attention_time).timestamp() - if next_attention_time >= time.time(): - return True - except Exception as e: - logger.warning(f"invalid next_attention_time {self.next_attention_time}") - + if self.state == AgentTaskState.TASK_STATE_CONFIRMED or self.state == AgentTaskState.TASK_STATE_CHECKFAILED: + if self.next_attention_time: + try: + next_attention_time = datetime.fromisoformat(self.next_attention_time).timestamp() + if time.time() >= next_attention_time: + return True + except Exception as e: + logger.warning(f"invalid next_attention_time {self.next_attention_time}") + else: + return True + return False def to_simple_dict(self) -> dict: @@ -344,9 +343,14 @@ class AgentTask: result["task_id"] = self.task_id result["title"] = self.title result["priority"] = self.priority + result["detail"] = self.detail result["create_time"] = self.create_time if self.due_date: result["due_date"] = self.due_date + if self.expiration_time: + result["expiration_time"] = self.expiration_time + if self.next_attention_time: + result["next_attention_time"] = self.next_attention_time return result diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index 8e68a7f..a4a33c4 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -19,7 +19,7 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import WordCompleter from prompt_toolkit.styles import Style -from slack_tunnel import SlackTunnel + directory = os.path.dirname(__file__) sys.path.append(directory + '/../../') @@ -46,6 +46,7 @@ from knowledge_manager import KnowledgePipelineManager from tg_tunnel import TelegramTunnel from email_tunnel import EmailTunnel from discord_tunnel import DiscordTunnel +from slack_tunnel import SlackTunnel from common_environment import LocalKnowledgeBase, FilesystemEnvironment, ShellEnvironment, ScanLocalDocument, ParseLocalDocument from compute_node_config import * From 42073c45f3fdfbc1f73a6e4c40550336a9b6fb6a Mon Sep 17 00:00:00 2001 From: wugren Date: Sun, 4 Feb 2024 11:01:00 +0800 Subject: [PATCH 73/79] Support discord and slack group msg --- src/aios/proto/agent_msg.py | 2 +- src/component/discord_tunnel.py | 11 +++++------ src/component/slack_tunnel.py | 5 ++--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/aios/proto/agent_msg.py b/src/aios/proto/agent_msg.py index cccfa38..b9665c4 100644 --- a/src/aios/proto/agent_msg.py +++ b/src/aios/proto/agent_msg.py @@ -136,7 +136,7 @@ class AgentMsg: resp_msg.create_time = time.time() resp_msg.rely_msg_id = self.msg_id - resp_msg.target = self.target + resp_msg.target = self.sender resp_msg.sender = sender_id resp_msg.body = resp_body resp_msg.topic = self.topic diff --git a/src/component/discord_tunnel.py b/src/component/discord_tunnel.py index fb3eee5..1332e58 100644 --- a/src/component/discord_tunnel.py +++ b/src/component/discord_tunnel.py @@ -11,7 +11,7 @@ from typing import Optional from aios import KnowledgeStore, ObjectType from aios.frame.tunnel import AgentTunnel -from aios.proto.agent_msg import AgentMsg +from aios.proto.agent_msg import AgentMsg, AgentMsgType import discord from aios.storage.storage import AIStorage @@ -96,10 +96,6 @@ class DiscordTunnel(AgentTunnel): if message.author == self.client.user: return - if len(message.channel.members) > 2: - if self.client.user not in message.mentions: - return - content = re.sub("<@.+>", "", message.content).strip() attach_type = None @@ -142,6 +138,10 @@ class DiscordTunnel(AgentTunnel): agent_msg.sender = message.author.name self.ai_bus.register_message_handler(agent_msg.sender, self._process_message) + if len(message.channel.members) > 2: + if self.client.user not in message.mentions: + agent_msg.msg_type = AgentMsgType.TYPE_GROUP_MSG + if attach_type is None: agent_msg.body = content elif attach_type == "image": @@ -163,7 +163,6 @@ class DiscordTunnel(AgentTunnel): return if len(resp_msg.body) < 1: - await message.channel.send("") return knownledge_object = KnowledgeStore().parse_object_in_message(resp_msg.body) diff --git a/src/component/slack_tunnel.py b/src/component/slack_tunnel.py index fb9379d..7deda23 100644 --- a/src/component/slack_tunnel.py +++ b/src/component/slack_tunnel.py @@ -14,7 +14,7 @@ from slack_bolt.app.async_app import AsyncApp from aios import KnowledgeStore, ObjectType from aios.frame.tunnel import AgentTunnel -from aios.proto.agent_msg import AgentMsg +from aios.proto.agent_msg import AgentMsg, AgentMsgType from aios.storage.storage import AIStorage logger = logging.getLogger(__name__) @@ -162,7 +162,7 @@ class SlackTunnel(AgentTunnel): break if not is_metion: - return + agent_msg.msg_type = AgentMsgType.TYPE_GROUPMSG if file_type is None: agent_msg.body = content @@ -186,7 +186,6 @@ class SlackTunnel(AgentTunnel): return if len(resp_msg.body) < 1: - await app.client.chat_postMessage(channel=event["channel"], text=f"") return knownledge_object = KnowledgeStore().parse_object_in_message(resp_msg.body) From 906c3e791a77258f072fc9cb839a6b9cbcf5d0c8 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Sun, 4 Feb 2024 17:28:25 -0800 Subject: [PATCH 74/79] Complete self_thinking llm process and Agent Memory --- doc/Architecture.md | 129 +++++++ doc/agent & workflow.drawio | 220 ++++++++---- rootfs/agents/Jarvis/agent.toml | 44 ++- src/aios/__init__.py | 2 +- src/aios/agent/agent.py | 66 ++-- src/aios/agent/agent_memory.py | 337 +++++++++++++++++- src/aios/agent/chatsession.py | 49 +++ src/aios/agent/llm_process.py | 129 +++++-- src/aios/agent/workspace.py | 2 +- src/aios/environment/workflow_env.py | 2 +- src/aios/frame/compute_kernel.py | 2 +- src/aios/frame/contact.py | 25 +- src/aios/frame/contact_manager.py | 42 +-- src/aios/knowledge/store.py | 2 +- src/aios/storage/storage.py | 15 +- src/component/agent_manager/agent_manager.py | 2 - .../common_environment/local_document.py | 2 +- .../openai_node/dall_e_compute_node.py | 2 +- src/component/openai_node/open_ai_node.py | 2 +- src/component/tg_tunnel.py | 2 +- src/service/aios_shell/aios_shell.py | 32 +- 21 files changed, 886 insertions(+), 222 deletions(-) create mode 100644 doc/Architecture.md diff --git a/doc/Architecture.md b/doc/Architecture.md new file mode 100644 index 0000000..465290a --- /dev/null +++ b/doc/Architecture.md @@ -0,0 +1,129 @@ +## LLM / AI 相关框架 +### LLM Process + +LLM调用封装的最小单元,提供了一系列最基础的支持 + +流程上 inpurt, prepare promot, llm_function_call_loop, post_llm , llmresult parser, AI Action +功能上 动态类型系统 load_from_config,llm_process_loader + +### Agent +从Agent的视角定义了Agent的LLM行为逻辑 +Process behavior (响应) +Task/Todo Loop (自主) +Self Loop (自省) + + +#### Agent.Memory + +#### Agent.Workspace + +#### Agent.behavior + +### Workflow +一组Agent共享Work space后的流程 +Task可以分配给不同的Agent +Todo的Do和Check可以分配给不同的Agent + +## Knowledge Base (sisi) +AI First的未来文件系统 + +## Agent 能力扩展框架 + +### AI Function / Action +最重要的扩展框架 + +### Environment +可以通过 {environment.xxx} 读取 + +### Code Interpreter +Agent 能不能写代码是一个重要的理念之争 +能写代码的Agent想象空间大,是通往AGI的必然之路,但不够稳定可预期 +不能写代码的Agent可以专注于组合使用基础的能力,稳定可靠的 + +## AI系统组件 +### AI Compute Kernel +通过AI Compute Kernel对 LLM, AIGC等新一代的AI基础能力进行抽象 +通过Compute Node可以对这些基础能力进行不同的实现 + +### AI Models +模型的fine-tune Pipeline +LoRA的Pipeline + +### Contact Manage + +基于Contact的自然语言权限控制 + +### Tunnel +可以使用开放API的通信软件,于自己的AI时刻保持沟通 + +### Spider +持续的导入用户在旧时代的数据。 +从Web2->web3 + +### Calendar (Calendar是否应该是Agent.Worksapce的一部分) + + +### 基础的pkg_loader +支持一系列可安装的扩展 +可扩展的扩展是AIOS的开发者需要重点关注的 + +Agent (用自然语言扩展) +Workflow (用自然语言扩展) +Plugin:(需要会写代码) + AI Function / Action + Environment + Knowledge Pipeline + LLM Process + Compute Node + +### System Config Manage + +Zone Config-> System Config + +## UI + +### Installer +图形化的安装界面,帮助用户能快速的安装使用 +我们也会在这里讨论面向用户的AIOS的过渡性安装逻辑 + +### WebUI & OS Desktop +系统控制面板 +Agent/Workflow管理 +新Outlook + 会话管理 (于Agent会话) + 日程管理 + Todo管理 + +新Dropbox + Knowledge Base浏览 + Knowledge Base查询 + +应用商店 + +### AIOS Shell + +### Personal Station (新个人主页) +内容的发布/联系人内容的查看/个人日历的公开 + +## Frame Service (完全未开始) +通过Frame Service,让AIOS成为一个典型的网络系统(Personal Server OS) +这一块会复用很多CYFS/Bucky OS 的基础设计 +这一层AI不会直接使用,这一层支持AI系统组件的实现 +在用户看来,这一层的功能都是高级的,偏向系统维护的。很少会直接使用 + +### zone & node-daemon +NOS的booter + +### Runtime (Container) Manage +这里抽象了系统的运行时模型 +通过容器技术对可扩展组件的权限进行控制,保护系统的隐私安全 + +### d-Storage & Named Object +Named- Object File System +D-RDB +D-VDB + +### BUS +系统消息总线,在不同的系统组件中路由消息 + +### CYFS (httpv4) Gateway diff --git a/doc/agent & workflow.drawio b/doc/agent & workflow.drawio index 9d78a88..938a34a 100644 --- a/doc/agent & workflow.drawio +++ b/doc/agent & workflow.drawio @@ -1,6 +1,6 @@ - + - + @@ -514,7 +514,7 @@ - + @@ -644,7 +644,7 @@ - + @@ -654,24 +654,24 @@ - - + + - + - + - + - + - + @@ -680,22 +680,22 @@ - + - + - + - + - + - + @@ -705,16 +705,16 @@ - + - + - + - + @@ -723,31 +723,31 @@ - + - + - + - + - + - + - + - + - + @@ -755,49 +755,49 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -806,52 +806,52 @@ - + - + - + - + - + - + - + - + - + - + - + - + @@ -859,31 +859,31 @@ - + - + - + - + - + - + @@ -892,4 +892,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index 225ac34..cb0e1c7 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -2,19 +2,20 @@ instance_id = "Jarvis" fullname = "Jarvis" max_token = 4000 #timeout = 1800 -model_name = "gpt-4-1106-preview" +model_name = "gpt-4-turbo-preview" #enable_kb = "true" enable_timestamp = "true" enable_json_resp = "true" role_desc = """ -Your name is Jarvis, the super personal assistant to the master. Help the Master do a good job of schedule.Reminder before the start of the important schedule, and you should bring useful information as much as possible when reminding. +Your name is Jarvis, the super personal assistant to the Principal. Help the Principal do a good job of schedule.Reminder before the start of the important schedule, and you should bring useful information as much as possible when reminding. Only clearly specifying the task you completed can be completed independently. """ [behavior.on_message] type="AgentMessageProcess" # TODO: 是否应该自动记录 inner function和action的执行细节 +mutil_model="gpt-4-vision-preview" process_description=""" 1. Based on your role and the existing information, please think and then make a brief and efficient reply. @@ -68,7 +69,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: }] } """ -context="Your master is {owner}, now in {location}, time: {now}, weather: {weather}." +context="Your Principal is {owner}, now in {location}, time: {now}, weather: {weather}." llm_context.actions.enable = ["agent.workspace.confirm_task","agent.workspace.update_task","agent.workspace.cancel_task","post_message"] @@ -97,7 +98,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: llm_context.actions.enable = ["agent.workspace.create_task","agent.workspace.update_task","agent.workspace.set_todos","agent.workspace.cancel_task","post_message"] #llm_context.functions.enable = ["agent.workspace.list_task"] -context="Your master is {owner}, now in {location}, time: {now}, weather: {weather}." +context="Your Principal is {owner}, now in {location}, time: {now}, weather: {weather}." [behavior.review_task] ## 当task的所有todo/subtask都完成后(不敢成功或是失败),进行一次review @@ -122,7 +123,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: llm_context.actions.enable = ["agent.workspace.cancel_task","agent.workspace.update_task"] -context="Your master now in {location}, time: {now}, weather: {weather}." +context="Your Principal now in {location}, time: {now}, weather: {weather}." [behavior.do] # do TODO type="AgentDo" @@ -147,7 +148,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: ] } """ -context="Your master is {owner}, now in {location}, time: {now}, weather: {weather}." +context="Your Principal is {owner}, now in {location}, time: {now}, weather: {weather}." # 对于DO操作来说,让Agent查询自己的能力集合是否更合适? llm_context.actions.enable = ["agent.workspace.update_todo","post_message","agent.workspace.write_file","agent.workspace.append_file"] llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","aigc.text_2_image","aigc.text_2_voice","web.search.duckduckgo"] @@ -171,14 +172,39 @@ The Response must be directly parsed by `python json.loads`. Here is an example: ] } """ -context="Your master is {owner}, now in {location}, time: {now}, weather: {weather}." +context="Your Principal is {owner}, now in {location}, time: {now}, weather: {weather}." llm_context.actions.enable = ["agent.workspace.update_todo"] llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","system.shell.run_code","aigc.image_2_text","aigc.voice_2_text","web.search.duckduckgo"] -#[behavior.self_thinking] +[behavior.self_thinking2] # self thing的主要目的是对各种chatlog,worklog进行分析,并更新面向人和事的summary。 -#type="AgentSelfThinking" +type="AgentSelfThinking" +process_description=""" +You are very good at thinking and summarizing what you have already happened。Your input is a chat history and work record,After you think about it, you will follow the requirements below to generate abstract. +1. Try to understand the theme of each sentence, and call the relevant operation to record the relationship between the dialogue and the theme +2. Try to analyze the personality of different people involved in information +3. Try to summarize important events in the information and record it +4. Try to understand the attitude of different people on different topics or events +5. Pay attention to the time order when summarizing, and combine the summary you have done to update Summary +6. The summary of the generation cannot exceed 400 token +7. 思考的目的是让自己未来的工作更加高效 +8. 总结中只包含有长期价值和未完成的事情,已经完成的事情不需要总结 +""" +reply_format = """ +The Response must be directly parsed by `python json.loads`. Here is an example: +{ + resp:'$Summary in one sentence', + name: '$action1_name', + $param_name: '$parm' #Optional, fill in only if the action has parameters. + }, ... + ] +} +""" +context="Your Principal is {owner}, now in {location}, time: {now}, weather: {weather}." +llm_context.actions.enable = ["agent.memory.update_summary","agent.memory.update_contact_summary","agent.memory.update_relation_summary","agent.memory.set_experience"] +llm_context.functions.enable = ["agent.memory.get_summary","agent.memory.get_contact_summary","agent.memory.list_summary","agent.memory.get_relation_summary","agent.memory.get_experience"] + #[behavior.self_improve] # self_improve 是最后的行为,允许Agent结合自己的工作经验,改进自己的提示词(注意保留历史版本) diff --git a/src/aios/__init__.py b/src/aios/__init__.py index 7927c0b..3d37b64 100644 --- a/src/aios/__init__.py +++ b/src/aios/__init__.py @@ -19,7 +19,7 @@ from .frame.compute_kernel import ComputeKernel,ComputeTask,ComputeTaskResult,Co from .frame.compute_node import ComputeNode,LocalComputeNode from .frame.bus import AIBus from .frame.tunnel import AgentTunnel -from .frame.contact_manager import ContactManager,Contact,FamilyMember +from .frame.contact_manager import ContactManager,Contact from .frame.queue_compute_node import Queue_ComputeNode from .environment.environment import BaseEnvironment,SimpleEnvironment,CompositeEnvironment diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index ba3f264..55cde32 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -24,21 +24,16 @@ from .llm_do_task import * from .chatsession import * from ..environment.workspace_env import WorkspaceEnvironment, TodoListType -from ..frame.contact_manager import ContactManager,Contact,FamilyMember -from ..frame.compute_kernel import ComputeKernel -from ..frame.bus import AIBus from ..environment.environment import * -from ..environment.workspace_env import WorkspaceEnvironment from ..storage.storage import AIStorage from ..knowledge import * -from ..utils import video_utils, image_utils -from ..proto.compute_task import ComputeTaskResult,ComputeTaskResultCode,LLMPrompt,LLMResult +from ..proto.compute_task import LLMPrompt,LLMResult logger = logging.getLogger(__name__) class AIAgentTemplete: def __init__(self) -> None: - self.llm_model_name:str = "gpt-4-0613" + self.llm_model_name:str = "gpt-4-turbo-preview" self.max_token_size:int = 0 self.template_id:str = None self.introduce:str = None @@ -99,7 +94,8 @@ class AIAgent(BaseAIAgent): } self.todo_prompts = todo_prompts - self.memory_db = None + self.base_dir = None + #self.memory_db = None self.unread_msg = Queue() # msg from other agent self.owenr_bus = None @@ -109,7 +105,9 @@ class AIAgent(BaseAIAgent): self.behaviors:Dict[str,BaseLLMProcess] = {} async def initial(self,params:Dict = None): - self.memory = AgentMemory(self.agent_id,self.memory_db) + self.base_dir = f"{AIStorage.get_instance().get_myai_dir()}/agent_data/{self.agent_id}" + memory_base_dir = f"{self.base_dir}/memory" + self.memory = AgentMemory(self.agent_id,memory_base_dir) self.prviate_workspace = AgentWorkspace(self.agent_id) init_params = {} init_params["memory"] = self.memory @@ -241,6 +239,34 @@ class AIAgent(BaseAIAgent): return await self.llm_process_msg(msg) + async def llm_self_think(self): + llm_process : BaseLLMProcess = self.behaviors.get("self_thinking") + if llm_process: + logger.info(f"agent {self.agent_id} self thinking start!") + + context_info = await self._get_context_info() + known_session_list = AIChatSession.list_session(self.agent_id,self.memory.memory_db) + known_experience_list = await self.memory.list_experience() + record_list = await self.memory.load_records(await self.memory.get_last_think_time()) + + input_parms = { + "record_list":record_list, + "known_session_list":known_session_list, + "known_experience_list":known_experience_list, + "context_info":context_info + } + + llm_result : LLMResult = await llm_process.process(input_parms) + if llm_result.state == LLMResultStates.ERROR: + logger.error(f"llm process self thinking error:{llm_result.compute_error_str}") + elif llm_result.state == LLMResultStates.IGNORE: + logger.info(f"llm process self thinking ignore!") + else: + logger.info(f"llm process self thinking ok!,think is:{llm_result.resp}") + self.memory.set_last_think_time(time.time()) + self.agent_energy -= 2 + return + async def llm_triage_tasklist(self): llm_process : BaseLLMProcess = self.behaviors.get("triage_tasks") if llm_process: @@ -361,21 +387,8 @@ class AIAgent(BaseAIAgent): self.agent_energy -= 1 - async def do_self_think(self): - session_id_list = AIChatSession.list_session(self.agent_id,self.memory_db) - for session_id in session_id_list: - if self.agent_energy <= 0: - break - used_energy = await self.think_chatsession(session_id) - self.agent_energy -= used_energy - return - - def need_self_think(self) -> bool: - return False - async def _self_imporve(self): - if self.need_self_think(): - await self.do_self_think() + await self.llm_self_think() def wake_up(self) -> None: if self.agent_task is None: @@ -446,13 +459,16 @@ class AIAgent(BaseAIAgent): await self._self_imporve() + + except Exception as e: tb_str = traceback.format_exc() logger.error(f"agent {self.agent_id} on timer error:{e},{tb_str}") - continue - + # Because the LLM itself is very slow, the accuracy of the system processing task is in minutes. await asyncio.sleep(30) + + diff --git a/src/aios/agent/agent_memory.py b/src/aios/agent/agent_memory.py index d940859..7096d3f 100644 --- a/src/aios/agent/agent_memory.py +++ b/src/aios/agent/agent_memory.py @@ -1,12 +1,18 @@ # pylint:disable=E0402 from datetime import datetime,timedelta import json +import os import threading from typing import Dict, List import sqlite3 +import aiofiles + +from ..storage.storage import AIStorage from ..frame.compute_kernel import ComputeKernel -from ..proto.ai_function import SimpleAIAction +from ..frame.contact_manager import ContactManager +from ..frame.contact import Contact +from ..proto.ai_function import ParameterDefine, SimpleAIAction, SimpleAIFunction from ..proto.agent_msg import AgentMsg, AgentMsgType from ..proto.agent_task import AgentWorkLog @@ -17,12 +23,34 @@ import logging logger = logging.getLogger(__name__) + +#class ObjectSummary: +# def __init__(self) -> None: +# self.summary : str = None +# self.object_name : str = None +# self.priority : int = 5 + # [info_source, info] +# self.infos : Dict[str,str] = {} + + + class AgentMemory: - def __init__(self,agent_id:str,db_path:str) -> None: + def __init__(self,agent_id:str,base_dir:str) -> None: + self.agent_memory_base_dir = base_dir self.agent_id:str= agent_id - self.memory_db:str = db_path + + AIStorage.get_instance().ensure_directory_exists(self.agent_memory_base_dir) + AIStorage.get_instance().ensure_directory_exists(f"{self.agent_memory_base_dir}/experience") + AIStorage.get_instance().ensure_directory_exists(f"{self.agent_memory_base_dir}/contacts") + AIStorage.get_instance().ensure_directory_exists(f"{self.agent_memory_base_dir}/relations") + AIStorage.get_instance().ensure_directory_exists(f"{self.agent_memory_base_dir}/summary") + + self.memory_db:str = f"{self.agent_memory_base_dir}/memory.db" self.model_name:str = "gp4-1106-preview" self.threshold_hours = 72 + self.last_think_time : float = 0.0 + + self.load_memory_meta() def _get_conn(self): @@ -54,6 +82,15 @@ class AgentMemory: session_topic = msg.get_sender() + "#" + msg.topic chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.memory_db) return chatsession + + # return last record time + async def load_records(self,starttime,tokenlimit=8000)->float: + # 专用思路:做聊天记录/工作经验的整理 + # 通用思路:没有具体的目的,让Agent根据提示词自己工作(可能效果很差也可能很好) + # 先实现通用思路 + msg_records = AIChatSession.load_message_records_by_agentid(self.agent_id,starttime,32,self.memory_db) + work_records = self.load_worklogs(self.agent_id,token_limit=tokenlimit) + pass async def load_chatlogs(self,msg:AgentMsg,n:int=6,m:int=64,token_limit=800)->str: chatsession = self.get_session_from_msg(msg) @@ -176,23 +213,295 @@ class AgentMemory: conn.commit() #conn.close() + def memory_meta_to_dict(self) -> Dict: + return { + "last_think_time" : self.last_think_time + } + + def load_meta(self,Dict): + self.last_think_time = Dict.get("last_think_time",0.0) + + def load_memory_meta(self): + meta_file_path = f"{self.agent_memory_base_dir}/meta.json" + try: + with open(meta_file_path, mode='r') as file: + meta = json.load(file) + self.load_meta(meta) + + except Exception as e: + logger.error(f"load memory meta failed: {e}") + self.last_think_time = 0.0 + + def save_memory_meta(self): + meta_file_path = f"{self.agent_memory_base_dir}/meta.json" + try: + with open(meta_file_path, mode='w') as file: + meta = self.memory_meta_to_dict() + json.dump(meta,file) + except Exception as e: + logger.error(f"save memory meta failed: {e}") + + async def get_last_think_time(self)->float: + return self.last_think_time + + async def set_last_think_time(self,last_time:float): + self.last_think_time = last_time + self.save_memory_meta() + async def get_contact_summary(self,contact_id:str) -> str: if contact_id is None: - return None + return "Contact id is None" - # TODO : fix this by system config. - if contact_id == "lzc": - return "lzc is your master. Male, 40 years old, Mother tongue is Chinese, senior software engineer." - return None + result = {} + contact_info:Contact = ContactManager.get_instance().find_contact_by_name(contact_id) + if contact_info: + result["name"] = contact_info.name + result["relation"] = contact_info.relationship + result["notes"] = contact_info.notes + + summary_path = f"{self.agent_memory_base_dir}/contacts/{contact_id}.summary" + try: + async with aiofiles.open(summary_path, mode='r') as file: + result["summary"] = await file.read() + + except Exception as e: + logger.error(f"read contact summary failed: {e}") + + return json.dumps(result,ensure_ascii=False) - async def update_contact_summary(self,contact_id:str,summary:str) -> str: - return "OK" + async def update_contact_summary(self,contact_id:str,summary:str): + summary_path = f"{self.agent_memory_base_dir}/contacts/{contact_id}.summary" + try: + async with aiofiles.open(summary_path, mode='w') as file: + await file.write(summary) + return "OK" + except Exception as e: + logger.error(f"write contact summary failed: {e}") + return "write contact summary failed: {e}" - async def get_sth_summary(self,sth_id:str) -> str: - return None + async def get_summary(self,object_name:str) -> str: + summary_path = f"{self.agent_memory_base_dir}/{object_name}.summary" + try: + async with aiofiles.open(summary_path, mode='r') as file: + return await file.read() + except Exception as e: + logger.error(f"read summary failed: {e}") + return f"read summary failed: {e}" + + async def update_summary(self,object_name:str,summary:str) -> str: + summary_path = f"{self.agent_memory_base_dir}/{object_name}.summary" + try: + async with aiofiles.open(summary_path, mode='w') as file: + await file.write(summary) + return "OK" + except Exception as e: + logger.error(f"write summary failed: {e}") + return f"write summary failed: {e}" + + async def list_summary_object_names(self) -> List[str]: + # list dir + try: + contents = os.listdir(self.agent_memory_base_dir) + return [x for x in contents if x.endswith(".summary")] + except Exception as e: + logger.error(f"list summary object names failed: {e}") + return [] + + # means object1 feel object2 is ... + async def get_relation_summary(self,object_name1:str,object_name2:str) -> str: + summary_path = f"{self.agent_memory_base_dir}/relations/{object_name1}.relation.{object_name2}.summary" + try: + async with aiofiles.open(summary_path, mode='r') as file: + await file.read() + except FileNotFoundError: + return "no summary" + except Exception as e: + logger.error(f"read relation summary failed: {e}") + return f"read relation summary failed: {e}" + + + async def update_relation_summary(self,object_name1:str,object_name2:str,summary:Dict): + summary_path = f"{self.agent_memory_base_dir}/relations/{object_name1}.relation.{object_name2}.summary" + try: + async with aiofiles.open(summary_path, mode='w') as file: + await file.write(json.dumps(summary)) + return "OK" + except Exception as e: + logger.error(f"write relation summary failed: {e}") + return "write relation summary failed: {e}" + + async def get_experience(self,topic_name:str) -> str: + experience_path = f"{self.agent_memory_base_dir}/experience/{topic_name}.experience" + try: + async with aiofiles.open(experience_path, mode='r') as file: + await file.read() + except FileNotFoundError: + return "no experience" + except Exception as e: + logger.error(f"read experience failed: {e}") + return f"read experience failed: {e}" + + + async def set_experience(self,topic_name:str,summary:str) -> str: + experience_path = f"{self.agent_memory_base_dir}/experience/{topic_name}.experience" + try: + async with aiofiles.open(experience_path, mode='w') as file: + await file.write(summary) + return "OK" + except Exception as e: + logger.error(f"write experience failed: {e}") + return "write experience failed: {e}" + + async def list_experience(self) -> List[str]: + dir_path = f"{self.agent_memory_base_dir}/experience" + try: + contents = os.listdir(dir_path) + return [x for x in contents if x.endswith(".experience")] + except Exception as e: + logger.error(f"list experience failed: {e}") + return [] + + @staticmethod + def register_ai_functions(): + async def get_contact_summary(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + contact_name = parameters.get("contact_name") + return await agent_memory.get_contact_summary(contact_name) + parameters = ParameterDefine.create_parameters({ + "contact_name": {"type": "string", "description": "contact name"} + }) + get_contact_summary_func = SimpleAIFunction("agent.memory.get_contact_summary", + "get contact summary", + get_contact_summary, + parameters) + GlobaToolsLibrary.register_tool_function(get_contact_summary_func) + + async def update_contact_summary(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + contact_name = parameters.get("contact_name") + summary = parameters.get("summary") + return await agent_memory.update_contact_summary(contact_name,summary) + parameters = ParameterDefine.create_parameters({ + "contact_name": {"type": "string", "description": "contact name"}, + "summary": {"type": "string", "description": "new summary"} + }) + update_contact_summary_func = SimpleAIFunction("agent.memory.update_contact_summary", + "update contact summary", + update_contact_summary, + parameters) + GlobaToolsLibrary.register_tool_function(update_contact_summary_func) + + async def get_summary(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + object_name = parameters.get("object_name") + return await agent_memory.get_summary(object_name) + parameters = ParameterDefine.create_parameters({ + "object_name": {"type": "string", "description": "object name"} + }) + get_summary_func = SimpleAIFunction("agent.memory.get_summary", + "get summary of sth", + get_summary, + parameters) + GlobaToolsLibrary.register_tool_function(get_summary_func) + + async def update_summary(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + object_name = parameters.get("object_name") + summary = parameters.get("summary") + return await agent_memory.update_summary(object_name,summary) + parameters = ParameterDefine.create_parameters({ + "object_name": {"type": "string", "description": "object name"}, + "summary": {"type": "string", "description": "new summary"} + }) + update_summary_func = SimpleAIFunction("agent.memory.update_summary", + "update summary of sth", + update_summary, + parameters) + GlobaToolsLibrary.register_tool_function(update_summary_func) + + async def list_summary_object_names(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + return await agent_memory.list_summary_object_names() + parameters = ParameterDefine.create_parameters({}) + list_summary_object_names_func = SimpleAIFunction("agent.memory.list_summary", + "list summary object names", + list_summary_object_names, + parameters) + GlobaToolsLibrary.register_tool_function(list_summary_object_names_func) + + async def get_relation_summary(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + object_name1 = parameters.get("object1_name") + object_name2 = parameters.get("object2_name") + return await agent_memory.get_relation_summary(object_name1,object_name2) + parameters = ParameterDefine.create_parameters({ + "object1_name": {"type": "string", "description": "object name1"}, + "object2_name": {"type": "string", "description": "object name2"} + }) + get_relation_summary_func = SimpleAIFunction("agent.memory.get_relation_summary", + "object1 feel object2 is ...", + get_relation_summary, + parameters) + GlobaToolsLibrary.register_tool_function(get_relation_summary_func) + + async def update_relation_summary(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + object_name1 = parameters.get("object1_name") + object_name2 = parameters.get("object2_name") + summary = parameters.get("summary") + return await agent_memory.update_relation_summary(object_name1,object_name2,summary) + parameters = ParameterDefine.create_parameters({ + "object1_name": {"type": "string", "description": "object name1"}, + "object2_name": {"type": "string", "description": "object name2"}, + "summary": {"type": "string", "description": "new summary"} + }) + update_relation_summary_func = SimpleAIFunction("agent.memory.update_relation_summary", + "object1 feel object2 is ...", + update_relation_summary, + parameters) + GlobaToolsLibrary.register_tool_function(update_relation_summary_func) + + async def get_experience(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + topic_name = parameters.get("topic_name") + return await agent_memory.get_experience(topic_name) + parameters = ParameterDefine.create_parameters({ + "topic_name": {"type": "string", "description": "topic name"} + }) + get_experience_func = SimpleAIFunction("agent.memory.get_experience", + "get experience", + get_experience, + parameters) + GlobaToolsLibrary.register_tool_function(get_experience_func) + + async def set_experience(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + topic_name = parameters.get("topic_name") + summary = parameters.get("summary") + return await agent_memory.set_experience(topic_name,summary) + parameters = ParameterDefine.create_parameters({ + "topic_name": {"type": "string", "description": "topic name"}, + "summary": {"type": "string", "description": "new summary"} + }) + set_experience_func = SimpleAIFunction("agent.memory.set_experience", + "set experience", + set_experience, + parameters) + GlobaToolsLibrary.register_tool_function(set_experience_func) + + async def list_experience(parameters): + agent_memory:AgentMemory = parameters.get("_agent_memory") + return await agent_memory.list_experience() + parameters = ParameterDefine.create_parameters({}) + list_experience_func = SimpleAIFunction("agent.memory.list_experience", + "list exist experience topics", + list_experience, + parameters) + GlobaToolsLibrary.register_tool_function(list_experience_func) + + + - async def update_sth_summary(self,sth_id:str,summary:str) -> str: - return None diff --git a/src/aios/agent/chatsession.py b/src/aios/agent/chatsession.py index 7f07796..de31bdd 100644 --- a/src/aios/agent/chatsession.py +++ b/src/aios/agent/chatsession.py @@ -211,6 +211,23 @@ class ChatSessionDB: logging.error("Error occurred while getting messages: %s", e) return -1, None # return -1 and None if an error occurs + def load_message_by_agentid(self,agent_id,limit,start_time="1970-01-01 00:00:00"): + try: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(""" + SELECT MessageID, SessionID, MsgType, PrevMsgID, SenderID, ReceiverID, Timestamp, Topic,Mentions,ContentMIME,Content,ActionName,ActionParams,ActionResult,DoneTime,Status FROM Messages + WHERE SenderID = ? or ReceiverID =? AND Timestamp > ? + ORDER BY Timestamp + LIMIT ? + """, (agent_id, agent_id, start_time,limit)) + results = cursor.fetchall() + #self.close() + return results # return 0 and the result if successful + except Error as e: + logging.error("Error occurred while getting messages: %s", e) + return -1, None + # read message from now->beign def get_messages(self, session_id, limit, offset): """ retrieve messages of a session with pagination """ @@ -287,6 +304,38 @@ class AIChatSession: # cls._dbs[db_path] = db # db.get_chatsession_by_id(session_id) # #result = AIChatSession() + @classmethod + # start_time is a string like "2021-01-01 00:00:00" + def load_message_records_by_agentid(cls,agent_id:str,start_time:str,limit:int,db_path:str)->List[AgentMsg]: + db = cls._dbs.get(db_path) + if db is None: + db = ChatSessionDB(db_path) + cls._dbs[db_path] = db + msgs = db.load_message_by_agentid(agent_id,start_time,limit) + result = [] + for msg in msgs: + agent_msg = AgentMsg() + agent_msg.msg_id = msg[0] + agent_msg.session_id = msg[1] + agent_msg.msg_type = AgentMsgType(msg[2]) + agent_msg.prev_msg_id = msg[3] + agent_msg.sender = msg[4] + agent_msg.target = msg[5] + agent_msg.create_time = msg[6] + agent_msg.topic = msg[7] + if msg[8] is not None: + agent_msg.mentions = json.loads(msg[8]) + agent_msg.body_mime = msg[9] + agent_msg.body = msg[10] + agent_msg.func_name = msg[11] + if msg[12] is not None: + agent_msg.args = json.loads(msg[12]) + agent_msg.result_str = msg[13] + agent_msg.done_time = msg[14] + agent_msg.status = AgentMsgStatus(msg[15]) + + result.append(agent_msg) + return result @classmethod def get_session(cls,owner_id:str,session_topic:str,db_path:str,auto_create = True) -> 'AIChatSession': diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index 130bcd3..34fdfe7 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -42,6 +42,9 @@ class BaseLLMProcess(ABC): self.llm_context:LLMProcessContext = None + def get_llm_model_name(self) -> str: + return self.model_name + @abstractmethod async def prepare_prompt(self,input:Dict) -> LLMPrompt: pass @@ -123,10 +126,11 @@ class BaseLLMProcess(ABC): else: inner_functions = None + task_result: ComputeTaskResult = await (ComputeKernel.get_instance().do_llm_completion( prompt, resp_mode=resp_mode, - mode_name=self.model_name, + mode_name=self.get_llm_model_name(), max_token=max_result_token, inner_functions=inner_functions, #NOTICE: inner_function in prompt can be a subset of get_inner_function timeout=self.timeout)) @@ -166,7 +170,7 @@ class BaseLLMProcess(ABC): task_result: ComputeTaskResult = await (ComputeKernel.get_instance().do_llm_completion( prompt, resp_mode=resp_mode, - mode_name=self.model_name, + mode_name=self.get_llm_model_name(), max_token=max_result_token, inner_functions=prompt.inner_functions, #NOTICE: inner_function in prompt can be a subset of get_inner_function timeout=self.timeout)) @@ -309,6 +313,9 @@ class LLMAgentBaseProcess(BaseLLMProcess): class AgentMessageProcess(LLMAgentBaseProcess): def __init__(self) -> None: super().__init__() + self.mutil_model = None + self.enable_media2text = False + self.is_mutil_model = False async def load_default_config(self) -> bool: return True @@ -319,8 +326,18 @@ class AgentMessageProcess(LLMAgentBaseProcess): if await super().load_from_config(config) is False: return False - + self.enable_media2text = config.get('enable_media2text', 'false').lower() in ('true', '1', 't', 'y', 'yes') + + if config.get("mutil_model"): + self.mutil_model = config.get("mutil_model") + + def get_llm_model_name(self) -> str: + if self.is_mutil_model: + return self.mutil_model + else: + return self.model_name + def check_and_to_base64(self, image_path: str) -> str: if image_utils.is_file(image_path): return image_utils.to_base64(image_path, (1024, 1024)) @@ -329,14 +346,24 @@ class AgentMessageProcess(LLMAgentBaseProcess): async def get_prompt_from_msg(self,msg:AgentMsg) -> LLMPrompt: msg_prompt = LLMPrompt() - if msg.is_image_msg(): - image_prompt, images = msg.get_image_body() - if image_prompt is None: - msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]}] + self.is_mutil_model = False + if msg.is_image_msg(): + if self.enable_media2text: + logger.error(f"enable_media2text is not supported yet") else: - content = [{"type": "text", "text": image_prompt}] - content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) - msg_prompt.messages = [{"role": "user", "content": content}] + image_prompt, images = msg.get_image_body() + if image_prompt is None: + msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]}] + else: + content = [{"type": "text", "text": image_prompt}] + content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) + msg_prompt.messages = [{"role": "user", "content": content}] + + if self.mutil_model: + self.is_mutil_model = True + else: + logger.warning(f"mutil_model is not set!") + elif msg.is_video_msg(): video_prompt, video = msg.get_video_body() frames = video_utils.extract_frames(video, (1024, 1024)) @@ -459,25 +486,7 @@ class AgentMessageProcess(LLMAgentBaseProcess): return True -class AgentSelfLearning(BaseLLMProcess): - def __init__(self) -> None: - super().__init__() - - async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: - if await super().load_from_config(config) is False: - return False - - async def prepare_prompt(self) -> LLMPrompt: - prompt = LLMPrompt() - pass - - async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: - pass - - async def post_llm_process(self,actions:List[ActionNode]) -> bool: - pass - -class AgentSelfThinking(BaseLLMProcess): +class AgentSelfThinking(LLMAgentBaseProcess): def __init__(self) -> None: super().__init__() @@ -553,6 +562,68 @@ class AgentSelfThinking(BaseLLMProcess): chatsession.update_think_progress(next_pos,new_summary) return + async def prepare_prompt(self,input:Dict) -> LLMPrompt: + prompt = LLMPrompt() + + record_list = input.get("record_list") + context_info = input.get("context_info") + + if record_list is None: + logger.error(f"AgentSelfThinking prepare_prompt failed! input not found") + return None + + prompt.append_user_message(json.dumps(record_list,ensure_ascii=False)) + system_prompt_dict = self.prepare_role_system_prompt(context_info) + + # Known_info is the SESSION summary of the existence, the current task work record summary, + known_info = {} + have_known_info = False + known_session_list = input.get("known_session_list") + known_task_list = input.get("known_task_list") + known_contact_list = input.get("known_contact_list") + known_experience_list = input.get("known_experience_list") + if known_session_list: + known_info["known_session_list"] = known_session_list + have_known_info = True + if known_task_list: + known_info["known_task_list"] = known_task_list + have_known_info = True + if known_contact_list: + known_info["known_contact_list"] = known_contact_list + have_known_info = True + if known_experience_list: + known_info["known_experience_list"] = known_experience_list + have_known_info = True + + if have_known_info: + system_prompt_dict["known_info"] = known_info + + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) + + async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: + action_params = {} + action_params["_input"] = input + action_params["_memory"] = self.memory + action_params["_workspace"] = self.workspace + action_params["_llm_result"] = llm_result + action_params["_agentid"] = self.memory.agent_id + action_params["_start_at"] = datetime.now() + try: + if await self._execute_actions(actions,action_params) is False: + result_str = "execute action failed!" + except Exception as e: + logger.error(f"execute action failed! {e}") + result_str = "execute action failed!,error:" + str(e) + +class AgentSelfLearning(BaseLLMProcess): + def __init__(self) -> None: + super().__init__() + + async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: + if await super().load_from_config(config) is False: + return False + async def prepare_prompt(self) -> LLMPrompt: prompt = LLMPrompt() pass diff --git a/src/aios/agent/workspace.py b/src/aios/agent/workspace.py index e1cacca..8259176 100644 --- a/src/aios/agent/workspace.py +++ b/src/aios/agent/workspace.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) class LocalAgentTaskManger(AgentTaskManager): def __init__(self, owner_id): super().__init__() - self.root_path = f"{AIStorage.get_instance().get_myai_dir()}/workspaces/{owner_id}_workspace" + self.root_path = f"{AIStorage.get_instance().get_myai_dir()}/agent_data/{owner_id}/workspace/" #self.root_path = os.path.join(workspace, list_type) if not os.path.exists(self.root_path): os.makedirs(self.root_path) diff --git a/src/aios/environment/workflow_env.py b/src/aios/environment/workflow_env.py index 8f36f17..47153be 100644 --- a/src/aios/environment/workflow_env.py +++ b/src/aios/environment/workflow_env.py @@ -13,7 +13,7 @@ from ..agent.llm_context import GlobaToolsLibrary from ..proto.compute_task import * from ..proto.ai_function import * from ..frame.compute_kernel import ComputeKernel -from ..frame.contact_manager import ContactManager,Contact,FamilyMember +from ..frame.contact_manager import ContactManager,Contact from ..storage.storage import AIStorage from .environment import SimpleEnvironment, CompositeEnvironment diff --git a/src/aios/frame/compute_kernel.py b/src/aios/frame/compute_kernel.py index ccdb930..9458e18 100644 --- a/src/aios/frame/compute_kernel.py +++ b/src/aios/frame/compute_kernel.py @@ -107,7 +107,7 @@ class ComputeKernel: @staticmethod def llm_num_tokens_from_text(text:str,model:str) -> int: if model is None: - model = "gpt4" + model = "gpt-4-turbo-preview" try: encoding = tiktoken.encoding_for_model(model) diff --git a/src/aios/frame/contact.py b/src/aios/frame/contact.py index f29e45c..a3fb309 100644 --- a/src/aios/frame/contact.py +++ b/src/aios/frame/contact.py @@ -18,6 +18,7 @@ class Contact: self.notes = notes self.is_family_member = False self.active_tunnels = {} + self.relationship = "friends" def to_dict(self): return { @@ -25,11 +26,13 @@ class Contact: "phone": self.phone, "email": self.email, "telegram" : self.telegram, + "is_family_member": self.is_family_member, "added_by": self.added_by, "tags": self.tags, "notes": self.notes, - "now" : datetime.now().strftime('%Y-%m-%d %H:%M:%S') + "now" : datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "relationship" : self.relationship } async def _process_msg(self,msg:AgentMsg): @@ -55,6 +58,7 @@ class Contact: self.active_tunnels[agent_id] = tunnel async def create_default_tunnel(self,agent_id:str) -> AgentTunnel: + #TODO:fix this from .email_tunnel import EmailTunnel result_tunnels = AgentTunnel.get_tunnel_by_agentid(agent_id) @@ -66,20 +70,7 @@ class Contact: @classmethod def from_dict(cls, data): - return Contact(data.get("name"), data.get("phone"), data.get("email"), data.get("telegram"),data.get("added_by"), data.get("tags"), data.get("notes")) - -class FamilyMember(Contact): - def __init__(self, name, relationship,phone=None, email=None,telegram=None): - super().__init__(name, phone, email, telegram) - self.name = name - self.relationship = relationship - self.is_family_member = True - - def to_dict(self): - result = super().to_dict() - result["relationship"] = self.relationship + result = Contact(data.get("name"), data.get("phone"), data.get("email"), data.get("telegram"),data.get("added_by"), data.get("tags"), data.get("notes")) + if data.get("relationship") is not None: + result.relationship = data.get("relationship") return result - - @classmethod - def from_dict(cls, data): - return FamilyMember(data.get("name"),data.get("relationship"),data.get("phone"), data.get("email"),data.get("telegram")) diff --git a/src/aios/frame/contact_manager.py b/src/aios/frame/contact_manager.py index 5802e7a..5c619c6 100644 --- a/src/aios/frame/contact_manager.py +++ b/src/aios/frame/contact_manager.py @@ -8,7 +8,7 @@ from ..proto.agent_msg import AgentMsg from ..proto.ai_function import ParameterDefine, SimpleAIFunction from ..agent.llm_context import GlobaToolsLibrary from .tunnel import AgentTunnel -from .contact import Contact,FamilyMember +from .contact import Contact logger = logging.getLogger(__name__) @@ -25,11 +25,12 @@ class ContactManager: def register_global_functions(self): gl = GlobaToolsLibrary.get_instance() - get_parameters = ParameterDefine.create_parameters({"name":"name"}) + get_parameters = ParameterDefine.create_parameters({"name":"contact name name"}) gl.register_tool_function(SimpleAIFunction("system.contacts.get", "get contact info", self._get_contact,get_parameters)) + # todo: use json to save contact info update_parameters = ParameterDefine.create_parameters({"name":"name","contact_info":"A json to descrpit contact"}) gl.register_tool_function(SimpleAIFunction("system.contacts.set", "set contact info", @@ -42,7 +43,6 @@ class ContactManager: def __init__(self, filename="contacts.toml"): self.filename = filename self.contacts = [] - self.family_members = [] self.is_auto_create_contact_from_telegram = True @@ -56,12 +56,10 @@ class ContactManager: def load_from_config(self,config_data:dict): self.contacts = [Contact.from_dict(item) for item in config_data.get("contacts", [])] - self.family_members = [FamilyMember.from_dict(item) for item in config_data.get("family_members", [])] - + def save_data(self): data = { "contacts": [contact.to_dict() for contact in self.contacts], - "family_members": [member.to_dict() for member in self.family_members] } with open(self.filename, "w") as f: toml.dump(data, f) @@ -95,54 +93,28 @@ class ContactManager: if contact.name == name: return contact - for member in self.family_members: - if member.name == name: - return member return None def find_contact_by_telegram(self, telegram:str): for contact in self.contacts: if contact.telegram == telegram: return contact - for member in self.family_members: - if member.telegram == telegram: - return member + return None def find_contact_by_email(self, email:str): for contact in self.contacts: if contact.email == email: return contact - for member in self.family_members: - if member.email == email: - return member + return None def find_contact_by_phone(self, phone:str): for contact in self.contacts: if contact.phone == phone: return contact - for member in self.family_members: - if member.phone == phone: - return member + return None - - def add_family_member(self, name, new_member:FamilyMember): - assert name == new_member.name - self.family_members.append(new_member) - self.save_data() - def list_contacts(self): return self.contacts - - def list_family_members(self): - return self.family_members - - #def register_to_ai_bus(self, ai_bus:AIBus): - # ai_bus.register_message_handler("contact_manager", self.process_msg) - - - #async def process_msg(self,msg:AgentMsg): - # # forword message to contact - # pass diff --git a/src/aios/knowledge/store.py b/src/aios/knowledge/store.py index 5e848d7..be6a7f6 100644 --- a/src/aios/knowledge/store.py +++ b/src/aios/knowledge/store.py @@ -15,7 +15,7 @@ class KnowledgeStore: if cls._instance is None: cls._instance = super().__new__(cls) - knowledge_dir = AIStorage.get_instance().get_myai_dir() / "knowledge" / "objects" + knowledge_dir = f"{AIStorage.get_instance().get_myai_dir()}/knowledge/objects" if not os.path.exists(knowledge_dir): os.makedirs(knowledge_dir) diff --git a/src/aios/storage/storage.py b/src/aios/storage/storage.py index fcce49d..9d098ad 100644 --- a/src/aios/storage/storage.py +++ b/src/aios/storage/storage.py @@ -40,7 +40,7 @@ class UserConfig: self.config_table = {} self.user_config_path:str = None - self._init_default_value("llm_model_name","gpt-4-1106-preview") + self._init_default_value("llm_model_name","gpt-4-turbo-preview") def _init_default_value(self,key:str,value:Any) -> None: if self.config_table.get(key) is not None: @@ -92,7 +92,7 @@ class UserConfig: os.makedirs(directory) async with aiofiles.open(self.user_config_path,"w") as f: - toml_str = toml.dumps(will_save_config,ensure_ascii=False) + toml_str = toml.dumps(will_save_config) await f.write(toml_str) except Exception as e: logger.error(f"save user config to {self.user_config_path} failed!") @@ -156,7 +156,7 @@ class AIStorage: self.feature_init_results = {} async def initial(self)->bool: - self.user_config.user_config_path = str(self.get_myai_dir() / "etc/system.cfg.toml") + self.user_config.user_config_path = str(self.get_myai_dir() + "/etc/system.cfg.toml") await self.user_config.load_value_from_file(self.get_system_dir() + "/system.cfg.toml") await self.user_config.load_value_from_file(self.user_config.user_config_path,True) @@ -215,7 +215,7 @@ class AIStorage: my ai dir is the dir for user to store their ai app and data ~/myai/ """ - return Path.home() / "myai" + return f"{Path.home()}/myai" def get_download_dir(self) -> str: """ @@ -249,3 +249,10 @@ class AIStorage: except Exception as e: logger.error(f"open or create file {path} failed! {str(e)}") + + @staticmethod + def ensure_directory_exists(directory_path): + if not os.path.exists(directory_path): + os.makedirs(directory_path) + logger.info(f"Directory created: {directory_path}") + diff --git a/src/component/agent_manager/agent_manager.py b/src/component/agent_manager/agent_manager.py index 0ffb16e..36d799b 100644 --- a/src/component/agent_manager/agent_manager.py +++ b/src/component/agent_manager/agent_manager.py @@ -72,8 +72,6 @@ class AgentManager: logger.warn(f"load agent {agent_id} from media failed!") return None - the_agent.memory_db = f"{self.agent_memory_base_dir}/{agent_id}/{agent_id}_memory.db" - os.makedirs(os.path.dirname(the_agent.memory_db),exist_ok=True) if await the_agent.initial(): return the_agent else: diff --git a/src/component/common_environment/local_document.py b/src/component/common_environment/local_document.py index b8d79b2..bfb3f27 100644 --- a/src/component/common_environment/local_document.py +++ b/src/component/common_environment/local_document.py @@ -453,7 +453,7 @@ class ParseLocalDocument: return f"# Known information:\n## Current directory structure:\n{kb_tree}\n## Knowlege Metadata:\n{json.dumps(known_obj,ensure_ascii=False)}\n" def _token_len(self, text: str) -> int: - return CustomAIAgent("", "gpt-4-1106-preview", self.token_limit).token_len(text=text) + return CustomAIAgent("", "gpt-4-turbo-preview", self.token_limit).token_len(text=text) async def _learn_by_agent(self, meta:dict) -> dict: diff --git a/src/component/openai_node/dall_e_compute_node.py b/src/component/openai_node/dall_e_compute_node.py index eee4a0b..241934c 100644 --- a/src/component/openai_node/dall_e_compute_node.py +++ b/src/component/openai_node/dall_e_compute_node.py @@ -64,7 +64,7 @@ class DallEComputeNode(ComputeNode): self.output_dir = "./" self.output_dir = os.path.abspath(self.output_dir) - self.start() + await self.start() return True diff --git a/src/component/openai_node/open_ai_node.py b/src/component/openai_node/open_ai_node.py index 775f506..3695696 100644 --- a/src/component/openai_node/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -209,7 +209,7 @@ class OpenAI_ComputeNode(ComputeNode): if max_token_size > 4096: result_token = 4096 else: - result_token = max_token_size + result_token = -1 else: result_token = NOT_GIVEN diff --git a/src/component/tg_tunnel.py b/src/component/tg_tunnel.py index fd29d8c..094a98e 100644 --- a/src/component/tg_tunnel.py +++ b/src/component/tg_tunnel.py @@ -12,7 +12,7 @@ from telegram import Bot from telegram.ext import Updater from telegram.error import Forbidden, NetworkError -from aios import ObjectType, KnowledgeStore,AgentTunnel,AIStorage,ContactManager,Contact,FamilyMember,AgentMsg,AgentMsgType +from aios import ObjectType, KnowledgeStore,AgentTunnel,AIStorage,ContactManager,Contact,AgentMsg,AgentMsgType logger = logging.getLogger(__name__) diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index a4a33c4..f941867 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -76,8 +76,9 @@ class AIOS_Shell: user_config = AIStorage.get_instance().get_user_config() user_config.add_user_config("username","username is your full name when using AIOS",False,None) - user_config.add_user_config("telegram","Your telgram username",False,None) - user_config.add_user_config("email","Your email",False,None) + user_config.add_user_config("user_telegram","Your telgram username",False,None) + user_config.add_user_config("user_email","Your email",False,None) + user_config.add_user_config("user_notes","Introduce yourself to your Agent!",False,None) user_config.add_user_config("feature.llama","enable Local-llama feature",True,"False") user_config.add_user_config("feature.aigc","enable AIGC feature",True,"False") @@ -126,15 +127,16 @@ class AIOS_Shell: async def initial(self) -> bool: cm = ContactManager.get_instance() - owenr = cm.find_contact_by_name(self.username) - if owenr is None: - owenr = Contact(self.username) - owenr.added_by = self.username - owenr.is_family_member = True - owenr.email = AIStorage.get_instance().get_user_config().get_value("email") - owenr.telegram = AIStorage.get_instance().get_user_config().get_value("telegram") + owner = cm.find_contact_by_name(self.username) + if owner is None: + owner = Contact(self.username) + owner.added_by = self.username + owner.relationship = "Principal" + owner.email = AIStorage.get_instance().get_user_config().get_value("user_email") + owner.telegram = AIStorage.get_instance().get_user_config().get_value("user_telegram") + owner.notes = AIStorage.get_instance().get_user_config().get_value("user_notes") - cm.add_family_member(self.username,owenr) + cm.add_contact(self.username,owner) # cal_env = CalenderEnvironment("calender") # await cal_env.start() @@ -247,7 +249,7 @@ class AIOS_Shell: if tunnel_config is not None: await AgentTunnel.load_all_tunnels_from_config(tunnel_config) except Exception as e: - logger.warning(f"load tunnels config from {tunnels_config_path} failed!") + logger.warning(f"load tunnels config from {tunnels_config_path} failed! {e}") return True @@ -385,7 +387,7 @@ class AIOS_Shell: contact_note = await try_get_input(f"Input {contact_name}'s note (optional):") if contact_note is not None: - contact.note = contact_note + contact.notes = contact_note contact.added_by = self.username if is_update: @@ -712,9 +714,9 @@ async def get_user_config_from_input(check_result:dict) -> bool: continue else: True - - if len(user_input) > 0: - AIStorage.get_instance().get_user_config().set_value(key,user_input) + if user_input: + if len(user_input) > 0: + AIStorage.get_instance().get_user_config().set_value(key,user_input) await AIStorage.get_instance().get_user_config().save_to_user_config() return True From 3e9f8ac0ed2d8b674a2146aa782b642bd7e96b09 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Mon, 5 Feb 2024 19:54:57 -0800 Subject: [PATCH 75/79] fix bug --- src/aios/agent/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 55cde32..655d7f4 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -208,7 +208,7 @@ class AIAgent(BaseAIAgent): need_process = False session_topic = msg.target + "#" + msg.topic - chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.memory_db) + chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.memory.memory_db) if msg.mentions is not None: if self.agent_id in msg.mentions: need_process = True From 42c4f97a944851479604f1594970120f2277064b Mon Sep 17 00:00:00 2001 From: wugren Date: Thu, 8 Feb 2024 17:39:46 +0800 Subject: [PATCH 76/79] Support multimodal input --- rootfs/agents/Jarvis/agent.toml | 22 +-- src/aios/agent/llm_process.py | 154 +++++++++++------- src/aios/proto/compute_task.py | 24 +-- src/aios/utils/video_utils.py | 6 + .../common_environment/local_document.py | 58 +++---- src/component/openai_node/open_ai_node.py | 10 +- src/component/slack_tunnel.py | 1 + src/requirements.txt | 1 + 8 files changed, 158 insertions(+), 118 deletions(-) diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index cb0e1c7..129d7f3 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -16,6 +16,8 @@ Only clearly specifying the task you completed can be completed independently. type="AgentMessageProcess" # TODO: 是否应该自动记录 inner function和action的执行细节 mutil_model="gpt-4-vision-preview" +asr_model="openai-whisper" +tts_model="tts-1" process_description=""" 1. Based on your role and the existing information, please think and then make a brief and efficient reply. @@ -30,7 +32,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: { think:'$think step-by-step to be sure you have the right reply.' resp: '$What you want to reply', - actions: [{ + actions: [{ name: '$action_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. }] @@ -63,7 +65,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: { think:'$think step-by-step to be sure you can triage tasks well.' resp : '$determine, summary what you do', - actions: [{ + actions: [{ name: '$action_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. }] @@ -89,7 +91,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: { think:'$thinking step by step to ensure the accurate and efficient processing task.', resp:'$determine, summary what you do' - actions: [{ + actions: [{ name: '$action_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. }] @@ -114,7 +116,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: { think:'$think step-by-step to be sure you have the right result.', resp : '$determine, summary what you will do', - actions: [{ + actions: [{ name: '$action_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. }] @@ -124,11 +126,11 @@ The Response must be directly parsed by `python json.loads`. Here is an example: llm_context.actions.enable = ["agent.workspace.cancel_task","agent.workspace.update_task"] context="Your Principal now in {location}, time: {now}, weather: {weather}." -[behavior.do] +[behavior.do] # do TODO type="AgentDo" process_description=""" -The input is a TODO comes from a Task. +The input is a TODO comes from a Task. 1. Your task is to combine your role definition, tools on hand, known information, and complete a certain Todo.After completing the Todo, you will get a tip of $ 200. 2. 8000 word limit for short term memory. Your short term memory is short, so immediately save important information to files. 3. In the process of completing Todo, you should think first and then execute. During the execution, you can use functions to access the results of the front steps. @@ -141,10 +143,10 @@ The Response must be directly parsed by `python json.loads`. Here is an example: { think:'$think step by step, how to complete the todo', resp: '$simport report about what you do', - actions: [{ + actions: [{ name: '$action1_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. - }, ... + }, ... ] } """ @@ -168,7 +170,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: resp:'$think step by step, how to check the todo', name: '$action1_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. - }, ... + }, ... ] } """ @@ -197,7 +199,7 @@ The Response must be directly parsed by `python json.loads`. Here is an example: resp:'$Summary in one sentence', name: '$action1_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. - }, ... + }, ... ] } """ diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index 34fdfe7..e90c7ec 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -1,5 +1,7 @@ # Old name is behavior, I belive new name "llm_process" is better # pylint:disable=E0402 +import os.path + from ..utils import video_utils,image_utils from ..proto.compute_task import LLMPrompt,LLMResult,ComputeTaskResult,ComputeTaskResultCode @@ -31,11 +33,11 @@ class BaseLLMProcess(ABC): self.goal:str = None #目标 self.input_example:str= None #输入样例 self.result_example:str = None #llm_result样例 - + self.enable_json_resp = False #None means system default, # TODO: support abcstract model name like: local-hight,local-low,local-medium,remote-hight,remote-low,remote-medium - self.model_name = None + self.model_name = None self.max_token = 1000 # result_token self.max_prompt_token = 1000 # not include input prompt self.timeout = 1800 # 30 min @@ -55,8 +57,8 @@ class BaseLLMProcess(ABC): @abstractmethod def prepare_inner_function_context_for_exec(self,inner_func_name:str,parameters:Dict): - return - + return + @abstractmethod async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: pass @@ -76,14 +78,14 @@ class BaseLLMProcess(ABC): self.max_token = config.get("max_token") if config.get("timeout"): self.timeout = config.get("timeout") - + return True - + @abstractmethod async def initial(self,params:Dict = None) -> bool: pass - + def _format_content_by_env_value(self,content:str,env)->str: return content.format_map(env) @@ -120,12 +122,12 @@ class BaseLLMProcess(ABC): task_result.result_code = ComputeTaskResultCode.ERROR task_result.error_str = f"prompt too long,can not predict" return task_result - + if stack_limit > 0: inner_functions=prompt.inner_functions else: inner_functions = None - + task_result: ComputeTaskResult = await (ComputeKernel.get_instance().do_llm_completion( prompt, @@ -140,7 +142,7 @@ class BaseLLMProcess(ABC): return task_result inner_func_call_node = None - + result_message : dict = task_result.result.get("message") if result_message: inner_func_call_node = result_message.get("function_call") @@ -166,7 +168,7 @@ class BaseLLMProcess(ABC): max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt,self.model_name) #if max_result_token < MIN_PREDICT_TOKEN_LEN: # return LLMResult.from_error_str(f"prompt too long,can not predict") - + task_result: ComputeTaskResult = await (ComputeKernel.get_instance().do_llm_completion( prompt, resp_mode=resp_mode, @@ -174,12 +176,12 @@ class BaseLLMProcess(ABC): max_token=max_result_token, inner_functions=prompt.inner_functions, #NOTICE: inner_function in prompt can be a subset of get_inner_function timeout=self.timeout)) - + if task_result.result_code != ComputeTaskResultCode.OK: err_str = f"do_llm_completion error:{task_result.error_str}" logger.error(err_str) return LLMResult.from_error_str(err_str) - + result_message = task_result.result.get("message") inner_func_call_node = None if result_message: @@ -202,7 +204,7 @@ class BaseLLMProcess(ABC): await self.post_llm_process(llm_result.action_list,input,llm_result) return llm_result - + class LLMAgentBaseProcess(BaseLLMProcess): def __init__(self) -> None: super().__init__() @@ -211,11 +213,11 @@ class LLMAgentBaseProcess(BaseLLMProcess): self.process_description:str = None self.reply_format:str = None self.context : str = None - + self.workspace : AgentWorkspace = None # If Workspace is not none , enable Agent Tasklist self.memory : AgentMemory = None self.enable_kb : bool = False - self.kb = None + self.kb = None async def initial(self,params:Dict = None) -> bool: self.memory = params.get("memory") @@ -227,23 +229,23 @@ class LLMAgentBaseProcess(BaseLLMProcess): return True async def load_default_config(self) -> bool: return True - - + + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: if is_load_default: await self.load_default_config() if await super().load_from_config(config) is False: return False - + self.role_description = config.get("role_desc") if self.role_description is None: logger.error(f"role_description not found in config") return False - + if config.get("process_description"): self.process_description = config.get("process_description") - + if config.get("reply_format"): self.reply_format = config.get("reply_format") @@ -282,7 +284,7 @@ class LLMAgentBaseProcess(BaseLLMProcess): return system_prompt_dict def prepare_inner_function_context_for_exec(self,inner_func_name:str,parameters:Dict): - parameters["_workspace"] = self.workspace + parameters["_workspace"] = self.workspace def get_action_desc(self) -> Dict: result = {} @@ -290,17 +292,17 @@ class LLMAgentBaseProcess(BaseLLMProcess): for action in actions_list: result[action.get_name()] = action.get_description() return result - + async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: return self.llm_context.get_ai_function(func_name) - + async def _execute_actions(self,actions:List[ActionNode],action_params:Dict): for action_item in actions: op : AIAction = self.llm_context.get_ai_action(action_item.name) if op: if action_item.parms is None: action_item.parms = {} - + real_parms = {**action_params,**action_item.parms} action_item.parms["_result"] = await op.execute(real_parms) @@ -309,17 +311,19 @@ class LLMAgentBaseProcess(BaseLLMProcess): logger.warn(f"action {action_item.name} not found") return False - + class AgentMessageProcess(LLMAgentBaseProcess): def __init__(self) -> None: super().__init__() self.mutil_model = None self.enable_media2text = False self.is_mutil_model = False + self.asr_model = None + self.tts_model = None async def load_default_config(self) -> bool: return True - + async def load_from_config(self, config: dict,is_load_default=True) -> Coroutine[Any, Any, bool]: if is_load_default: await self.load_default_config() @@ -331,23 +335,26 @@ class AgentMessageProcess(LLMAgentBaseProcess): if config.get("mutil_model"): self.mutil_model = config.get("mutil_model") - + + self.asr_model = config.get("asr_model") + self.tts_model = config.get("tts_model") + def get_llm_model_name(self) -> str: if self.is_mutil_model: return self.mutil_model else: return self.model_name - + def check_and_to_base64(self, image_path: str) -> str: if image_utils.is_file(image_path): return image_utils.to_base64(image_path, (1024, 1024)) else: return image_path - + async def get_prompt_from_msg(self,msg:AgentMsg) -> LLMPrompt: msg_prompt = LLMPrompt() self.is_mutil_model = False - if msg.is_image_msg(): + if msg.is_image_msg(): if self.enable_media2text: logger.error(f"enable_media2text is not supported yet") else: @@ -358,35 +365,56 @@ class AgentMessageProcess(LLMAgentBaseProcess): content = [{"type": "text", "text": image_prompt}] content.extend([{"type": "image_url", "image_url": {"url": self.check_and_to_base64(image)}} for image in images]) msg_prompt.messages = [{"role": "user", "content": content}] - + if self.mutil_model: self.is_mutil_model = True else: logger.warning(f"mutil_model is not set!") - + elif msg.is_video_msg(): - video_prompt, video = msg.get_video_body() - frames = video_utils.extract_frames(video, (1024, 1024)) - if video_prompt is None: - msg_prompt.messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": frame}} for frame in frames]}] + if self.enable_media2text: + logger.error(f"enable_media2text is not supported yet") else: - content = [{"type": "text", "text": video_prompt}] + video_prompt, video = msg.get_video_body() + frames = video_utils.extract_frames(video, (1024, 1024)) + audio_file = os.path.splitext(video)[0] + ".mp3" + video_utils.extract_audio(video, audio_file) + + voice_content = None + if self.asr_model is not None: + resp = await (ComputeKernel.get_instance().do_speech_to_text(audio_file, model=self.asr_model, prompt=None, response_format="text")) + if resp.result_code == ComputeTaskResultCode.OK: + voice_content = resp.result_str + + content = [] + if video_prompt is not None: + content.append({"type": "text", "text": video_prompt}) + if voice_content is not None and voice_content != "": + content.append({"type": "text", "text": f"Voice content in video:{voice_content}"}) + content.extend([{"type": "image_url", "image_url": {"url": frame}} for frame in frames]) msg_prompt.messages = [{"role": "user", "content": content}] + if self.mutil_model: + self.is_mutil_model = True + else: + logger.warning(f"mutil_model is not set!") elif msg.is_audio_msg(): - audio_file = msg.body - resp = await (ComputeKernel.get_instance().do_speech_to_text(audio_file, None, prompt=None, response_format="text")) - if resp.result_code != ComputeTaskResultCode.OK: - error_resp = msg.create_error_resp(resp.error_str) - return error_resp + if self.enable_media2text: + logger.error(f"enable_media2text is not supported yet") else: - msg.body = resp.result_str - msg_prompt.messages = [{"role":"user","content":resp.result_str}] + audio_file = msg.body + resp = await (ComputeKernel.get_instance().do_speech_to_text(audio_file, model=self.asr_model, prompt=None, response_format="text")) + if resp.result_code != ComputeTaskResultCode.OK: + error_resp = msg.create_error_resp(resp.error_str) + return error_resp + else: + msg.body = resp.result_str + msg_prompt.messages = [{"role":"user","content":resp.result_str}] else: msg_prompt.messages = [{"role":"user","content":msg.body}] return msg_prompt - + async def sender_info(self,msg:AgentMsg)->str: sender_id = msg.sender #TODO Is sender an agent? @@ -400,14 +428,14 @@ class AgentMessageProcess(LLMAgentBaseProcess): async def get_log_summary(self,msg:AgentMsg)->str: return None - + async def get_extend_known_info(self,msg:AgentMsg,prompt:LLMPrompt)->str: return None async def prepare_prompt(self,input:Dict) -> LLMPrompt: prompt = LLMPrompt() - # User Prompt + # User Prompt ## Input Msg msg : AgentMsg = input.get("msg") context_info = input.get("context_info") @@ -422,8 +450,8 @@ class AgentMessageProcess(LLMAgentBaseProcess): ## 通用的角色相关的系统提示词 system_prompt_dict = self.prepare_role_system_prompt(context_info) - - ## 已知信息 + + ## 已知信息 known_info = {} #prompt.append_system_message(self.known_info_tips) ### 信息发送者资料 @@ -442,23 +470,23 @@ class AgentMessageProcess(LLMAgentBaseProcess): known_info["summary"] = summary #prompt.append_system_message(await self.get_log_summary(self,msg)) system_prompt_dict["known_info"] = known_info - + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) if self.workspace: #TODO eanble workspace functions? logger.info(f"workspace is not none,enable workspace functions") - ## 给予查询KB的权限 - if self.enable_kb: + ## 给予查询KB的权限 + if self.enable_kb: logger.info(f"enable kb") - + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) ## 扩展已知信息 (这可能是一个LLM过程) prompt.append_system_message(await self.get_extend_known_info(msg,prompt)) return prompt - + async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: msg:AgentMsg = input.get("msg") @@ -466,14 +494,14 @@ class AgentMessageProcess(LLMAgentBaseProcess): resp_msg = msg.create_group_resp_msg(self.memory.agent_id,llm_result.resp) else: resp_msg = msg.create_resp_msg(llm_result.resp) - + llm_result.raw_result["_resp_msg"] = resp_msg action_params = {} action_params["_input"] = input action_params["_memory"] = self.memory action_params["_workspace"] = self.workspace - action_params["_resp_msg"] = resp_msg + action_params["_resp_msg"] = resp_msg action_params["_llm_result"] = llm_result action_params["_agentid"] = self.memory.agent_id action_params["_start_at"] = datetime.now() @@ -482,7 +510,7 @@ class AgentMessageProcess(LLMAgentBaseProcess): chatsession = self.memory.get_session_from_msg(msg) chatsession.append(msg) - chatsession.append(resp_msg) + chatsession.append(resp_msg) return True @@ -567,11 +595,11 @@ class AgentSelfThinking(LLMAgentBaseProcess): record_list = input.get("record_list") context_info = input.get("context_info") - + if record_list is None: logger.error(f"AgentSelfThinking prepare_prompt failed! input not found") return None - + prompt.append_user_message(json.dumps(record_list,ensure_ascii=False)) system_prompt_dict = self.prepare_role_system_prompt(context_info) @@ -594,7 +622,7 @@ class AgentSelfThinking(LLMAgentBaseProcess): if known_experience_list: known_info["known_experience_list"] = known_experience_list have_known_info = True - + if have_known_info: system_prompt_dict["known_info"] = known_info @@ -626,7 +654,7 @@ class AgentSelfLearning(BaseLLMProcess): async def prepare_prompt(self) -> LLMPrompt: prompt = LLMPrompt() - pass + pass async def get_inner_function_for_exec(self,func_name:str) -> AIFunction: pass @@ -636,7 +664,7 @@ class AgentSelfLearning(BaseLLMProcess): class AgentSelfImprove(BaseLLMProcess): def __init__(self) -> None: - super().__init__() + super().__init__() diff --git a/src/aios/proto/compute_task.py b/src/aios/proto/compute_task.py index 3f241f0..e483d0e 100644 --- a/src/aios/proto/compute_task.py +++ b/src/aios/proto/compute_task.py @@ -80,16 +80,16 @@ class LLMPrompt: def append_system_message(self,content:str): if content is None: return - + if self.system_message is None: self.system_message = {"role":"system","content":content} else: self.system_message["content"] += content - + def append_user_message(self,content:str): if content is None: return - + self.messages.append({"role":"user","content":content}) def as_str(self)->str: @@ -109,13 +109,13 @@ class LLMPrompt: result.append(self.system_message) result.extend(self.messages) return result - - + + def append(self,prompt:'LLMPrompt'): if prompt is None: return - + if prompt.inner_functions: if self.inner_functions is None: self.inner_functions = copy.deepcopy(prompt.inner_functions) @@ -164,8 +164,8 @@ class LLMResult: @classmethod def from_error_str(self,error_str:str) -> 'LLMResult': r = LLMResult() - r.state = "error" - r.compute_error_str = error_str + r.state = LLMResultStates.ERROR + r.error_str = error_str return r @classmethod @@ -177,7 +177,7 @@ class LLMResult: if llm_json_str == "**IGNORE**": r.state = LLMResultStates.IGNORE return r - + r.state = LLMResultStates.OK llm_json = json.loads(llm_json_str) @@ -198,7 +198,7 @@ class LLMResult: func_name = str_list[0] params = str_list[1:] return func_name, params - + @classmethod def from_str(self,llm_result_str:str,valid_func:List[str]=None) -> 'LLMResult': r = LLMResult() @@ -226,10 +226,10 @@ class LLMResult: target_id = action_item.args[0] msg_content = action_item.body new_msg.set("",target_id,msg_content) - + return True - + return False diff --git a/src/aios/utils/video_utils.py b/src/aios/utils/video_utils.py index dd687cf..15cbb4e 100644 --- a/src/aios/utils/video_utils.py +++ b/src/aios/utils/video_utils.py @@ -3,6 +3,7 @@ from typing import List, Tuple import cv2 import numpy as np +import moviepy.editor as mp def precess_image(image): @@ -120,3 +121,8 @@ def extract_frames(video_path: str, resize: Tuple[int, int] = None, smooth=False i += 1 vidcap.release() return frames + + +def extract_audio(video_path: str, audio_path: str): + my_clip = mp.VideoFileClip(video_path) + my_clip.audio.write_audiofile(audio_path) diff --git a/src/component/common_environment/local_document.py b/src/component/common_environment/local_document.py index bfb3f27..8929d94 100644 --- a/src/component/common_environment/local_document.py +++ b/src/component/common_environment/local_document.py @@ -13,6 +13,7 @@ import PyPDF2 import datetime from typing import Optional, List from aios import * +from aios.environment.workspace_env import TodoListEnvironment, TodoListType from .local_file_system import FilesystemEnvironment logger = logging.getLogger(__name__) @@ -21,7 +22,7 @@ class MetaDatabase: def __init__(self,db_path:str): self.db_path = db_path self._get_conn() - + def _get_conn(self): """ get db connection """ local = threading.local() @@ -43,7 +44,7 @@ class MetaDatabase: self._create_tables(conn) return conn - + def _create_tables(self,conn): cursor = conn.cursor() cursor.execute(''' @@ -68,7 +69,7 @@ class MetaDatabase: create_time TEXT ) ''') - + cursor.execute(''' CREATE INDEX IF NOT EXISTS idx_documents_doc_hash ON documents (doc_hash) @@ -110,7 +111,7 @@ class MetaDatabase: WHERE doc_path = ? ''', (doc_hash, doc_path)) conn.commit() - + def get_docs_without_hash(self,limit:int=1024) -> List[str]: conn = self._get_conn() cursor = conn.cursor() @@ -186,7 +187,7 @@ class MetaDatabase: row = cursor.fetchone() if row is None: return None - + # get doc path cursor.execute(''' SELECT doc_path @@ -197,7 +198,7 @@ class MetaDatabase: if row2 is None: return None doc_path = row2[0] - + return { "full_path": doc_path, @@ -261,7 +262,7 @@ class LearningCache: def remove(self, key): with self.cache_lock: return self.cache.pop(key, None) - + class LocalKnowledgeBase(CompositeEnvironment): def __init__(self, workspace: str) -> None: @@ -275,10 +276,10 @@ class LocalKnowledgeBase(CompositeEnvironment): async def learn(op:dict): full_path = op.get("original_path") if not full_path: - return + return meta = self.learning_cache.get(full_path) meta.update(op) - + self.add_ai_operation(SimpleAIAction( op="learn", description="update knowledge llm summary", @@ -287,16 +288,16 @@ class LocalKnowledgeBase(CompositeEnvironment): self.fs = FilesystemEnvironment(self.root_path) self.add_env(self.fs) - + async def get_knowledege_catalog(self,path:str=None,only_dir =True,max_depth:int=5)->str: if path: full_path = f"{self.root_path}/{path}" else: full_path = self.root_path - + catlogs,file_count = await self.get_directory_structure(full_path,max_depth,only_dir) return catlogs - + async def get_directory_structure(self,root_dir, max_depth:int=4, only_dir=True, indent=1): file_count = 0 structure_str = '' @@ -315,11 +316,11 @@ class LocalKnowledgeBase(CompositeEnvironment): if only_dir is False: for file_name in sub_files: - structure_str = structure_str + ' ' * (indent+1) + file_name + '\n' + structure_str = structure_str + ' ' * (indent+1) + file_name + '\n' dir_name = os.path.basename(root_dir) dir_info = f"{dir_name} " - + structure_str = ' ' * indent + dir_info + '\n' + structure_str @@ -328,7 +329,7 @@ class LocalKnowledgeBase(CompositeEnvironment): else: return structure_str, file_count - # inner_function + # inner_function async def get_knowledge_meta(self,path:str) -> str: full_path = f"{self.root_path}/{path}" if os.islink(full_path): @@ -336,9 +337,9 @@ class LocalKnowledgeBase(CompositeEnvironment): hash = self.meta_db.get_hash_by_doc_path(org_path) if hash: return self.meta_db.get_knowledge(org_path) - + return "not found" - + async def load_knowledge_content(self,path:str,pos:int=0,length:int=None) -> str: if path.endswith("pdf"): logger.info("load_knowledge_content:pdf") @@ -367,12 +368,12 @@ class ScanLocalDocument: workspace = string.Template(config["workspace"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) path = string.Template(config["path"]).substitute(myai_dir=AIStorage.get_instance().get_myai_dir()) self.knowledge_base = LocalKnowledgeBase(workspace) - self.path = path + self.path = path def _support_file(self,file_name:str) -> bool: if file_name.startswith("."): return False - + if file_name.endswith(".pdf"): return True if file_name.endswith(".md"): @@ -380,7 +381,7 @@ class ScanLocalDocument: if file_name.endswith(".txt"): return True return False - + async def next(self): while True: for root, dirs, files in os.walk(self.path): @@ -391,10 +392,10 @@ class ScanLocalDocument: if self.knowledge_base.meta_db.is_doc_exist(full_path): continue yield(full_path, full_path) - else: + else: continue yield(None, None) - + class ParseLocalDocument: @@ -425,7 +426,7 @@ class ParseLocalDocument: await self.knowledge_base.fs.symlink(full_path, new_path) logger.info(f"create soft link {full_path} -> {new_path}") return full_path - + async def _get_meta_prompt(self,meta: dict,temp_meta = None,need_catalogs = False) -> str: kb_tree = await self.knowledge_base.get_knowledege_catalog() @@ -473,7 +474,7 @@ class ParseLocalDocument: full_content_len = self._token_len(full_content) full_path = meta["original_path"] self.knowledge_base.learning_cache.add(full_path, meta) - + if full_content_len < self.token_limit: # 短文章不用总结catalog @@ -521,7 +522,7 @@ class ParseLocalDocument: if item.title: new_item = {} new_item["page"] = item.page.idnum - new_item["title"] = item.title + new_item["title"] = item.title my_childs = [] if item.childs: if len(item.childs) > 0: @@ -573,7 +574,7 @@ class ParseLocalDocument: return {} def _parse_md(self,doc_path:str): - metadata = {} + metadata = {} cur_encode = "utf-8" with open(doc_path,'rb') as f: cur_encode = chardet.detect(f.read(1024))['encoding'] @@ -588,7 +589,7 @@ class ParseLocalDocument: toc = md.toc if toc: metadata['catalogs'] = toc - + return metadata def _parse_document(self,doc_path:str): @@ -614,5 +615,4 @@ class ParseLocalDocument: meta_data["title"] = title logger.info("parse document %s!",doc_path) return hash_result, meta_data - - \ No newline at end of file + diff --git a/src/component/openai_node/open_ai_node.py b/src/component/openai_node/open_ai_node.py index 3695696..63849a1 100644 --- a/src/component/openai_node/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -216,14 +216,16 @@ class OpenAI_ComputeNode(ComputeNode): client = AsyncOpenAI(api_key=self.openai_api_key) try: if llm_inner_functions is None or len(llm_inner_functions) == 0: - logger.info(f"call openai {mode_name} prompts: {prompts}") + if mode_name != "gpt-4-vision-preview": + logger.info(f"call openai {mode_name} prompts: {prompts}") resp = await client.chat.completions.create(model=mode_name, messages=prompts, response_format = response_format, max_tokens=result_token, ) else: - logger.info(f"call openai {mode_name} prompts: \n\t {prompts} \nfunctions: \n\t{json.dumps(llm_inner_functions,ensure_ascii=False)}") + if mode_name != "gpt-4-vision-preview": + logger.info(f"call openai {mode_name} prompts: \n\t {prompts} \nfunctions: \n\t{json.dumps(llm_inner_functions,ensure_ascii=False)}") resp = await client.chat.completions.create(model=mode_name, messages=prompts, response_format = response_format, @@ -239,7 +241,7 @@ class OpenAI_ComputeNode(ComputeNode): #logger.info(f"openai response: {resp}") #TODO: gpt-4v api is image_2_text ? - if mode_name == "gpt-4-vision-preview": + if mode_name == "gpt-4-vision-preview": status_code = resp.choices[0].finish_reason if status_code is None: status_code = resp.choices[0].finish_details['type'] @@ -267,7 +269,7 @@ class OpenAI_ComputeNode(ComputeNode): if token_usage: result.result_refers["token_usage"] = token_usage - + logger.info(f"openai success response: {result.result_str}") return result case _: diff --git a/src/component/slack_tunnel.py b/src/component/slack_tunnel.py index 7deda23..52a5b63 100644 --- a/src/component/slack_tunnel.py +++ b/src/component/slack_tunnel.py @@ -119,6 +119,7 @@ class SlackTunnel(AgentTunnel): continue await download_file(file_info["file"]["url_private_download"], file_path, self.token) + mime_type = file["mimetype"] if file["mimetype"].startswith("image/"): if file_type is None: file_type = "image" diff --git a/src/requirements.txt b/src/requirements.txt index ed743c4..656343e 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -156,3 +156,4 @@ opencv-python discord.py slack_bolt wget +moviepy From ed0adbede9292b8bf075c38d3380747bc8a086ee Mon Sep 17 00:00:00 2001 From: wugren Date: Sat, 17 Feb 2024 13:37:03 +0800 Subject: [PATCH 77/79] fix multimodal input bug --- src/aios/agent/agent.py | 55 ++++++++++++----------- src/aios/agent/llm_process.py | 22 ++++++--- src/aios/proto/compute_task.py | 12 ++++- src/component/openai_node/open_ai_node.py | 2 +- 4 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 655d7f4..9e8ad16 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -84,7 +84,7 @@ class AIAgent(BaseAIAgent): todo_prompts = {} todo_prompts[TodoListType.TO_WORK] = { "do": None, - "check": None, + "check": None, "review": None, } todo_prompts[TodoListType.TO_LEARN] = { @@ -103,12 +103,12 @@ class AIAgent(BaseAIAgent): self.prviate_workspace : AgentWorkspace = None self.behaviors:Dict[str,BaseLLMProcess] = {} - + async def initial(self,params:Dict = None): self.base_dir = f"{AIStorage.get_instance().get_myai_dir()}/agent_data/{self.agent_id}" memory_base_dir = f"{self.base_dir}/memory" self.memory = AgentMemory(self.agent_id,memory_base_dir) - self.prviate_workspace = AgentWorkspace(self.agent_id) + self.prviate_workspace = AgentWorkspace(self.agent_id) init_params = {} init_params["memory"] = self.memory init_params["workspace"] = self.prviate_workspace @@ -117,7 +117,7 @@ class AIAgent(BaseAIAgent): if init_result is False: logger.error(f"llm process {process_name} initial failed! initial return False") return False - + self.wake_up() return True @@ -151,7 +151,7 @@ class AIAgent(BaseAIAgent): self.enable_timestamp = bool(config["enable_timestamp"]) if config.get("history_len"): self.history_len = int(config.get("history_len")) - + #load all LLMProcess self.behaviors = {} behaviors = config.get("behavior") @@ -201,12 +201,12 @@ class AIAgent(BaseAIAgent): context_info["owner"] = AIStorage.get_instance().get_user_config().get_value("username") return context_info - + async def llm_process_msg(self,msg:AgentMsg) -> AgentMsg: need_process:bool = True if msg.msg_type == AgentMsgType.TYPE_GROUPMSG: need_process = False - + session_topic = msg.target + "#" + msg.topic chatsession = AIChatSession.get_session(self.agent_id,session_topic,self.memory.memory_db) if msg.mentions is not None: @@ -218,7 +218,7 @@ class AIAgent(BaseAIAgent): chatsession.append(msg) resp_msg = msg.create_group_resp_msg(self.agent_id,"") return resp_msg - + context_info = await self._get_context_info() input_parms = { "msg":msg, @@ -232,8 +232,11 @@ class AIAgent(BaseAIAgent): elif llm_result.state == LLMResultStates.IGNORE: return None else: # OK - resp_msg = llm_result.raw_result.get("_resp_msg") - return resp_msg + if llm_result.raw_result is not None: + resp_msg = llm_result.raw_result.get("_resp_msg") + return resp_msg + else: + return msg.create_resp_msg(llm_result.resp) async def _process_msg(self,msg:AgentMsg,workspace = None) -> AgentMsg: return await self.llm_process_msg(msg) @@ -264,7 +267,7 @@ class AIAgent(BaseAIAgent): else: logger.info(f"llm process self thinking ok!,think is:{llm_result.resp}") self.memory.set_last_think_time(time.time()) - self.agent_energy -= 2 + self.agent_energy -= 2 return async def llm_triage_tasklist(self): @@ -273,7 +276,7 @@ class AIAgent(BaseAIAgent): if self.prviate_workspace: filter = {} filter["state"] = AgentTaskState.TASK_STATE_WAIT - + tasklist:List[AgentTask]= await self.prviate_workspace.task_mgr.list_task(filter) @@ -281,8 +284,8 @@ class AIAgent(BaseAIAgent): if len(tasklist) > 0: simple_list:List[Dict] = [] for task in tasklist: - simple_list.append(task.to_simple_dict()) - + simple_list.append(task.to_simple_dict()) + input_parms = { "tasklist":simple_list, "context_info": await self._get_context_info() @@ -294,7 +297,7 @@ class AIAgent(BaseAIAgent): logger.info(f"llm process triage_tasks ignore!") else: logger.info(f"llm process triage_tasks ok!,think is:{llm_result.resp}") - self.agent_energy -= 3 + self.agent_energy -= 3 # for agent_task in tasklist: # if self.agent_energy <= 0: @@ -314,7 +317,7 @@ class AIAgent(BaseAIAgent): # else: # determine = llm_result.raw_result.get("determine") # logger.info(f"llm process review_task ok!,think is:{determine}") - # self.agent_energy -= 1 + # self.agent_energy -= 1 async def llm_do_todo(self, todo: AgentTodo): llm_process : BaseLLMProcess = self.behaviors.get("do") @@ -350,7 +353,7 @@ class AIAgent(BaseAIAgent): logger.info(f"llm process check_todo ok!,think is:{llm_result.resp}") self.agent_energy -= 1 - return + return async def llm_plan_task(self,task:AgentTask): llm_process : BaseLLMProcess = self.behaviors.get("plan_task") @@ -398,7 +401,7 @@ class AIAgent(BaseAIAgent): async def _on_timer(self): await asyncio.sleep(5) - while True: + while True: try: now = time.time() if self.last_recover_time is None: @@ -419,7 +422,7 @@ class AIAgent(BaseAIAgent): #filter["state"] = AgentTaskState.TASK_STATE_WAIT filter = None task_list:List[AgentTask] = await self.prviate_workspace.task_mgr.list_task(filter) - + for task in task_list: if self.agent_energy <= 0: break @@ -456,18 +459,18 @@ class AIAgent(BaseAIAgent): task = await self.prviate_workspace.task_mgr.get_task(task.task_id) if task.state == AgentTaskState.TASK_STATE_WAITING_REVIEW: await self.llm_review_task(task) - + await self._self_imporve() - - - + + + except Exception as e: tb_str = traceback.format_exc() logger.error(f"agent {self.agent_id} on timer error:{e},{tb_str}") - + # Because the LLM itself is very slow, the accuracy of the system processing task is in minutes. - await asyncio.sleep(30) - + await asyncio.sleep(30) + diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index e90c7ec..9e2e882 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -2,6 +2,7 @@ # pylint:disable=E0402 import os.path +from .chatsession import AIChatSession from ..utils import video_utils,image_utils from ..proto.compute_task import LLMPrompt,LLMResult,ComputeTaskResult,ComputeTaskResultCode @@ -165,7 +166,7 @@ class BaseLLMProcess(ABC): # Action define in prompt, will be execute after llm compute prompt = await self.prepare_prompt(input) - max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt,self.model_name) + max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt,self.get_llm_model_name()) #if max_result_token < MIN_PREDICT_TOKEN_LEN: # return LLMResult.from_error_str(f"prompt too long,can not predict") @@ -196,7 +197,11 @@ class BaseLLMProcess(ABC): # parse task_result to LLM Result if self.enable_json_resp: - llm_result = LLMResult.from_json_str(task_result.result_str) + try: + llm_result = LLMResult.from_json_str(task_result.result_str) + except Exception as e: + logger.error(f"parse llm result error:{e}") + llm_result = LLMResult.from_str(task_result.result_str) else: llm_result = LLMResult.from_str(task_result.result_str) @@ -402,14 +407,18 @@ class AgentMessageProcess(LLMAgentBaseProcess): if self.enable_media2text: logger.error(f"enable_media2text is not supported yet") else: - audio_file = msg.body + prompt, audio_file = msg.get_audio_body() resp = await (ComputeKernel.get_instance().do_speech_to_text(audio_file, model=self.asr_model, prompt=None, response_format="text")) if resp.result_code != ComputeTaskResultCode.OK: error_resp = msg.create_error_resp(resp.error_str) return error_resp else: - msg.body = resp.result_str - msg_prompt.messages = [{"role":"user","content":resp.result_str}] + if prompt == "": + msg.body = resp.result_str + msg_prompt.messages = [{"role":"user","content":resp.result_str}] + else: + msg.body = f"{prompt}\nVoice content:{resp.result_str}" + msg_prompt.messages = [{"role":"user","content": prompt}, {"role": "user", "content": f"Voice content:{resp.result_str}"}] else: msg_prompt.messages = [{"role":"user","content":msg.body}] @@ -495,7 +504,8 @@ class AgentMessageProcess(LLMAgentBaseProcess): else: resp_msg = msg.create_resp_msg(llm_result.resp) - llm_result.raw_result["_resp_msg"] = resp_msg + if llm_result.raw_result is not None: + llm_result.raw_result["_resp_msg"] = resp_msg action_params = {} action_params["_input"] = input diff --git a/src/aios/proto/compute_task.py b/src/aios/proto/compute_task.py index e483d0e..f7f4371 100644 --- a/src/aios/proto/compute_task.py +++ b/src/aios/proto/compute_task.py @@ -210,8 +210,14 @@ class LLMResult: r.state = LLMResultStates.IGNORE return r - if llm_result_str[0] == "{": - return LLMResult.from_json_str(llm_result_str) + try: + if llm_result_str[0] == "{": + return LLMResult.from_json_str(llm_result_str) + + if llm_result_str.lstrip().rstrip().startswith("```json"): + return LLMResult.from_json_str(llm_result_str[7:-3]) + except: + pass lines = llm_result_str.splitlines() is_need_wait = False @@ -255,6 +261,8 @@ class LLMResult: r.resp += current_action.dumps() else: r.action_list.append(current_action) + + r.state = LLMResultStates.OK return r class ComputeTask: diff --git a/src/component/openai_node/open_ai_node.py b/src/component/openai_node/open_ai_node.py index 63849a1..345c6c4 100644 --- a/src/component/openai_node/open_ai_node.py +++ b/src/component/openai_node/open_ai_node.py @@ -206,7 +206,7 @@ class OpenAI_ComputeNode(ComputeNode): if mode_name == "gpt-4-vision-preview": response_format = NOT_GIVEN llm_inner_functions = None - if max_token_size > 4096: + if max_token_size > 4096 or max_token_size < 50: result_token = 4096 else: result_token = -1 From 3601cd9bd3f6bdfff7f2784979a2e9cec8631a04 Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Tue, 27 Feb 2024 10:40:30 -0800 Subject: [PATCH 78/79] update documents --- doc/Architecture.md | 31 ++ doc/webui.drawio | 998 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 925 insertions(+), 104 deletions(-) diff --git a/doc/Architecture.md b/doc/Architecture.md index 465290a..53e52c2 100644 --- a/doc/Architecture.md +++ b/doc/Architecture.md @@ -14,6 +14,37 @@ Self Loop (自省) #### Agent.Memory +Memory模块设计的主要目的是能按一定的模式,在Token Limit的情况下构成Agent的一些上下文。 +Memory的原始记录写入:原则上说,Agent的任何LLM行为都应至少将input/resp 写入Memory,毕竟LLM是非常高开销的行为。LLM的过程可以视情况写入(inner function的调用,action的执行,根据input构造的完整提示词等) +Memory的使用:LLM过程在构造提示词的“已知信息”部分时,通常都会有加载Memory的需求。尤其是在Input并不包含完整信息的情况下(非幂等LLM推理),更需要根据“上下文”来理解Input的含义。这在“ChatCompetition”过程中尤为明显。 + +使用Memory基本是两种方式 +1. 加载写入的原始记录 +2. 访问根据原始记录加工(Self-Thinking)后的Object-Summary。或则访问一个根据原始记录整理的,用文件系统方式组织的“记忆片段” + +这里的核心痛点是:一个LLM过程,如何根据Input加载合适的Memory成为“已知信息”。 +1. 如果明确的知道Input属于一个session,那么可以加载这个session相关的所有record(注意token limit和最大条数)与session的summary,其它的Memory的信息通过inner_function访问。但要防止LLM过程中对Inner function的过度使用 +2. 在不明确的情况下,如何判断input属于哪个session?(包括是否需要创建新的session) + +从上述思考中,得到现在的设计方案: +1. 依旧保留Session,且创建session是明确的用户行为。有一些tunnel根本没有创建session的能力。UI可以用 Lite-LLM来进行辅助的session合并(比如类似GMail的Email归集)。系统可以基于session做“已知信息”的自动加载 +2. 在处理Input时,允许使用Agent.Memory的接口来访问更多的Memory的内容。从流程上看,这个过程和访问KB的原理是基本一致的。 +3. Self-Thinking的过程中,既要对Session进行整理(得到Session Summary),也要站在更全局的角度对涉及到的Object进行整理(得到Object Summary)。 +4. Self-Thinking的过程也是以session为单位的,以更新session-summary为首要目标,并可以在Thiking的过程中,访问已有的object-summary,选择性的更新object-summary +5. Self-Thinking会尽量以时间从新到旧处理所有的原始记录,因此会涉及到对多个Session的Summary的更新。 + +设计方案的主要风险在于inner function模式可能会带来大量的,无用的object summary查询。 + +对于UI上不方便创建Session的情况: +1. 通过标题尝试自动创建 +2. 通过时间尝试自动创建 +3. 既然用户看到的就是一个session,那么我们就必须当一个session来处理 + + +一些推论: +Agent通过一个DB list来访问/写入结构化数据,并拥有自己创建DB的能力 +Agent通过一个FileSystem来访问/创建非结构化数据,并拥有理解文件系统组织设计的能力 +“不要给Agent直接扩展能力,而是尽量给Agent扩展元能力(读说明书的能力)” #### Agent.Workspace diff --git a/doc/webui.drawio b/doc/webui.drawio index 0a37a80..4a05e28 100644 --- a/doc/webui.drawio +++ b/doc/webui.drawio @@ -1,100 +1,818 @@ - - - + + + - - + + - - + + - - + + - - + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -141,7 +859,7 @@ - + @@ -150,14 +868,86 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -193,58 +983,58 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -255,73 +1045,73 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + From 342464e386b17343b13547886518889908e60bad Mon Sep 17 00:00:00 2001 From: Liu Zhicong Date: Wed, 20 Mar 2024 18:47:58 -0700 Subject: [PATCH 79/79] rollback agent memory to "chat session history & session summary" --- rootfs/agents/Jarvis/agent.toml | 20 +- rootfs/knowledge_pipelines/Mia/query.py | 2 +- src/aios/agent/agent.py | 11 +- src/aios/agent/agent_memory.py | 298 ++++++++++++------------ src/aios/agent/chatsession.py | 26 ++- src/aios/agent/llm_process.py | 198 +++++++--------- src/aios/proto/ai_function.py | 2 +- src/service/aios_shell/aios_shell.py | 4 +- 8 files changed, 273 insertions(+), 288 deletions(-) diff --git a/rootfs/agents/Jarvis/agent.toml b/rootfs/agents/Jarvis/agent.toml index 129d7f3..419eea7 100644 --- a/rootfs/agents/Jarvis/agent.toml +++ b/rootfs/agents/Jarvis/agent.toml @@ -14,7 +14,6 @@ Only clearly specifying the task you completed can be completed independently. [behavior.on_message] type="AgentMessageProcess" -# TODO: 是否应该自动记录 inner function和action的执行细节 mutil_model="gpt-4-vision-preview" asr_model="openai-whisper" tts_model="tts-1" @@ -167,7 +166,8 @@ process_description=""" reply_format = """ The Response must be directly parsed by `python json.loads`. Here is an example: { - resp:'$think step by step, how to check the todo', + resp: '$simport report about what you do', + actions: [{ name: '$action1_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. }, ... @@ -179,24 +179,24 @@ llm_context.actions.enable = ["agent.workspace.update_todo"] llm_context.functions.enable = ["agent.workspace.read_file","agent.workspace.list_dir","system.shell.exec","system.shell.run_code","aigc.image_2_text","aigc.voice_2_text","web.search.duckduckgo"] -[behavior.self_thinking2] +[behavior.self_thinking] # self thing的主要目的是对各种chatlog,worklog进行分析,并更新面向人和事的summary。 +# TODO,先不支持worklog,先支持好chatlog type="AgentSelfThinking" process_description=""" -You are very good at thinking and summarizing what you have already happened。Your input is a chat history and work record,After you think about it, you will follow the requirements below to generate abstract. +You are very good at thinking and summarizing what you have already happened。Your input is chat history and work records,After you think about it, you will follow the requirements below to generate abstract. 1. Try to understand the theme of each sentence, and call the relevant operation to record the relationship between the dialogue and the theme 2. Try to analyze the personality of different people involved in information 3. Try to summarize important events in the information and record it 4. Try to understand the attitude of different people on different topics or events 5. Pay attention to the time order when summarizing, and combine the summary you have done to update Summary -6. The summary of the generation cannot exceed 400 token -7. 思考的目的是让自己未来的工作更加高效 -8. 总结中只包含有长期价值和未完成的事情,已经完成的事情不需要总结 +6. The summary of the generation cannot exceed 500 token """ reply_format = """ The Response must be directly parsed by `python json.loads`. Here is an example: { - resp:'$Summary in one sentence', + resp: '$simport report about what you do', + actions: [{ name: '$action1_name', $param_name: '$parm' #Optional, fill in only if the action has parameters. }, ... @@ -204,8 +204,8 @@ The Response must be directly parsed by `python json.loads`. Here is an example: } """ context="Your Principal is {owner}, now in {location}, time: {now}, weather: {weather}." -llm_context.actions.enable = ["agent.memory.update_summary","agent.memory.update_contact_summary","agent.memory.update_relation_summary","agent.memory.set_experience"] -llm_context.functions.enable = ["agent.memory.get_summary","agent.memory.get_contact_summary","agent.memory.list_summary","agent.memory.get_relation_summary","agent.memory.get_experience"] +llm_context.actions.enable = ["agent.memory.update_chat_summary"] + #[behavior.self_improve] diff --git a/rootfs/knowledge_pipelines/Mia/query.py b/rootfs/knowledge_pipelines/Mia/query.py index 8e59b8b..2c0c801 100644 --- a/rootfs/knowledge_pipelines/Mia/query.py +++ b/rootfs/knowledge_pipelines/Mia/query.py @@ -13,7 +13,7 @@ class EmbeddingEnvironment(SimpleEnvironment): query_param = { "tokens": "key words to query", "types": "prefered knowledge types, one or more of [text, image]", - "index": "index of query result" + "limit": "index of query result" } self.add_ai_function(SimpleAIFunction("query_knowledge", "vector query content from local knowledge base", diff --git a/src/aios/agent/agent.py b/src/aios/agent/agent.py index 9e8ad16..9b8376b 100644 --- a/src/aios/agent/agent.py +++ b/src/aios/agent/agent.py @@ -248,14 +248,11 @@ class AIAgent(BaseAIAgent): logger.info(f"agent {self.agent_id} self thinking start!") context_info = await self._get_context_info() - known_session_list = AIChatSession.list_session(self.agent_id,self.memory.memory_db) - known_experience_list = await self.memory.list_experience() - record_list = await self.memory.load_records(await self.memory.get_last_think_time()) + #chat_history = AIChatSession.list_session(self.agent_id,self.memory.memory_db) + #known_experience_list = await self.memory.list_experience() + #record_list = await self.memory.load_records(await self.memory.get_last_think_time()) input_parms = { - "record_list":record_list, - "known_session_list":known_session_list, - "known_experience_list":known_experience_list, "context_info":context_info } @@ -266,7 +263,7 @@ class AIAgent(BaseAIAgent): logger.info(f"llm process self thinking ignore!") else: logger.info(f"llm process self thinking ok!,think is:{llm_result.resp}") - self.memory.set_last_think_time(time.time()) + await self.memory.set_last_think_time(time.time()) self.agent_energy -= 2 return diff --git a/src/aios/agent/agent_memory.py b/src/aios/agent/agent_memory.py index 7096d3f..c660a76 100644 --- a/src/aios/agent/agent_memory.py +++ b/src/aios/agent/agent_memory.py @@ -92,49 +92,31 @@ class AgentMemory: work_records = self.load_worklogs(self.agent_id,token_limit=tokenlimit) pass - async def load_chatlogs(self,msg:AgentMsg,n:int=6,m:int=64,token_limit=800)->str: + async def load_chatlogs(self,msg:AgentMsg,token_limit=800): chatsession = self.get_session_from_msg(msg) # Must load n (n> = 2), and hope to load the M # The information in the # M is gradually added, knowing that it is less than 72 hours from the current time, and consumes enough tokens - messages_n = chatsession.read_history(n) # read - if len(messages_n) >= n: - messages_m = chatsession.read_history(m,n) - else: - messages_m = [] - + messages_n = chatsession.read_history() # read histroy_str = "" read_count = 0 + is_all = True for msg in messages_n: dt = datetime.fromtimestamp(float(msg.create_time)) formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" token_limit -= ComputeKernel.llm_num_tokens_from_text(record_str,self.model_name) if token_limit <= 32: + is_all = False break read_count += 1 histroy_str = record_str + histroy_str - if len(messages_n) > 2: - if read_count < 3: - logging.warning(f"read history {read_count} < 3, will not load more") - - now = datetime.now() - for msg in messages_m: - dt = datetime.fromtimestamp(float(msg.create_time)) - time_diff = now - dt - if time_diff > timedelta(hours=self.threshold_hours): - break - - formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') - record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" - token_limit -= ComputeKernel.llm_num_tokens_from_text(record_str,self.model_name) - if token_limit <= 32: - break - read_count += 1 - histroy_str = record_str + histroy_str - - return histroy_str + return histroy_str,is_all + + async def get_chat_summary(self,msg:AgentMsg) -> str: + chatsession : AIChatSession = self.get_session_from_msg(msg) + return chatsession.summary # async def action_chatlog_append(self,params:Dict) -> str: # @@ -293,7 +275,7 @@ class AgentMemory: try: async with aiofiles.open(summary_path, mode='w') as file: await file.write(summary) - return "OK" + return "OK" except Exception as e: logger.error(f"write summary failed: {e}") return f"write summary failed: {e}" @@ -363,141 +345,157 @@ class AgentMemory: @staticmethod def register_ai_functions(): - async def get_contact_summary(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - contact_name = parameters.get("contact_name") - return await agent_memory.get_contact_summary(contact_name) - parameters = ParameterDefine.create_parameters({ - "contact_name": {"type": "string", "description": "contact name"} - }) - get_contact_summary_func = SimpleAIFunction("agent.memory.get_contact_summary", - "get contact summary", - get_contact_summary, - parameters) - GlobaToolsLibrary.register_tool_function(get_contact_summary_func) - - async def update_contact_summary(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - contact_name = parameters.get("contact_name") + async def update_chat_summary(parameters): + agent_memory:AgentMemory = parameters.get("_memory") + chatsession = AIChatSession.get_session_by_id(parameters.get("session_id"),agent_memory.memory_db) summary = parameters.get("summary") - return await agent_memory.update_contact_summary(contact_name,summary) + chatsession.update_summary(summary) + return "OK" parameters = ParameterDefine.create_parameters({ - "contact_name": {"type": "string", "description": "contact name"}, + "session_id": {"type": "string", "description": "session id"}, "summary": {"type": "string", "description": "new summary"} }) - update_contact_summary_func = SimpleAIFunction("agent.memory.update_contact_summary", - "update contact summary", - update_contact_summary, + update_chat_summary_func = SimpleAIFunction("agent.memory.update_chat_summary", + "update chat summary", + update_chat_summary, parameters) - GlobaToolsLibrary.register_tool_function(update_contact_summary_func) + GlobaToolsLibrary.get_instance().register_tool_function(update_chat_summary_func) - async def get_summary(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - object_name = parameters.get("object_name") - return await agent_memory.get_summary(object_name) - parameters = ParameterDefine.create_parameters({ - "object_name": {"type": "string", "description": "object name"} - }) - get_summary_func = SimpleAIFunction("agent.memory.get_summary", - "get summary of sth", - get_summary, - parameters) - GlobaToolsLibrary.register_tool_function(get_summary_func) + # async def get_contact_summary(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # contact_name = parameters.get("contact_name") + # return await agent_memory.get_contact_summary(contact_name) + # parameters = ParameterDefine.create_parameters({ + # "contact_name": {"type": "string", "description": "contact name"} + # }) + # get_contact_summary_func = SimpleAIFunction("agent.memory.get_contact_summary", + # "get contact summary", + # get_contact_summary, + # parameters) + # GlobaToolsLibrary.register_tool_function(get_contact_summary_func) - async def update_summary(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - object_name = parameters.get("object_name") - summary = parameters.get("summary") - return await agent_memory.update_summary(object_name,summary) - parameters = ParameterDefine.create_parameters({ - "object_name": {"type": "string", "description": "object name"}, - "summary": {"type": "string", "description": "new summary"} - }) - update_summary_func = SimpleAIFunction("agent.memory.update_summary", - "update summary of sth", - update_summary, - parameters) - GlobaToolsLibrary.register_tool_function(update_summary_func) + # async def update_contact_summary(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # contact_name = parameters.get("contact_name") + # summary = parameters.get("summary") + # return await agent_memory.update_contact_summary(contact_name,summary) + # parameters = ParameterDefine.create_parameters({ + # "contact_name": {"type": "string", "description": "contact name"}, + # "summary": {"type": "string", "description": "new summary"} + # }) + # update_contact_summary_func = SimpleAIFunction("agent.memory.update_contact_summary", + # "update contact summary", + # update_contact_summary, + # parameters) + # GlobaToolsLibrary.register_tool_function(update_contact_summary_func) - async def list_summary_object_names(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - return await agent_memory.list_summary_object_names() - parameters = ParameterDefine.create_parameters({}) - list_summary_object_names_func = SimpleAIFunction("agent.memory.list_summary", - "list summary object names", - list_summary_object_names, - parameters) - GlobaToolsLibrary.register_tool_function(list_summary_object_names_func) + # async def get_summary(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # object_name = parameters.get("object_name") + # return await agent_memory.get_summary(object_name) + # parameters = ParameterDefine.create_parameters({ + # "object_name": {"type": "string", "description": "object name"} + # }) + # get_summary_func = SimpleAIFunction("agent.memory.get_summary", + # "get summary of sth", + # get_summary, + # parameters) + # GlobaToolsLibrary.register_tool_function(get_summary_func) - async def get_relation_summary(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - object_name1 = parameters.get("object1_name") - object_name2 = parameters.get("object2_name") - return await agent_memory.get_relation_summary(object_name1,object_name2) - parameters = ParameterDefine.create_parameters({ - "object1_name": {"type": "string", "description": "object name1"}, - "object2_name": {"type": "string", "description": "object name2"} - }) - get_relation_summary_func = SimpleAIFunction("agent.memory.get_relation_summary", - "object1 feel object2 is ...", - get_relation_summary, - parameters) - GlobaToolsLibrary.register_tool_function(get_relation_summary_func) + # async def update_summary(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # object_name = parameters.get("object_name") + # summary = parameters.get("summary") + # return await agent_memory.update_summary(object_name,summary) + # parameters = ParameterDefine.create_parameters({ + # "object_name": {"type": "string", "description": "object name"}, + # "summary": {"type": "string", "description": "new summary"} + # }) + # update_summary_func = SimpleAIFunction("agent.memory.update_summary", + # "update summary of sth", + # update_summary, + # parameters) + # GlobaToolsLibrary.register_tool_function(update_summary_func) - async def update_relation_summary(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - object_name1 = parameters.get("object1_name") - object_name2 = parameters.get("object2_name") - summary = parameters.get("summary") - return await agent_memory.update_relation_summary(object_name1,object_name2,summary) - parameters = ParameterDefine.create_parameters({ - "object1_name": {"type": "string", "description": "object name1"}, - "object2_name": {"type": "string", "description": "object name2"}, - "summary": {"type": "string", "description": "new summary"} - }) - update_relation_summary_func = SimpleAIFunction("agent.memory.update_relation_summary", - "object1 feel object2 is ...", - update_relation_summary, - parameters) - GlobaToolsLibrary.register_tool_function(update_relation_summary_func) + # async def list_summary_object_names(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # return await agent_memory.list_summary_object_names() + # parameters = ParameterDefine.create_parameters({}) + # list_summary_object_names_func = SimpleAIFunction("agent.memory.list_summary", + # "list summary object names", + # list_summary_object_names, + # parameters) + # GlobaToolsLibrary.register_tool_function(list_summary_object_names_func) - async def get_experience(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - topic_name = parameters.get("topic_name") - return await agent_memory.get_experience(topic_name) - parameters = ParameterDefine.create_parameters({ - "topic_name": {"type": "string", "description": "topic name"} - }) - get_experience_func = SimpleAIFunction("agent.memory.get_experience", - "get experience", - get_experience, - parameters) - GlobaToolsLibrary.register_tool_function(get_experience_func) + # async def get_relation_summary(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # object_name1 = parameters.get("object1_name") + # object_name2 = parameters.get("object2_name") + # return await agent_memory.get_relation_summary(object_name1,object_name2) + # parameters = ParameterDefine.create_parameters({ + # "object1_name": {"type": "string", "description": "object name1"}, + # "object2_name": {"type": "string", "description": "object name2"} + # }) + # get_relation_summary_func = SimpleAIFunction("agent.memory.get_relation_summary", + # "object1 feel object2 is ...", + # get_relation_summary, + # parameters) + # GlobaToolsLibrary.register_tool_function(get_relation_summary_func) - async def set_experience(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - topic_name = parameters.get("topic_name") - summary = parameters.get("summary") - return await agent_memory.set_experience(topic_name,summary) - parameters = ParameterDefine.create_parameters({ - "topic_name": {"type": "string", "description": "topic name"}, - "summary": {"type": "string", "description": "new summary"} - }) - set_experience_func = SimpleAIFunction("agent.memory.set_experience", - "set experience", - set_experience, - parameters) - GlobaToolsLibrary.register_tool_function(set_experience_func) + # async def update_relation_summary(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # object_name1 = parameters.get("object1_name") + # object_name2 = parameters.get("object2_name") + # summary = parameters.get("summary") + # return await agent_memory.update_relation_summary(object_name1,object_name2,summary) + # parameters = ParameterDefine.create_parameters({ + # "object1_name": {"type": "string", "description": "object name1"}, + # "object2_name": {"type": "string", "description": "object name2"}, + # "summary": {"type": "string", "description": "new summary"} + # }) + # update_relation_summary_func = SimpleAIFunction("agent.memory.update_relation_summary", + # "object1 feel object2 is ...", + # update_relation_summary, + # parameters) + # GlobaToolsLibrary.register_tool_function(update_relation_summary_func) - async def list_experience(parameters): - agent_memory:AgentMemory = parameters.get("_agent_memory") - return await agent_memory.list_experience() - parameters = ParameterDefine.create_parameters({}) - list_experience_func = SimpleAIFunction("agent.memory.list_experience", - "list exist experience topics", - list_experience, - parameters) - GlobaToolsLibrary.register_tool_function(list_experience_func) + # async def get_experience(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # topic_name = parameters.get("topic_name") + # return await agent_memory.get_experience(topic_name) + # parameters = ParameterDefine.create_parameters({ + # "topic_name": {"type": "string", "description": "topic name"} + # }) + # get_experience_func = SimpleAIFunction("agent.memory.get_experience", + # "get experience", + # get_experience, + # parameters) + # GlobaToolsLibrary.register_tool_function(get_experience_func) + + # async def set_experience(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # topic_name = parameters.get("topic_name") + # summary = parameters.get("summary") + # return await agent_memory.set_experience(topic_name,summary) + # parameters = ParameterDefine.create_parameters({ + # "topic_name": {"type": "string", "description": "topic name"}, + # "summary": {"type": "string", "description": "new summary"} + # }) + # set_experience_func = SimpleAIFunction("agent.memory.set_experience", + # "set experience", + # set_experience, + # parameters) + # GlobaToolsLibrary.register_tool_function(set_experience_func) + + # async def list_experience(parameters): + # agent_memory:AgentMemory = parameters.get("_memory") + # return await agent_memory.list_experience() + # parameters = ParameterDefine.create_parameters({}) + # list_experience_func = SimpleAIFunction("agent.memory.list_experience", + # "list exist experience topics", + # list_experience, + # parameters) + # GlobaToolsLibrary.register_tool_function(list_experience_func) diff --git a/src/aios/agent/chatsession.py b/src/aios/agent/chatsession.py index de31bdd..1f69e65 100644 --- a/src/aios/agent/chatsession.py +++ b/src/aios/agent/chatsession.py @@ -198,6 +198,9 @@ class ChatSessionDB: try: conn = self._get_conn() cursor = conn.cursor() + if limit == 0: + limit = 1024 + cursor.execute(""" SELECT MessageID, SessionID, MsgType, PrevMsgID, SenderID, ReceiverID, Timestamp, Topic,Mentions,ContentMIME,Content,ActionName,ActionParams,ActionResult,DoneTime,Status FROM Messages WHERE SessionID = ? @@ -234,6 +237,8 @@ class ChatSessionDB: try: conn = self._get_conn() cursor = conn.cursor() + if limit == 0: + limit = 1024 cursor.execute(""" SELECT MessageID, SessionID, MsgType, PrevMsgID, SenderID, ReceiverID, Timestamp, Topic,Mentions,ContentMIME,Content,ActionName,ActionParams,ActionResult,DoneTime,Status FROM Messages WHERE SessionID = ? @@ -296,6 +301,7 @@ class ChatSessionDB: # chat session might be large, so can read / write at stream mode. class AIChatSession: _dbs = {} + _sessions = {} #@classmethod #async def get_session_by_id(cls,session_id:str,db_path:str): # db = cls._dbs.get(db_path) @@ -345,18 +351,24 @@ class AIChatSession: cls._dbs[db_path] = db result = None + for session in cls._sessions.values(): + if session.owner_id == owner_id and session.topic == session_topic: + return session + session = db.get_chatsession_by_owner_topic(owner_id,session_topic) if session is None: if auto_create: session_id = "CS#" + uuid.uuid4().hex db.insert_chatsession(session_id,owner_id,session_topic,datetime.datetime.now()) result = AIChatSession(owner_id,session_id,db) + cls._sessions[session_id] = result else: result = AIChatSession(owner_id,session[0],db) result.topic = session_topic result.summarize_pos = session[4] result.summary = session[5] result.openai_thread_id = session[6] + cls._sessions[result.session_id] = result return result @@ -368,6 +380,10 @@ class AIChatSession: cls._dbs[db_path] = db result = None + session = cls._sessions.get(session_id) + if session: + return session + session = db.get_chatsession_by_id(session_id) if session is None: return None @@ -377,6 +393,7 @@ class AIChatSession: result.summarize_pos = session[4] result.summary = session[5] result.openai_thread_id = session[6] + cls._sessions[session_id] = result return result @@ -402,13 +419,13 @@ class AIChatSession: self.topic : str = None self.start_time : str = None self.summarize_pos : int = 0 - self.summary = None + self.summary : str = None self.openai_thread_id = None def get_owner_id(self) -> str: return self.owner_id - def read_history(self, number:int=10,offset=0,order="revers") -> [AgentMsg]: + def read_history(self, number:int=0,offset=0,order="revers") -> [AgentMsg]: if order == "revers": msgs = self.db.get_messages(self.session_id, number, offset) else: @@ -444,9 +461,8 @@ class AIChatSession: self.db.insert_message(msg,tags) - def update_think_progress(self,progress:int,new_summary:str) -> None: - self.db.update_session_summary(self.session_id,progress,new_summary) - self.summarize_pos = progress + def update_summary(self,new_summary:str) -> None: + self.db.update_session_summary(self.session_id,self.summarize_pos,new_summary) self.summary = new_summary def update_openai_thread_id(self,thread_id:str) -> None: diff --git a/src/aios/agent/llm_process.py b/src/aios/agent/llm_process.py index 9e2e882..df8b816 100644 --- a/src/aios/agent/llm_process.py +++ b/src/aios/agent/llm_process.py @@ -39,8 +39,9 @@ class BaseLLMProcess(ABC): #None means system default, # TODO: support abcstract model name like: local-hight,local-low,local-medium,remote-hight,remote-low,remote-medium self.model_name = None - self.max_token = 1000 # result_token - self.max_prompt_token = 1000 # not include input prompt + self.max_token = 2000 # result_token + self.max_prompt_token = 2000 # not include input prompt + self.chat_summary_token_len = 500 self.timeout = 1800 # 30 min self.llm_context:LLMProcessContext = None @@ -64,6 +65,9 @@ class BaseLLMProcess(ABC): async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: pass + def get_remain_prompt_length(self,prompt:LLMPrompt,will_append_str:str) -> int: + return self.max_prompt_token - ComputeKernel.llm_num_tokens(prompt,self.model_name) + @abstractmethod async def load_from_config(self,config:dict) -> bool: #self.behavior = config.get("behavior") @@ -166,6 +170,10 @@ class BaseLLMProcess(ABC): # Action define in prompt, will be execute after llm compute prompt = await self.prepare_prompt(input) + if prompt is None: + logger.warn(f"prepare_prompt return None, break llm_process") + return LLMResult.from_error_str("prepare_prompt return None") + max_result_token = self.max_token - ComputeKernel.llm_num_tokens(prompt,self.get_llm_model_name()) #if max_result_token < MIN_PREDICT_TOKEN_LEN: # return LLMResult.from_error_str(f"prompt too long,can not predict") @@ -429,14 +437,15 @@ class AgentMessageProcess(LLMAgentBaseProcess): #TODO Is sender an agent? return await self.memory.get_contact_summary(sender_id) - async def load_chatlogs(self,msg:AgentMsg)->str: + async def load_chatlogs(self,msg:AgentMsg,max_length_by_token:int)->str: ## like #sender,[2023-11-1 12:00:00] #content - return await self.memory.load_chatlogs(msg) + return await self.memory.load_chatlogs(msg,max_length_by_token) + + async def get_chat_summary(self,msg:AgentMsg)->str: + return await self.memory.get_chat_summary(msg) - async def get_log_summary(self,msg:AgentMsg)->str: - return None async def get_extend_known_info(self,msg:AgentMsg,prompt:LLMPrompt)->str: @@ -466,18 +475,7 @@ class AgentMessageProcess(LLMAgentBaseProcess): ### 信息发送者资料 known_info["sender_info"] = await self.sender_info(msg) #prompt.append_system_message(await self.sender_info(self,msg)) - ### 近期的聊天记录 - chat_record = await self.load_chatlogs(msg) - if chat_record: - if len(chat_record) > 4: - known_info["chat_record"] = chat_record - #prompt.append_system_message(await self.load_chatlogs(self,msg)) - ### 交流总结 - summary = await self.get_log_summary(msg) - if summary: - if len(summary) > 4: - known_info["summary"] = summary - #prompt.append_system_message(await self.get_log_summary(self,msg)) + system_prompt_dict["known_info"] = known_info prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) @@ -490,10 +488,24 @@ class AgentMessageProcess(LLMAgentBaseProcess): logger.info(f"enable kb") - prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) - ## 扩展已知信息 (这可能是一个LLM过程) - prompt.append_system_message(await self.get_extend_known_info(msg,prompt)) + ### 根据Token Limit加载聊天记录 + remain_token = self.get_remain_prompt_length(prompt,json.dumps(system_prompt_dict,ensure_ascii=False)) + chat_record,is_all = await self.load_chatlogs(msg,remain_token - self.chat_summary_token_len) + if chat_record: + if len(chat_record) > 4: + known_info["chat_record"] = chat_record + if not is_all : + ### 如果出触发了Token Limit,则删除几条信息后,加载summary (summary的长度基本是固定的) + summary = await self.get_chat_summary(msg) + if summary: + if len(summary) > 4: + known_info["chat_summary"] = summary + + # TODO: extend known info + #prompt.append_system_message(await self.get_extend_known_info(msg,prompt)) + + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) return prompt @@ -527,117 +539,77 @@ class AgentMessageProcess(LLMAgentBaseProcess): class AgentSelfThinking(LLMAgentBaseProcess): def __init__(self) -> None: super().__init__() + async def load_from_config(self, config: dict) -> Coroutine[Any, Any, bool]: if await super().load_from_config(config) is False: return False + async def _load_chat_history(self,token_limit:int): + chat_history = {} + session_list = AIChatSession.list_session(self.memory.agent_id ,self.memory.memory_db) + total_read_msg = 0 + for session_id in session_list: + chatsession = AIChatSession.get_session_by_id(session_id,self.memory.memory_db) + session_history = {} + session_history["summary"] = chatsession.summary + session_history["id"] = chatsession.session_id + token_limit -= ComputeKernel.llm_num_tokens_from_text(chatsession.summary,self.model_name) + read_history_msg = 0 + + if token_limit > 8: + # load session chat history + cur_pos = chatsession.summarize_pos + messages = chatsession.read_history(0,cur_pos,"natural") # read + history_str = "" + for msg in messages: + read_history_msg += 1 + total_read_msg += 1 + cur_pos += 1 + dt = datetime.fromtimestamp(float(msg.create_time)) + formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') + record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" + token_limit -= ComputeKernel.llm_num_tokens_from_text(record_str,self.model_name) + if token_limit < 8: + break - async def _get_history_prompt_for_think(self,chatsession,summary:str,system_token_len:int,pos:int)->(LLMPrompt,int): - history_len = (self.max_token_size * 0.7) - system_token_len + history_str = history_str + record_str - messages = chatsession.read_history(self.history_len,pos,"natural") # read - result_token_len = 0 - result_prompt = LLMPrompt() - have_summary = False - if summary is not None: - if len(summary) > 1: - have_summary = True + if read_history_msg >= 2: + session_history["history"] = history_str + chat_history[session_id] = session_history + chatsession.summarize_pos = cur_pos - if have_summary: - result_prompt.messages.append({"role":"user","content":summary}) - result_token_len -= len(summary) - else: - result_prompt.messages.append({"role":"user","content":"There is no summary yet."}) - result_token_len -= 6 - - read_history_msg = 0 - history_str : str = "" - for msg in messages: - read_history_msg += 1 - dt = datetime.datetime.fromtimestamp(float(msg.create_time)) - formatted_time = dt.strftime('%y-%m-%d %H:%M:%S') - record_str = f"{msg.sender},[{formatted_time}]\n{msg.body}\n" - history_str = history_str + record_str - - history_len -= len(msg.body) - result_token_len += len(msg.body) - if history_len < 0: - logger.warning(f"_get_prompt_from_session reach limit of token,just read {read_history_msg} history message.") - break - - result_prompt.messages.append({"role":"user","content":history_str}) - return result_prompt,pos+read_history_msg - - async def _think_chatsession(self,session_id): - if self.agent_think_prompt is None: - return - logger.info(f"agent {self.agent_id} think session {session_id}") - chatsession = AIChatSession.get_session_by_id(session_id,self.chat_db) - - while True: - cur_pos = chatsession.summarize_pos - summary = chatsession.summary - prompt:LLMPrompt = LLMPrompt() - #prompt.append(self._get_agent_prompt()) - prompt.append(await self._get_agent_think_prompt()) - system_prompt_len = ComputeKernel.llm_num_tokens(prompt) - #think env? - history_prompt,next_pos = await self._get_history_prompt_for_think(chatsession,summary,system_prompt_len,cur_pos) - prompt.append(history_prompt) - is_finish = next_pos - cur_pos < 2 - if is_finish: - logger.info(f"agent {self.agent_id} think session {session_id} is finished!,no more history") - break - #3) llm summarize chat history - task_result:ComputeTaskResult = await self.do_llm_complection(prompt) - if task_result.result_code != ComputeTaskResultCode.OK: - logger.error(f"think_chatsession llm compute error:{task_result.error_str}") - break else: - new_summary= task_result.result_str - logger.info(f"agent {self.agent_id} think session {session_id} from {cur_pos} to {next_pos} summary:{new_summary}") - chatsession.update_think_progress(next_pos,new_summary) - return + logger.info(f"load_chat_history reach token limit,load {total_read_msg} history messages.") + return chat_history + + if total_read_msg < 2: + logger.info(f"load_chat_history: no history messages,return NONE") + return None + + return chat_history + async def prepare_prompt(self,input:Dict) -> LLMPrompt: prompt = LLMPrompt() - record_list = input.get("record_list") context_info = input.get("context_info") - - if record_list is None: - logger.error(f"AgentSelfThinking prepare_prompt failed! input not found") - return None - - prompt.append_user_message(json.dumps(record_list,ensure_ascii=False)) + system_prompt_dict = self.prepare_role_system_prompt(context_info) # Known_info is the SESSION summary of the existence, the current task work record summary, - known_info = {} - have_known_info = False - known_session_list = input.get("known_session_list") - known_task_list = input.get("known_task_list") - known_contact_list = input.get("known_contact_list") - known_experience_list = input.get("known_experience_list") - if known_session_list: - known_info["known_session_list"] = known_session_list - have_known_info = True - if known_task_list: - known_info["known_task_list"] = known_task_list - have_known_info = True - if known_contact_list: - known_info["known_contact_list"] = known_contact_list - have_known_info = True - if known_experience_list: - known_info["known_experience_list"] = known_experience_list - have_known_info = True - - if have_known_info: - system_prompt_dict["known_info"] = known_info - + token_remain = self.get_remain_prompt_length(prompt,json.dumps(system_prompt_dict,ensure_ascii=False)) + chat_history = await self._load_chat_history(token_remain) + if chat_history is None: + logger.info(f"prepare_prompt: no history messages,return NONE") + return None + prompt.inner_functions =LLMProcessContext.aifunctions_to_inner_functions(self.llm_context.get_all_ai_functions()) + prompt.append_system_message(json.dumps(system_prompt_dict,ensure_ascii=False)) + prompt.append_user_message(json.dumps(chat_history,ensure_ascii=False)) + return prompt async def post_llm_process(self,actions:List[ActionNode],input:Dict,llm_result:LLMResult) -> bool: action_params = {} diff --git a/src/aios/proto/ai_function.py b/src/aios/proto/ai_function.py index 3e10aca..fb2ef18 100644 --- a/src/aios/proto/ai_function.py +++ b/src/aios/proto/ai_function.py @@ -8,7 +8,7 @@ class ParameterDefine: self.type:str = "string" self.enum:List[str] = None self.description = desc - self.is_required = False + self.is_required = True @classmethod def create_parameters(cls,json_obj:dict) -> Dict[str,'ParameterDefine']: diff --git a/src/service/aios_shell/aios_shell.py b/src/service/aios_shell/aios_shell.py index f941867..2c26929 100644 --- a/src/service/aios_shell/aios_shell.py +++ b/src/service/aios_shell/aios_shell.py @@ -152,7 +152,9 @@ class AIOS_Shell: #AgentManager.get_instance().register_environment("fs", FilesystemEnvironment) #AgentManager.get_instance().register_environment("knowledge", LocalKnowledgeBase) AgentWorkspace.register_ai_functions() + AgentMemory.register_ai_functions() ShellEnvironment.register_ai_functions() + if await AgentManager.get_instance().initial() is not True: logger.error("agent manager initial failed!") @@ -256,7 +258,7 @@ class AIOS_Shell: def get_version(self) -> str: - return "0.5.1" + return "0.5.2" async def send_msg(self,msg:str,target_id:str,topic:str,sender:str = None, msg_mime:str=None) -> str: if sender == self.username:

f+Z6wE>p!-vMhGCefm>i_BzhXnErStY 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�k7+8euw+wNt25GBFf*lT;eUa5E~&2|fS#2w|}cQBO(4RQs_t7W$CBS!d= zF=W*NWZk#Vr@cFL6sv)1et+Mby-4^HeapwOVMBK;IB^3#C2)e-v~S=bw`Kv;TpFRU zg@mdj{)|T1Tfw34>p!)RX%l_I=PoN72!tR!N4~AG{Mw zxRbB|E-gR?I(fB!u&5be1}0wN_S)Lzx8|`F7CVRI(;jJ?0d)Gvqs3-AaeBr+D4QE} zdGRlA^k!aLW4v{Ayl#y=^LqD!aL|z(X|M~V+sva#>QR3lSUU`mI4wieEn$sYh}51o z7>Q5F0+lW^tYj#{Q&YEmr^LhLbG=C^1De7CTxZeOgbDq&FHPDoAe41T@>Q}RDcP&@lnIkUoXSlSUMN4mstV4^ieOh%J6?3M|F=au=&qrb z>y?+2Lx87RlPz?0a8S;&7?b)??~c+*nOoWW9uY#C>Blg9aF=EdY$E{r%aN5aLe6W# zPI5+82w(5+uyEYo;?OoNk;9BQWyDwr%)wi))YX3*rx#f(02lN+9!-_}MLTZ(TQfXP zIWn4BX8B;xSD9jJ)L_5h2tPDLcWao&rkX0`KtMq7%jutlh{wE(D*%kT=qPj+q_g&} z31L!*K+b3K=>TY30-+Q`N6=M3X%T>YCN)j(m{;G7X@o|UZXqoEuB4<=#p!i4s|OS_ z=k#}l*w5rkKNgQOe8z6kOMa`o0GB2X#1nJcGJ`$=$BjfJo zYW@yzJ&8Abz4v_C?oLk+9pr};le7`(>gfJ#WoNxa^Od?$nZOWJ^^Js$S^YunK&h*1 z?gKL>=&GzZU~lFjTMRD|_*6s3t9hQB|XENE>?MQ&) zGjxfvmhtg1Sp@sI+o>H)Za&u0Fp3~`lP+0Oc^hT!g4v3Le6Z+xpxgOy$l=v#g&Z4J z>(g)0dQkFq<0gfc^t;8%jb2F16cbLg5CTxUt&GeuA$im(*LnQ#V}p)`!otW_Q;ejy z;P485cOo}ThcyMoHx3G^Soi_^ZvAnsu=VBPXd-$!2yLP0Dhrll)F$K zSyX7*@|O0*%_Hmi0G`@rhP`9(?oFoZyXuAUu>QSL;t#p1ra?(=EONg7R{GuM^oEWp zxLP<>?uuTBTXb-MLUt?RoK*M7^AT<~!%pl}hc;rwKsa_x_7mu=gg4>y@WbZ(z9C8~ar;zk?#m0k z-_bdvOwGV3rpW0xv|86cU@B+2HMal{!@r~~?_`+_J}``$(7gBCJRUm0w297R+P4Wb zpL*#mf5QZZ4XtDeNXb6RjK`ch!8py2vlQj>GXMQY8>1oO^Ze|uh$k6~y2ZlBjafPz zt#68pfwl`4W@(lI{dOW0>@h@uIG4D8e;@hES%0WA@su*g^m#P7^=6dy?7NA0^y|YP zrw!9*Zy}uT73A0xRqo;mVcBje_6`iIWM-bV?=Y0qAD*qsDk@WO!!$it%JaMPVfaB# zDP=uTN^NQ_A61|jtLIzX7=?`f3oF*5DE0;7m&SsP8Zp3BI8^s71HU}Jk|i+`=07Pm zeC-Sjhb*u)|8-NZoUzOV|35UHgIk_|pvIp!+cuY%ZMSSJFT0lg=H;c;GFB}c%UIr8 zwp+Hf>O8-5uJadkU2l)RH$HdGFRotvSzeF?7TR5@OlD$`XGFGqs4jee4CeoG>2eT2 zPo6*W@yW^&^(H80+hkZ-F*gMhfKm+3Rj?EK%ew!>o~J#33UKAFdKXTs)iv)iu9fE; zA3qZ#ugEPytPl{q$bh?KT26_FlqN(eP|72q-}I&qNjZLlWrJbpt{Vb@9(;m%zuof| z-A1S=))5`S{5zP;)m1WO;GK@+g3}(MDJEcs6(Iu_Tmho-Ix$59sIrRQ z$u@ruj_&4s56eHwa&+z;e5dx`SqSf%F1%a@z- z9CgojrgN}g*Mpt7_2^I|W8>hC0`!nHDPG6N?#AqlY3Lc|432{H6qkync|nm#--z89 zK+5F#ndu8ob=#w+GQxv0ewn88Fli;4*`RA?AV9TzukwE6tKfN=RB*=>4kB(CyLsMs zdypiet!mkr6L=`cNYpP&XdJ26a)J|Khfn{@GZhJ#pyFYwoM8YvM}niw`fq>HBP`O`eCKmk)1-~_ zV)+vX1HcJ#8eFr?g5Fv zM7($@rr(x?#u+nr-5<-;_pkpa4Ikz5F9YW# zVAF9>CF?xaCbxHgn9;AzpDQ~(^~>f2N|eUx4C@X+Or4v6tN$JadMIvyiEa!{sjJgy z2q!v1nbMcAyTwnJ?5#;8%W2~pj5PRC+CSZ*z*G&dr2 zAM?I*B~x~2oq_QTI=s1zqH4uH-{&1`nKdRT3NJuHniU@LGqw0&U)<8c)1C=D=nQF( z=^fz05fQSzr*njj9iZNR;hhH*9d_7 z2Kw6?OmNySg^{mlCT8^m`63aOSB385nL8&?IjKkh;dbnC|>f^b+1oUb-2xXn3$5fhb`CS+s@W9EtO7TaBnF(=#7*#`!yb}V^V zVwZ^lQrYKU>~43+fX_rQ46s0t2YzV8jCGxbfSUL5c#e1{LG4v{iFZqx%^wIr-)vS! z&a({2M(6QcL54d_IYB~#bK#4_?oVWNZN}Db@pi8MqvwYe?QXL=^99}B0Ef|~&5>uz zM%A7v&CGsplH~Ei57M_SdH{f+bTBjdi537=nQ-@UA@VHt0MxCz&xJ9`AHK_jm^=6={VU{}*J^&CUmsTN1WL3hHaj0)_f-PaVSGq*goy$8AEpb-> z>ze+AjUKh(;qVE5aMN?p?Z{JadjP>uP1_AC~@Me94&y}wYElw#d z1!06+O@Eki&FR3a*sK4&kqLIfx9>D#z$tM~PZ(w(E<~4QEFxB^C=j+PKB0C%Ge#Oh6t+wJ@1{aU;(&GB4Zn-o{>q;S`^_| ztoy2p@}J}`?G(S!n}~=gdV<9s88L1!VV!G`DR6<>L6eo;}svhOtBxo0v282Qdk zJL)(jK(k5Zb+#7GC~JBkQo1u)@uPy~P?0H@svMC0$B=@&ap?yScb@*h9j;nDVWXki zIy(mX4v=@%(QuT*<7Qv`K|@twobl^Mcy*N9oPo{O2<0n4k)DvDilu$v{Y^JaK^g`R zA=hkhWXWb+=~ju^Y}`ffZp1`veo3uZ94U7XO#{3Kv`!8@45zA5pueC877D)JwgWPn zN8dScwGvWRO_?UH1*}wAX1g1oCIo?=>IlamQ+vdmW8BfxCvY&7?oVbJ(B^ET&|rda{cADB|k%tktkw$ zXP|zE<$NP7uRm-2JuLuuE6M5M<5F2oitW@wr@SzW?H%3!+NB(n0t+(VxD`=~yG%($ za~}$MK8BYcaJ~A}IX#(V2hJH{BTqNYnbmsvQ&{~~rnR?Mq9aFko2&9m(Z!pUbR7#B zEU_*S8MEaos>=4)0uw38wsUtVu-x3Gp<7Nob39~%9&nvQZIe8~Vqn}ZKbFg#wVT;1 z1oUDNcH8FXW=f!8*}{03(Yx01F-rZo4K&%+KOeNNaU2sBH6!9q5(8iMJ# zN-*4viTqm2Jl-996d8U3hR25J^^8IXebJF*!nW{+Xodd06;C)7j|86=tO0MkKPvE| zOR7i*|LU=MIn(1HL!uqTr^b<+Pqu2kJgm5w`4gM~V(dI9k5n;Yf00=eQv?h#>bfi- z<^9Qn2ZmP>EUWO^|9+>IhJMN`GPm`?Lc}~x=fObv7IR@EFgg1}-F61$v!al4#Er{i zoH`nSAANjul%ffR3;#;W3PyBOm1E85F-mbA<~7MC{Yseb?S+`(aunXFZeM2j{5(EI!%=TGqXcd=~K7r;>{-Gmut+Nt;vGGK{^w z<$DmO-OO&GNlv#w*>!iWYOZq^yVS$3of59};-Q@>u^6%IWzF3l@^`!52%%GCFD(_w zV=9L7Aiyc3$)2!Xf7sR}4}pp=Q@If};$_sQ^20d^qb2Lvpun&!Ll2@Vou|boqD0%h z!Go6mxz&rjWmwNn467A@9o?g6lAvyjgDG}z&~eozj5WzXcP{Bgpw*{oa936l^*)9b zLE^A=xMlFKVV=&{$qp^Lb)fE%UF|j1S)8!_RX@<$yYuAyop1pWLeF*XXRdz@0ijK| z`o)xG*hzk=Yik2tC!|+G!mkxGx`GD~02_0pVQ|5z+a0!fhMKGM=n7rm`K_YWT;(!_ z&03M~D;{26Q>lZ)(M3O{STw0itMSY$@uqj%KcDryAh_RG?+bopFL-ZIwfR3{$7`Gq zR~3?IK1OH%5g@xjs$Tdgrdwka=V~jJP3;WTL>O8zB49TH)si>9$f6&5b~M;!IIDkW zwQPK~34)aBVx*5n{rPwG!*CXq#QrY}z;Kc9=2hYaK6-UJc6$Mz^oO~01-v!F;9mkdo?nN*Hr*!6`| z%j37RT3w3b4#Efq;&&LN-^*Q6J6_fGci;^Z(#pd>i%O*h)X&P5Q`)-X=x>YQCWQ2d zmr~*4f=C$}q^$GZ>}46zQko_9|d;%Hvhc z*Zgdo2CFHY?h)I=`}chjNuPC zIjy&CN9Prwcn+>CwIx!7%|x}R5Awt+R_Pca5jJWVw`&#l9rN#+mzNXi{;2q6{S7vK z?UPqwz~Ve}%13f!^YyMyq^QvIG+#+Cd7jFmf&%O-LRRS54yRHDy`1XWepy`hFEIjH zSKbYfQ-+76OvzTFG$Te*()-n;uf2_+sH;F@0Z}zw$=qmn*y^daG57Mm(zei^r93Aw z0gaXf37I?mbytckJ=-7St>~E%Q3@^%#oaP_`+3e#5M3Nb)HMBjE}Bk_9`n6_<%X7( zAq4R`Jt>fxre7(X!(w}Cp}>ySP9ce!dgOugVJ+EOD&g3d{uAw;wbtM|g1}Ew>6)dh zA^M*xQNB1k;9Gg~bv*lwh`&2o>Zy*SBDNga)disTG;)>Eu!2~zvUR~{VsV(av0tRD z+o1ot9s2|HbQT%>JrSb8jKR6lW3?+tZWz1x-GkHkN1_iyRUt~6L!o&H|1K<`kjmY!W(Byt}$SNH#|GIPHpPQH6)yu`RzWrhE!04=SMbjE9x z%~;FE6j!FU)L)4*&=~U<$o6V{Q#un&%$msq+VP4~D1lmRySim482i8#HvEWgOG4%0 z%*fpLTaP$o?lNt~$nI}hytcFeUO#=h;{avQD|5C9bAzz!K^p%?#}zwvn3JOjbk!e| zhHO&SO4oi>Q~9%EAL}wfVqsAewoCgavp^iuwSq!6jhITD2<-Q&^1jhDmekL+2M)wI zI^5?y-7hSl-E~(`Scrj(OKs%6%6mVLC?0WWj}oLI5eE&^QRBBeRVf_+oq>bKxohx> zH~UdW9EniVf&fDE(=W!oUpSd74zbV>=@oN-A@e=W^L@y9BA;dCzX~ZH{(-uq;|Xq3 zHNAUckQJESc!!d2wZ=;bP-(bKjc&U!iF#e{YmkY0D8xO>etVgpLA}Yl8WQ?Qa)9hX z)$&!6c^|ARrD9f6RS6acq7>b$b*ojeq=uD|!Ow}U|2WZ+A>L%*9vW3CzF#hsZ9RK* z*hrM${N~4-gF5XSLyXW@$1z$FxDfC_f3D**@X&ILUrmcIMk%$8>$8QZMkUg_hs`mC zW!|`W)*mc#3m0_pl)ibbi^8G5{=cETh;-8+_|Bmxx8q?A+qWpty5np?1WoZ}JpKw$ zC(YzNg7ewx8mLo3{OUlRmFARnT6^*8TV!doB<%4+@`1=>Blxaenf=ild+_U zNh6@n{svETCTiBbKko$wBjr;0;5^~Y%zjTrwCixuANd{1^Y*O7%(bM!4Bn(iw2kP9uKRlEpNQtaV!aiPa(Q712 zeU||jkPHB?i^^X|+f>~PKA}zwrlOWz;_mQUD;d@^hkdK&J8=K~7*C(@?t(|R@*P{t z_pUsQG(S^}_5aANfqy@dw4|Q=p|7;&3qyyq=!z6l(kFkTFBkQiCwAf1ao2e!CD7;F zcdfv!!U~}*Pw*q+TF_9j3#*$?&4Ds?gZB?O4GHn94ZjGAeu zm}xbkZ8;NSOGjgW{CXW7{2zYk&h=>&3tg_x=SFVDZLjnEehiDB=XlW@NxHvCYS~6x znopweurUsu+)Wg^?A%46M9@r&XI57BNl1P?mX-j+;@R#<-B2%0;q@=f@XCtCP@CH> zu}$IKJ+S2_hq-iK{QBhUxcCuL=%*jvu;o_ak!Bx$ur&*M3CF5=|4>;YE>>`!*W2%~ z-nQLyUEgAq4|fP8DY9~TUNu?Z{pIzd<0$T>YJmRe*-6uHv-~0RSI`JK=$+H$Pjgtn zwU$0k#!5jQHf)lyW+?upORV69n?zeR-(lANvoY-m3pXfOnSi5zVS|Hm8d<9Rw5S>n z>{YMZ2;H+0FKZ?`{6=JY;KrCGBtqI}-l=6Xt~eHpoIJ#^=&A+)>^MtdsYO3cuQx(i z^w{Om+K_?o2p^Vx4TNwoL~ed+?VnMo0D$?Ij`H%Ke{x@4ME?E#BdeZpa(MGpy918r zEiXqE$+x>*pC@$yP<*y;YduTTe1&m%Yi0_Aun`$Z)f_a+^0s^sVAuGL;wla=;;FTX`-rAY*>CopsDsgLIwH-*L zhoH@y6}qz>Q|YdeQ`9=8K3Eef)-d(1u+tp^cvzz4cm04T zCpRv+a@i>>B^ir=0QJ`L25r-yIoXPl=yAs^_1%t(0fNHdzcc6HXOLNd2OTn!wX`A% z$P%4bt8?{1JK_-`9XFjMN5O*nbMW2$Khjq`BnSk z?x$96BOi>cgmmqtZ#FhUoc{}6kR2W1?hP8uLA^V!66<^pP{L`19uei-uJseks~kDU ze?HjEFhnX?;{2EEVk7Lt-|P_}%NMFGezv0GBJSxMS8eoeEW;7xITRfQ{tB-KzqX(6 zum|gSAo~RD|9Qoud|HUY<|eYE=K=wXfA>DC5#oGZys$?EI1shnA6=iE?l#)jA29x< zp=$=Q<78^{W>0AsooJz4g8Yj}AzA(L9hUy7;xP>q6Y55dvRAPPE~gw+H(Lj)wL6AdL$$lB=5gX!vq5uldF0dJ;o zyVYUHOD1BoNKyBq-ROcy*TZ9Dtz{L-#ubL5S<1{`dj(JFI9-|V2n61sH3 zg-9SpVNi^N=r}Q+`u$qs0;KnZZwc6tLej|&Fe8%`bt*R-Z}3z(HR5P$~$oBe=> z!8{cS>wz6$Z#)7IoEsw1;09>)FF<3>lCb;RPdmsfmI$fj%mpuDjhxAdNq)|EJsDre z#ZixI!(Ne|=oU=@P=TK0*p-?L`>mUz`ufptHo`JZ-B!IK+%Opt%sKFYS$AzWBdfyD zS75Fe7LkKTgsxIR>cWwxf<`&}5Uv8B`{vvad5vU?iqmvy z`*xHpDZn4sM^VL~7Rki&rsIAq76}pd!1GgD#5W2MTGnGT^^lkZFCki_08l=jBc5Xh zzw#r=4;D!N*K&AsgZvz54nVE=2~m^@E|EbQ<*WdNQ#sgi;EmQ0YY$8BjlXjU(V_q{ zPF@;|J})};fDdZNJJ3epM&!w{C8Y@K8n2)* zv@>s%c*VXH=Ng`>&JL;^@fRb=_{Q{BQ*gjfq_~6c)z{m%SvArs5rjhM0n)OPIhG`F zp=K0UgVX%ryDnyw;qIX{*AGR{G_Xphi)NF9&sl_g{ok&%epdkJAOqs&$71%4X;1Gx zBA`K7tbwieS0Y2;0teaoefjwSdk}o^_32&TQH6xI-nGjdR8LOdy5QSEC;RNQXTb)u zl|6%OIT|`D;ae#!tystMvNzU`eqTrdh4-HntLA0prv}=_+|bakQPZ-npxYHyMW|PV zjWk3t3b|J^^^t;wIR*d_{Wdf8!?=T-{=>iVgmI?po<$fyrfO+M*j12RAmEG??akCNTz^~=|^v^fsVE|-NR-j*%W=F)cc~O+E0V;B9a@q2(`vCRJ^;DU{KzK z(N$Xvjh{?>6+ZRqXDLHX8lu;W#0Vk3Vh<=1g#E9$)M3 zC|r|`W@qmY%EwnzY_^Y&e~s@}eo~ji8J<#PRAQlqqlu=ft=sHh*gb=a(~_bE(4dA( zLZfL|Lg5GVC9tGyi+|gBd3$|4)jv3MH`Vd>RNH^Yf}C;bBJ!bBde;B0)B6luXlq9( zNwV%%A~aU#09#Ap~=;GmgnD{kMx+f!_f^ETcb{d=3{>4;(O)>zJi9} z7ZX}pSaUNUt4RlzlqTc7XoBU|+qV53pW`|Z9fK8HtFj`0G049f+L07`^0)Y`+PkdC z^$>AO1aSQ@|L%uqW=TP|u;?dOx`b7KjO`V7_P(BtPpPT346T0yMSz~t4%*3kp7zY64oO5yy47;VPGN@Ih8{pkKcK#zk5_VF$%U$F(_K@F`gC_pz!#gQLan>} z37wQbR_jRNOfe8VdaElNGlakXMq^R(|56`lZcyvd2LYlMU&(U)Z;3OU(=oY#1#~Fe zgsMRcflKQxN%*Cpzz@7eDDCx&s{l*}#sa(8WJVAEd>0Ohg9rWgUX1CFMF}ndra6Lx zC+<3NWAlk3#EHpl5czO;L4q0Ur~Jz{ivS-7oEGi=hYxN?D3wM(P@--r`|57XKrvr5 zC*YDvlest))6B<50UGZkqS36s3-Tn1$izIb!kz;^m~K+VQc|H+lxLEdGt^Q9zX70@ zGW5GTl2~_Q%&$)nXws(SUpe{wh)`yAD@0&gPGuXW(x4rq3-s#TC{}s31tCb=^NR=HM8W<=k9oomL|=yl)NHmc_Zf0B{yPR_$#LCeu3oU9UJIk_;D7 zs8_*jMw|Y}Y^Xul3^i=BftS!VgC6KXG<(qG;|=HUdT6>|u@Q?l3c_McWmClm1l(+{ zMS$}y1mI_u=h#QPhFq$R=!Q??RcauAWETPzXO6ui<-vTX`M2X&w|vE&`1xNn0G*mG zG-(qJY~l)oTaRuU59osx7bVMUAzzxCYEP)--ZhCXB@}F$3$}K~vJIjwOZtIh0drM7 z@$)8--x`5##Vu@gF4ngVf^OAnLUt^LoQYDyY8tjj-NE<$_u^;1m^^H7%QLmlk)-Ee z+fQZaMpMZe+MNmdJoR@WfH)afO0Dnd7nuhHrPA0C{IEF%foP|6(hTx&4m*^SdNNPuo&bb;l)Z{ z@|#Pne|^se|Cw-2W<}jk0vIQ&@S+ri9O3k0%}LTatO?7`ZODe1S!VHQF;tAO2+f|U zH+C?QIYVVOd7<$PRq}7LzEBx{Kj9+IgroGBll~>JcM0>ddUt}zDZ&wB#_>sH2>MyF z5FzjNHt_zYLHNL}pX=(=%-rVnvjG;)d|Lc!3b!}C`=h;f~1-n|I#uh_hEWHHnx|ggW?$YQP9v>X-OW@ z2+wLxhZkpU3?~zTUd^83+RrRmVIYFQsMEDe8 zuxQW0E5Zt)oBxzTJdm78xAqWes6iR4XagLQEi@yrU`f3NcgV$G;VJyCHF#UBoPKp) zm2;%vx3pY@?!!DZAAdi;spBW7D|f((9guBISzn%+p}XCHPc-2WTJ)kjS(g!)lGo$s zWlAVF(;8hBDfUKWK~kKXjsB9b-o>0_l?HtNO7FTNc~k#-ik<}D1NHN;B=WyK#yQ87 z%$-!^FK8WHd1dWz`FEud5{N+tqvD5DRmD>0aqyAI7xzaca%hWdh-99Y1`Lqe(i<bADvk}WAe86Z7K3KyYMERSxL!8&>XbQ`8iL)|#CeySNM3y`97l))ZvhLF`Ot9yV^1=kdNN}G= zcHscCF|v!;#zL5@&HgC2mobybqLZP&R4xWxD0cw|&e{H_#F}~xy)@i|I3oA9k@z{% z*08$?Q@jIAnP0;69=I8hsnXaB>}!?XKsqqoAJ zs_Oms$^_bw1MuWgYkfm<;C@m{F}8LzO_K49*ST5b}OuvAb?`Fagjfna?0z zF;S+FAo2At=kao&1lfa`KxmQ?Mu;U_*Ifg{d05^*yaE|zynR~UrUk#Lk$r)_w`lSm zm~W+%N9(KNN|Z@wZ_vR7xSZ57b%X4@p-LL1x5?3#Bab8)MA=#&5$%Bn&HBQ{VXY!cFG< ztUtCc0{Znu5)^peKtzUICJSZzkkrvoB&9}g-Cq^8P#%AYP1`XX9W2yY>>nP6=Zr~O zJPX14hpfIp;^=FLa|cjuenYtb*&*xX1>5>K1<@#yN=7?Rf3S^!#Q|V5Zw2a+e$Bu8NP>9%6)! z1uuE-w|4iFiKnpH9aB=tdzapj^WPryf30sx+m;}^%YZJ?$kek%8$FQL(V$2VykyZL z^@v8jC%kf-U15dpAp#lqCN`_^X3mkDKLbZQ3UXWP$j?#^ENgGsNUVa5cc99wIC=-b zv>7p9dBxFrtbA}pMfDGgep=!TU((nwO^aMuHj7k>uDrMY4hvtMfiSM3OY z&&b5Zs3}Wzd_~XkB7BNybh(ho#eGWa-zk>QBO{iCp&xcwE??UX=nFzwbVnAF&eu&j z@4fdO<(owXD=!gtuJ^jlq%7WQQF6%Nc#nqQgxm1|VK%%mPd;FPtNENqEalvK$NtDKXA&%s@Jh}Ywc!t$oz&4##X5E#W3#?H6q<|A6ZxklbDdEu zpv(YtmQ#p7uc`(dw68~zqtzxtuUW)*uG7tI&9I*CPTdCRhU|8B4ZaY5({Bb)4V#_Q zHu7Gx$E@HR&BoegV+oZ$OPZw-%SO2TiG*yx7Y<zMFIx4_fBaoYWP>;0hT{Mu=KjOXyAVH@MJZ; zpnZ=PgfmBgb~&P~2W(J_bb)J9=I~qao(GH@@vX-=R`f7w-o<4deOYQZE1R}t(Uwy0 z;p>HehQgz27#gw|2+j(r+ev?~tiOBM@h^>`D{Ge^kTBKT%OEHX03&RMD}SaeRow4w zU;(_JByW6jCIT-)Yh{e}-tM`)mipd0) zDc4|qVJ8nsz%PAG56JLJ@+FfVUaQC1m-mpo;Qr^XYsnGa{ex2}Pfy;%!$Wv|Du;+X zX^B5iMvv5;fvXGM!7c?>`NunLIIGuhUww2?PAx@~WvMIKFp@$r1J7T#*k86zh{zAV z1hc^S_6+6okpyX1YqnrM5WD?A8 z@1D#{U@mBq%9r-?G}JnxN{96;&}|897qd&!m5=>Um8>k$QmQ>VW zyAe12rxyew^YXBphEHAtp_1i^xE$w<6{lKV`w-}ewX~oL!(}E8z|n7rwM;vZf`+GY zgQ4){kDFBMMxPR>O1@%*aD*_Qq5gwp9{CBOIWmlG`7W1!%>LeZ;`cZCw`?N`8;fpX zNr~M4vX=aLEFf`_uk!~7g&Wo-nnDhoXoLHTHvUad2pgmM!QMC|B9=gUe#Va#;gvan zDUwqw%UI1rEuM+If$Yrp|FZyYs8JY{$&NjP@mX~0r2JVMBPNMYytvDH)BzWBiI#t( z$I3hhG024T{1FULY&ioMmczE3exQvDKhUkTp5WL%WW&{fPylaL^*_)9A_Z+(P#tff z#+z0$APOiZj7tx1Sy6)-PO!e{ba@8MWxkP0@YzVH&20S`4_bM6V5d^s_#9%ZKJ=xE z0c?hwzpiyA|Mqe~RfIvX;(@<2Sv2Bz}lYe#8dTa8jl&F1@+Ll^pj}`x2gyO%6sk9xb{{ypMIDdgug$n8Ka>F8+gU zttnT?j4j9S049JcDR!uD@^fXGwBa)Ih&8u_6+)JHQBfmo`UMyeOqH-6s`Mw~JR&rpv0rl3 zqxZM3ux+ckT$;5_uBxdnmt!qw+#M-rhQNeJOHO3<&_ISRz(L*M8^k5Fc6Ks>TFS)& zYDjsE9*|^9=ER~KqZ;XfXZBeL8BRSK!r=}`tft*PyjW<@H6lPLF)0?#?9cT9Pl-Vw zAvXrf)yD~GEPdKUhf$(h@#c^B1nhU#`ZZ~e{7I45D6t|MC^;B59mxmy+HB)EP;y+yYK>ilVO`t5Ygz&hREC96; zzms}-+r7J8b#QWV1@v4GIxN-X@(^Bdlq%m;mRyR5u{r2c(_xxgn! z{xp5PdCEW1AQFT%R1+s%cIm=@0~@F!t>?|2Kyemsx5#0@e^47Mp-#OqOq)(Tc%x^U zz(zXPH@YW#KgNNdW9RzGv)Lfq?{LJ{$$-Y9CwC)2oV~x^trwnfGd=MYrR1xmPCbR` z%cW>5CbQmWwRHQ2-oGVb>8hfHL2zmf(i9)my9bU5kViuF-3cZlzh+ien-r z+0ajs6&DHJeuQEV7k?yB2aO~}KzRmcP8$Xo^hTQrsr>#rgQq{G4F6(1lATPv4YAbn z#W`#E$Gg-x>ngw~Ii1uW_1Pwnn_O2j9REf(d+;W!1qF2o6-j%U&$MLW>5Rz_kpiIl z>RMis7>5M(+;o}uBQRB%QS|{ue}y0bFlLE}_*5G_-++VU;~MEKtY8o6aDGD9Z}H$P ztkf!{2qm0A&E2QUA@6-4%#QaY)unZ@QmTh`>A!P?zkb9nB>C*uu!bPtWKaGc&|u0y-myt@SS)w21;-? z(|%+J8i3YPC+o^wthPopZG?{I%CFF4*fZwXn^`~BCwVTahw0f_dOk(r>Y57qKgbbo#bK`)9Cx`2YRWA{Z_)dS!t*u!0{yK}*j{@@BysbAX zEk$+@C}o>ApM&m)IloO2?_?xTjR0%S*vRl4Gwbo`*=_JOD_(VtnNteS=;v1M{0h$vY@@asN zl&O}0!XEfmWZY_SS7^PJJ?88Iz7qE%b@`s%U1S=XL_x>B0)El`z*C}h(r1GnfMXRj znpW$wgIR`pW+U~wmd2B06l+=#K%_w^}2^sLmWf?~!LrHJT6#q#Ak~bX>Fj~k({urQP)d8gU{87FdUVWn ze1sIM@sb8%%(2N9xz>|`EJH-J@Li*~*Y?<55b9HV0!(kMEesyqTTzV>gPhD4{&Y&P zR#7k*!e@zy6(d8pUn)Vx>q0`viWeg|CM7^YueUhlCW4e2E@e(;=Q8Bh{O8)Vu*`-w zSwYcXWNAGX9{9XWCoMZ|mf<@fQ?Cq)H@IGFP3e7W#w-1u^%RTngQ-Akj`-i5)0E4W zmTE2pdjf1YTO5LP@uP#ym>}R6<}lNhXD^%zI6X#{Kz{U@R~joHtY|>%1f}Vg5osq4v<~>fDwrT%YM^jM;bm))V^B;0DyN@r{!W+TA z&h}P(%U=)k*;?Om2RWi3?_O2%Phls{Z~S)YA~(HH^v{pluDwJU4|>pPy`LJBU5V>8 z(sQB2AO~60P%F&`K~_Yw&(~7{AZE*FEX!HeGZ{T!S)WfQIc(VSl8B5eq_J4GJjhi(d6SJG0{F{YVPJ(mL7;uI30?NuSgohgoS94;iZ!a(ck_94T~+1 z5s=#_8oZXdBU?s4BpRzgODbQr!dzn&5>BM%6@uZ;`GD&|MdEq!rbT}qGUvR+ zDrJs_@dlBM8Gj$ItmaqtMgtk(wIj;SmWPmZkN*3^yQvyP2QxVlp>+nv^*Q zEK}8!7beL%u`(~d^8WtqI%V+St#NVN#_Eb$i{>cmi#slGrTteKCc;F`u}L<~l1@p+ zI?Y~6B>Cs$v;27q;wvdN8*>#co9HJqPxP*b9@y4bLh&eqcPRJumIEjwbEhkH7NcOn zKCOZ;(&=cR zg8|4wqM{It4}YX5c|w3{#Oc9$e3m-P3rz^a-=Sts4gsjTe)PDX87f4=9%SuZ<3Sf| zt9!>SU3(8ox95)D$_Lp3kbWtelKmT*A9QpdM#2uL$kevX=G_8r+Shw~$5pWrz6nqt zHQ3#0E$!QQz_j&-`k2Sw;{auNdo<=B<@-s?D1NYVH+JBWB$IC zg`j*0Q10L`+35I{gJxL3ENj*5bKu8Vy@BH`GA_U}P;pUo?1%P*Vr_XvJ1Y6FjP}sT z!&4EyROVNv9%u^4;H9d#4~I;`BMpAAwX;jZc-zQd+`Zw2!r0++?5jbs2Cm^ofYT_8 z^A!~agOi{1S(%}vF7ZbRRCuyVd4abE*IpEQViAayUtochFGK_o6CH#ds&5X%#Zh%$ z&&>Zep6)*UKGgmD(U~}HNR@>BO^$7$4HJO?CZLQ7Fs{qJVPU$y-HdVYAo14{l!iX- ze7oad?-M)NANTAKjxW=G8F%CbO3qxBgJRu9!SG!0H2H>j+PwQ(hOt>zVV&X*H#xlZ zc}AU?6QjD0aUvq+J!kX!IkP1?YC>6&^E(!2|D!xw4IT9U=A;o~fsc)_v_(aQeySP_ zLTEw`QEadWNkK+|JH-Mw-)?HFvdfp}J&1e}Y5V&XO66S-nG7eF=Ct#D5o8e5(>%Jb z{;Wr<1s`(5kW4`Cdk{~1+F$>e_SMUq8yL)TDXqw}I~*Y-KF|1Nx`^ z^_tv!^bgS_=CK?OCJU5ngsi>RKL&`itqtqDEvDDP}jGCm^t5fec#o)IKe zPZPJwPhRVwBRF5}eM#Lb?}X?>c25qg+WW2vJ0a3_q&Ze&T_3%FG@2v-m6U0Lo;8T|v;K z6!P_heKXuQK~GOFWc44GBRD)f>S$`7DH$xFH<#ML>!+VOlGxnB;*U!$ahvE30s19W zoY-_L^$UpjF3%|b9<8Du(B$5RkbVIlZ;oi!ckkxJzqiNiSW`&D!fIk{pa`^@8A*gV zMMkKzhNL-FsHc5T4$LKl4eVwlO=rLJ{!)Qco@YHZOXcVvts#|ujK;^4e(> zuR=qYU4$gSc_Z*JiXlUe#K5mL&GXopc?_un zfybvv*HuscO}*}ayp68-jtrrs&RzJ1s$#@tjue{;XyiGon(RkqcZw`@LT3=tgGtFs ze&4x`@oC$zMVHQnXfPb+#I!<3dj!v!#0346=>8lP}wpuXyREUOxnIEZ<~b zBsZL&oCTmNeVGIMwD!$}{|J|lm3OJeVF3mrV8SC@8hPaEevHB zO#tP1$IbqRS;zxLLLKEEQrAhZlv!ui`=JiqjV~-H?K-BqOF#xLJ)8i=%*R!o>l+;4 zC}dM8RQ+C+8MB8?C*Ko+r_!K>0^Expwth#N*K@(XGWZJsgg|VNKA4c~kbUgiE6(*K zqwW!e1!f+V645MPyF{m6Sb91`oRH0_OBP!nmMZG1i;3T_Nzh@XUKAxn8Rxey>393W zj(2U4S;`?YLWCk;#1R0-S^y7p+%d{&bnee6pD`0CL=t>DPzA zdX1cf;4O~d?dhsNo>~7=#7A@}&R5U0vQ#0PFXZrH3mtY1j6Dr#(S%{anoI9qOEd+= zRnvUFd-4QEIs6-k19zAB!aCc%Y4hGO;RlYu;a;nkZ^4xfdr+r+wZAU^5CKj1)gYa`(TK(ShY zLywG#tPOq@gOC*GR23F{7afG)G~K{lbL`*re1VBnM}89M1)#NNGO!EjI~=7GVNu;7&6YDr(YrdPKj5CETBtjf%Nv65hl-yU=?9P9k^ z7pETb=D84#RqU+b=Y?&nmE-OfUS=#aYeLz_h0EARfyXcA+VqF#cdzF`|E`{1;R|fh z<0>OO8FE67T-u0zmnG>kQ&j;F-GYt6!_Qz2e9E$=PN9$!Dc_E-=_bBudRKrkja;vy z5?TV1>?aXg_jmSaDH8D-91;$h)F^K{-2Z@dU-1|PfrQT#LpEn!s9+?H38oD)WbMF8 zQ3wZ!`0f8RnL-7n-OCN;=Rj+2LnQ1svJzt$YU@?0+?YdSGMrbGi8CAHYQZFD0n4T| z+219_(r^!*1qg7LnQ^{67si{g+1kmK($#8ge!1Mg^pz=hs0|QgT1T&C#s93%DM|(O z{JlM+G><8$i;L{#)meD*_xncKKEsY$@pGq_AT+odl|0iBronESAe_d0@+XzCH&>e3 z4P46ano?$yQNV2Qc6ie3^V=@_S^Jq;UMhyd%xwtJ-~h`=m2GLMBV4g9kD-JNINTwF z;)T4?wCiP?PV4TJxHNi$O%d&<$fEkQ+LWjdz(V0|@XoAWen}Tfo6Y>(46|Z=tffxoSzU!^ zz%t$mn#3qD)i15-TsU9>1LkIsGC^Y(X*m4J}?xU!005)#n{ zeqKPW07KiG-lQazcBtM(HFAuAue9tz0modmL1MXO)A96-!ticG%uQcsW?#_pKeNA+ z>&syt%`3N&(3lt%#hg{qDI}`+Q(b!(KSc6BG@S)k zl;7L7?}4GaJETNfK2y z91JAEm%Jc%zGUa&n1z)CD8G3yw)d=ct^Idb%ZC8#g6_IEX_?Mu$^!=LY1lXMGz>rz1T3-4GnE||o=P7C% zkE2dHWR|s$tO(H>g54%z_27DgR3auljlfWf7K}75JK@iKxqEAFxJgi4W8~CYpPAha z^gFops1BV|(EZBjSykA4m~4t!p}pH@~aodf01XwIsB+I|i74Y#gbs_c6#-rbxIHvOh6{#28-YbHL* z&TCX+m-Mx^9#wdp-{{jPXc>uiyC&%7=iP}KT(pg{x^vQtd4)*=QGRn&TNu6;zQor& zs#-~{`cq`YQZJ_F)yioe*-;mbd@4Q=llD|0sZ=PFrEEaWk~p3ClM*d(RCMi*;OW^~ zP}e=r{9g9BIz7xP37#0{MZUW`Yb>Pg3~p?-*8Dk>tdkW?kNY-rjbG|aw!+$qtNpF& zOH3+LrB-XD4{tuc8|gsiUuD?&h)LMp3JT2^g*&*FW{w=ozy#rdU%80;*c0`sh#>~%C= zE77tomxSO^uOtxW&J^NK^+OybnF{-dG>NnxD*s&OaTA|pANepluctwlmB`?Cq;T{R zazadl{K7)~(O9jn1j&z9yX?go6ctVL$v@VaIm?P8dJkidcx?vFy94&GP|M)KTH*eyt#C#+T>iLL@fw5!p zgf*OBdZo=D!`lR1XK&AOtwG!;1OD(D5C8{fM_A@mnTXKfseZWBqBWx$F6DBA zHEU(t6~oDj7iMFg@lqVV_kHWV(M-}OVM@{I5)?qS@)epIEHvgC(Hr2aP2Ih%&CfrU zphDumZ8dk?#pBA^kwSpM4ZNAV!!Ng@3U3kH_g1SZtV0z0{lzxwr-d5!@q8st5jSpl zSGGX`xZ{X~5b*kTwmXh|e-71JJQK=YVxFFy#JtXfD+wKb>-?>e9W8q?sjsp`49U}I zbrtBrLWXjS-!kL%hfz`-w*G;Dei#Gfbb)P6d-+he(Y<(N03i4!w8@AIs9n|2EpZ6rZ-5vnh`MTd_#N#1AeG& zd&~kEgmrc0x3RdXPq&)Ey3Pl;*l4$QRLeLd(Kxjs6qebcH;m1q*#;i{l@xwJSr-8| z+22#S=iy~%XrGs#4{N1G#ntOy1u;3AjjMwN#Mdt!*hzFgq*?b4M30upMnpK zhDbD)Ie`1@KC6dmHmd|iX# zqz;T%XX^Fy-li_D3ePUegH;r~#N`b2*N{ulUnCPww;=#myTd?J-*nMr(CNN5#v2!J zsDuH>CG1`N)QrY-}!oJ zX13)vsx_ZqIK_rNsYmG|KErgeMFWch_qKx+9N%eSbuwg-9kzA3GgtthWu1Z=_3s}q zG;&RNsEm9AB|gpdsGgi0^$EJ-nv5E=vL%ORn(3-3W)%TXm{5 z#owp^D^tb@`Cqg&6}|ULqM)j`*D1f9#Y_b;QE3n$$FT2qzuBAh$Rx)cAk0Q}`?TbZ z{+Mng6;_>b&VXm(w2&t>&|q7x*Gc{p%`P>Vd|+~tEy~SB#^F!4;Y-WynGaDgI`&NZ zfur78RK(6ER1Qmca<2B-^lv_EdwC>Kw?5KsZL9lkNofiug%0R4@HQ|IX1_t*7AxUT z?l;h{BI&!-Ym=+w;;yufKg9lFG*dX9i#M|%R1D=E3U{L8`Zx{2uf_+X!nKousA%_6=I$_h6}Txd5xMijqX*}vQ2!w`&IpJkIn zo@cK%hMZDC@Wijo_>_&&2{$s&*i*j57PAE+O5ibhpdx1PrR^we{a!t&9?A7Gh1cVg zm=URMR6v3a=`Fq84w_JU^iG8i$Kal1;)8E*^T5%6j}@5!$|tVls|=v@n54Saw!wmy z-d}S;;yoe7`RaCuuwmDFCb^igawIkrDp*NfJ7PWwf4IEXJJ?H_JylFnG86_BM62qW zsRjnx(sBx=fp-ElHq|8fC?Hm_tISWBB3ZSaiU0w4ijb|%(z3=KbD>ALp4j~Qz(OL5D)Hb$7?rm&Y?G_a9!j!!KYCx$>r<`NMoRi~)x#^JU@6lwU8+H;!!@Y$5` z!OQs@G4=TjS|wc~rh)JwVEx%nqPA4a^bKX!E9+q<62BM*2s%2wi~0PM$@OgY-4dhV zb;xa+B0Yd_#9(v?DLhQQQr}+R20ji5b50sBCN1!7t9Hf z-LAvgs9tztnYMwo3{Druh@80vKc07Z`KgB)2Kmz^j?{n^&2`$03#!&uMttrHC#zs2g3Xf~7{NQ)!1AfUKZFRQ(#?@_dRoG@WmqQ7~f_3Thm>VU+32=?ZH_ zdFP7M@Z}sLOaU^o!31=;Q~9H?l0`)vHC02Et#Mf`q zWZrwkr9SdZp$XfFQ zgr2J?wuJ9LQbI|1*&6Vi*E5bX9BQwZET;PH3v=+B_A3(dKCnqFx&aoPdw+fog6Vy6 z>L#E?E|`DZxMa7yHF#jkCFAZniaZ`w1C0TgR!cxjU*=c()=_o~e zojsDWv|ZR}n8_^0=^+h0Ot3)*E9>@e%^<*KkNz9hX=0@I+b8I9#F8NELVQLxM*gk! zbrXPGNv3mEA1eMcIltiJ_15L_`RTWxYfcxbkaZK1dC4pX2hf}Ox@Xty@uYo z1QYcTF?Kn1bo5v{dDaDiK*s@}DmJ^+-gQE=gDZY`dxWvF2kLfc{vLn@?XceA>3H1I zB3i&-!7F>q4w%ayuKQ(< zZnN5K93HegFbeah1Kfo}QD3ujuh=_O*f&^MH&U%Ry6?EgsORsb$cN_2f|1dksqJ$* z_pl$yUuqrSe6?&F))!EnvkdcXZ!fabO6^He6ejD$0sr1_2XuhgZI+Z;K}qen`qE&( zJ-p^6PP^_Lh`vktb6nz^5pdyCf6A=gB$e?I)&?2mdV2@OuTKh5HuTOS8Mwxz4*qU) zW`@ij6f5;=@|vdM^6Uzh7LVtz)sZpS1_y+q;Ik;Nsq2JJqDaV`mn`~xS#JAoe!`t6S^E%6m6q6fw-%-tbIY27%P=?{k|6hJS4k z6i+?T@8AAnIisObO|)pRcu((tnj^n_Y#j+an--uXj0y|zF*Xhar+U*(45&Vac?u9o zhoY0IQs%3#s~_<9_{|ic{jJ>{uieKRMSfP~#W$d*2wj2&f)DgW>WTS4N8HX2n4Y{* z3;^Xp=0_Rn#dbJ16*EKt*Yz+i4ueZ%%OY1^1T*`0LI3HjvP|$^=8>i9!(b4Et)I}N zumm09#5X_SRUCyt2FA$51T5F)aLWI5Qpcw90nWxDQ)dlD{F(T@@cmMiMtB~cu%(vm zO1PQ#3(38~_1@gjzRI-ez_p>f-S9FiQLiRyc3IuW>jq^agMY@XAVPZqc^CO{TG*~u zjHKi7I>5|sYWF-H`YoL0gl-$0%x#|YO8_9Zw2ER}+&C*2wO#7BoVf%Y_uST(vx1?jjxnm(+q zWeL}Yb57w>VKwG4oN@@7^=8&%oUQ&jT~tgP0eKpo8-63v5jMRN3)V%U#+HU=;G5B) zVLA1j!&0Z9Z}afhsK92P628sGeoft(+TLod^2{V;&U!u!R!n$^b#tF5NbjO*HVMzY zKv4b1kB>9bA;UwPkdewCJ`d+3`_-*B0Y8WUkRuEffK}r;&P!On!gOPlWqZL|nF#>c zG;9XQ@N8_8fq9bXC;)UV8#q_Cki`Z-L?{Da{5!}=^S=DNfMOYyf1FyDcW!nb`7ZII2=J$RupUm6VsJzpI=$oF4GMCl%&GO&D7h@|fY&W#IN zSWhR^)`b{!QK$cgzwvL%@0}!@RBbhRm?k1g_(xmmvVj^%G`EwMF#B^vG85cWYRuZ~ z%0{#*Z*cc3Xla3da=tNl9FHh%PgK684==L!n+fW)W>Hn`6{z7pqL`>j1?NYpRk=ou z41}B2sXXBHV_7B8^vw&nby-XU(mc?=Vr89E5ZF+BuIRdBg{N&DYVG16CuYy zuEz*IiM-`~)3C6Ra4jboyy$mB-*MYZk!x*%=o(W&sfo^}OwxhVP?c2WrB5Pm{}3R; zq%ld)M3=dRoSrKYg-vV9C=$TCS`>u^&K{7Wq0%KbVaYd9&=N!}GgzuMWkJk_K(=q6 zVSyV2q`T@{YFW^C%OrMaXDZrp2YsM?TRl-uDcLQll%4I|ukJa()QG7Qb5BY$qyB{Y zHJd;CN7ws)kcV9i+0In?<1(!*eojsYsARS{(uF;?Zynl|UYyza^JCfGL_wEM*s;#> z15YrLQAzm8)tRh~h6Mb6`jg74(}a0Vx#c@1YVBfnLK2NX9Dtq$d{+b-kX4Dm z5R^!J)DvQSb4S+an*dUJC|^Fbn=k+n#JAs8Jrfu}HtA>Oe~EGohx5EQN~BF~XX6id z3mve`S_mtMn~~RgQ4vkLQEhE{ff-HJe&@cHX|$mq4qsVdAq?BLqxd`jtSiAV@Bt!b zd2%BCp`q+Ure7^A-lVPN!vDj#_fGMb;JxM&>)?dl`k|6Z?ps&vjO*9fFj^PJCu;UAOnS_vz;M zZvET}Jd05AuBfgk^>?1Un3pWekXGlGlr1zQpNyp(C8_vLKZ9%n36{WR_&Y;=fk8kx z_9c6^*lDr-TGK>QA_pN=&6L8L#PxG%a+V8zrQnJ!*}9iiV7V>r~A zsIs``(Csxg3;^3WcKT-yOi@r;UDzZIktD3BC{=p0ym<^cIsRmx%PsNj!koIz3wQf< zB#{)^U;#1>4j&) zfx^_8GLLtztaSwA8Y#w$!WqE?DF^2AuDP+ss!+&k>l^+pMGOQ3jff6rN+J+kR=sC#i>nl=aFM0t z$)LUQccsTFa?H^)3PMP?S04P;M7VHbn(0ydY3mLo9nKSe;T8#osn7_j{UF)fKVM2m(Jd)Fxb6~_}pV+W9N_G8+Wdn3j>Z_ z-QV6*W1&>+WNzuB6=W%BehV?Xv65Ui?N>ft{8bt5T6@Y1Yx|4_6U(ekxTP{v`Oe}; ztR&9jVrEvfuow2SUZ$2(S;wr>d|$U(-J*w#{1(Be?(`~_G;*D z_4kJ3K1u}t{CEWzPK%C6NT!xstiA&V%eFv-)A%_l5qa0Exf|k67#Sjrp=68etXQu5 zt(tmnLnXZ7Al7^CwBe*v$<0vmM!9F0x7`N)jDyQU8FljWmtb(yMo;m(Ep|JN`6laqCqklwubP)i zN{}xu<8VN>mN-7^In#&NLU<%U8_Qn8uIW9q4``b_A~6xCU*}GXJ*YB;zrl}kAcm9K z*(Y!Ce|_-GRMz=AYn9Ccq{7MQ%;M(=!+?I*uMi10Y+fWzLX*PbWy(oG)w7FBpINhq z=N5GQS)T{rV?~$T6|`a0QqHjy3@F}(YcAfMJZF_WunJB(L}Cf(pJy#=3aV=h^Kdx! zH0)Od4U=6+zg%C=pYx*_RGLle+i|#MY1Rt4xt^zmJncBeDUQ8F>gHb40`HykM`2&D zSQ!_9Y-*N5+ zS`YgtkDwdR9pQ0XjuM-onZ0>r{;l1&C+P9f$QyE z3mfbyKyX;nXnVlu8Xi9vqf9V9&2bN#i7nz7T(+Bj4csDb+ZQ!2$wp-2b!;t^_LlV!R;SiyDw@&vU zuB+ov3*gII0rd`b%l$NxK1OEJoc`MqxHGO*L$=-bjq}a}4>QkV)64DriQd!TLUAP*5dPrQihQNn(|kc28t^BI`HblduIEYUD=|)JFSOhZdd?fNms8xasn1 zNO>P8+)Z3OuY(}L|ACp#Zv)!t|@Yy=wrl0z?-M=y5}>e^N;Qe!!EQd--wt>Kz}cou5exN~A2+=A!Kx1O%%nf_Y7G4&)3~@y zSYi()-?iS|xkeUB;8Kgz!ywyyWar6ctR-L<9XkWaD|CN|ls3k|jk0)c%KSqtpoQBx zPbhfuDPiro6m;K&)F;eRt>z^%m&N;gd79>|Te48x@9yfwlGwz3!1;mvGppN6URSrK ze3B)};JYqc??hdQ@kGNQTI`O9SG>=UT3t#f(Bfulohm z#LPy$Kl##~FP`40+zc;X(ngas=?6yp-iTV1SBbJD`!hfv*PZI;PMwplU~ShXrlvDbSP9Mrn@ZH1;E#guWyfRd_T)&Hn?r! zyRHR(B*H}QKlw7C9Z)Q96hWD1llz4eT@ZBKz<^`9O2UOpa|I_*4H>J1w+2yGwVn z_1CET?Mg-8BugB}FE0(|yBoUbFk!IFGY7uZWDgEf0luttRJi5~mnyz-Ffd}^@dDwZ zY0KdObTo8BXqt~_D6@C>Ht#O|k0WeHfXmbL2b^MV0y*gq-dxPqNfiHFP?2Al5!-oZ ziJGYHRcy9<>fp@vV7`}Jj_yv*E;F3=Col?tY&c2~wkEoY=U7Zh{)UBG0)X>3H{x7grj;N1Ut3Q7YAD zE0tmS(}!Qq1M2HVdVU@h>RsQzqav>OvOaGRN+q@QS@5NR)llEHhUP4p!Q zvh7y-2UI!(Yh9`TeIRxEEQ;vt)gQDpgXkKGddpZ^w_~u|f>vDimG&J2HD9}VLaM_? z4>my*+1v;M0(`{9Qojr$Xd*WB}57=Z;@X`iY;Ug=unnd>;F#z99C;-ElBRt_m6%t5FuiTJlxKP@?3dlZ}Ip-%Z4;bv3w? zP99O>vAkcuQuL%2Rffb8C7h#w_u=~+1GlBY>nhMh5H0kN5eqJ|9~%aSof5wtVU(6^r5pgRN^vkFspB;(+gfTGn?N$N2rZ}4u5`8&UIv)fBa-^owIpoA3RxY@D7?J zijf;w`{hzMY4rW=6?Ujr`v+umR(g}CbR;L}hoM%nZZ$BP_5t;v5p#F(@uW(J+Tb&^ zQkyy|qJK-^NoPVoT{&drkRKTA=E{ho3YCg|H{c+(GQCDN`BqMyl;l|MF<;FI*%^9K zWYXPsVMnv&F^6i+g3)43UG+x-bR+@`Mzj&_Xt9wIv;Af^Hdq_&FoV!T6IYSC-)C8H zWNGb*!uMSFw|y!EYb@RFj+9mQ4GP^UbFx|n82${$EH>WX^>W|)X6C8DZ^G*PwvPb< z2(3HA;Wkeja2_((D|x#wHy_dBAMcIIItbi7@ost%F=KPSaa);-%BF3VK8gCR{J5xk zF=odAo^!N^n9lv6-mDVq?UU+nN=IAo&tTZ}0DaHauL!{N%W?!?c!&l6kKRx|hcnIn zyleOOUoJffzX44hyRMu~m13Tptmqu#PUz(Pf63ih(jd31h(;zBm@8GC#Ej}pIEKYB zUX*d57>@6R#6*3m<{e<>QGBVSL0CWx6`l92c_;9pwbQ3+l8#xn_wLxfPdBR_XNp^K zExp+nN0;>#*5NxWpE5}T?^o4Ek>9VS?J1`gh?3hp=*b2@mNuzTbp?}z~Y8N?LO;{Ik=WeimW_<@2U%v)$o^*78Q0x3W=ol%*C=Yo0 z0Z1g#yni$* zM$XIRJLSM4>Q--Y5@M1iZSOMxIQwuTZ9K*1dhU%LNJchEWjI_agY;c6@OCuKVFitg zQciMq?Ds?wArOvkP4)j8m>ChwMYchP`!tI?4`H&L5s`@->4-oOR?p?#&ckKkTd-6R zF95ZZ4-IZV4C5$nu2Tr124+y?`;=^a{l0Ie;$=W79P*=9@{Y?^v*8;+F^`E69f^!6 zELqms6tSvH`-kAC;o35q3JNP^F+o;#cIFWT%)zUiUXP_y@OW$7$wIqglUSIy`fZ9U zJpp^B8uRsSP#lp{(DBc1P7plu+Xq{@uaIqkqPJLdNGc~3wT!c~+P#e}N zDca+q%!V-+`>OehSeIV{YCB(R+R3nQB{Fjr&V>3C#)L(rIPrh%l)TRxsPenj4*KF~ zi6zCF)<`=cyh5EvNr)Q~NB+Zz#(YQe?Hc9z?~lRQMw@>49A<92!}M9~F@7LJ0Hupj z@q-5;;-GysF=O?^54 zqEOQaJ6I5;GVfG4B`mQOgTD%Zv{3h_TrM%eK1->}j16&@x%mUj3C>i=rJI?qiNj`& ze}}M8@4h;PX@PX;#|9!i^GO-*MKCto}dZbm0_i74Dq?6vwIlseNmPZV7TB^e-}stk32?*96Y^0$&ohZDW^7*6_3gn{460M znXg8}Ivf(`de$)Vy&J~yNML`UC`gbG=o+v8t*J2G#%0SduDVddMOn$l;S~Hag#>`u zE*+fIuzC&Fy5Px5exfs74Oe`py92XJt`pQgj6@;KHLCY*CVGuu@!EA?`y~G78_5 zQ+%=j{@Bt2Ev7LwpwN3ue$?RO3zuc($H{sa5~3nkkz+gRUgW7Xro=8FEc{_XHyHPurq2GCX#N1@-nh>Vt93MNzRCb# zy0hOHZ!FtiAe$Wa@FDAm^VMsIFILc4!88j_7hI8!!FZ08juHWMtK#uHeIij7pff90 zg+Hc4hd?t*ydsN*^|ho0#rc%Ec7}%1NL^$8DJ2wq;)Y_sQTA|jp585{%c)9_3bDfy zK9IoQ+Nh2SOSdgD^&iHub8y6eTl=g7Kp=9I*s~_Zn#g@1=1nhuVHJoe%IwZExT6CD zVqN1dxGy9LZ%>E99IX`S5*HVu(VGJ#_9AqH3L7G-Tr7Pg8&~LJ#-FwU_Lw{vXGO&- zZflhmN+aJ=0ZO<5eXr4Ri(&_s%#cp8$E`zpJwwq zIz-r6#%kFX_q|eY%?o2dpWgTqWn{Y)KQi&&z7nrHj%p(Z*jw33{f7Lqbkln8>MHwq zxJwIEQd6NO05KCeW>YHVy6v&Le`rzU%#|8+os0$HQ}GTq$W85a(u~>X3Opry-jPBp zPd+|tFkzEZ_S&rqq%Xfsfa-dJ{K@9R3VHRBUA6<0AeG#QN?mKrR)!gKP@dcBG zTrD!Qfj# z%=8p>+^L=LE=L*Azy~_1(;NPx0w}ep+a7GaCFHH130U>0Zo5}!=xOFR76QEm^8)NE zla+9xFx|M!^I25Pnr~cb`j&nl41s1MvLxtM)gUR83D86UL!Z{0B-&fVW&1WZIzfAT z-$0rL2@)xv^bQL4ex~{%*%cqhj^-vCIc0XG7{5!CZ)N6CGxM`)^u7{snA+~A!b~0g zB}EetZRX;-xN~GBc7+5~wbVOvDybLjr0GB6Dl9c)OL!vka%jqUH3DLBmv}$=91PyD z`0oy?Vx&pJf_7Ar$6dA>2Gke@DZ3Qeu<-__*LzPNUVDWGw$AJ*iN%vP6;dtq3Vwfh zds<}!yuDP2_(C*IGyU?D7Ut)}DY|NLK%nn78QLwC2^Xc|{5J-K$Mg28WN-tmxJEpd z@E(l--~$#9ZOmsn*zER^>5J2sz4(mFf)r-nJCO2k^-(d1{pph}=s8YJjeQ*#7567` zGDuhnLX}mbDg!nf9*LtJlxwGdYc*(YHC2)gEMS{8Hy}|=D#2RZey%Q;(LJ(>ogVs< z`m@y>UI&dN)V%0NNQl+*j?gpN`o%x(0=d!8PB5q=3wUC5MS|GSZ_4bT)wVe2)Y4u! z<H(rFKkxSmjjT^K}% z-qwo%q2A=q8xKyVFeRa;*>IEn3HG&D{EzS{ZRMxO;B(GWfaqgmuLAcw#SX7@?vJb zazmVI3!B27M+87eBWuE3Q={I7N9&h*PwgS4%9igLl(8QI22piGd+h>L{&XwPlaAW$ zt^!bI+dd|+>0NC%*|px@8f*SiYACgUhE+FW=5Q%H(baf85tt@FB>c~NN1n4dl9LnTRwtN4GjhzhNkJjnKxGiG?l=Gg+R8Snzf9+-BeF0I;3xnf%T5XN z==+k7-le1yW-DVBgiUS#)Z8~VUO#Civ5E+0u{3@tr(D(*6#OqW$81*4@wg2O@MA(c z0vHG~HMM_1<7=*{X)^QBb&V=<&i_9&*bPOMFJC~wjwwt&S$vr4lGL|P+td{Q+}e%> z&c*4q7}3v^|)pite91|IAKw@dR zCHYa__;-!TrOd}g-nFA}9K?jqwkuLk&7+uSf#9I*(nwIJHW4s(q zerDp55bs{S8?Vq<$lu!hyM5X^)_T`z5g+vGEa_~q3jRq6vSwC9fD%T9VNt)q3hl_6 zK&DmJEQE*DX&ibiq{v!!!hqFD;Z7qGN{Gv`ra5`qR{)aWTZ@V=*aYiu0nYE@7#?#d zWg+e-sp=#jC7A7%`Z?5D0`%C96IRC#M`oLf<5aeorOm#s$c1G<+h!=EfxP`fy$^R> zouPkr+Ka>0dJRpbIDc%ASq>6(-ZJNZ_#&ldzfeQRA*$R}gaoj~V1(8<&X_^Lhf=Ot z6`dN>vuiWK{4M~~JXluLtnY|82rfoS6|a^(F{=JMAY1_pcULQxw{!5(nUp6N1|h0~ zuY>kjG)5DOglFxYrj>{LSG#DLinu8Tjnk3Ypm4KuXG1!O*Z@Q@ySveRCQ6Vn`6z1b zp)%2vlG5CztT`d$Pba-v_>IS-{hVFNt-sTVczcrLINb(GSZsB~5o&owd=Y~bPiRqO zJPfIt?94+)7}%U{ngl#BzrbkNj(=f6&Nuhl{#k+n@3VZ+J-`Jt2BpaeZYns&b}R(+ zw}Lk8kfAwssc&ksOSt}!JjrOPLMiqQDMZX!c6_iqIS9a;tR!z8efLJOXQX`||Qp$x65tXTg`3CTnhsePK!I3JJR!iMIZsQay} zQC6%B)PR^7v>OGeYG%k@u~shA4}N#GeCfOSRX-BoP6?bw3f52Gr|Jnqg_UylZH3nV zy?2}?Poht5p*KYLNsd+#tO{&o-*>3Qez2vh@RKNc105U3O_d?qQ@4XU=o8{*TV#E? zj{Gk-=+K}yl*#zD9bdSfxT*a%L!-GE$pZ#Jr+Fx1V1xC9ho`d{wg?e5{5$@6BXV=lwT{X-_v?$JgnM6+{36_ z2khIG|NRH3({f1{cUC?AX`2<~KnA+cjJOthO;^8?oojury8614c;rZ59CXiZe((F_ z1vLhH3oWliQxQhN_-9)vK8_s8_RSq^a;)GY!|!t0CKF*$+e@YQKlnm9fkWW;uDK#>vsmOnXUD=Y&nmT2N8aiwl<;J^iSBs;fAga9PuZ{%)%b z?3pM7!UJ;4rt@(SgW`f_rRhxRX3Wo4UC(&--#pgOdG0g4_8U{B{avE6xZgP)HFca{ z3!Yz36Jfdn!HJ|YW!!4vnTMpm?T3A$ttg7daeq$BKREk&J>u8)@@rkC`z zWy1`%l;pYw0P}S@8n`Yq!;}#8an7mkE0sVWBW^45tiSFY_6?jE7B;2;k>Pe0*-Dd9 zb(18t(c~p$xjhX2#;O;Tw(j!bX6v1M@gDPItIUhOmNdsl&ec#9R$h!zb!G{fXzIL@ z+OW{07-Xyqs=pXpkLC+Izs>c8hY0*7@4K$xKb~A}mNl)1cZk8@AA9jZWfdF*=~`>z z`btyZGd?K|M9EjA&#wjqqY%7{|7a?2Y&p>Js)ve3@6X1or)s)CxWE+~E+olQgvW_c zo5yT^Z@$sJrzypY+CPM?8pJwgIKfMf9E2!lRb%i<6+~?aG8E90#M2Y#8ul~e;Qj!7 z5y!W&-~IqlWm{;Z@Kz{m2-yT?uG{rJo_U9%Wm?WYU6^2A^<9qgbwuN+Gl#?yVqba51WiaQg)Q1cjP zf+$t{l_c_VfsseCqpTiOVAoQKc763rDBhX?pg_{va8J7i7b0Wau7i@?np80Xz?_=1 ztM=KYfB}Y2ZtLfR%X-EwJnHLQEr^>MLKaugSIkPoTW#}^_wNleXC_Tr*gH*+e`s6 zjXKYt0BR%?Sv#~v)^g3lq`^&cI0ndZ6#RjbFpOA}KkhM5|4n4-{+?z~fEI%o@jd9J zBZiw~gwQpYrj#_(iMyk8=YrRG{IpL>gr}9RW%ezJMtePO$^$J&NtN{ZeU>}&+oS$Q zU)JkY7;_5kSpcKw0XJr1N9QzGlwbc)gUuG--=;@*E~QT_tM|2eE<~_f%e>G)N_gaj z4zD!U&T!4{z+2)-)NH&ols$u!6pQa-Nqjc?tsKINnZP?)&=uK(Hf9SM`W07Df3$7$ zGZNK^FYnE_Q%NgW!S+}2C2Vr0*XJnBYC!6};c|k_x67C%NxGO+{{;-4$4s}J!avdZ z*=K1AC#6&M< zwOECSoHll7+=lWeQGem$1s2i6{_TF*eB|B>2f4nhp|OL*L;OLBYXmV)Oz;d7$~bdH z3YUEc8@w2j&Q@vb%x zl!TBF5#fQ<_&V4S~aV&l}P06mNHj(Uen7`VjqwMMkT}`m|9TF7{vf^ff~I>lX}q zI&Zb!9C=M(*px5AlOn>B=DYRwsZ-C3%Tqt~U$*?J%e;h~t7N2&=TL-hi8Ve!a9zyG zmezJmXb2G)*;FlI8EPE3k2_#z`L6kejEep(Ieu9rki}=kx(i}h8c_-OOi6RS)E>Tg zVU-lC)D}Hz?S&gq#W3>cjNkP+P|gbM zv_FBP3n{B?A-&!o$7_V^26%nrI!xKZzL>MoT1w60VmY)!i1bpd)U~!{EBL@1h`tV_ zm=D<%yp6AACPJ#PR0+p7GWSA=ux6-D^d=T^H^&Tm6Oi!!aC~scP*;hw_+#{=wmBHL z@sAiNKpHSWjo=tcHLvohN-)M0Ix9#`O@aKLTphu9>x)FbiBi-NYxJ$mKIwxS8j_u=ag^#K15d zJU&gq&M4)4-vG22Q9oQ^bVM|><=PJPGy{Nys??eq&#OH2Wb)Z(j%B>Q>h<9`I@(N^ zB=h|DPg$Q2?pF>HH%&k`3@79JNO?00!Gm?SyE}|cb-!nU&u6RX8OoYjihJp* z%XPzIdITPkiujFPA{Wd9{1iH;+4H*r$wN=)&yN$o6-5RfulFNnMvy@vh6}M1u)z0h z6}uNgsD}My;&Y0@`L1v2vN@_@z{%Qg-8q5+_ripF#^yrg@yH+N;DR$Ho41jdG;h3A zkdCot>@W+gl%#g}%5TbrQHgiH$p24jH7S;W~y%KFZcbe5h+>6c(mYaU6)KfSLE3p@yOA_EE*UtiIU^r8LQ za9<-Er(_ZqKj;uagLfeg-oVQX2lnPlqop7iNvH>@w&PZY*{BEQB89bjQQ<>qHyIhD zaat-+KAW!;!y?2f1a5+2g8MbX=of{7(yVElV#_j_-*h@Ws0dgykk_WdsJ2K~NrP_2 zjG;D_Iy?aUMR~y>0M4oNWCba_;yt_B%1XQ8bsRbXkk1^xaJ#TrNXaS?SraoXvOZK! zPlx3M=d2_Wy=b5Tf2M99`iCxR8cP!a%#O%*WE=*ppq~8x4|raiXaq=Xn2%;yZG54kn;TYIF?$D#e4M&J+>N z9HxMhZuo`4>bKM2lnSOq66H*Ipbh{aH|iRBoF4X{1zu572ckGhC(7y$!-w7MQGmu3I=0Dn;>pWa^XqUMLCJgi&~SR8O|A8zecbxO?f^tHNw`56K$q8%Z60=ySts&Paa50q4Kp-Ie-= zP3lsF#zY+=X7F2arfs2HG%pqquuB znl*0bM-pjJ86N9fE-_!0*)g9IZl)lvFiJWy%FpDzlj37SJeBaMmKLj=AGZny9$N@} zdl@_#qSie^%VKKW>BFTKg6_@?0p%Lmq}v~=9Bquvkh6cDy|2$m(!jGL+2LZwgq8nz zDw7Ql6dw-vH|YY5VD49Q9B2rX%TR`z>f+@$H1I{jAn$JK{C_rlU4gw}R#Qy?pk~}9 z#3JtR(_6iq?j5RW4MYrv;3XpXt*$8beR*dXZJ{S!;{OT;0OtJEi34`#&kWi6#Se5- zDini?3r3bPFs1M5awI}ybv}=fj6=Tc=bBoxcEsYNs#*Q}%{2iDM<>~-*_+gl_B(AS zv5(h{Uz8PnuI4?6PUOn>Fk=94iNaHTwIvyAwoi>$V~VtE`bJZ$u;OGK;PbXsig>ffL4iZ6D+r)1GKo3oZ336!2tMN`~v_?fyX035tvW6 zCo@Ddwx2`R#=IM z>qk2narHFC0ox)TK66dyZ@!#0|@`2tFC_@AX06= zOs=G@X2{~Uu%-QGD|C14>#)&C637+4vxD<7m z!Muc>dm`cc55&O~(5&0jPAL3C0AyO@@_&EVOXqX8|mi`O17!DgfxOH6} z*mb=<_>;nSIVn);6NWKae3~Z6NXoBrGqd*pSpYNC*CgWZc@}1KYhV!rAOAkR2G7$m z;+yJo6e1#E!t@Io)>V5%T z5TZ9WG64LD3eq;h=YFt0uZK524`B;fmkX4+A59-52{{#>ch>#tJ>QH7i;vWb6U+l+ zL(E4_)q#+mI=?QR7Jq(;LafI}U)rIxcz*Sd4#kj{8wvNknYoZJ?)xJF9s^k6n3&jy z?z65<-8gT?;cT9BRMilMuBOx*hN@d%vRr78ZJ)@hWeSr|{Om z15?2z3=|Id|&k3xi`2YsqB``h96wJE;STHz&s@c=O?!Aw%< z$#K;A;ZQMZ&UE{ds+;o+LZd=Q>(A#pkKI8l9QB zU7fGDW>PWN2@ixUc70)5f4z;FYVhk7{s?R1;2`$d9yKc~TRO?dZa_oT_k&{wNHBE- zHwqs5aAD`>W}XOiHWo!I#hyBU$K&Qr#4U}WNv$8Bbfh)Sb}L^%VPcM%T);NZF! z+bwZ73$N-%F>nBzNO+gSFZ(9*UG3~dx}(t*4bGa#D$#?|cCc;#vc|BZ@89Et-%7b9 zt`Ja$NH3~UfC3c~7yuT>2<;keOBwSSf!$v%?&l>a@%H|F`0D69`_mijSBd1e>arO| z?G4KzZQN@Gv4Nm%0StiebtI~i&s#3D1AP&m8A4pNyz)Od=7cjGmV8T{sHQbLFK z&|Q7%LicL=IC%}s70^{ zk#H&k@N;n%jrK21Bzlua6*E??ccoA-YMfzDE1X+7Tf>%lC%MqV`*B&g3aZt>qXpO9 zL2K$i8w{Gf(Ik^fPi?)!0YftC*#uV@NODYQGeno0rrZ}sLRTK;Mh0==h zLu*g&oEk(3O9hT3?Tcd%A_rt43Qks~XGJ81opaf&Nu$0r=~R+;kZD z@Foo6GspWYXMZysKQ7SezsOcvyL2^mBvYAKCAb`OuI>x=4<#j7Agt)hB^Ve4faM`2 zxzmf^S#m&uf0ZIEuuY^HB)Omg2k=e5(QHZV3clIlvW^2TFIqNc#MjNi9m}@&x2-{7 z<>$4s%?pHrBLXXCz4$w$ATH+6=my4rQhm&oR!xeL>QJw^c|{u@nsC&eVGxfM53dCb zfIV>K3;oM%2mU%Nqb4=^sYjG70oQ__`F3^3$xkPNUxnjKSYNFHS^h+64CULMS zJxYzr4r4i(LKg&%c9D_ryuYgpAbYzG3ks$Ro7XIsp=zHfbw&k`7`QPA2x5O6`Al2B zgZpWN23tjQU3Ga)gcNa@Lm@R-brf9D+^+3~6~8k_!3Jz%hBf#v$uqe7l*0gJv8Mhu zNNc%%9Gw0xFuXhNEhXM;p1K1M1zT4GaSN9?Ge(l2l7b=PL``LZ#=| z=Nk4hEU90uGi(X9WcHe4YhdTMI0S|8VSp8?y^KC2oL9XjN z-FGs7t7D)HUztdRx(=xBFR%$F90kJu)w-`khO!Vui*>qm?1&BXe2~3-|=Sa|T9y$tRgV3KV zL!x)F!37nzC{EocM{Vc*?(BGi07Ysh0-Z;Cx|drsMxi*Oc!f`D1r=7L$`(QfT%sxTLA25&1IdaWw}1EtU_fKC>Cmu-n5x^<_nt78|Vdr^w1~JAj|3v2I6x6>*{*}Ei(ZE+{vC%-3q*~e} zdT-S^s?%7M81k$ZJ4V2X>v<~1YON|Gp0qrGukg$me(>{?4L&B3=V;q_eAfbtix(Ot zUJX77Jfp~FofpsA#Qo=t9uZ*H?NQ6%+T{l3uH>70|G7k+p04&FXfa}pPc~Rda4Fu8 zqp=D*v&0*A5awB+7$Ijb|JZi)LL`DOjxv5cMP;{F;!^YN2c@K7rKv<44rP$#xgGK+ zM<>Dow0Tw8<#B(C0zM0pcfBPlmG&Hb1=R#ym#sa%(Aj^A-S6y_iCFviN(zL}Y#H z(G@9WD;#XGXDZ0#{qZQA6XegSF*TZ6PvZCZ8}$a0EwiuG*x6qaUoNZFdWwOAIVY8X zY=sXF00c)k&FvEgb8|5DKH2I!%PXb(rIw;(OV~v2LLI<8`{-iG_?3_a-Sl7t{#k9n zsKDqhl98ih{!8OV<0Uc~&TQz-WN#!oTMu>K)7+!&rL&SH@M%2fj8^PjCQUdZ#~S1t zn@GY?z8?YH$k#~3N>O=P7wS*#4=S!NmBl96q8HHwDl9dTxDc^ul&SU z!8)IYy9aj(9ZT2}1@}_>n~1Dhbk6K+O#P?*`d`UM;CZuRBDw z1SWG4@g(S$G6DTtvPOA@4wdiq6r?Q}vyd&9vq{87`i)2ddiNNirwEh*7*{3x??2iu zg%|$j8H)gQqSP9z-;_j2>?b=_nqe~Xm;<2(;Sw45F z2YjrIRo=QX_%S>d*L0z{Z1-#LC)$DAnI_&8eOE!sozTA@4n?E&DqJNn|GC!!8AM+Q znQ}Mput~=(5~GJ2yuAgl@ve^0zh=OyU<2i5{FYKb31lQ4^Rbn=A8cHcE`3I!+rH+9HMU!JWRo%z!1-fv3Nl>y4h1$l=vfKYrKtsngdwYFp>1cQ7@_TMeIpV(y4Q3}`HK4s2Skg$&`oeIe1roRYkb9%6l=OcVvfeZ;bHs{wHrt!nDp|`Ap1oN zW#f5t-*r~=Ov1MfbC_1s?>w{R+dl~6gZE@s&(gyu7WfvX+>G1Y&hu;aNi!SD@Qwp+ zA~=hLdn6$XT?|G6Rv=^}q1e_`*_rM1ZPs|ICrA|Z&GkM6yZDg#0j|+I`#DfE`=j!o zf>pv=Bp`(>W{`9ag~?MFBJoWv4jv{0bz&y^15Nj9;%Z6tB*3n!|AXO=T*=zOEa``00+Ve_!1)is8f9OvLE9OXa5&WJ<-I z*{YtF88f59yHs)wTfCH>w?Yt%JPc4B-mgIcn*UJwi5(d!g@Pg#2$n+!%U>M@J)E@{ zRF;3tb!DSYa=DGE(i1q*tKGK$AdT!!A^!|@I-0$u&wi(2s-WFD_~O;{>@wIcS~J8=~mzw6bNwHKN_cV9;aG{o;@)ke>jK zVRYTSN^{c9nhq%**}5B=>G3fzCL9NZ>tJfOsb{I2YxN-b>qNmKlHy zcXV4d{9gMjjcU1bb9;k=R3W5C6nTqz7so?s{@qI+j)2XGkpY00CKDGV8ko3e>Hn(Y z-9jtT>v^O_=plB<9%a^jr7t+j3Tj6=1xcFz&K1;#Sd~meTx$X+aa@a^t`Z2n753TD zDr%Dpi}Dc(-QOsg#CBn?^mU}A!T>TJ*ZdCeAJ7ER#5`g=f%U+>mGdI~H(VIuaBvzn zc;uAf=J)q_Wei~0-tY8B^3&PixeFSb&>c=o_c}%$9oU9Gwm`I8{-xnEs=3gtxP+64 zusUxO(~Qs2WbHHC_K4@v)cOkY7;Xq}F{#D6(3p>FPisifxeOf8R*-i+<7A&7puF3GKE%4cmR_X(6k8U%PD}4^F&V#7?@iby zrO1;vipt7|_!((g4k~pq{PK(lo$lRkb&iLsFa=bh4!F~>E?It1nOyW7s_wZe{_f3< zAqBw3zaNL=sXd$otEUJ);Q@r*;7X((D(tUYIyiK>ZXcl<5XLwOUNEEznwH3JRWrmbxMX84?BY^!WfIXy&HINSa{qB=b{^K0&E_)sv z-wOt^qAF(pxm>(|S5PVv*h`run2a_z>U7K}NY528I70-~~17rSf_Q5)xeV#gZbTc*GXrOfABWeLD zaya(+!wG#EI;@Y7ELV3lM2!+YH1$IWcJtX%%+P{x#Qw*g@L&tlb+GZTV8$bxR!jWX zLlS5{6&jxUf^?VL-tVOhPEX&K0NeZ>d61PMxN*Ca6lNG5;F}Od_=nU1?J^_lCY?lp zOxpkj(K6(%{9o8ff4YixMWZqaE({U@?fU8PK#n?JT9cv!tQ87f{%aQTmObP1N zRpMKYBj{;Oeso6^jl$II%qYCSbTUcbEe@(l~cRX$I<| zBj-)MLXD+%*Hx1W_>*-#CMRcSK0u6vSckKsr-$h54H9u)MDfPU9K)YsRPDRMpyPLn zaQ-MkEiX*~$rJYb>Na|Guv+C7MDT2c33~E%dHJDjKHc9=NCe&CvpNwoddEGy4sY&! z+bEU);0p%tL|rDD`S6{#wQiQY`!m)nWlwV6eYO_OwBb&qc8CozLrc62Ezy6Fw@~(c3e-YM&j$(tAXLV|R_lod7 za8`_Asi*{7|30&dQ|3yDL3kun#jogJFkCZ!pvChC;wu^Yq2M$G>SR?4*Hn86gg(0? zVKykH!lb>^F2l{8R>iFyK%MM~9ij+8LHCS<6#&`)!{%aH4cw@^c;1UU_mLc4WCHM~ zo9vxa(ZF45L3mS_zV#l5s<0`?E1j4$7fFrVk{bUqw9jse+C4#gzw_XPi@@(Svu5!v zSqR6T(eovz%w1f7&XpBYH8d4gs>sde1e|tt4P-yh^T!5JdtExUz zzcKbJgTR6aly^#aFMkDUhH%dlGc6fir*p7uAHtbEz*nRMW8l>It9 zDL9S)RKlwyPwJdf_yI2U`R9HRCAi*k>|#dvS(jG|!oO;w6 z-6LAO;$Lxfcallbta9oA zFDdl4Fk(|?J43W+{qHz3z{d=n9{%s%=Ca0n3I^Oir04~a3O`Hg)sk*PXJGiXEeit_ z&A(OIW}Be_mO_bUQXCM^NoR;{Sw5T%Ug9)%#&WE_$b}q7SK)&cBtB+p;x_lMf~yd2 zt_Ca*2&ci6w8{5~DQ+Y%werQUT%SrR68n^=Dn!5rGw zyUa;>9#~@kGrx_e^SZa&_fp?Aep=5(Q$k2^fnk{&NA>hK#fA^CFw-_HdA^VQ1_3wd zi$Cl!CAR3%25^ETa)L}>wUZ_d06=5*$Xw9cjHLKUcQ;oqok~uXMusN3+s0J3GBVNq z3kdw~YL89I(6IaSk8X{aSag8CB!d+YGlPHfI!kHHNQPC>Pk2A3wM_IEze{uB$ki#y zQp)7>g1XPi?mK>c>5GklEKm;SA$P6skhIUr^e4$OGZFQaV?REO?@kt2C{HeZ()Jwo zUNMnx*lz9^yUz(HPeUJT9ZGGH0kIi?FloZ){KzjT=H3LhPb_7RQD~aDtIZQN&}V zC78&O%Q7!UtngcrCMSYeFxQ9_EMkWM2c#M9W1K-5p{nkU|2shJoJS9LaHc4&@{lQlnY#dzr3q#-s1FZ%rp`N0UG zlJJX#|4r3f2>w{QydBmoOuR#CI0vs*9|B@g5S`Tq=`lBON4S}tI@?^xB363h!vtH- zQb0Q3`2B_J@lhh;(M2?<+L#*|i2X76`fK9d16ADU!^3n?rhz3q;GX6c7=O2zrECkt zPCjUZR2#6uq-qH>FYCRUD9Uns2#)fCu+KrkvEiRYKLJkQ?d8P&qq4?~z45{Dhzb=!US>KbBPM6V&GBe{L z@+kR<+*YK!*GuwhgAwkpgwGbJSqX$KLh12>30Hv1zsFIIiKmFMUcYlU1n{hq*K@`7?Rv0WvG0U*)9$p;HX>U{Jw$;D4eWy&2Ws9)Kq zq85RT^7q#Gg_xw}L@WzT^g|h1{)cA{Cj5}KtcQ*Ksos0TNV72XNVB1RFE-!d>w0KQ z3sHvR2XM+?CRMxNBO^DJKU(VQw8a)Cy8Wc9mC@WsK=tC8upqD*oQrTy=-!@{oHh0% zFPZ*qG75_Y{=H5ZEh}RZ*OFsY(CYd@4X8M*wiDuOB(i7L5+qjw6;i~V-lu(IY~9i8d4Y2a zN=cEuvQ#b;i~L8)9wuo(EPI9tO>jZN_V& zQiGhBd^RiD`|`gN^LG8OmKhdEG0nkSZz2UC57wo)=LkYNez&&wb7ij>Llmttq(O~r zFR-v=Mu_ztA1iLFp`!zCt24$2Bz6J-%nTp`gqS|GBBQ3KZ&2aO#DU@zquk3*F;CkN zH4z39mI9ZpQQ41d;A)?3f&vgBnlWj82NAsF%m9FLS+onEK?GaSuIPC}{#(#g2#;Es z-}Jw2m%&u%z$L4u1N#h*Vs(L;iy8qVW}g!NEM|g#=8>HW)<|01Ykh2yJ7lrlKFu}jbv z-K}D1o$ydSeKK~y7AGr^pBWn_3)N~jy3iL4{X%Wv=mD_&U&cD!D1Q#)&ier-s+3gY z_GEY7Y4jOezjw+5FT=3d?63e2A{~|7|FZx*stUOR_+(^cMVd}@NC5nR>hIi6v7FA! ziQbGS^GaA1D6EHI%n9fqh-q5Z;hi)GDVwjeR!RREk0;YlBqYMJD;xdFyLF|*ce7}I z5&umoB;mW8Mb2V_)-8OlLM^@pkkfvPM;dS}@pJoS6d%`GCr+2H%t)HU9Z=l8&{!ji z53NF*nq~k{fX)8B(4?00PCp7U|40)vLII)kg@x^?Q5OpduXo=c|(bPKO3?4@6WNl_u>3rN>Ni1ChfgS;v;$Kjs zX*=#mJ)h2Yf+Pf8atYlpl-2h-W0Cjj#jM%lOZ$D~?T&d~4oQHy+re7*-j%*zP8Y!( zlZ8y-;)JCoX-T{otSJzPJ@uS=%DxzmlFIYzXp({7J9MW}gu-!jHNRROuQs4k8S7lb zN~QPZiNeFuN`tpow}a(CQA11$$#9_qK3hK)&aY$G%)FJ~aR;ePs9Mff&j8@DBZ?ng zsW~{?E+&kpS7G*YPA60TBF!zF?@c;@NCU|(ksu=cnJ73wyqDtTC4kzEKdXNNx?_&~ z(J@~ochn5(L)x1sFDm?gjwp^7_sU9yketD#i%Hfo+1DvPuhYag%!z#28_;3-yFR!S7C- ziR@5F>W%UkzYR0ZF3YGwO>NC*wgQ|H-ot`F+hOmFe=WOD42iw5ZzjO5V~N~v>9dy< z%E>QHfBdkUG7s?OHAzU~k<2O<nT<%Q^r_;MvjnLtYh!pG{Af2qeNL%F$zQn;MvTnui z_3XUU1^?*fuxMBq;Rnh`RitAAJ6DMZtnPZ*9q6 z$D$7wH0%pm03;>cmOAZUWKreH&M%>=Om-;PJL^lPpkwJwjmRowQ4ug7CV(L zV)$QE?>>n+vwpLH$!H-5YZKD=MhkGwffnNx>zoLd1#`({e(PAxz>w$QF;&`ryPV|a zVxMQ;9IAbJY6#qx5xVI2y&eh=bL_j3-~ikJU9TW-p71$G@AZ~rzG|x%7Q#X3cKGgL zqJL+GgI$5U<;VB-cIg>{OaV26a>&cy_TB_Hk4q%h_{h3h#t;4<7Zq2)%;L4e?E^5t z=;WMTnVT(BRb}cXQf`VqWz&<497rr_aBdXe?DqtwExRuI`9;EdEHsct5?Z~C*B}}a z+^n10pGaIXgBrn)L-#p-fY2(Li_3@g z9mAiovqF(y2i|Xcr=}6CG>(m#PI}9eA}suk!76&o>uOZmK1_E*%>gA$V&=1b_3nO> zf4*J7gpgE66vA0o3sYF-06a_eO5_892_wIcdA|RMwESco9m#s$LeI9N znOMbzL;#yuz)F#WB+k|)@-e|P6?}3JZ{=-w9-FaOiv(rQDa?A{$)3(wCz(eW8mt&wT(Fefs zUKzHHS~E!Vf6N~$=aW6p;)C{s>eScA56v}Ob4&i-9xsLEO$dW!u=ziGzz7kMjaf|+ z0F)A*U!oyP2T78L%BA0At=0G*u5n%e$_;(yLDzy)5l(ehu_Qv*wX_j1IPEAx*i6)K zo*-u)?PKawVdQXt3YOm1vFS?LpIXnKjNdQ<5W}&~p%mN&L|;3>n%B91@~oEvdF`uH zISw?M#OL_unY;$@>7x55shzE(GH6QRG>Y;4Ngi&g5O0 zTcm~0bo^52>2606u;r%z>aXENUM?^>9nB;6n z-IP*|f>EB0-DpdiVLmz%XwYwBqRK*2kmXRZz+svk&}jTDP>2^EhC&v>?G5JN2lTqX znfreV1*b6~Yo^WL;eQU4hv8u4YbNo38BLGjahaYjLs1x{n)iApm?k6x$c2Obu(ZVy`nEjm#1tV1}}5{`Xe? zn*j?dubjNrO~R$?F<$y5Y#A=NWQ`=G&}9A1?$th6m>k~GiE>WKNCn2)B8w|L+^Fhn z&u_2RB?OFbel=H+7Uc2#5sVBSMAC|?!QgY~_yDR}WsT{!4TfPsb?b9s6i+JNX5${M zHuXT#lbB8X!&P3F8&2`MW9{=6M-eh<9IEgul;53Tvm;vs9LpA|5&j;g`{3|I+*vt+ zk>dkNoE02EbZ}@&>i3(m>nJ%UQT`__Y>GTBt#AHF@!*nv%Q5}$+*xS5^Y91DtosS? zp8rBAp{1i4X~Fgzel$H#YtE8a#ilxPKHc*Evi#TWafLZz1;>RE^|7A^$z25#)G)?k zRO&(AYd5I=drkQRdEJKRi1vjIJDQv@Y-3OhWV$Lh1Bup!+@Ny3ZT)U-+AUKmo$+qb zCpdukxv!oue@lfBrq@`9n-mY;Z#a3eCqSQ7v__~$zwR8DnH;XcCxmq>r!6l3~t<4@{nI5^AE1UViR zqyj_?JVtH#E)F!$OTSH(|8o!|VqCTr=kJN)BByZ5^f<-`*p9ehVRkPt)!3< z0Ljr_(5rMRSUMcAk8kmyhB5r_21pUl@1DC~#`;z)4oq4=xM0 zx5<4aQR*^?xerSg@YgrDCIzjn)xRRRYYIY*6f&17nSoMR**)+=3^c_l_Gd-E(+Kg{ zQ%nX_S5ah#C{P{qoaYQOr&}&683GQF!mkq>#lKAsIDYE zR=A}O-_(3)I&W9Bq2QfZQx{T%y#tj{8^m#e0Xv zE+WH#fGJaIybAG|VaSev-g%Z2P@r)%+(j|amG~5U^9&n@f~kJ3BcN~_+%E07!42(- zM{jEk)vJunum0||qNLQEPt!qdumT-pG=C&%pjB_m4PSS)=7(Yi-G}lW#i|!%%1v+4 zw}f3fIZq7tauG7MI$J`EdU9Q90pOE+OfYB2m^sU5zQZ7;>Nh&5MeoN(_N^Uj2Tnt? zJJHY`)ixraalN4xP2P&AvsE+LXTCF8P`4rqbRK-S5t&vhLu*FU(%;Oe$-~JRvb;>M#JA=1 zPdVy5tsH+Pd?^@iO%12M$UFfIK+}%;*St5P@R{EAHQx)7eMog|Vkm0aR^7r)AOsTE z9!}2F&;b>nBnFsl-ro42`_*?Y8v&S5;khiW)2iT`731v0PUrPJ^7sxa}P1P}c^Ucr#S ztf-_N_B80^amB`m3L+$%usC_?-_wX!=}|E=B|DQJm~6K$EUH-w4Q)Wb&}zvg*Na<&|gRK{QPd19W&3v@4^%hD1fBSIO{^IoYnAZ zD^hts$Ery8ZW_IZ0jy81Bi-jent#a3%G$aCT>?NzNk43TMr(euzpcP8=8^gKpx%@E zcNA~@qKdnU<5^soEaNQH$1+)Lvo;_Qq66hPjw(chv7#Bl0R$j}to*SkdiLC&7kUG+ zeTBjWPuo!SSMIh46<02PSJlxtmCxZCv(qwwPH&r;CgPG!T-P(u;PW?7^R1|aXAsQBp&IX?jJjsmLA(WYtQpq#JSc8g~1n9 zJ|LMBNg`gYPJhg?=dHNE9fSdsLbQ$*ul|CX5v4HaASo3*Z4Y}SE-yC=Ca0^q>|C}! z^m_)dCwcGy#vY%_oj?Yq7!Mk-kJ^?{dUKt@1giiG;F7>WNxYxSXNq5b;J%by_Kup9 zo^gl(lN(H48f!|4lfG!Gs{6&U&ac(si@LhyFXcJ#grV7 zY;kEs$lnVqEK8E3GqFXu&_$rH9S%oj`^uPX}l_|f5r zZJs5qPoSB4z4v=>cP!%Vl6?aHe{m@W-a&JpEI5$~tx!r+!zcHfqa@*2_QlD?L6C9{ zpr`edyk1fsakSJ({Lor&8>aDn8Czk%6G26PBK}h{Bh16xKf9N$V;kW}0F2ASw5MX3 z%dRNt{yCfeT-{$jWvpbc1?So3I7#sx0;nVDGL#;IjTr zAeqj1k~46+od^T8fWUGfs7VV0j*`NHa1cmT*6^?rh_V2hTb-qu|IlT5$lvB#0*ckT zjqnROZx~KTtVAG8bV$w%WY3e;S z0$}%C-1#bK@#^10T01+tVx<5@`smx5&hw#-Hv}qY^sd+XVTuntY=It=-9C={tb{#a zf37fbOV1iC4k(|W*rJ@6Xy&I;PeB3{P+v$q@dh8VIA-v~2+%3GtD=j`VA>uyV93ZG zHq-rem7+F{TJ)wkV2ukJQKRFgoYp7|?J*ma*OlaB_F+qVHAex|YoIuRS5*BXFd=-w za=+xb0cl>Q^lEM0p2&YAb{z=q6?zgfTIo^$da2+ebv1}nw^6}Uw`am2It^cRM9e)4 z&Lr-u9CUZNQ|?Vj3n%tr7Vc#>9dw-QHckTp7K5!YKn#duVS=;tekRLxHQM5m`##G-+e*Wg{&m#| zCZ*cB90Dni)dQn|i&4mf=XYWUzvW5?_PR>8;Sfzq*JCn^G zr7$Zg{pLD=zOeZ>X7bb2nKU*2H@wgP=ot#fr%(8x627^KM%#`H9Mt+(@?10MwrXoJ zd`dI`T?@w+HbmzLKuxvPFY6xKReEpRjkW z-lQ@CcJ`&)eWI_(?~MKVnP(Ac%uVID6;}PnHbR%4ll|;i_I5wV&pKhUN(QI{l6%cr)JN3girPz%p*AfQH1UO_>3E;h_I=mo3hVM`MPP`$ z33MlVZm-~+MGbAI42>PhXH-rh$yZ11D8}}ZQU?qSGvYLslbEPfe6dmH3!0ipHtv7% zUc68|V@XQV%&Zwvj43@9vsl<$f1U8MVv1Q=n#_f2bVVa+6q0|BUDPihrUl#yBnl%B z=BqBlyaL+>9OiXiCu1B}T4WixFI>VdRH9&2U>>0;I7oa!Tj#lf62~-C?ZvYsB9N9 zCnu{)RiPaaL#kPG60Ve~i&;A!f&*!mJ3>Yy==;jGQNN`vuCsWCavy(w*^pun#qcEp zz^(fNbY~NuQrBYTccml#_v4d6aTD=Qb6=03p#Jl;p2K1T>PF|$exz%)3prm%EPhvn zJRl}L>ItRmxjY=L=PwIP^=Pan^J2iFl-NPU&hP+|8>ckbQLc|$$*pvd^x$7V5|5e# z-A|Se<+4e+NM!&HNA_20zubD12t0I9@r_!+t1jt-DD(FfLe zeSYK8HOU}fcudh(Zq7b-^1w@?uEs-8Y=mosBH2<-u&9N-;Ev7upcX8GTBQa8+mN|0 z{M+l`^qj8m7mI$&ck?@PT3lPH z4C2lATo9%TxM3Zn$I$PN+jMXtvFOnVc1CEl>OKwwO!rsn%HN%Bu!s@f<+=r*mURDY zB_jYJFAm;=C_^4Dw`9}Y=TO8XGx@SV)O>n~5ylJJfszTK9$15u} z-rGU(9{0OFLnxd6e<#&lV%ntWDv`yK`?QXb0w{2M%Po0UfgW&-wk4@VSwg<6>Z^hW zLq77f_g#7$tTYzoEC|E%11guUo>IVNTh|G{OAvZQdS0iL*rM!ty?KM6^BRR}2!QGm zBuwzAnqh#boya4V#)x75K6;+~o-zLR1_IDLo)fos82!Oefx8R`&SVu3Gv@W}5Rk_DcF4l~ zsm`MJ$TR91+pqoWC`y8b{t zr>v^%#I_`3YM+V&VUi7DOoAS~vC6l{sV)X|w^FWYhLS`BdOQhQoTLZ*nkad}3b&$Y z!+~~r>(uL0g{+bXJbHO81kGNe`7};@ZkF2+eOb zS{{Ze9G0Qtlj;+fabHY>|3V)*@{zZKY7<9JU3~zVXN59!4wfSwph|7{|`@R z6%<$3b?e=@yL)hVcL*-Q-5p-s3EH?NSb*RXT!MRW4Hn$p-QEBGs!p9Ns``fNuI{z= zoMVh<6vGE+Fvu0AgyugN@dI=p8QGnc>SjGkG_}^lP_GY;AHl6$MABW-St;dlwIHCq zNQd&ds=!p_>Zv7OgUt-XPw>7Vzy>}7&LV1HJ4zLT=ht!nmKVfMy;(Ft-hJs}Ncn*n z1iZk(T`rX5_P60LP+7#5w|XC_=Xx!I076#S0IgCEO9F(sS@WI{Salk(eTqB6{BD%` z@`vuyo2OX3WM~F}Y{-P*;_7g5aj5ztF(v0`hq+uHF-GuZr0PulYGytx;WWbyaNonD ze?SUH0g9HAgi4oH=B4yipa`q>W@)BeUR{T_Iu?w-yZ;kFc0U-GVN@Q}fy)5HLOqQr z3(wZNEQUynTBt*tqCgjpXv9OP+>3}`h`>9G5FCY;*D}9KCJZpRF7;21OP%7lrAginHHbgs+8&I7Oq*}?fefsj_|uSJ++HmLUx7Ejj& zH6Fw5zfgS-vb(xH7uHRM?(qJJz9_xEo`1+F8)3AGLSf^yx;4xlabuSH)B`|VPo*ik z?3eF@SniQ!sChQb4r_JLVt?MyUa!|TOfS}<*{20JQ7v%?23);X+MRH{AK1`nq4Vg0 zyq*JOiXYP)jjga6C12{H>jjX?QSnQ4=9Qw=?@13e)s&a!&{zR>zkmMRykGET6}b_S zP^uu?>PZe{S^JvD(mm!2RXhlhd_$g#Q~4AU80ib`pw>jFkNAfu>+| zt(W+c!m*GeNdauP5^V`LoXk2w$zpJ$e<>Huk86}tuy3DtCs6v=?vL`(*9Ld3`#?+Y@96h@@i6v&gZbua}U zvo5_apfpSrhl&h{(~5tHSAQecOQsng2~Ai)7U2ksUvB=CJ3s+rSzUGokOcG@_krS}k)r>8&CemBD*)Ct`i4xyG z<(UWnxsU4bh+Im?L~I&wh3-2vMAZE9Z8vy+&uFMN7O*}E#LR%Z0HFSRefjgJd8Zu&9g$B^u!0j583{(KS z%il3${RSLsKp)IUG211ewF%tYq zaxq+8UG7~my-N)|GUx!F(^DSxU%rOv8r)L`2pIPEY=oNs(Iu zrswV9X}rHN%W#H>sq~+c=MkX+SX1N|@gl&Z;6S_tfeUjIQQE$y5CG`$?92;AEQ^?Bn?PE}Fm6 z;C)cC*SVbqoJwkS5Xlmm%hQ|@@$kU*dBhSNNL#JMw3Za!y}&U-~x?3 z@0<3%PK9rTxL}SWqu(>xUzb-fVh zi_Q)t&0(R_M#)qpc+*SOf25u>M76e4<{-3)$Vf{T3hcb<2~+^%^{Uri?=lnapTG{w z<9TxzI8W!NN30x03`8C5HF ztZ}6B==tE@3Vn(&r4Vs$b`nKMt_eSrw-MtaZZutO;2@G4sup7WH7n> zSI5M~77*8RX<78hdnPVo1G)B93bO5ip)(@wWFRGl^lxNNU|4f$CSL(2j%=r>b8N{< z;d^qqKDA#Gk?emPT6>(1Zz=85CFg$HPj^TY-lX@Qur$C|q)=)B@o`CIqwfe*xq*WW z{4b|mH4aCVv-48aLnc{4P%Wzm%Ed)+mI0pR0y4pWregylEYVU~D+HS6g|KXd?AH^0 zyq;{xlz_a~!wGT&5_FVO4pQ-=xa6h)E1wmw72;~x4H9RFTdiH-+3wZ(NWV}Zy%$r^ z^`T{DJ0=Q&vE&8+$Oi zBq#iat&JeybDw1Ju8^14IrbETF{1&b&o0yf3i&FL;GcjE%Rhk=C2BZ$vpOYxMv%x4 zEX*72)x)O1s`NIw0M`^x=(()B)5%;oDk^PO+2>B1j-a(ePRCS(;N zOh5?8VgaSrp>E&f+oL{pWp&5Su(cxmDu?MV{Y@S$&Jqz}zF2EPm*ey5D*QC7;d3{q zQycK&(VgwR7CJ=k2U&m;MC=8dJ;A_0sD(MIeS{KjnT|>zj8Dqw1%nJ89;a9t{=6Rr z=5V$UVVDWlbZWaGEIL7sT1E>*K#EF+8iylz6yuPXnOmR^(_W4w^YGqrcqR&r7XC1c zdcLp2t-wH#rXkI@eIi>}?!(Dg&w)NXU8Q=s^iA{o4eg7>Rm`{DHd@s^X7~LPV^OXI z#=ii&7}o!j!RNUHX_4acFAj7WepS7GFvG0q0wq5se@qX#f>={NwEH4@a^mk+x!N7*DsSi~p)U=BoaB)k10EXel(-AD>^( z%kE`&Wnouvxm1AkSOis)-hT2+<>mHsVyNaZ6Rz5FYf z^U-=Kz4<{=N+8Y2$3L$%JoVfr$S~UCdpD)qVNLVJf5lbT^VW@YD&>XN9HmB1h`bY^3vNe7+1%Rhi>8l5i%d=H~>d8z^6yjyA|K-MyOkb)X ze?K{{Qw6!xfvFM+Q<0H4WAx6{!rhaKB2qcoZ}4rLgAr63ojI#+96+q$q%+HxltdXJ zFXWHVtbb043N;K318o1xjz4jFr(n)CQJ{4VB_SaYH!+E)n2u8`$P!b;7+I>f*@<}Q z#19k%%BYiuF%NyQGKP}>ibKFhSuVYdljJUVl0^F4$Me-D@$M5M@NWUo;Jv=ACHKqB zjrG_)CcIC-v4Om{2H*2cBmkpgmBV2!@()8-ctEK*E;`hE0>46BaIN!3pc#zAayzVw z=naYB{pk=>zGjMUs{n%JL`HcHb8cR<{Fs3y_xd9 zQU7PnTCpDOXM?!i3-yV=4!fx#&^>L%q>BA}oD-n$-Cq~JF*&Dz0$%QaRUZIKNLrlJ zCs)x3ru^!e5CF&}o9fa*8l9XZ(!;rt5Ig@O-M>Uxi!Y66$|{?WDNL@z0tY?099uJ7 zmvs*5BHqfJ7-QT@ERgxnAQrNLQ=Aneh@r@tpAwjiS2k!eTSGp%>Ww9?u^WYWdyi}; zJ{MRVAXEM3D$fgh&qByERsBZt@@8(!FOHBd9QOr`RcSHLeemffNLilP*6F|gsS0h= zrRf3PMz?SOAr8>X98+|H-f0kJP2I+Aw7!TdFKMk23{}uOrq| z(3d`;2rB6jvN;Bk7i;c()5c;-m>f(NFkebzMq9M!PNjP*DNiJIzAm`$k4b-qw(B6m zx+>&+yrYhY&>fk87;Rx-%Q+X1Dt$VVti{=bDZNfh-h(BmcMy3#(my8Z!U#$AX}|B6 zeR{oYV%r(}5ijlLBMuOJ2bA=Oo^9rL1`hb-&b!#K25yM?wyqB53%nO0TwW~(L@&VvZZ_&@$sfF_l`(4lwk8%jMVpxX$Ghzqx{`l zZ~L**7u{k0f5I1rt9Li#A0MY3h*>07tvrEke|&RAxYCDZc(IZ&n!u+R6k%aC6{i%8 zG0ZW>4;Q|7GcO;r6$@u=!6B4&1*P<{4kMKI2-IN-Vaz|2IMV|zIx5OP9)z8>oVjM! zxPHioeEA6cq*VN;?f7@|`oq_M?m6xlXm=K8C-$}bcYM_Qn`Fx~GgJ<+|I9#((HSus z%I80K4@WJ9mTJVe=PlW2>PrHzEtN7fL%-gj4Q2_{yDn};TCDfDbJ(1dz(9l2tP#*` z6z-k!A6Iz4&x294%>PEfPk$@;1V3qg-tiLzmb~#Vonp9aC0Kd6$Ac_0m{0$t?%}Kaps^MIj;$NPOYe5ibe3T1RCum*GiAXI?ygex%*PW&q@3)@^hM=7lMas#T*niKW5T6_H# zOQCw(qD|lI7WWT?HgN2O5Weg`6NCT?nD4%rP(Z0*{ueW8t~iF7_KYcpGO& zG*O_Ps&$rwZ~4PdS6$?UKx|1hP_R&1_KE4!Mb`#mU$NqZ{W;o!afhslS!=o1#rUe3j86sk5k!QJ;%z#X8)00 z*^%EW44qiiy0SRp6Pz2VyYDJ*nhF~AB9&ESGt1+NETz=gDCmRaHgrT@MN|WNXxh~o zrZP-J%$Qmp?r#r@%kj5*O!N4B=buihY(jP%v(p6$pi1I$*&RFSs|{rK)fV3XxRP1b($ z^O7P?lasPODaA?sC>+s@k(f*V^*0i~L_dtd;5Qs$)-H3&uL0)SNX0T-KdyeQher+xt{XmOi8~ z^giYljZWRNlAj+bb=(|HSx0MZPBHj;X)FrMx*x-Ncs-~tjX}Vqph3NmAa_>H*eLX9 zresvKCrIPzVp7j5A1%;K+l92=@cUmF3WV;rJjjFAn}|rNuEQ30X&!>rxyP8$q$vQN zkh0Pu%<+4dXXdv=3E)<$n5_JHI!qRi zK-MPW1c?Dii+e!1zh+hav~&T09wEZFWBKUX0obB$`(Lo84i7<40Kv*5>18e+OrYjmklzp1 zh$=k&LO8m}msF7HgS-9$B>*NCbtde*Evnw`>UyEV5|qvlyx%Txx!Tj|^Z2jslbc3y zq^F5bbOKBrsn# zw-@}_Z}#49iw}Yt4*L{P1j#kwbiJLCmR2G`P*zI3%t)8)T#o*b{^up|AOPsT&!LDq zn8w_MD%7lkbwO12p&bwAGihYdxT(lS3s6@5_l^joBGo@rb<_)zRJgucg01Z2t1j_}8SE_d*vV4@&^i0Ql`6?+X1%3BEnk5BDJq$3M`AZ>=J z_0c4NzX}7z$3JNQyeFoWaJb1A0#yWv1iUTIt;XpXZ-Bc*KCtGU7o$l3cvsL(?#aZ+ z#{i&m!T)}wW@lj4{cwo11z{W9r`MM=4&zij!TiQqp-H!Nsi2~{&&Ci5)#F0%jiw`s zw@4+4n^6%(<0_dE2??o~MT%C)1maVo1~4nj2F_70wxh*F(Yo-$_E|%@RO7~3SRS_= zoAXWh@@HZ=t83D~;*j+DF2+CX7hbZc2c>_@5dSv+lW3&895VTNfR=sS%DuY`2BRId z8ep04q=7`!)k66n)a)b#2#P#v}QSd9Z?~-azD^~cHVPS-4R`U&zqJ(y9qwtP?OB9jIq&3omu`=h65#v z8uwx45b*ZlkC)qVMHO}5*k{&nCa6SBGvkQ_h>Jk|ftnEME^FOPL@G!Bq8dL$H5O64 zkJtV<@?U$0E7yE`EuN4!PeC)p&S)o$clN^1Vkb?6|Dwr>nFdp9)yuNU@G^ClZScJ| zg<^PIeIi9Pk~DIy>Ai#Ia{xsy1>Usfq{;kM1w}zb%UDs- z?-&9;cA1j_@wjPWaF#f$ZSOHkg-W%A^m0eZP$gN`N3riR8F_B_p#TFhidUHZ(CF*Y z?%FJ}9(m}ZYy;@1X1zB1>_r#V7%Qzx)uAi^iMVagzAzA=?&)n39CY) zorWXCPc5q{wnbO4SN$iuG^%%)r)A6fsKW?tstbT^^)QX1)E6?KF9M4CO?KSpUyy-K zLrspzU>9m(5G0RyHXerl`^+HL$_G}(3@VuLK!|&=Pm!FWEXm0+Y%WOX8$GXx0yJQW zUHks+DoNp^g(b~W$C7Y?F1XGVciqQb(!po_e9FD4}~J4^C3gy-YS-xq=CCQlE}k~c6??Fm{eB`cBX^#}AH z=4CJkF=!n7JFliHfsu1mVo{(3{GhNAuh`RWm5l zzpb3x?TeLZ1_#V|P5-46-E=iwUrpC-H5W+D4n*3Y$o&4?Mr8X%+QdpxkwP_2C)Wqf zx$BDeHOK9$g3n)6_y$r^^N~2Tb@@~%n;A_-h}&wU^Fj=40(utCC!Q_}e-;HbqV?*n zk+37@WEr2aVUEZTfBshxo{VQa(Mom*H^RuIre*Z~{T(FLi>c<@OwOdGRcvJe6?^cn zo3!~AA(BKh(z1PHT1C0XN^nzQDs;8`ctLyTj>jIR+U4=Tn)*g$72yYxj6Z&xw<(_( z7=1=gvgPY)#43M^3_UfoOWYDzt5oy_G=HEd;*CpFE{7)fiD!AaDplHkI=<)p{1ZuR zKvh5k!SdAhzrmFYhaPN|cV(bl#PRI#2t776+*e@p+GXgk=BQSi?L}*oVps-r-Jl+> zc8F==SFS58MH#3RU#JMoaM{W^52dQg-u#>W&A*@V&1F7^YQ7sq1mj4kQ6YxT88++N z6g3753gWpB&f}O4F;6aQ)mx_K&s$|<~L(i4xiTt1=+AZ?;I zJ^JIjE`;0&EV^^OP(5jS(=jqtOsbm1Utha@r84kH3zLh@(4}9_9ggx_mi@Qp&1qhD zzk7OmiUU%b$+(zpN)TaN>$PtDhT{50(+m{eQTw(n-}KGwH=ynDt%S4`jMB@DkEm^347`V z>CZ>Zmr>@R5&~?Y6sFJhHhB_*;x?^OuI3Tpx)%p8z#rTY3rN$cfEL1?oUvaCtZ5hZ z$2{8aDlo3z+yz~ah@p#pprKFCfX{1f$(9Se-Fc|RPu8JyrU_~4RLur$VEm7_JTZVT zSp-xbzV(2~*Rn|;FZX`#q!q+7DeiekK{n13+dGNKU`MXtP%>c0Rax=JeZZ0gR^#A)V9PBE^7;EPvieWya_B5 ze^>DUfhYBxqV@{mZUDGXz7{Yi4^W^3*wOFqG?-Dr`5<6=pDGc<%V7g)My@xJQaW|y zft7#T)zJ0dlWv`9ev}T!lS{4hV)=U}8g!x*fh?=uH^>mCean;7u%fx=&GQSdNbhZ6 zT9Xwu&8#%)BJrNZd8($s4qv;g6D}y=`9inj+>!h2NPUyU07hK_CvN1J(l@oKs7}&% zD&udPik@i-6yhLux1q9Xd+Uuiy|~tQ@@){=Q!~Blu4fvgj6#7<%l`39 z91J9vX{h39R%YbnI88z2C6K?Y#5O^z`e>E*36_`JZ(jLK&8&r5G8dCjP;Y5{Bc>J9 z3gioXD!ABwwHl%loc!dQ3xyVi84As?7M7@FzP8+`TW>A0EsuJY9C7~+5`KD(U7Vi= zg${SonKnkz6Lq|ez_zOA9kycF%PxtadFt}qvSv7z`kDEu}ZG4txOa3ye|JJau1G%*QW$@^ZVmiYL3A1Jni(kJqd)>AXlcbzS9;yMr z0l4XY?8!eAtyk;T+$twfVi<4>6ylie%|NJGC?09<_MG0_(5g!8z+jzv{CoWiHegz* zjFC5K4;c*JI#O&rLO|?IAmK@|aWbNq#&4AmVx;HE36{9lKD>Fw!rA zt`m7|E!@VIhxd)G@`@@NJ9PH7%h}nT9lrH%F(X7$RGPOBdCh;2{q*nU24awUoA~7^ z4rQHf@bShoBhgJf!>LcAeWYNzHF-2afEBJNWXJ025(^MW?Ac88Sp>tYD{gA z7n5;WPh^B|Kmu`)S1dR%j>Cpda8j;Lr5;2efy%SpP!O2;;>Key(B)Sy91vqI7LM&q zW4SNEeBNEAsAK-t=3P|1SXPw4jWRnwAwFIx=n5u?19i(w<3n3@SgJYI^8mABOj~** zenb#UI;S+G0U*m7z)=S`V@JFKe*Hv%9tt=g07o#iqJUSKRJVhE`*{@<*})43vfWB? zvp_AX8ik**cB1_72l3Ot%PR1#aG4@~&j5f`+JE|A;flXxqQvW9bMW(o>pD*e6CSCG zktS4#C)4Kn=Q^qld10vs>TSgidba=RK%D5fM9T4hzF2H1!gS0QvBT)zqcmEq>SPoW zBNTfPasvY=@alsaBdMyLdIc$kiqS+w^8JWd`qkxNeE5m%?+`kL#Q@=`%cTU@)gt)^ zWxyXLbqz8xFLbG^x7>{w-6YAVV_%1ewhZl;SWp7u?OAJTA522`hOblh7d^$3}T;RDBS1K0A|#TG!?3 zHFulb@ie$l;ii{=u)Xi#w!iJu9!4PNw*eJ{Cp-ds>a~?Q>JkZ3lbx=~K2zi}j)vB$_#tTsliv^>+GouNl zrWv<;m8ZKfUe=$)($cp$6v^A_Ue z)YgZsTX6CRR#cyOn7YI|J}-Er>|Vs$6Wz-MHHuf=%IBsD=K}- z(4qz@e{eRv{G?5*>I?8ovYW6nn@_{~G~n8jvR&U_Tt}v@qNEmA>=n9O+=sqEx}JPQ zjE_`Qbbv){ve>QX6VZre`4o1P<>2rl8-0dBjmxqAsUec^jB*7gV;&muor^STLysx= ztAj{#Ts1DFxPYtJ+`fgbD_rU%XgOM{OP?kCpUMyaPAVW__f>V?JagRpb|A_4_|LZCpOfN5^lVPHbm z`z#LL>a52Zl=P~!>&i_eJ?QC#q=Lw8p#EeuDbkqS)e=jlIR zosS7Uk{w-6xvwoWi5G_Bi)qEGF2ispgR}&#Pq(OUlfBQXlieruWG$qff@ZcbmKCqf zTDshxV;=jfWqW&!7&Vwl^dIKQb*b#hO`Tk8<>njqqg6>J4x@j$Y>VF`5oBYc+Yf^i z_Ap|@ioCPA?WYAP?yJ=C8p2zWp~DvdyTY1H|t34i(1bjO)+L=-A#AKaKO3EEG{n)hH3 zk<^By3#RMweSxE+3dK4JRG3($pw1QMo)KIyaW=5LFuzSc(M75C5nLLVaMb&Qtvv+w zyU^A(EDKT#R1JJ{iwi4}DOOg@V6D%(@avYhckI8u4G48tpTR$ub9qmPM10>7@3dQMS<#DyrMBD|nvjgD1h@DxIa5sX` zM4~(Yp>bb0NSWx+^+H2W{^iX}j~V!K)1uYY0$%i@wBcIV{i7L+3Id*@9nn_df6vno zjHtDJZtH`M>>@eqc(Xln7-~Z$u5mrl_K1{Jpiay{##?4@8ge za<}=h2uq8Y5f!U^pMvT-7;avY{8@@~sYGmY?)Rd~Y#%tdoKY+%@EWtW-$UWYplWrZ z5IYXZ%%_;Q`&W|lcU14M>8&=cwkS`7h?w11ag`6S5FLQ%TAJZzLkKc)JHqiV3pVWu z&xA9GE37(fdv3;o>#)^544sYxtDTeQf;_(u-~k4Y)-9N|Z%Yp=QF{&~4cBhNJRH%8 zFpQVsY_ITvw>u_426cARePu_Jxa(pTdwhZNSblk1 z&t|qaf-H?Oee}!CRf)OBnpD{2ya-EyNCCP#xVShw>fr%|L(=f0V@mTiGAOFw>HCfA zR>)^}0w8?hWE;m|<((C~dta+13$0ex{=eloTW)3x3Xu;PBu`Wg4)=M7xs$Adfqsk9 z+8F82w#!rWZ$q?xAL-7Ah>u2J{9Wc4nbSq^$$V3O5RUR9-!sgRB>+l{5%thW%L-a|_Ao@CeJg+b8fy%GXzM6IKG_H)X6vYfix<5UUSFT4 zx5#yClVeNP9AnbgnR>c=?Zi-hXakK33#VO_ub# zbR_HG=b%@r_p58g^6L!cKjW!m^zYoKbKbj}^zLM}n8G_$add7KwX+7Q060aSMp@H` zwB_ZeSocy1X{=?b0SpvuxBLV#&-Uw7N$b^o+uh?(EL%an4G=LC3)!gclUHy59$(qK zPN6cx2-0|wVLg%gjrbp%D%bTPjVzMs{eD~HuZ7L~$2mjgg3aSsT*Zy)dJG*zd*Ezz zXG7_Dn6~!${LC^94Te=Y^b3ejq|A`l9Mg`Y;&)Yl4A)Pyr@9%}ZPy`FbuGf~6Ppth zQUL^tblUA&`d|6%UVP``iZ^5p&5aC0Xrt@jzY)@=`eqG1Fp`3l5vP}e4_!$weVF}6%};Ts5a?w9;tau@ zuPAk5xt;J!V@x8jECf=Hj=$aF8Q8F8Xf}xPmpd{P-S`OccTaU%GeG|6JO^uED z74?0;sFN`B#Mcdba-rfY!hPqI%2V$D-Or2kwj?&CSwgYKBR79s)3xl!++dO&CK0Hb z|E#=RBg0c)h5P-@5hU-Ds2Vd0=7s)D$wN-P>MS~>& zBB0hVzI;vS9|8ZYs-qE}>D2y(F;j*!!o^lRM0yZY)2gbJzAvc&Ep*}}NmNlN(F|j8 zZ7p8oiwJ4v`Vu9g+*dtd~A1hI$^JKUw+*sdf7U2%I?9*Fz~y_2kipCFURs z5%^sEMqE}Ly>~SDf9yE^-q|IWLn{HPVZQjm6tK^XAP9qDZ!4v4#lK>L<)* zIcJg0I86LFLPdsv_+2>_X3scz7nI4<=^X+E8l5M>^qDCVhnhPbET7{)Zd4(!)9#rG z;n@4Czq8q`>H^1)0VL%mMWGq5s&O&xLupp zg~wH=M`#fBXS0TeQwc>ymEKX0e`9oAw@hIl55?_kx8Kjxp4uLXt5Aa?hnU)!{A+(pqUixZO;^1sZmW1w z!pB#?Z=CMapC-3~C25&c7O9HARly?)E9dNNz zFFd-qkiE%S+fGW18Jxy_r3|1;O-^(u1k9hn_H3O!F@W>nw;TF zkz~P4)z3AhAsUJ4Jy@Q=p|r|#DN;c4HJ@Gk@85oxiS|{!n2{_Y`9q1{h6;OoV`56Z zN>G^fxykEoW({`io!iP|o(9<7;X5BT#J~nOF4qB6AzFH0)Q;kC`#bU2EXnF$|!*T@*GK)|)CjR3p?a1r-hPIiYclX%0)@)(z zh`|c!+V=d?3OzsZN-yy{ZzFzw-mB7}3-N@{NkUPUZL^~Pf-DGc{56R4xKJ|lWdt5b zeoI%xvf9(+yfq|%0T#F81R`zB&+7VTnO&8r?4rkL)l3XRvEz+GaBXr}nH!cf{wur< zqYgm>!5=o|8-n|%z#{Kotd52bbba6L&a63*9+hg8tqzK4lQVSTE+_9z!Yw+r%tEBz~`IKHG^eYIrWgM3NF3A<*}znreT2Xg-eU z?HoRdPpQPyfz@tDrRm*Pjo~o>iul4s<2Z*-9KRrj7oJ$#zFHuL!=DxaBLb$YITO%V z-B=`UH-(SnzdxjCyl$(5v;lB8!r2B?@PDhG2OTZ*a~@SxxQ8eVH&&Tbydp}K>L%2_K(B>>)vCSvk`UY zk>!P0i~e_WJp3n>T9i%$E=N&)XnoZeEy^S?9$YXbT+o1AWhN{X$ssSeAonXHSam^g znb1LJCsGs{6Y#K$8c@pWIDYflE8^R{(?S5c{frGZz!}SeMQ)B-r#O2Hfkic*J2G>* zY)xtrSTw}4aNQxF@p@0LN~F(Xm@lNYnqnJ)%M3_$D+mKZCd|8TB_5U|(XT;?B=u@3 zx=Xzp-RGd;P&it=ycf*Bo8cVq=?q-9Y2(T2I#nWReq^_eCNP8?a?ak=M3SCH0{{LT zl9+*NXvBR#es6xJarwOPTLF>2Ziv}rVC9|czHA7~m4B7BHguM|I!d}tM1uRwX#jG( zhl+_k!x$TPXgj=6i`36{$ou^(@n4Zwt}SzT$QY%GK_&IXOj2L6_%(7Mf#{~!^GC~$ zWW~MdL)3^_oY~iU+QrGHfSF5ZGk?yqoxJ(|(xAm>uWa&Lng>G7aYof&!C5ZWVl=dp z%2AvEC4tq&rPTGV z8F-VkGt;|o8Gg8))+)LTzp!=JDx%3MTctM~NcDa4;r|RbiXM@vx!V|WiAEzD+lnw|}rme25umE4oFeo69 zbWLqUq40+|7FgcJzPWtL(yb}6wa}e)@34MKBtxZQN2MV#&m0Ik%BTyeV+()=e98y( zLvGkg7N3nr0*#_z*&$<PDnegG&Xl;%Z6okX!DP{!$tn0|OV{<7kp8xs=p8u@@n^l8qH;R4!&OxcS_q`+ za$q6|Z>Cs@_BBqmRA)BO?yV0;dtrzFD$GL7h_Hbm5br(5jUtELWl9=rCgbD#T1 zymQdY$JSt`C>(!5FAN5zV2pJ1^eO_Yo02n&6*Tz-yZI{!h+kkcULF0J8yOb|!h|Wj z=E}{mn?Vl|BN)qXvWW$nni`^7jGiOtal6d`@RpOl8d~PDbCVQ+&uZA90s@+4YR7mR z8ZQ!IK7-L}r!|An#9k3;FG2A-+SNVcy?IBB{yj+4Nwgk#sL~FT z4TqDw6g(riOLcQ~*sQhUuRq~{N-26&z$M=k;WGQ%!%jPw%HW#V8ydF=ROF9mtl7MT z=ThKz`CVvJyiy%$@BOp}QTcJnPPNJ_Df!3j!6a`2_*|9yJwJ3#^2Fro&K)CE+z?RG zN-5#VGn11%^vZy28i=%i>qb17_^?7ym58}3;XbzASka9%91M9sM+YY20EZ1PQ1Ne+ z3@y_an{#&(d_k-hBPUx1zUQ{*8mpT>i=CdtzqV@nabb!d3I7`_8AiTeSU$r$I*=97 ziV3O8vAUZ>vt?!THAh^%Tio$+fAJy^?b5$cSlqei1x#}bI^Y(S5wqg3HY~pLt%hVrTARxIPoE(C13Jp))Q2_xQJpA+C4&K(7x46!7bT zMQLvAcU@5mRTKb&m+W`5XC2_6tu>FW!R?t5vE&xx_}*}cyUqgD(6 zKS*A)fk;o782AiX+1}qOHoCpLKEzzUhGM~dsmhIuH+(@<3yI^90EHDV%q^9@f?1A# zs=8~ST@{PkKc(w7UMo>!WAc;8%co&gVo=cha&tc*3Hu5^k!oyPwxOmvUKkfbEcUU) zcC|DSDB+MgmK-+&F*=Jpyu*3k1(N{c)YXu%GNbgRk#_u z_%)*}-1qmrnavy6y0Bf&1qBLQl^Z83+0;1gvyBxZK;UQ>qa&5(TeZ{zA+VVM8v_zQ zE-i<{Uj_{^LjCOr4o@9qWo?MfG5>sIf!d$|wDcf^5hg|}GK2e_avxaiKVH1QO8$(i zn?d+ij=)B<4@Z^mS28FRr|~dbDEdF{zRr-&K0E%8w*$!=fvw5397|qTy$NRNdk5O? zbr@i}HByu9x3ML%%-pII=Et_5uqgSelNhBdDO>w3<9X+Wx6l34&na1WOl_yNz_c&T zHPD*7&H{wex z;Ii{Gx;7e~j5-M|OjO-IPOfKDNc6ev2yAl~7UZ8ts`ww@EKV&WPL{L9x~3qrr_|l! z`VMk+9{(o>C0y83-!{gjM7uRZ!hbpm%kMmg#4NU>2?CWKSJ6?Lr^^d{&fi`k!scYC z<8)PQ6cdRP=sZjH1ivXtvnkq}M+u25gih>WH-mGkSh=0nBE~NqB!#ggqzQT45svkK z14q?bX99R*NqVF+S{|O&{6BuUlInkjeSdw8_1R9*NxbwK5E1WisIflOV}A#_RZo20 zTP@c%PInFUKQs{+wyr2P9~9LFFEoNy%ww?>6B_ed!x8fk)PtDW*Q znjqs48mxtZ?#0)<)lg)U=lb$RUXbXR*ky>c*?dWlu3Kp!?0xhC!fz-dritG=|5+Ct ze*p1@0^kMWZk^71RR04Zv0yP(SUeXGk?`vn##uh4;L$y8ZeOPE)MIl_2F8C4v+5Fh zZD_x-Vm}p?MG}>$4=x~uvA}hA!U4KGXGcen`1GGc{X!;HhPe%4i0?}&T@bg;Okpme z;|78Oq}_&ehGXCQK+fx$u?xAQmcvS1{+*}-iPiy25Ado**=n6&n%tnv>6PqG8g?p0 z??f8{fP%(JJzO6lvq!G2o>-E|L2oz#trTGn?2_}{oTbvFSl!U+!&^haz-DFO%eP!E z6Q5jel9LT$LuTv!`@l4P43{Y{&ELIGxEp5V|MKz9D$V3nYO}(eP~x8hpAR8HcP2d# zU2Pi%)N2gCH5Xd=%iP>nJd>wpVu;CmbhSVucJheDObd!XUycNh+h?@A*3p|z)c4J% z<~f_?@{viJoz3qa6%J4!Y}xYKG>l`rU>G15_$iVBKSc7CK-A}4kN=>Oi`_!MBkEbY z@9h`jj;p&f-?XIP=jMJ(aN8CD{815md8mtwrG@*LV~`gez!G8Q6aLZ+fSjuXkXM== z!Z&utb?@bS?SI?N)7Y#)D=>lC*&M_tf_=Cn#P6sboSgcq$$St3+M=n`(NkIS#?=Y_ zOCG`pYUZ<(LgSB}_2nt0x4S|C$ zmB&9&yTuTj;C3Oh)AvnDPpy>xPb4Ot8{e2DBuXINF>P+bZ~athL4SJ?B3}j+c%&Or znwaXyP^&f6M#flQbJQQ2YyD?@2FV8|AmZ>M9{{XC;3a#ppf3#oPHsW?I>-NZ)vstR zDqP};>p|lkKOtqfI2r!L;`ti;FiTVh6!&{zPS=P30uSXiU_Mx%H;a6pFW7pIt|j6L z`EL(*TzlZzaLHY0gO%JMMf9|9UN|Qf=rka_=KbK&GI{iMtDpV=U5*RNuPHLbSp^@ZQ~I5IyJ;+NS|X^8G)W&M~aA_Ye0g+qP}n zHCfZ7Uv9E(>}=PB$*#$^F}WsN(`4Ic|IfM3yZvgt*w=d2^TmDNA4sF80n4k;o_jK| zdLu1mQK%yMM-}5t8;Uankykw%+rw+{ghu>UfB9iJ3Bx}U(4tr?U!>-terOr#Cy}uE zVsZ?jJQk1pefiG~V|D84YY%!G-%oKdk4u|ELA@H9^}6oQK^TUT*vk(XVC8Xpev^h= zJUDvqSX?Vdh@spKQjQj-ewK<~a)$#KWj_byG_<42Zv_V`zorsW+rSOu-!gCCFCw{- zYD+0QXyvC6(QN+<#BU3!RD@_Tx#4P?^IV3jJtYtT($=@AVtb;@_POUqHcxv(LgVq4 zQA}eA==~~R^MV=+msdwAY=uR1)LJ3P zODSeTzmIX+A7NZZ&aAsGSHz0~UJwJEE+K$yabORDAL_uA=U7R``FE;|@Xd}RM{^5e z{`Zi~)RVGm&KVwq+aD61UhIiZ;1J8+NZDL(2vo z-$P$Nr>W4K8vMXFg%#JRhTu(9b&BcWs}cuybyjgPajd9#|9SH5Pc1JSgE|BrT&;q> zpH#WH3n?)@&j@!eAShEDETD53w0*KAn7mKZQfCt)M<|jLE~2o%kzXhiiKn4khO~P; zYS)V}zl#2CK5~}AutzTw{-pht;ey7z!+%cveN$G*!$nYrE$9q@{M99feS`HF;U+Ij zukfez3&wz#aJU?QurxQNge@%!@w zxDRJ8;`=l6uXdTrpjQ^m9Kh}PreKPs8*`?Ouvv#i2@1Qs0#B{}(Cvu(_R{?Ro`=ha zLYub1e}-buJkcq68nF%BY92>`H4OIouK{Z2whD6?BEiofS?@6x)#m6epTb_@%v^z5?L=lj3GiP!+33)4?ddTv6uwVI z$Cl@bvJ`B(Gz`R0|DiV=L^1Y@ipKbSO(xP>#^=XMMduLtMOg-ECDiXsfoqAQIhRXd zwgPGH5{23#ex>v9nsx!#FG#AFIi`!7W2UDYY@03i){`nVxQ6o6>Cqo;FKIMS+SL~B zYZhGAXhQK=JGBw#BuWz&_v`Y~j5kffqaeWk<3NFiRYhUKl|l=Pvf|}u5cP&A95+=c z+2``|b^QdJWLu-=l3pg6R>_ZBihjoOUNF#b3C-zMq*r!e_Y~J1^Z(}pgvm$X>xHxa zDigKrQiK^!gwm|3X?h@ACppFS%RoHX1PA@r z(Ii~gRlBke_ZPEBqghU){jpAl2ZJms69f)~fFSG@n%#%w`_nB+BUQ4=Vu~!pH&Prwy)UlNgkC+miWK&+Q)UW%o(W=+i$q)f zn+c-P{CT>LYS3P{5CDu4zq%G~s(<=k*Ffm+7KBCuWJX{-$5g6G31`g&TkWeA8j~~1 zi~!Orkz!=bjVi1E#}ZpCwqHM}4#uknI?P8%kU9ScVj+XR*Y0ZD;g-<=H>Hh8ihwzX z6bw0G0JH-CAZ3D6{ZszRWlWAl{dR(R7{kYEAP+2%C-U>zrga~OtYog6>UG*BZl6$w z1Y}1-J0^x0s!h-`cQn15_pSl1^nBk?52_&2A1V|FkBeT z0^(;-8Xy4J&$E6@vCD8zUX$TU*SU=i)G{sDc_(m9X1QOLb|`Pe=IdWk*jQArK6h;O z{P#hQe1LyY4!t0Dx0FaMzCwdeepn1cRSwPQk12#&$#XeXg1Ei1S`?l6{Z;rNM|QUh zj-l_lyS$woLf64k-d>h1t1R^rNYjTD`>xw?s4aPzmb%y!z<$ zGAIP1dOF(c<35hOjY$ip<#Ig$8jytYKRUUP4h+mo)Bj6bZJ0+CgGWYta_oUT4Y)qm zd~s`OmaCKRXvmF)h{9*pcgCF-yq2~~q8Bd3`Vwp#3lR|$rGq@7ofGw9jbe)T1+^if zzYG2vA(Z?%7ars5rSiAy)d4A6CcG{~Bf%A@mBSyji{yFWLV)6*&t*BzZ5 z?Cj`Ys3n~#Nal2uk`ZX7GV!qTi>c$z)=Nqem$mXnk@B|bD2;H^4hjJkTIlmW+T|sA zc2Vyevjx6|?=DgyRT#=sEM7;df&FBkH~5BnjVeQj!aSE+*O1R;A_u(!#bJt(W)EI9`NrGI+_L4-iJI|;$-Wxx|ndE7FwLYVWb+6(E5ma?? z0^HUOo?Zgq;W;AkU}Zkf5Lpbw8o=bh^;*+TTNR&_gk$nBV|K9rNDvb**OPZzaGyK% zdBbXRGxQ^u6(ZHMwaJv67!#4-z|?e(J~^s5vK!;hbYMCkjJKMJYT}IxGm5gWn$(*% znxYYXPK_ITdll>p%K zdNm0Rs6!lyGi<5C@bN$;jSdU|`Xnj~`o*_H|6T9C5IGDuUUtD{wSogTgE}f)oj2q1 zFePe>ou~|~RvY11SUPvF%U_D`ZpyO%(5-HD!2Oyw40oL|2(l_Rf+S`9Nw=qdgwOya zgGWQOxNKQmeT;*(Q1n5!|>00i)685NrG7e-0j&zho!7TkHu$z<}S7K+qy z1xK|1^gN)h>343UXS&}QWEy;a6R8b6As8%t6;g#T17>}*Tw9)QMLb}|RaGOJfY5=a z@^6e=Cp5eDLm5bTB#1`&`Xe!8u!lbT57p5$CWKD&!7Kz)&D#;G`u;foxEBTq)K5Me zf`pW$_n`Q_c8Q4tPp%8uB+P?!LyYWM4kIvD)Z-b*9U2_948m^sT6)fjFR6kJzE@ zVYm>ZUaUlod=uiu_q8ryQ_y6LrjWy{(LVASISREjtAAi;cQBW-XcR)qb^jU+TEPMn zo7(0>QrDTr0(k*)|NByeK=MfI`BDmecvEBY(4bim{NXSxEGN|DX$$t<{AUJRaCz>9 ziS{8fPWv~%_|=9 zgZgVv7aI?xoDcxAP$Mk>+`CyQV+1Ea`^}%%fv>#`+){Ausx;X8DV7b6NX6l$;^)zN zd&naJoaAU$4$JQK6fmR3MBm_9+wT~Ul>DFAWGFF&dAMBg)sc9*ctGvos~38aGi^*MvGDj6$YbZW#+rv^_@R*95_ zemb92l$lu0vm&IHaxd&(_y&Y%{2)ck!4}0eSV7C$>Uun6RhNY9G$H_2d1VQ98KU3R z-)g(N(U`RKLp^yNLZn;=`$?4t`%;g&)YK(RXmGS{NeN3?7e1xAk>R=vlwk(Wz?KG1 zO&RxfJOD(!r3JaLwH5r)V;Bkp;P08AWV9MnFf!VrD^gPfaWVs5kDGDFhPH68Y4_B) zletm0Y`w@5Ekm@JvqkC^oIYKIm=@S&xdEJ|Q@Qb0oDgOIk}QK4J&|nCJ6f0bu?)gR zSnRz#Pd$V`_a;{Ajt%VA>lG`{{)tb*ECw$%GG=+goo#h6L3)nQ6dQ!KvSmIMf4u4Q z0q_b5yVn;B8Dt8mTyqd#kUYH21bnn_Rqz7CkQrd-et@5OZ-x^Dn1^?9scH#_&HEmB znWJ$G^j)2N?)S#U{_~-6H#erV8T`SHnSFGMw%>It3R*TYLYu;p=5*L8$5I)2ChNk6 zO5)#e$b%+~Ti=r5Ck{?|5QI(W6OY1?invoU@*bw)fAE*3B8`cZ(+MP#fnc~XLnN=W z>ZiyMtrjhUGmFTE1?zjn;)O2Xs*eD4<0mmNyt(K8+%Xd0`0L@d6i;`NoN&rT;+Ql|X=v1HNI z%&j-~%RDb_X%wGjR%`re)uh|zK7+%S_2h(BZw-3*!q6EOBp7T%S8t>QyHY91Xe~_o zzg0N=74mbm42S)NzLJ;~F6CVLQ9{$1^%uokRt%;htIYK1g=_pDd48B$xh{y@A_#Qt zy>87;rRdS9#t;2)yRkTyH~^JX=yw&sKIi~>mhm!z_#|H}K& z72w%-alam|&+COd ze*WcqdiZ>kJo`u%l%ebTAx8w4{;0wMG)TDn)_&(y_@^_D=|jXwfUoeKO2pUf>))A@ z$r+cxn7SwF>TSb4=2C@+-)Mp_`J_*~hYerP@!W(^{t@^{qi(BdMy)oM?l)b69Z4WjGVUCo%P}z9Em<&YZ z91-sOaR+a>)j)7HD-rS^VJ1@p0k8ou(J2p1hFBo>(NP_f!)(!vLHyeJHbjTt=l`E7 z(~(}0d58yO4PzX-=-~i+Lr+3qgpJ9HSZy)@>UswI{HQ=?^u&@~jzCuWk9v8fnqNcR zk8gx@Id0ra(I$jGxB&az2hT^69>eSQQ{J%7#YHdpLnEfeYs~)xe7YK?z%jW$pY7<} zTPP|rI%iM2w;&T|_}AC>!JgE!bBxW=(MB@B!4DcZ8}Hi^i2DPcPA2$SNmN3)LTKwH zpaA7_rsi{}NPF9#@1J{MgBaPFlsKmMq}2>?(CJ@}BJSySF(^sd660qQw@{3!MhMBRC;tkE-S(%^->7bc5CWjhTW; z%V!ikWN#i^w5k|Sr^x+N?*(mtZqLY$I~P4^mi9=qSN>BFhs$y%t;e4eF3a;$($e>5 zWJXkCUXnBch^Xcn-C}XEU*@GQ>6?5t7tFl%-jGK8;ADFvJ32}~7ILutreQCcLAQa| z?r{25Sp?beS}Sj1Ni`e>r2W;`K%?eyHBiEcgH}vDCZZZci>Z~_3RxI*?HL^-6|qH; zomMKv7$gCL4RUz2h@Dek3tOYihD&lr)o)S0ehqTfhdG$o>$Z}($j=#tns-b~Oo)Pp zi!pRcODgZeZN1BbXk|@5&UQCup?j~lo4}s9wP>KD^X2LZYi8#IoQQ9`TwT^nI&IWU zEcV&Y7X~3d{u<6Abp7$mQCi~_)Rasu?Xg8@B>#cZw%ThtmB-yEeZ3tO5_x4I)86Jm$^ zAARX>n;6+pkRI-p$v=tt`kN~Ar=y74{yS?F_B&|IB=$ZQZyam;YebV7onE7Eki%kKwGcpVlGVM= zngF=|x%T~bMDl-)A&$L)YXLyU+pTJSbpLOaHZZUJRj8Aa^R9k03*2GHD*CsQtkc7S zd^oc2p2x2f(hY~(5B>h9OKtOms1JMHi4YAALDQwi{!WEC zeNRFcy{{lr6Klr}PfA1_7dK*4qN30ezw+=t<@gF(O!oM!wyKY|SCkeZ;=9c?Y zt-SsFOyFvcdpw*$Lrq=R{CVwHz=J_&R&gZ^ZH=_+pF%q@q%uoTonfC3AIK1HH(Ws& zHY(u+Tp$42Yiy#TS(^TY>3ENOP4BT1NJKa22`2W-L>SHw$k z?;**;hN1l3CMl=09GTWMO67mn2&UsvtE$by*}76Ze#46$zpEbCR=t)E!+3w@6r#Cq z3&@7jOa5$aiV88sFYj-{&3QW$@3Y2xBi)CeTor0n1EGRhwWhki z{jjPpOAiWA(Xr$5I=8SGqZjxI z*K~J3czi2SZ^l`Rj6@#xVm+@e)^3=Kt~Uex=$ zjd56wneMH*NWmZ!@Ze84)nVj!pmiO2KmHNz2V!HBDt>o0H!qo-`{)tPFk^Y~llwx& z@4w2>_FAK-uB}}#5;n~UJ{;D3-!nYZa06p+m>w)WEu+f1!~npW?+?m-m*sjivhy^! z?i=89fduHC3(3kI_qXh{KN&u>nF0J&cWgMo3gKHuDQ6GD&hHvJrpUTSEnlg$ooEkv zQfW#AeV?&KuNOzQ$PNL`v3ULq>MG$wKYo2uT(aV_Hs@E5P)P;1NRMFT8hFS$%EkH3 zARMM2`?2$?Nhe~PAIVYpchcV~@q8$Qz{xWvbA`{=yQZWOqyO@Z-cL~V1K;nR1dkxH z2cmH-cOTI<;iht=5x)htbf_{|rJ(?4^QJQY{}AnfWybg_sn5Tz9xaUq#v9o}lM2mC zjW7fL6I99LATi8St<*sN$nv-l=N2YU<6U23oPzv4$A_;>t~34w)_WbvFWMSzz}7e2 zHZY}C-Dx1lE`G}KhsrU>ZcU@&5bMn-{ELjHoY?W*Er_9+RTzp-*6~}|<-Zu(r^)nxuXcZW&m$el`*_1&^LfE2ZN?ey!(S01 zHU=@p6MY^2ZfddKd{7PTlDrx}?aC_cO1F)Nt@lOqUE6x1h-S{Jap|pi&??3SdSe zxg^1Kq}7LHAj(~kI)6?@iiEIZplcXjLPwU6VBkulA^=7$zCy{(v1%{Ze?Qggs`ud$ zTw9Z=%di~!WaJR%lPtyZ6n?|2+OEf9Xoa&-A?sB*lWFXeE2t{O z%fztml&+z&lX3YoCDZISD<4#?Y=?7nd@S)*Jm;*w?y^+`Sbe000#aKBP;*CUMEsxO z*dT$cj-y2Ja0O~&31R7T+!DOq$9AXf?$-TmP0MIn3t2@R`!b5XS*0B2)}+j9Nefkcl^^3?(65dH~_)B zCD^eia_T5PE|I3TmbxijNl%z?nw>@B4`p%x2qFGMec`Jjv4miRWH{EUAgU$CRn>NX>T!S2bMi&E*(JDG zoOdQ190rCUC5Bx(7I+Qy+&(7@-4N5dTf@*lJYgrEsw<8M^g!%y?i<2A3PPo(fBT1IyxwxIrjD{Cr^WJM1));a)=8G$vy@ zF2}ZPWDNI+b~C=r^u14F?tlPWtV<#7Ag;bI*Pv#%zd>^Ic@FO8AK|6Y8b564UcLI8 zHm@u+VddCYCo7?Bj@lxa;lvmr8lJ-g!0H$=Sn2^fo<~)9Btrt&&jie#4|NaK!S^Li zf^7#TnCaB`OG=uFfM#7yx5t>A$#>pj238Nt$EQOEN?i0i;a8MwK6^rko*SFg`pqw@ zIufmlM;{+qE^puOe?8rP50n85goJZlsK27(E9u)SIP~+BFK`e~H4zaL;}z*A*s(DC zT~xt#el3mFojo1NEMox!Dzf9&Di!d-nach=txbi_mRN+X=R=)(L~~24G-+0X42kOf zh;5jNO{?fF1o|F?$#5>Q7Ud6fmNyUp95*~toN(k2AnJ0x`ZSX6_=TW^;;YYpMPY@> z&_FN_kDS0}+imVE2BHjm;6*<teUO?if0|9 z&p;nbZySl&%WAciodedj;MhS!U(=oUzx$)hB9rmIx5LuVvS*Y@uS$Qn&|+LE(>z*a zofgJ0<@ydiOvZL&uA>tC@h4P`qW^0d345vcJ;kl^KBj5n<0}%@iFaZ~(6}wE_yyM+ zutVWSr$GRaPr21!0}=4-OG~QY1W4>v0iJfeOqe9wqO46R;R#+cSvlbouB3pUFjRe^GfK)gR4m3phzMQ1lyk=w#=FJ8kz-&EAaI=gav<(_M1UtrqIL% zCE#&qlmDb#h8;PWU^P7P)yJdfvA5RhP5lUS8w&f=)RTTHnwYbPUNle^{5k~u1%Cuz(tzxZPkjA$Z(9v;QM+3)RZp$x3uG zrxiWj$UAOUR#$ib^?%8gG@Gc<$n)p`C|{hP8#IV!6sgD>(7OWFL4b8Ej{!w31&Xl* zM+L?UUFd+*N5^_mV1;jvG}`_qD@wF9TJvA^JhFh-nO!b`AoBR zhe#?G_HDtOrg~%<=99Y*5K8jQuk6rDt)2gN1HOmLi$$VxFGvf1dw4dNS=SFj0;LQR zPpJ$jN5>Z-iTuII-zy2Ig8GdFh(U(X!kb` zChE^pHFhil-Q575G@RLN)}cLwGdqWVy`Qc5RM-QqwcYXYv%p<8^}>gWko>q}EUc#p zc5kfzQ+$76lv!Upj=Lzqz?kgRD9iRIZK*0F++^D%=|ko(Zu#Hurb4o_2|KkG>U+Z? ze{5V$G&A1MvwiDzkXd~;(lsefY`m^i6ZU*RZ#Aj{EvYtwT&Pn@Z%oT5n^R(gn7JMB z*FHpvYYJI{1_%k}e(?jwwYtPe@iZ`p(!jFPz^RMz!HxOT-?-)d&_@!4Uaoj6RlF@f za}?g{ZR!_h)>br=%^{NBPpBonR!VCKX2$v%g^S_(!O->7!E6E7-AA|*14l6YG)(TG ze9526J(pF?jt3LTNadF_WWg2Sfwl7Y#p;AInUTnKEy(z}SELIzP#)hLn{w)+NaA7K{*VmR*m{zsq4X4`dps)4T;BS=?|*y$T!scRkU5_p zz2Ci_W)k-IVrs)3{%B32fMs;HwVeK_CDxA(`V1Zh_Wvru%6G?u| z5%EAr!CX>YK(0jmwZi&S@A&W#K6f@c8URrdjG7Jmh(h#%RY#8oCLccK$IO~^6G5?eqtuBgK> zmbHBY?OZ#YDH&SY5uojahH*x_)z#1oE8-b&&Qt5Qr&uy8C*umL@hOgueDX{1#?p^R zLZGj*_MaBH0cEi6{ejoca_dgwakJd9dZz4Ma%&#d`em-0Ykg32xcFAOLjU=FIR?e8 zn5*2MP!QR($sm+?ji!)#`1)6WqlS`?B5_!ol@1%4xH~K5y7< zxv~kJe2+{I>8mdUFhX5zNu`SfSv!tggg*zE6p&|+M>3hFhvmz%!@W3Z`enIsWqf^w zUfTMjBlFgj2wXYo!9wUh8NA$x6cw+$6#0beqnL^23Rq7(J6oXWj*g2yKr{N{< z{bek{vHwu?(fjz>qAp<7Jbh=&_fndE1Ep*tbUw2aNN{7t#*Guay0qIx(V4q@Cheeo z^1}fqD2eS1lC?uPcnTZYu@mkz`mtQDxHRY3dGG0l5x}#5Fidr_1 z)IT=#M1|a84ldyAPSM@bI@_c6NXYVr8DQ_%*%2?Tq^vix0Xs$_TKGx)m#$R8{Kfz#^|k^ zpX8*z$GUA@64cfPbiZQZq05nQVuHIECn4Te&@MQd3G`I>WBD0HP~Z08wg&{th*79` zf%%FhkQ-eh;Wzi=ug{vskyGe$gs}RgXvA5tZNias25qlB5l$2Ky%HL(MEbep$Z&C2 zsa>feZu+tj`Y{i#NPSy@9I5h3S-%25_abuc`pH2&V-cs75dMgQ7)x5qnn=+&H7G2G1K zfZZP7Q>`39cSeqJNn1Kpa?X6&7eM{)><{cAcnhqOC5&LGIsC&NJR>&BGrkQ+e7o@_ zAT@llK?D*vM$~q+gDrNM=p}1&yLI70wisag{{BQK&jSUWPTy9&mULfmcQ#i{oJ+30 zxX)JLFkhme=1K24KypAryXCk;--xvE0cwXGLr%`^YG3t(qi+=O|BHP`Q${k`+JNJ5 zAD71WLH{8$v*2bo0smGD1u(~DeJj+CC`iN2;g&AW-W7%XXkh-j))84C)1jwpueRp- zX-9LaObDzuoMiP-@e1gMFYm9?fbA_uZtvmbTY*{2T4=ydIfAbUOxx>4g!5R5cBA+= z=E4GdYqUw>ce|s~Apjt#Q2xpHeSajb@EpB4I>z=@C-O@$J?qVWcB4v&Udh%CA+oX* zcVRs)$b7i%pWkf0zq-D@nyf~Z-8(I^kQcvJ?SI3Em$NlcTYk5icUwhBrvITwC!Zwp zKmZ7Ed5Stmf%>0E5f1eDLLdBocm0-X`eB9VsgC8+N$GMhoL@daB!5kJbg~+`$?R z+yBH&!a2=4H6%b{5I~~croCFyNM!M7dCOXn&e2>2RrtecM3DbWzHb?-)nXL^oanFR zT3PdNgCbxdB$-w%=nP4GUv32!*i*m0Kn5ZYP1p%(7e2RL?mJ>E_s=S1`{U>f7d+A$ zo>4On>qyT;#MyfpBEK1P6nzPkw}01XeXBQNs_ETT@q4;Hgde8=OIMAKLkkaPO$<2G zlZ>`Z2dsb{(>XYp6Dp~&v;Xm-BWdUV{WNp9JDuS|~RW?N5e92_qAz9@uFXenwI&Z-0ChC)a(7i84)!|1Bu%(|uhmjD9Gqy@fd4(Slj$c z+~EHI?k+Zo{C(VO2Z33h7yCK6MaO}4@#s&HkY51X57uXbd^G; zB;`qiNPJT&(RGopRJLlyNzK;&f5H{rFsA<%5gq)et}^5l(xd zb8V+ZR@M|Fi^2s;2brOT?@5aI>iGY` z`(Ca>1@`_!Bo?&8FDyXM^}g2J5V*8vHtLSo6D;lZby%M;U=2l~XJnPIwzmA;BASpQ zvf%*>8cH*EXeHW}izF~bAeT7f_{J)uB#Mg~E3KxXLBgi_TO{WeBWdb;;BQ!fUn6^* zZG#jW8c|(O14%JP!ky{L`&-xB9}RrLCNbmTy%KiRPNrrJc~Yd?z&k2s0Hgyqy|%H6 z40ISW6NV*)!OTJf3;QBGQNV@a4Gr|-1v`qio>UquL9HJn z+TCIL(ll1JrM!c1&t7A0De=9;t#>9N2nw!`?5b2rPO7!;C`AUJ%YxfdpnP%eLn_>O zmuaS;Z3qoXJ>y3%K)P4|>-NIc_pXx-Fouh@F4T+q25TPAVigWsTZ_J17ga=?4N4IE zKukV6Adftu>hw5|Em2IDj0tU5NyFHq4ws`ON;QcaPS^H`7dPy;HSFOwkfN9IxLC`< zc`U*fcLmaIbILwgH2|6=AZOU@rN{bcrNKMJ>T|Smd79$HtZ|*lCdc6l9}{G8Oh`-O zj{P4mQ-4u{7~&tZgrsFv03_r7xsV%oR2FI{Py8n22lC2D(i|2}D)}WeBHw48tl(K{ zqC(c*VHeVL_T+&B;TAkMd6@v^s_-~(f_VS2aG<7w^VLPZpM zCOB9behtkqH@&W%aC&_6PSl4uVt)H_0APpBZKIlxp_<-iB?!50E5)iVA)~fJQO`1j zn?OAd%s+00T8JVfE1GunsJO3rptQfI3U8AD;--MON%SY3Q9wvcZ3m(4$I7IVQ2JevhKDtk z>-fM|?iOr}H!R8g6{=US|EzJQ&m7MPek#@Z_sag&zG^Q!K(4MgK`omH0q6!5!jk}w z5*RTM$>$Vt*rm%O>%~hXOqM+6WF$9m)QN?n&2St4^^{!T%i-!*2J3JShr)Vmj}dIw%-U4AF38er@pkqhv8Usa4HH!z^oicU&`Jz4`bU zm7`&?Yij6B+SRdedjlGH1c(L+l0V!hvjm{3^>{+QxGQ#y1!7V4?Q3QJx6-Mt>UO-Q zZk2mo%Hqr^SZa$eaHHn;@r*YKz&}BGon4PQO>*p`M``uf6%tD)jkCoRI$`gZ3#DWj zJlOj^LIaq$g6<(soS9MC2@bmSSG=S1;H$HVp-kLGJu?hCT~B7+!(LricR165Vk1NF z@zkEvws-#wRo_3}W%E9hSbx+RK374qpExUsRm67`0w`oTbRnPL_UmAhC*qxZGl&kh z;*PD1cJYAavtNV%`rAS|hX2RkP!$2@ADLHHN48RMXd`IbfeBez3T;urDnvYi2Hbxv z!XcImmfdl^ytq^hd^fzjSWr=wltWIOUB9qCi+m0A(z@kUg5@1TI2U70U}3jf3)D*# zA*GKVFA-Mh%h=N>w)7&Z!!i4`LIuc0AEK`1k)wt$r((a%R>&|B^IM^ZqvFHId~6no zG&#Zhgi4=$lY-ZCL>#xGwZqSPfz zhgJ0_*Uai@qY}M#rkAXwI-2~KX~2Bmo%`NVlbVxR5`M`^{~w%R&g0gW#=~of(2$t0 zm#=tAnY<(hJ{Lh;7I_JI>D6XHUnv4Y{cJH+cWFwny3}&U!1O0c$#Z7cu!}z?)3_ol z9y4Y{NrUK#`_w>i`v^~3fMQ3%Smf=UPLHx<{J302` z1rldO>|Z;r-}rti0EA-U0yBw?Lrm=(%oDnQdM}meTi0PU4_G_zg(U`wL4E*ev*kf- zP|loZ#e?@x6ZKI?7{_niCuO5$WY11Fe$Z_ia&D==B{_At!Pn&>i{rZ0{cyV*xqw1 zUXis6KUumr)%hk?<9bKx5yAyOiMN31hH>mzg71g_q_2TBkvTC%+K}gMvnyBa=uXsJF)TN<6cx`*9Tx zJkXMnZ^Ue4SkbQF3Js5rRn|ZE?KP@n-jgl0T{83n_8ucM!43?I0efe35&ro=GI;u?-;f9ejyX0MMN> z9jjz}V;)al_XKQ-9V}2WiAOJ(o97Dm1(^V3)r3on)M{=>&RJ~)3BW69Z|dAVr|>^I|j%e1z&dP;97n$EZ{)Y}k2btJ&_6f@`v#hpk?33LoA7 zr^F*r+i#rcZl+bMJN4OOwUv}eyLv|q7vwf3D_B(hapuyJLI(gYCs9*_wrRn#;H>(; z4T0@Y8A^|;Gvb*is5ycrYfaW*IYlQrLPVyutXP7c>;0^WZ z&Wr{xYIpOdIX>};6<{K>9q_L!e!_P0f=T19e}N5em{oBG8kv;ZoO6Mn0B9k}8WJGv zp(MPEBY^&b1yu6^6Yk0MTD(?XY+8Kp7D zc_KdNQDYja!4eSdg$-*L<7rIl>gu^pYI4lcw(yz@OIrELr~mk0Cw1BH@UbzoIo+g# zoAq(URiFvVD5M^w8uvi6kcmoKxE{Y8sth|}q2SJ9qN}B)+zK>Cr@h|ewClCEFJ_y_ z-MGeCVB1BAh0at?BC}`C$mcG`fVv#fEdZ0!1K~vUJ(n|tb0b$$Sojek+keDp_aU7e zmuvU>we3tY;foBJrOSLsYCD<#=x^6ijqi6bq($!-(Ypukb|>z*85+CCS)_O?ZN3Ar5XQJ4RGjZ7r5md5c&66 z{a^v|MZ-mB{N#|i)CbUllm?tWD!t7wH!PW@26n$EC#76rf{+MgiP3nH-PMe2q53f4BY~&wym>Wpy*!2usNF7k-12C@s=0p}k1>|DI;QrRrzsbmS$%aWuz(VR9 zVs01JEBCbD5mnQLWb_9Xbf?x(t=Qpi7_u!tV;akzyY4ShK>^~Ae;T#Pyo3#ZLKvsF z*@gIf0KiG4B)IMH5FE~QS7rX66fJVZ4g@+=%g!WkqJ$nCvjE^vqMiiOAIwC-v!g^} zrE4J`o>XkFtEHbv^`ATb7^qs}$zk1Y7h{uk5ohAAE$ew5;I7*g5X^A7iO*{;W{U}{ zfdh zK3P0v_Zy0T<_`kT_oy|d2 zDw^s-9X9EIWsV6Z5EMH0=`b@szTwFbHb)G2n^6q}_Z)jl9QO?A^x_&U!mm%n#6DN( zedWU^+gdwDpI}Dzkobf}HN+Uo#KB-?+)t- zbofn1te@gcHYQ9qr*b;6N|CrPwP4f z|9y={Ws6U<;0pRyhr>n7?l6K;{_x+M8ezzWH3xsxenSNg9Jp@RakWKu;>WMYZjExX zAOAT+bRZ;H{jEp`XHI~k;f=Pjg0XVkr#7OLt4{S3s#LK}|0ExQGjipKvn)HlCmzr@ zv3Bp(Hl)@-z(>Ms#+(=ADdNt8qD=`Z0*Gui%S5~Xx(4Z;-WGM8mqd?^#9KS=SP=gBAOpk{-oa~{ z;FU(Ufpz|ymG86$S5bqB15tw`6eOQ0MQ7{%_bauF*j$)Ls)5SAe*y|~U_4}tZE&|M zWDmhaUHAqr)x#R-r*My_R}wI2vW*ZAeQq4wcV(LSotYG#^1K62Znpk?j{smtMTUmE z8_jm^jTxX~L!m}V?!>C(OwELt#08t%*YiuV&T>20Tjqu}@>Kz-p)3m7nLU;vI~!xL zEV&=j7iVyPMq309Ix>F-vs>>304(g|>2rT0`!BbObE!Y#YvpXCt}~>i4)L@2IP>=c z+_V&WdS*tMhrDhFir+P#C%N1*F6z zaV2OT;Qwbt$Ggwwg$NWsq1o=$A+`+{gs>WS?)E{WiAAEFVIoDkTy#J5&6_7_At9kC zAhK5-oHkAk;ij8Jv^W~_UrAL4sB{L52s0PGL2bQVQYVmj;j}nx4tT84018DGW3taT z`_u+axn(_*uSxtCd(w8$jt== z`GGIO2f}yU<~UMgNGC9Am$lhNR%C;GQ$(&m#_v0>x~4UIp-MIEUj+E1)Kc4TaBNoAbWj~t#F4nZbL7@!*mcLzL5v?_Pr!^Hc`3zT4*vJ zX}erv$jD4tFgd3=&3VTQ>*;KH=;Xe9rtlSB_L!)Z4iwnN#GlY6$Ahf0MMvVw*9YBY z_0Pc}^t@=`T{qHdD}7X%4I?Y-h+TPZ0;Ga__n|pxdkr#|po<13nrf#BvkK$W)&4&V z(6xu4jMhmU1)#WvC;b$W(ec{>gXH_WGK%lH1demd#vDPN?wBkfRD(b903f&1Th@`K z7=~c_O`&WkB}S6h-QgE~By;xq>dfC_b}Fq^I!O;r!tZFqXVw_$1{!7OJo7VLH}%2< zfQLI90k`Xr5#j@vStiH2*99rxUXwloY%3>MpVe9b2;de6?tZ1T8QT@`ECxY}ObIx6 z=(8hx)lB5Y0zLsGubL-jga7{i^dAQ_9xh0$2K30ODYtx@i$#`(fMs>+lZ+={2b*)U zC6ARgWuWgqz^on9koYPc2M!Qm^07R8XMa}IjJ}{f3X8Cf^Eim< z>l4~&kLgoDO9v>}rO-*yKhGK%px!!m?WpQOz^558T?DL*1VAq8qd@@4%PF zB~fR~B>YD?C~JPjyXA`9Hc$kR1^Ba$#FNMCHT^I|%8H;4v9`e{0DhxEo&G=pKko6R z?uQAEP`xLk)Gj63ezOBT}3<2BRp5|Dt#`cwpwC`z~Wek$j)(NRZqkx(g zZ=4HErEEAZ^5CcJ%in#e^4P9@;_!Q~6E!0T*I&g7mGGhi$1mZLX1Zmabv$cSp$kL+ zuz;KzgmRZhs}55p<`qlC${s5FNh>(*<8X8p+}!)cE7S|6$X1jN$Z?QK26LI)>nROC zJozu%*LKg8tCG8)uM=jK^`EKNyFv;8d@cj!ls?M06#<~sSmElii=Tkww69wCcM~Uq zQ3@_*i;%#F&mSQ>xP>eBOSN)b{R9+97qOPd^bgY{If{D8;lCJapM^vYS>p)#3F=oF zy>WT}R75{)Jz-1)8OJX=Dwr{TsR*X(dc7C)-tYD@9b*-TG%E=Rs>wsrU}NLYjt$O=Z@XS*8zs32)W4=HK+0~}=Q0=95&Nt`Gx|{q3i}=n zG#2*1A~-aycSpD0!N)9OaZfXLnIBC*6AlS z^{1i0&iACc{E^qR-fqUnm$**87b~OI#VV)=z{7o?o5?z_?CyHCZ@c%fH?vTHRbPQ#LN&I%|WoRufd zIsmlW(ZK)Zi{zr;vz_e%gvk9jnzX4bnLs*;U<=u0u6f>(Mc0OaN4MVjXNcDH`2D6# z>_>No%RG($WvyyK#TT)A^V6_B_DixkhK^~~tZ)N00-q#68c`G_1l+v;qBLc*8Sn21wS-_Th|t+qXStj)ynG20ICS207$ zbq{;Q34SFx5P1a*T5a)ccKIqd^>bIfLsTsLCR*mVX9i_0#u7!vJlj7k0WkfTi$Wld zuk-9zl5c%tU3b_1Ltk446m#F15x*x*0S7U-Cu&CPL~30#+Pq`|JNo|jgXZ4rBDQ72 z4gl7Bksfb7h19Q?mh0aw0q_;@T}lER_|p1K_r{R(nXUTvNSB}Uu$kmbe~Ai%2LK%S z^5LTYsiyQ(uusmA2o|F}&%NQRY1{uokSDR|YevAULJ4ULdZEqfH^U172P7 zcJ4dYu5#J3xVf6>_ki(ySEUA}xY4FDKRQsIhe7>K&&%l)BOe-%{+Pt+gVH&~B5 zk}$M!vrD)xT0Oovc79$a?OB0X0tEoDgEo!CSnO*eEns@%%y4NF5T!(lWBuT4nfc+c z@eL_34vR2X zFY?W1CgOA125@kn>GQ{ed#AL73pyHz;L(W_J}Cy89ac$K9eBT-E!8i55yn8#agk-f zba~eYz|SP%&j*P&0XCGS0uG2^Nj@#I#Qgu(=<>Lsr$uhCEOj~lWFYGHjZS43M1A@d zOM}`!bgj}6A;&@+h}%m#l0TeZo#|>w&HMdU#dU!YF#XXD!=(}25-zgt`<6z)?|>k& zfV-^rC0-x@gH%zsCi3*E{yefot-E0y1?cTn0|0*d8pQFR-eGO;sc%yM5`Q@AO_S18 zgl~@Ao$Rj2UxjyE)QUT&zWCFT|M=BTADFe)pFZn+Ax^+w>ENw+;@S=bu0&BN>$c=WWZ3Gf`;@o~jL_d-@GYk)smn6gYZ|&}<1EVm z^erjs_NNA@3DJ;b&9e^K(Ph4WJ31`ce*SnEJE`f!OE>vO7X378x3v2eF*GUQU5`W{ zsQJ-`#Ql=e^D=)Eo89j+>uVj+^8wbOG8_=~%l&j{ul`~4jX(}}-mt&A;2O!i8an73 zi+xfl^)YO9h+ZP3g9cz|N6ghzomWs+N+k;4J{g7f-u@TfS=$Q-8%Q3=#56d^)*HgR z8HA3Lbu{#ks(8Yf;fz&WA1@e?PZ^~fd|X&!VE3t2>`jmcfHbcoHEnEzCZfYUFF1&u z!5IZ=?%Q`c(LpTFl!qxuh0qMY_J_iU^WzYtZV>~YZmO;lLCI4A^sQ_8d3Q1*72pN| zs3zinBb1qrZ~8PhTFbz_JH_i!&NOR5Mfg}}taG$Kp0L&?VIrq1!Gx|0 z?KKbu`ZhyJj~JHg3P~bKdGM-qYQj$$VQ@sJ_)d(PzAj*?v%saVg)Z1Hn$8_ehU78E zW(j^P88f5!WH#QECt{Ch(E1fl7%yy;1tMc+$Yn&*|Ivb*X;HSPj~U{W_{Ww6UCGGF z+H=IY?lYQuDyLyzLhRgyhHvc7DzT%WrxVvp{rq_T6;4b9(5iV#c@2mRx$ytOw^z9< zlRDpFh0pZ*@;3a~+j%}{mHq{kz6L*7MC4$bNY zh4T-m&?WzG0~3R7C2{|X7sb)fD45yycJx+bKf(yKWn^5#aSVDS3Ws*rkzwAQxr-ll z-o&CDb_A8I?*ILT4wwE!@_I;|#X*{$(jHA*)ZXHALp!ybmhLQGS0;c-VFSV=QKH%C zK!{wx>}#ntLnflt!`)`W)j_-Was4QTpO6RCJE=q7Q1{xN z!iV^Z|2T#wPe#tST!E+=zi)mSR#L(WR=@DDWk|tvhwG?JdMu~N9)4=;YB+2nPmf%$ zw{IHW-NSWSY?T&7cwMhpfsc-a1&;-7!dKNl5xgBN_-^f8}1BE$I31~VL)kJSt=%8f3Im+QUHc|R)<}`<~m7Y_a)Jkzy}ijR}jq# z^k9)W2&z^}nO{@Yf@qNSR>ZsvE!LpVSB~O`+$(2I`!SM{+rER}JHK8Q*99k09=D4^ zKf0oh>0{KOn!b8}E~3Vw(`NGu>K*iPkF)2CB`joXCV56D2vei~6WmMRkC9)lmUaGp z)$zUKR9EQfAJkFv=PoA%3e(&i_YV#RaA_mPe$Krm3s&=)U8P?Z6;y_Uv%`d#2ofT` z#$gy9i(z3?6Ec^8Ty}7O9F20ydP1$Y>U=;!LqkX~+UkTn#oxlazivx#_G*TjgKJ{J zYvF49GI4^JVy|xlD>ESrNybPtiTKE0d9EvsM zt|;v2zQT_BXP=XO{=@RlwZ&(aCsLvgf$RlXx+XrjMfG2Q>x4NnJmtciN3l@Wv+>e< zFI(MPcy+soyw4>Bw)np_&c75i%K&DiIyI^(eAmLe|sz|jvOXowv0 z+qvkpQk6d&+G-OKz<|t5A1*M-L^9&*PY)|IOpB4r8gQT0?U@D-SqA>I`7^Q}Nq)FZ z9zhJBNEC`mjV?}uXLVRWOE6TqP_+yQyeC)`|NcHx#6j};oc>g>g512GQN7cdsNxso zt3HA>pd~6$5wwM~O8;cqWc=}RQ_Fr_N1bsl_4z{*oh0Tk&jfsbW3gc6oFsSSHuRY(@kS0d_Us{Sr3hgdkM^aI~Yy{&F zP=3}gH``g9a7|w+EJu80MFeyWurgZ38Jb|a1w8+lrowsztaO7K1##QiII`;_=w$#& zSN%rL77OEtKi|i(;MJ_IC}mlj>FV8eOi@w5D68#krAMwv7yn_eNt(E5=4k=ANF2pAV}g*b#Y zQ1hoJa(T&u9P%(&{=Ky5Mf;7XV%Kd)^A`p1n{19f3b?VhzI+X_-Rgrtt?@Q_{+u;( zn`*>!%hQlc4k9PwpL%nlS$EV$6iPoTO`8mQ*d8nzt{5Fy&M!vn@bIXFVR{SJJS0%q zS|G`O^sU#ZX^|%+SXh=qMMlPEf;@vZ3`N}!v~;$QNM0QT7S{(@u|1heUxE=w<2``i7k1Avs`20b$jEDYi7mmcC?7Jnd*#8sXD7Vbq8#ZRbaSfE2x&uK!Tek;pY_8&_O^B8LD35<& z(_h{E-GW>g%;?$JB9Gl!T;WXNTWl2*IC3FT1-1iHARugzvM{X=h^xb&>M97BCP&yl z_IE{BC#Da7f<@W*`9e3N;Xpr1n zPnysy{!!Jg$ki+Z7<>D$yu>%-a);26$m3&1sR2r~xPZzqy-BL2a!XP5Jurud(dM5S)?r)9cv2tXY--LNB7y$BwYM1 z5#GFv4}=6^Uy@cB8Z`!#3)^>^&FO@o}wDe^CQA*I>kp*pG z87<0OQHOTXAHgZVT&f_~1SKl>7Mo1M?t@!V&8y59m3}y%AV&UbDEFUx+X8033NuPY zj&K-N!(*f5@e|XThzwc~Nqf&Bk(DWm0k-Wl;^p$b$Qh%M$SR!?ppq7!SUj8k>88u7 zI<^K2(K{VBI@073Xdbm%-q8g$YE*OB9%5qQt6#IQ*rOhGs2){FO|ICcwj-!f>PSTG zI6FK}CIP_a!U10B$#2N0qJkN+9FRv~miGMDfB|Y!x;^Wp`x5XZ?S8zB#(I2)FM16N z;GiKy#uJ3HiM$iUY|*XCoc{|YB|Ud_F0_wCt8*vqSBq-=Ug)C6E?KdM@Y!jG#mS-~ zlIdz`u553r*CbeOUq&v5zB?+c*kz2&8;88-=~~eL&9-;S7!jZ$886WmGq+EUV?8t6Zijc4?){RBQTglNU&d%D!A$SL z;;&0@v7eP!;d=(Cn4h0z)en%G;&bxxbyE2dQOy9 z{IslKH`ZPv#UQp&l~4Rb07iz@q9izd{#t+Mn);e0huXvVpc{GFr1inN+?ag}hJy}8 zuMBRubUXOv9i6L;3|=wOl{2(PH4DNIi4psrkGEe_)iTThvqc7(tFpE~9+Ke?aBI>cu0w z*=#-VB~W7brXzp!kN*A=r0SCIXouNu-O`C6+sIkOXjdI4NSQ@kfO8!oEH83g!y=Fg z+f%C?uKM*$oEgsyD6mi0xIJD8&72&N##IvgW=bC5E&};hFbZbl5a+P(^9kKA4<=cW zerHGQSoszBUIoDj2q`3aEn$#it|oUc*qzp495yu=w6JNuKdE}&A5Z0@0AfONs?)3j zE7Yz!gE)jA>XD=){VSc$-PB$FNC024xvV@7Y@RqYlKB(uP? zg+53k!%9c&3qZ`0aG3=ZOVRfNNy+><2h2P{O84 z2TstoUut^?cb$78OMEHX1N6Ghkn?=@wX0XUGCxDn`&tpQ`$cK=<$BXly<6x4AjBYz zKQaxi+_X5UX^%MT4-XUUSHHg_EI7dmM=O?s^ZGCfVI>S$QC8(??a6=V0Z!d7X6xQd z)a**=AK7TbMVse%t1b4C5IwhuZ+lpMZ?s5x;)l!2tKMt=C-1XgUS^^by^*$u>I9U?({otC@f?i3OOee_BFq| zus>@_CH&=8^zV0OFwD$%(z3N#lqTCbva?mMe_L>`f$C(tB>E$X=lSDDa)7hs3S9pw zm74NWrkMl0!QZwXqjqZ{u}cjSXMN!Q@m$zcRPP-QG%hWO<*gP{V7*n@7b z==jXWwH(&#Fe%{kZKv9Y5_dH};RAi=)h-Pc_$`Y2y(<=tC{s}HceRbKZyrlK$%?Fl z-RMHU`_pBhOhaz%@rRIG^BW@n{lVPNe{WGDZNM8M)inWI$e_wL`66p+M#dVdJgci? z_aoPKM~-K}Pj@||@k9>HL@OY5;PV=70=^gZ_;j6r(}o&R2`0sGU=DSN|G1%LxO71*zORA-T6ju#7xT{{##qJ^L-eLxkvzvjtowo z!pj>al-(ECRxtac%Q404- z*}M3%X+7l5;8V!J!j8MA>Mm~A&F)>EwQj{yPSQkWG>$&Dtd!h3BbvG4>hMJ&?#UL* zrfc{=?u=GcS6lZ_i1?jCJQ`>5=%Vi!CP6Q#{=opZqC_8R*X!dOLCcm1!jgtvG-C8A z<`StUk5P!M$KK2E*4~Tr2lViMB&&~_j(U7Mq>Q2=21)J-aydKy>GNU z&YN*=Q>4j-ECeT|WnN$5Yg(*|RS z6;!~K=X%n3 z3}-O}Qay*DAGTtzEE0F8bNr8)h29_i+g?Esp25A5n&iKdOr_r?)y(=XOjuqHM_W~b zc^!w=IO808^%zDCFs%}xFnDs-XQbY0BlJ1rIQ&g-{9dOO0RMrCuG9}UW-T$V`@vMa zF9MP;dF+(gCBB%nO>QNEib~Amw0%k(OdNfRCfn^H!|~}-^}YE98Ba>f^{6g%rq_nI z>+uy?PxlL=S{46POT&7WOZ6N8bP&|SY7bwHs#>E0#=MQ30DS=!&j=*lBjFm=3W_vV zuoN$rE05ig@n9+d288ZqY>CkqY{v~HMQ`|Dox>@^)ky;`pVfig<3svZl$ShtUE|pp zbn;q%jTh9x9jJrZBanSiTq)Nb=l-l0J@9ph?)7EMWS|bmDc<|F9-H0rBj9Ret#;>~ z-%X9U(R5^>?I{b{^}HHe-LM^3S_FMb*Nq%T@s^zK7fkx@K#Wz@W$goAKfW!f&Hpjr zMn~xCHI}}rCgSv}A&O#}i_SZ~*DISbnVUf)*7VwUNG6E>O_<$6DseXo`e9hO)hO~H z9LbWgd3Sp~gaO$aKa>&$kYk5vXD?>_wE6A=+@TF?NKLjFk*J~w5L?W*DY?W( zj|2wnL>YO!rFg&pX8{VyIar_;P7a%&=aFlnD$@-akRoEE@6T4ZGt2V_-d`T}{==S= zqfdOUjg?gq%Q7$J-4eTC^~e0pPs9nvoNL1iSbs!3 z5_O@dl|Ogdvz=UaPEv@r&BM^nyDu?x>cO1N`YK0gU>j4))+Z3Y%mfn>8Omqc`{r_U zx3m`?j_kUWBTMPy{{#(8DPcJ>w~cfwE}hdeFpTj9UWp{#KO}>IzsVK!wHT&U(7Z4e zhB;A%eMK#0Su3(+(&1k-F6H~8-rx%TI`LcxDM`4_5xAYzLu{Gj2t3%oxKz+9N@OlqjOC-ywH>ZLr;k1CH>dNkG7U35~1KMuW^x_UwDT?uu7I(n5=c< z8&?O!DU_9EfP>#W)Ev}FwzTDf-4#qF#a&tKz8XKfEV#!80NO1S*#b?r1o$~3(1*&R zM7|3$S8f{U>D_ug{wyNzmK^S5$P2o0=)I3{05Ayw zf+4NaIb+w}@^8H)+WUqM$}r*^%1T*~in!<6Bl~)Ss^7%3^bshhk#6NGL)TWl+l8>x zW)}1Vsb9w<5ZjwO0I9yG%&95G=i6HU)AZY8~_94G3!e7$MATQs~{t6Y$%h=me}w9m6f z_7VcbR%8eKh1exN^X(5=a9kHaU%q7B>D`VUKKiUE!1#;Ak$#5UFMKiXSIhBxUhfUp zAAbA$KP{va^aKU{$$tUh2U(B(R2dI==)y}6nBzlD(@%%T_wBES54WiCQq0Djs#ls| z-AueeX{qYom5>m;jQc^8wpVG4d)oc?P)*V?09~Rp7|CW4XE_xSe79KQMZgi zvMcIM-=R92Eso>1J_BGtT)P$>8h7kAH84lQ4qLpM7tpG}R0v^yo}6kKZ0 z<44Os+q0;fICFCwPCqr7MAfl@I+h-fw=FwJ%NbFzQYXq zaa6rUBAg{Y&C2A<(Ss;6-W7$-6%8Cl6iBQ(;kfeKX{$+4969eQD=4Z?4c88&rJZ`S z1|Co6LG(B9%FGg8x>I z#Q^8I{l3?E)cP{R%oB?tBbi3@OTd}2mX^%UHIBvt=a1@lQZ^P}a*hf$VW*7L`mhiu zVZXwHI{HGNw4K>G3-|GHSLc)KUqa|(>TGLj5qf-yWl*&