From ef224979cc26d1e9c494234f3e4382886bd06979 Mon Sep 17 00:00:00 2001 From: "jinli.yl" Date: Thu, 5 Jun 2025 19:21:01 +0800 Subject: [PATCH] init --- .gitignore | 21 +++ README.md | 42 +++++ cookbook/__init__.py | 0 experiencescope/__init__.py | 0 experiencescope/enumeration/__init__.py | 0 experiencescope/enumeration/agent_state.py | 8 + experiencescope/enumeration/chunk_enum.py | 9 + experiencescope/enumeration/http_enum.py | 6 + experiencescope/enumeration/role.py | 11 ++ experiencescope/model/__init__.py | 10 ++ experiencescope/model/base_embedding_model.py | 86 +++++++++ experiencescope/model/base_llm.py | 110 ++++++++++++ .../openai_compatible_embedding_model.py | 73 ++++++++ .../model/openai_compatible_llm.py | 156 ++++++++++++++++ experiencescope/module/__init__.py | 0 .../module/agent_wrapper/__init__.py | 0 .../agent_wrapper/base_agent_wrapper.py | 91 ++++++++++ .../agent_wrapper/base_agent_wrapper_mixin.py | 27 +++ .../agent_wrapper/naive_agent_wrapper.py | 62 +++++++ experiencescope/module/base_module.py | 48 +++++ .../module/context_generator/__init__.py | 0 .../base_context_generator.py | 37 ++++ .../module/environment/__init__.py | 0 .../module/environment/base_environment.py | 68 +++++++ experiencescope/module/evaluator/__init__.py | 0 .../module/evaluator/base_evaluator.py | 19 ++ experiencescope/module/reward_fn/__init__.py | 0 .../module/reward_fn/base_reward_fn.py | 11 ++ experiencescope/module/runner/__init__.py | 0 experiencescope/module/runner/base_runner.py | 29 +++ experiencescope/module/summarizer/__init__.py | 0 .../module/summarizer/base_summarizer.py | 37 ++++ experiencescope/module/trainner/__init__.py | 0 .../module/trainner/base_trainner.py | 45 +++++ experiencescope/schema/__init__.py | 0 experiencescope/schema/module_loader.py | 19 ++ experiencescope/schema/request.py | 24 +++ experiencescope/schema/response.py | 23 +++ experiencescope/schema/reward.py | 6 + experiencescope/schema/trajectory.py | 127 +++++++++++++ experiencescope/schema/vector_store_node.py | 12 ++ experiencescope/service/__init__.py | 0 experiencescope/service/model_service.py | 42 +++++ .../service/model_service_client.py | 24 +++ experiencescope/storage/__init__.py | 0 experiencescope/storage/base_sample_buffer.py | 17 ++ experiencescope/storage/base_vector_store.py | 26 +++ experiencescope/storage/es_vector_store.py | 169 ++++++++++++++++++ experiencescope/storage/file_vector_store.py | 132 ++++++++++++++ experiencescope/tool/__init__.py | 9 + experiencescope/tool/base_tool.py | 79 ++++++++ experiencescope/tool/code_tool.py | 42 +++++ experiencescope/tool/dashscope_search_tool.py | 159 ++++++++++++++++ experiencescope/tool/mcp_tool.py | 90 ++++++++++ experiencescope/tool/terminate_tool.py | 23 +++ experiencescope/utils/__init__.py | 0 experiencescope/utils/file_handler.py | 29 +++ experiencescope/utils/http_client.py | 152 ++++++++++++++++ experiencescope/utils/logger.py | 11 ++ experiencescope/utils/prompt_handler.py | 70 ++++++++ experiencescope/utils/registry.py | 32 ++++ experiencescope/utils/test_key.py | 33 ++++ experiencescope/utils/trajectory_utils.py | 18 ++ experiencescope/utils/util_function.py | 9 + pyproject.toml | 30 ++++ 65 files changed, 2413 insertions(+) create mode 100644 .gitignore create mode 100644 cookbook/__init__.py create mode 100644 experiencescope/__init__.py create mode 100644 experiencescope/enumeration/__init__.py create mode 100644 experiencescope/enumeration/agent_state.py create mode 100644 experiencescope/enumeration/chunk_enum.py create mode 100644 experiencescope/enumeration/http_enum.py create mode 100644 experiencescope/enumeration/role.py create mode 100644 experiencescope/model/__init__.py create mode 100644 experiencescope/model/base_embedding_model.py create mode 100644 experiencescope/model/base_llm.py create mode 100644 experiencescope/model/openai_compatible_embedding_model.py create mode 100644 experiencescope/model/openai_compatible_llm.py create mode 100644 experiencescope/module/__init__.py create mode 100644 experiencescope/module/agent_wrapper/__init__.py create mode 100644 experiencescope/module/agent_wrapper/base_agent_wrapper.py create mode 100644 experiencescope/module/agent_wrapper/base_agent_wrapper_mixin.py create mode 100644 experiencescope/module/agent_wrapper/naive_agent_wrapper.py create mode 100644 experiencescope/module/base_module.py create mode 100644 experiencescope/module/context_generator/__init__.py create mode 100644 experiencescope/module/context_generator/base_context_generator.py create mode 100644 experiencescope/module/environment/__init__.py create mode 100644 experiencescope/module/environment/base_environment.py create mode 100644 experiencescope/module/evaluator/__init__.py create mode 100644 experiencescope/module/evaluator/base_evaluator.py create mode 100644 experiencescope/module/reward_fn/__init__.py create mode 100644 experiencescope/module/reward_fn/base_reward_fn.py create mode 100644 experiencescope/module/runner/__init__.py create mode 100644 experiencescope/module/runner/base_runner.py create mode 100644 experiencescope/module/summarizer/__init__.py create mode 100644 experiencescope/module/summarizer/base_summarizer.py create mode 100644 experiencescope/module/trainner/__init__.py create mode 100644 experiencescope/module/trainner/base_trainner.py create mode 100644 experiencescope/schema/__init__.py create mode 100644 experiencescope/schema/module_loader.py create mode 100644 experiencescope/schema/request.py create mode 100644 experiencescope/schema/response.py create mode 100644 experiencescope/schema/reward.py create mode 100644 experiencescope/schema/trajectory.py create mode 100644 experiencescope/schema/vector_store_node.py create mode 100644 experiencescope/service/__init__.py create mode 100644 experiencescope/service/model_service.py create mode 100644 experiencescope/service/model_service_client.py create mode 100644 experiencescope/storage/__init__.py create mode 100644 experiencescope/storage/base_sample_buffer.py create mode 100644 experiencescope/storage/base_vector_store.py create mode 100644 experiencescope/storage/es_vector_store.py create mode 100644 experiencescope/storage/file_vector_store.py create mode 100644 experiencescope/tool/__init__.py create mode 100644 experiencescope/tool/base_tool.py create mode 100644 experiencescope/tool/code_tool.py create mode 100644 experiencescope/tool/dashscope_search_tool.py create mode 100644 experiencescope/tool/mcp_tool.py create mode 100644 experiencescope/tool/terminate_tool.py create mode 100644 experiencescope/utils/__init__.py create mode 100644 experiencescope/utils/file_handler.py create mode 100644 experiencescope/utils/http_client.py create mode 100644 experiencescope/utils/logger.py create mode 100644 experiencescope/utils/prompt_handler.py create mode 100644 experiencescope/utils/registry.py create mode 100644 experiencescope/utils/test_key.py create mode 100644 experiencescope/utils/trajectory_utils.py create mode 100644 experiencescope/utils/util_function.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..69361c03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.vscode +.env +.DS_Store +.idea +venv/ +.ipynb_checkpoints +.__pycache__ +__pycache__ +*.log +tmp* +temp* +private* +dist/ +nohup* +cache +log/ +.trash/ +runs +logs +alfworld_data +beyondagent/dataset/appworld/data diff --git a/README.md b/README.md index e69de29b..d96575d9 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,42 @@ +# run service + +```shell +cd BeyondAgent +python beyondagent/core/service/model_service.py +``` + +# test service + +```shell +python beyondagent/test/test_service.py +``` + +# qingxu +```shell +# 1. edit query, port, vm etc +nano docker-compose.yml +# 2. run +docker compose down && docker compose build && docker compose up +# 3. then watch vm at http://localhost:16901 (default password is headless) +``` + + +# vector store +If a vector database is involved, you will need an Elasticsearch environment. You can refer to the following steps: +- If you don’t have Docker installed, download and install [Docker Desktop](https://www.docker.com/products/docker-desktop) for your operating system. +- To set up [Elasticsearch](https://www.elastic.co/docs/solutions/search/run-elasticsearch-locally) and Kibana locally, run the start-local script in the command line: +```shell +curl -fsSL https://elastic.co/start-local | sh +``` + +Or manually download and load the image. Here, we take elasticsearch-wolfi:9.0.0 as an example: +```shell +docker pull docker.elastic.co/elasticsearch/elasticsearch-wolfi:9.0.0 +docker run -p 9200:9200 \ + -e "discovery.type=single-node" \ + -e "xpack.security.enabled=false" \ + -e "xpack.license.self_generated.type=trial" \ + docker.elastic.co/elasticsearch/elasticsearch-wolfi:9.0.0 +``` + +# run module service diff --git a/cookbook/__init__.py b/cookbook/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/__init__.py b/experiencescope/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/enumeration/__init__.py b/experiencescope/enumeration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/enumeration/agent_state.py b/experiencescope/enumeration/agent_state.py new file mode 100644 index 00000000..ae7269a0 --- /dev/null +++ b/experiencescope/enumeration/agent_state.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class AgentState(str, Enum): + IDLE = "idle" + RUNNING = "running" + COMPLETE = "complete" + FAILED = "failed" diff --git a/experiencescope/enumeration/chunk_enum.py b/experiencescope/enumeration/chunk_enum.py new file mode 100644 index 00000000..975f0b1f --- /dev/null +++ b/experiencescope/enumeration/chunk_enum.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class ChunkEnum(str, Enum): + THINK = "think" + ANSWER = "answer" + TOOL = "tool" + USAGE = "usage" + ERROR = "error" diff --git a/experiencescope/enumeration/http_enum.py b/experiencescope/enumeration/http_enum.py new file mode 100644 index 00000000..69354c45 --- /dev/null +++ b/experiencescope/enumeration/http_enum.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class HttpEnum(str, Enum): + GET = "get" + POST = "post" diff --git a/experiencescope/enumeration/role.py b/experiencescope/enumeration/role.py new file mode 100644 index 00000000..ae33f0e0 --- /dev/null +++ b/experiencescope/enumeration/role.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class Role(str, Enum): + SYSTEM = "system" + USER = "user" + TOOL = "tool" # environment + + ASSISTANT = "assistant" # policy model + CONTEXT_ASSISTANT = "context_assistant" # context model + SUMMARY_ASSISTANT = "summary_assistant" # summary model diff --git a/experiencescope/model/__init__.py b/experiencescope/model/__init__.py new file mode 100644 index 00000000..cf37ad31 --- /dev/null +++ b/experiencescope/model/__init__.py @@ -0,0 +1,10 @@ +from beyondagent.core.model.openai_compatible_embedding_model import OpenAICompatibleEmbeddingModel +from beyondagent.core.model.openai_compatible_llm import OpenAICompatibleBaseLLM + +from beyondagent.core.utils.registry import Registry + +LLM_REGISTRY = Registry("llm") +LLM_REGISTRY.register(OpenAICompatibleBaseLLM, "openai_compatible") + +EMBEDDING_MODEL_REGISTRY = Registry("embedding_model") +EMBEDDING_MODEL_REGISTRY.register(OpenAICompatibleEmbeddingModel, "openai_compatible") diff --git a/experiencescope/model/base_embedding_model.py b/experiencescope/model/base_embedding_model.py new file mode 100644 index 00000000..b675a51c --- /dev/null +++ b/experiencescope/model/base_embedding_model.py @@ -0,0 +1,86 @@ +from abc import ABC +from typing import List + +from loguru import logger +from pydantic import BaseModel, Field + +from beyondagent.core.schema.vector_store_node import VectorStoreNode + + +class BaseEmbeddingModel(BaseModel, ABC): + model_name: str = Field(default=..., description="model name") + dimensions: int = Field(default=..., description="dimensions") + max_retries: int = Field(default=3, description="max retries") + raise_exception: bool = Field(default=True, description="raise exception") + + def _get_embeddings(self, input_text: str | List[str]): + """ + Get the embedding vector based on the input text. + This is an abstract method, and its concrete implementation must be provided in a subclass to generate the embedding vector for the given text. + Args: + input_text (str | List[str]): The input text, which can be a single string or a list of strings. + Raises: + NotImplementedError: If the method is not implemented in the subclass. + """ + raise NotImplementedError + + def get_embeddings(self, input_text: str | List[str]): + """ + Retrieves embeddings for the input text. + + This function attempts to obtain embeddings for the given input text. It will retry a maximum number of times in case of failure. + + Parameters: + - input_text (str | List[str]): The input text, which can be a single string or a list of strings. + + Returns: + - embeddings: The embeddings for the input text. Returns None if the maximum number of retries is reached and no successful result is obtained. + """ + # Attempt to get embeddings, with a maximum number of retries set + for i in range(self.max_retries): + try: + # Attempt to get embeddings, return immediately if successful + return self._get_embeddings(input_text) + + except Exception as e: + # Log exception information when an error occurs + logger.exception(f"embedding model name={self.model_name} encounter error with e={e.args}") + + # If the maximum number of retries is reached and raise_exception is set to True, re-throw the exception + if i == self.max_retries - 1 and self.raise_exception: + raise e + + return None + + def get_node_embeddings(self, nodes: VectorStoreNode | List[VectorStoreNode]): + """ + Assigns embeddings to the nodes based on their content. + + This function accepts either a single VectorStoreNode or a list of VectorStoreNodes. + It retrieves the embedding for the content of each node and assigns it to the node's vector attribute. + If a list of nodes is provided, it performs a batch retrieval of embeddings. + + Parameters: + - nodes (VectorStoreNode | List[VectorStoreNode]): A single node or list of nodes whose embeddings need to be retrieved. + + Returns: + - (VectorStoreNode | List[VectorStoreNode]): Returns the input nodes with their vector attribute populated with embeddings. + + Raises: + - RuntimeError: If the input is neither a VectorStoreNode nor a list of VectorStoreNodes, a RuntimeError is raised. + """ + if isinstance(nodes, VectorStoreNode): + nodes.vector = self.get_embeddings(nodes.content) + return nodes + + elif isinstance(nodes, list): + embeddings = self.get_embeddings(input_text=[node.content for node in nodes]) + if len(embeddings) != len(nodes): + logger.warning(f"embeddings.size={len(embeddings)} <> nodes.size={len(nodes)}") + else: + for node, embedding in zip(nodes, embeddings): + node.vector = embedding + return nodes + + else: + raise RuntimeError(f"unsupported type={type(nodes)}") diff --git a/experiencescope/model/base_llm.py b/experiencescope/model/base_llm.py new file mode 100644 index 00000000..79707652 --- /dev/null +++ b/experiencescope/model/base_llm.py @@ -0,0 +1,110 @@ +from abc import ABC +from typing import List, Literal + +from loguru import logger +from pydantic import Field, BaseModel + +from beyondagent.core.schema.trajectory import Message, ActionMessage +from beyondagent.core.tool.base_tool import BaseTool + + +class BaseLLM(BaseModel, ABC): + model_name: str = Field(...) + + seed: int = Field(default=42) + top_p: float | None = Field(default=None) + # stream: bool = Field(default=True) + stream_options: dict = Field(default={"include_usage": True}) + temperature: float = Field(default=0.0000001) + presence_penalty: float | None = Field(default=None) + enable_thinking: bool = Field(default=True, description="whether the current mode is the reasoning model, " + "or whether Qwen3's reasoning mode is currently enabled.") + tool_choice: Literal["none", "auto", "required"] = Field(default="auto", description="tool choice") + parallel_tool_calls: bool = Field(default=True) + + max_retries: int = Field(default=3, description="max retries") + raise_exception: bool = Field(default=True, description="raise exception") + + def stream_chat(self, messages: List[Message], tools: List[BaseTool] = None, **kwargs): + """ + This method is designed to handle streaming chat functionality, allowing for interactive communication + with the ability to use various tools. It is intended to be overridden by subclasses to implement + specific streaming chat logic. + + Parameters: + - messages: A list of Message objects, representing the message history or current messages in the chat. + - tools: An optional list of BaseTool objects, representing the tools available for use during the chat. + - **kwargs: Additional keyword arguments for future expansion or specific implementations. + + Raises: + - NotImplementedError: This method raises a NotImplementedError to indicate that the functionality + should be implemented by subclasses. + """ + raise NotImplementedError + + def stream_print(self, messages: List[Message], tools: List[BaseTool] = None, **kwargs): + """ + This method is intended to be overridden by subclasses to implement specific message streaming printing logic. + The method raises a NotImplementedError, indicating that this is an abstract method that must be implemented by subclasses. + + Parameters: + - messages: A list of Message objects, representing the messages to be printed. + - tools: An optional list of BaseTool objects, representing auxiliary tools that may be needed during the printing process. + - **kwargs: Additional keyword arguments, allowing for flexible handling of extra parameters. + + Raises: + - NotImplementedError: Indicates that the method is abstract and needs to be implemented by a subclass. + """ + raise NotImplementedError + + def _chat(self, messages: List[Message], tools: List[BaseTool] = None, **kwargs) -> ActionMessage: + """ + Abstract method for processing chat messages and generating responses. + + This method is designed to be overridden by subclasses to implement specific chat logic. + It receives a list of messages as input, along with optional tools, and is expected to return + an ActionMessage object as a response. The method raises a NotImplementedError to enforce + implementation by subclasses. + + Parameters: + - messages: List[Message] - A list of Message objects representing the chat history or current messages. + - tools: List[BaseTool] (optional) - A list of BaseTool objects representing the tools available for use during the chat. Defaults to None. + - **kwargs: Additional keyword arguments for extensibility and backwards compatibility. + + Returns: + - ActionMessage: The response generated based on the input messages, encapsulated in an ActionMessage object. + """ + raise NotImplementedError + + def chat(self, messages: List[Message], tools: List[BaseTool] = None, **kwargs) -> ActionMessage | None: + """ + Initiates a chat session with a model, allowing for the execution of tools. + + This function sends a series of messages to the model and expects to receive an execution response. + It can handle exceptions during the chat process by retrying a set number of times. + + Parameters: + - messages (List[Message]): A list of message objects, containing the conversation history. + - tools (List[BaseTool], optional): A list of tool objects that can be used during the chat. Defaults to None. + - **kwargs: Additional parameters that can be passed to the model. + + Returns: + - ActionMessage: A response message containing the model's execution results. + - None: Returns None if the maximum number of retries is reached and no successful response is obtained. + """ + + # Iterate according to the maximum number of retries set + for i in range(self.max_retries): + try: + # Attempt to execute the chat logic + return self._chat(messages, tools, **kwargs) + + except Exception as e: + # Log exceptions during the chat process + logger.exception(f"chat with model={self.model_name} encounter error with e={e.args}") + + # If the maximum number of retries is reached and raise_exception is set to True, then re-throw the exception + if i == self.max_retries - 1 and self.raise_exception: + raise e + + return None diff --git a/experiencescope/model/openai_compatible_embedding_model.py b/experiencescope/model/openai_compatible_embedding_model.py new file mode 100644 index 00000000..a2240539 --- /dev/null +++ b/experiencescope/model/openai_compatible_embedding_model.py @@ -0,0 +1,73 @@ +import os +from typing import Literal, List + +from openai import OpenAI +from pydantic import Field, PrivateAttr, model_validator + +from beyondagent.core.model.base_embedding_model import BaseEmbeddingModel + + +class OpenAICompatibleEmbeddingModel(BaseEmbeddingModel): + api_key: str = Field(default_factory=lambda: os.getenv("OPENAI_API_KEY"), description="api key") + base_url: str = Field(default_factory=lambda: os.getenv("OPENAI_BASE_URL"), description="base url") + model_name: str = Field(default="text-embedding-v3", description="model name") + dimensions: int = Field(default=1024, description="dimensions") + encoding_format: Literal["float", "base64"] = Field(default="float", description="encoding_format") + _client: OpenAI = PrivateAttr() + + @model_validator(mode="after") + def init_client(self): + """ + Initialize the OpenAI client after model validation. + + This method is called after the model's data has been validated, + ensuring that all necessary attributes are correctly set before + initializing the OpenAI client. It creates an instance of the OpenAI + client using the provided API key and base URL, storing it in the + + Returns: + self: Returns the instance of the current class for method chaining. + """ + self._client = OpenAI(api_key=self.api_key, base_url=self.base_url) + return self + + def _get_embeddings(self, input_text: str | List[str]): + """ + Generate embeddings for the input text. + + This method accepts either a single string or a list of strings as input, + and returns the corresponding embeddings based on the specified model. + + Parameters: + - input_text (str | List[str]): The input text, which can be a single string or a list of strings. + + Returns: + - List[float]: If the input is a single string, returns a list of floating-point numbers representing the embedding. + - List[List[float]]: If the input is a list of strings, returns a list where each item is a list of floating-point numbers representing the embedding of each string. + - Raises RuntimeError: If the input type is unsupported. + """ + + # Create embeddings using the specified model, input text, dimensions, and encoding format + completion = self._client.embeddings.create( + model=self.model_name, + input=input_text, + dimensions=self.dimensions, + encoding_format=self.encoding_format + ) + + # Determine the type of input and process accordingly + if isinstance(input_text, str): + # If the input is a single string, return the embedding of that string + return completion.data[0].embedding + + elif isinstance(input_text, list): + # If the input is a list of strings, initialize a list to hold the embeddings of each string + result_emb = [[] for _ in range(len(input_text))] + # Iterate through the generated embeddings and assign them to the corresponding positions in the result list + for emb in completion.data: + result_emb[emb.index] = emb.embedding + return result_emb + + else: + # If the input type is neither a string nor a list of strings, throw an exception + raise RuntimeError(f"unsupported type={type(input_text)}") diff --git a/experiencescope/model/openai_compatible_llm.py b/experiencescope/model/openai_compatible_llm.py new file mode 100644 index 00000000..4a78587a --- /dev/null +++ b/experiencescope/model/openai_compatible_llm.py @@ -0,0 +1,156 @@ +import os +from typing import List + +from loguru import logger +from openai import OpenAI +from openai.types import CompletionUsage +from pydantic import Field, PrivateAttr, model_validator + +from beyondagent.core.enumeration.chunk_enum import ChunkEnum +from beyondagent.core.model.base_llm import BaseLLM +from beyondagent.core.schema.trajectory import Message, ActionMessage, ToolCall +from beyondagent.core.tool.base_tool import BaseTool + + +class OpenAICompatibleBaseLLM(BaseLLM): + model_name: str = Field(default="qwen3-32b") + api_key: str = Field(default_factory=lambda: os.getenv("OPENAI_API_KEY"), description="api key") + base_url: str = Field(default_factory=lambda: os.getenv("OPENAI_BASE_URL"), description="base url") + _client: OpenAI = PrivateAttr() + + @model_validator(mode="after") + def init_client(self): + """ + Initialize the OpenAI client after model validation. + + This method is called after the model's data has been validated, + ensuring that all necessary attributes are correctly set before + initializing the OpenAI client. It creates an instance of the OpenAI + client using the provided API key and base URL, storing it in the + + Returns: + self: Returns the instance of the current class for method chaining. + """ + self._client = OpenAI(api_key=self.api_key, base_url=self.base_url) + return self + + def stream_chat(self, messages: List[Message], tools: List[BaseTool] = None, **kwargs): + for i in range(self.max_retries): + try: + completion = self._client.chat.completions.create( + model=self.model_name, + messages=[x.simple_dict for x in messages], + seed=self.seed, + top_p=self.top_p, + stream=True, + stream_options=self.stream_options, + temperature=self.temperature, + extra_body={"enable_thinking": self.enable_thinking}, + tools=[x.simple_dict for x in tools] if tools else None, + tool_choice=self.tool_choice, + parallel_tool_calls=self.parallel_tool_calls) + + ret_tools = [] + is_answering = False + + for chunk in completion: + if not chunk.choices: + yield chunk.usage, ChunkEnum.USAGE + + else: + delta = chunk.choices[0].delta + if hasattr(delta, 'reasoning_content') and delta.reasoning_content is not None: + yield delta.reasoning_content, ChunkEnum.THINK + + else: + if not is_answering: + is_answering = True + + if delta.content is not None: + yield delta.content, ChunkEnum.ANSWER + + if delta.tool_calls is not None: + for tool_call in delta.tool_calls: + index = tool_call.index + + while len(ret_tools) <= index: + ret_tools.append(ToolCall(index=index)) + + if tool_call.id: + ret_tools[index].id += tool_call.id + + if tool_call.function and tool_call.function.name: + ret_tools[index].name += tool_call.function.name + + if tool_call.function and tool_call.function.arguments: + ret_tools[index].arguments += tool_call.function.arguments + + if ret_tools: + tool_dict = {x.name: x for x in tools} + for tool in ret_tools: + if tool.name not in tool_dict: + continue + + yield tool, ChunkEnum.TOOL + + return + + except Exception as e: + logger.exception(f"stream chat with model={self.model_name} encounter error with e={e.args}") + if i == self.max_retries - 1 and self.raise_exception: + raise e + else: + yield e.args, ChunkEnum.ERROR + + def _chat(self, messages: List[Message], tools: List[BaseTool] = None, **kwargs) -> ActionMessage: + # usage = None + reasoning_content = "" + answer_content = "" + tool_calls = [] + + for chunk, chunk_enum in self.stream_chat(messages, tools, **kwargs): + if chunk_enum is ChunkEnum.USAGE: + usage = chunk + + elif chunk_enum is ChunkEnum.THINK: + reasoning_content += chunk + + elif chunk_enum is ChunkEnum.ANSWER: + answer_content += chunk + + elif chunk_enum is ChunkEnum.TOOL: + tool_calls.append(chunk) + + return ActionMessage(reasoning_content=reasoning_content, + content=answer_content, + tool_calls=tool_calls) + + def stream_print(self, messages: List[Message], tools: List[BaseTool] = None, **kwargs): + enter_think = False + enter_answer = False + for chunk, chunk_enum in self.stream_chat(messages, tools, **kwargs): + if chunk_enum is ChunkEnum.USAGE: + if isinstance(chunk, CompletionUsage): + print(f"\n{chunk.model_dump_json(indent=2)}") + else: + print(f"\n{chunk}") + + elif chunk_enum is ChunkEnum.THINK: + if not enter_think: + enter_think = True + print("\n", end="") + print(chunk, end="") + + elif chunk_enum is ChunkEnum.ANSWER: + if not enter_answer: + enter_answer = True + if enter_think: + print("\n") + print(chunk, end="") + + elif chunk_enum is ChunkEnum.TOOL: + assert isinstance(chunk, ToolCall) + print(f"\n{chunk.model_dump_json(indent=2)}", end="") + + elif chunk_enum is ChunkEnum.ERROR: + print(f"\n{chunk}", end="") diff --git a/experiencescope/module/__init__.py b/experiencescope/module/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/module/agent_wrapper/__init__.py b/experiencescope/module/agent_wrapper/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/module/agent_wrapper/base_agent_wrapper.py b/experiencescope/module/agent_wrapper/base_agent_wrapper.py new file mode 100644 index 00000000..c32b88d2 --- /dev/null +++ b/experiencescope/module/agent_wrapper/base_agent_wrapper.py @@ -0,0 +1,91 @@ +from abc import ABC +from typing import List + +from loguru import logger +from pydantic import Field + +from beyondagent.core.module.agent_wrapper.base_agent_wrapper import BaseAgentWrapper +from beyondagent.core.module.environment.base_environment import BaseEnvironment +from beyondagent.core.schema.trajectory import Trajectory, Message, StateMessage, ActionMessage, ContextMessage + + +class BaseAgentWrapper(BaseAgentWrapperMixin, ABC): + max_steps: int = Field(default=10) + enable_exploration: bool = Field(default=False) + trajectory: Trajectory | None = Field(default_factory=Trajectory) + + def reset(self): + self.trajectory = Trajectory() + + def before_execute_hook(self, query: str, **kwargs): + self.trajectory.query = query + + def after_step_hook(self, action_msg: ActionMessage, next_state: StateMessage, **kwargs): + raise NotImplementedError + + def build_messages(self, + state: StateMessage, + context_msg: ContextMessage | None, + env: BaseEnvironment, **kwargs) -> List[Message]: + raise NotImplementedError + + def explore_messages(self, messages: List[Message], **kwargs) -> List[Message]: + raise NotImplementedError + + def action_parser(self, action_msg: ActionMessage, **kwargs) -> ActionMessage: + return action_msg + + def generate_action(self, + state: StateMessage, + context_msg: ContextMessage | None, + env: BaseEnvironment, + **kwargs) -> ActionMessage: + + messages: List[Message] = self.build_messages(state, context_msg, env, **kwargs) + if self.enable_exploration: + messages = self.explore_messages(messages, **kwargs) + + action_msg: ActionMessage = self.llm.chat(messages, tools=env.tools) + return self.action_parser(action_msg) + + def after_execute_hook(self, **kwargs): + return self.trajectory + + def execute(self, query: str, env: BaseEnvironment = None, **kwargs) -> Trajectory: + self.before_execute_hook(query=query, **kwargs) + + current_state = env.current_state + for i in range(self.max_steps): + self.trajectory.current_step = i + + # generate context + context_msg: ContextMessage | None = None + if self.context_generator: + context_msg = self.context_generator.execute(trajectory=self.trajectory, **kwargs) + if context_msg.content: + logger.info(f"step{i}.context_msg={context_msg.content}") + + # generate action + action_msg = self.generate_action(state=current_state, context_msg=context_msg, env=env, **kwargs) + logger.info(f"step{i} ====== reasoning_content ======\n{action_msg.reasoning_content}\n\n" + f"====== content ======\n{action_msg.content}\ntool_calls={action_msg.tool_calls}") + + # generate next state + next_state, reward, done, info = env.step(action_msg, trajectory=self.trajectory, **kwargs) + logger.info(f"step{i}.next_state={next_state.content} reward={reward.reward_value} done={done} info={info}") + + self.after_step_hook(step_index=i, + current_state=current_state, + action_msg=action_msg, + reward=reward, + next_state=next_state, + done=done, + info=info, + **kwargs) + + if done: + break + + current_state = next_state + + return self.after_execute_hook(**kwargs) diff --git a/experiencescope/module/agent_wrapper/base_agent_wrapper_mixin.py b/experiencescope/module/agent_wrapper/base_agent_wrapper_mixin.py new file mode 100644 index 00000000..e7d4f469 --- /dev/null +++ b/experiencescope/module/agent_wrapper/base_agent_wrapper_mixin.py @@ -0,0 +1,27 @@ +from abc import ABC + +from pydantic import Field, BaseModel + +from beyondagent.core.module.base_module import BaseModule +from beyondagent.core.module.context_generator.base_context_generator import BaseContextGenerator +from beyondagent.core.schema.trajectory import Trajectory, ActionMessage, Message + + +class BaseAgentWrapperMixin(BaseModule, ABC): + context_generator: BaseContextGenerator | None = Field(default=None) + + def execute(self, query: str, **kwargs) -> Trajectory: + raise NotImplementedError + + +class MockAgentWrapper(BaseAgentWrapperMixin): + + def execute(self, query: str, **kwargs) -> Trajectory: + user_message = Message(content=query) + answer_message = ActionMessage(content="hello world") + + traj = Trajectory(steps=[user_message, answer_message], + done=True, + query=query, + answer=answer_message.content) + return traj diff --git a/experiencescope/module/agent_wrapper/naive_agent_wrapper.py b/experiencescope/module/agent_wrapper/naive_agent_wrapper.py new file mode 100644 index 00000000..7c28a308 --- /dev/null +++ b/experiencescope/module/agent_wrapper/naive_agent_wrapper.py @@ -0,0 +1,62 @@ +import datetime + +from loguru import logger + +from beyondagent.core.module.agent_wrapper.base_agent_wrapper_v2 import BaseAgentWrapperV2 +from beyondagent.core.module.environment.base_environment import BaseEnvironment +from beyondagent.core.schema.trajectory import Message, StateMessage, ContextMessage, ActionMessage + + +class NaiveAgentWrapper(BaseAgentWrapperV2): + + def generate_action(self, + state: StateMessage, + context_msg: ContextMessage | None, + env: BaseEnvironment, + **kwargs) -> ActionMessage: + + tool_names = [x.name for x in env.tools] + if self.trajectory.current_step == 0: + now_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + insight_tag: bool = True if context_msg is not None and context_msg.content else False + user_prompt = self.prompt_handler.prompt_format( + prompt_name="role_prompt", + insight_tag=insight_tag, + time=now_time, + tools=", ".join(tool_names), + previous_insight=context_msg.content if insight_tag else "", + query=self.trajectory.query) + # When using the reasoning models of Qwen3 or DeepSeek R1, it is not recommended to use system prompt. + self.trajectory.steps.append(Message(content=user_prompt)) + + elif self.trajectory.metadata.get("has_terminate_tool") is True: + user_prompt = self.prompt_handler.final_prompt.format(query=self.trajectory.query) + self.trajectory.steps.append(Message(content=user_prompt)) + + else: + user_prompt = self.prompt_handler.next_prompt.format(query=self.trajectory.query) + self.trajectory.steps.append(Message(content=user_prompt)) + + if self.trajectory.metadata.get("has_terminate_tool") is True: + action_msg: ActionMessage = self.llm.chat(self.trajectory.steps) + logger.info(f"step{self.trajectory.current_step} size={len(self.trajectory.steps)} user_prompt={user_prompt}") + + else: + action_msg: ActionMessage = self.llm.chat(messages, tools=env.tools) + logger.info(f"step{self.trajectory.current_step} size={len(messages)} user_prompt={user_prompt} " + f"tool_names={tool_names}") + + for tool in action_msg.tool_calls: + if tool.name == "terminate": + self.trajectory.metadata["has_terminate_tool"] = True + break + + self.trajectory.add_step(action_msg) + return action_msg + + + def after_step_hook(self, action_msg: ActionMessage, next_state: StateMessage, done: bool = False, **kwargs): + if done: + self.trajectory.answer = action_msg.content + else: + self.trajectory.add_step(next_state) diff --git a/experiencescope/module/base_module.py b/experiencescope/module/base_module.py new file mode 100644 index 00000000..7616076b --- /dev/null +++ b/experiencescope/module/base_module.py @@ -0,0 +1,48 @@ +from abc import ABC + +from loguru import logger +from pydantic import BaseModel, Field, model_validator + +from beyondagent.core.model import LLM_REGISTRY, EMBEDDING_MODEL_REGISTRY +from beyondagent.core.model.base_embedding_model import BaseEmbeddingModel +from beyondagent.core.model.base_llm import BaseLLM +from beyondagent.core.utils.prompt_handler import PromptHandler + + +class BaseModule(BaseModel, ABC): + prompt_dir: str | None = Field(default=None) + prompt_file: str | None = Field(default=None) + prompt_handler: PromptHandler | None = Field(default=None) + + llm: BaseLLM | None = Field(default=None) + embedding_model: BaseEmbeddingModel | None = Field(default=None) + + @model_validator(mode="before") # noqa + @classmethod + def init_model(cls, data: dict): + if "llm" in data and isinstance(data["llm"], dict): + backend = data["llm"].pop("backend", None) + assert backend is not None, "llm must have a backend" + module = LLM_REGISTRY[backend] + params = data["llm"] + data["llm"] = module(**params) + logger.info(f"{cls.__name__} load llm.backend={backend} params={params}") + + if "embedding_model" in data and isinstance(data["embedding_model"], dict): + backend = data["embedding_model"].pop("backend", None) + assert backend is not None, "embedding_model must have a backend" + module = EMBEDDING_MODEL_REGISTRY[backend] + params = data["embedding_model"] + data["embedding_model"] = module(**params) + logger.info(f"{cls.__name__} load embedding_model.backend={backend} params={params}") + + if "prompt_dir" in data: + handler = PromptHandler(dir_path=data.get("prompt_dir")) + data["prompt_handler"] = handler + if data.get("prompt_file"): + handler.add_prompt_file(data.get("prompt_file")) + + return data + + def execute(self, **kwargs): + raise NotImplementedError diff --git a/experiencescope/module/context_generator/__init__.py b/experiencescope/module/context_generator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/module/context_generator/base_context_generator.py b/experiencescope/module/context_generator/base_context_generator.py new file mode 100644 index 00000000..da8ac911 --- /dev/null +++ b/experiencescope/module/context_generator/base_context_generator.py @@ -0,0 +1,37 @@ +from abc import ABC +from typing import List + +from pydantic import Field + +from beyondagent.core.module.base_module import BaseModule +from beyondagent.core.schema.trajectory import Trajectory, ContextMessage +from beyondagent.core.schema.vector_store_node import VectorStoreNode +from beyondagent.core.storage.base_vector_store import BaseVectorStore + + +class BaseContextGenerator(BaseModule, ABC): + vector_store: BaseVectorStore | None = Field(default=None) + + def _build_retrieve_query(self, trajectory: Trajectory, **kwargs) -> str: + raise NotImplementedError + + def _retrieve_by_query(self, trajectory: Trajectory, query: str, **kwargs) -> List[VectorStoreNode]: + raise NotImplementedError + + def _generate_context_message(self, + trajectory: Trajectory, + nodes: List[VectorStoreNode], + **kwargs) -> ContextMessage: + raise NotImplementedError + + def execute(self, trajectory: Trajectory, **kwargs) -> ContextMessage: + query: str = self._build_retrieve_query(trajectory, **kwargs) + nodes: List[VectorStoreNode] = self._retrieve_by_query(trajectory, query, **kwargs) + context_msg: ContextMessage = self._generate_context_message(trajectory, nodes, **kwargs) + return context_msg + + +class MockContextGenerator(BaseContextGenerator): + + def execute(self, trajectory: Trajectory, **kwargs) -> ContextMessage: + return ContextMessage(content="mock context") diff --git a/experiencescope/module/environment/__init__.py b/experiencescope/module/environment/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/module/environment/base_environment.py b/experiencescope/module/environment/base_environment.py new file mode 100644 index 00000000..a08875ad --- /dev/null +++ b/experiencescope/module/environment/base_environment.py @@ -0,0 +1,68 @@ +from typing import List + +from pydantic import Field + +from beyondagent.core.module.base_module import BaseModule +from beyondagent.core.module.reward_fn.base_reward_fn import BaseRewardFn +from beyondagent.core.schema.reward import Reward +from beyondagent.core.schema.trajectory import StateMessage, ActionMessage, ToolCall +from beyondagent.core.tool.base_tool import BaseTool + + +class BaseEnvironment(BaseModule): + tools: List[BaseTool] = Field(default_factory=list) + reward_fns: List[BaseRewardFn] = Field(default_factory=list) + current_state: StateMessage | None = Field(default=None) + metadata: dict = Field(default_factory=dict, description="add query / answer and etc for reward calculating!") + + def reset(self): + self.current_state = StateMessage() + self.metadata.clear() + + def step(self, action_msg: ActionMessage, **kwargs): + next_state: StateMessage = self.transition(action_msg=action_msg, **kwargs) + + reward: Reward = self.calculate_reward(action_msg=action_msg, next_state=next_state, **kwargs) + + done: bool = self.is_terminated(action_msg=action_msg, next_state=next_state, reward=reward, **kwargs) + + info: dict = self.build_info(action_msg=action_msg, next_state=next_state, reward=reward, done=done, **kwargs) + + self.current_state = next_state + return next_state, reward, done, info + + def transition(self, action_msg: ActionMessage, **kwargs) -> StateMessage: + tool_dict = {tool.name: tool for tool in self.tools} + + new_tool_calls: List[ToolCall] = [] + for tool_call in action_msg.tool_calls: + if tool_call.name not in tool_dict: + continue + + new_tool_call = tool_call.model_copy(deep=True) + tool = tool_dict[tool_call.name] + new_tool_call.result = tool.execute(**tool_call.argument_dict) + new_tool_calls.append(new_tool_call) + + return StateMessage(tool_calls=new_tool_calls) + + def calculate_reward(self, **kwargs) -> Reward: + return Reward() + + def is_terminated(self, **kwargs) -> bool: + raise NotImplementedError + + def build_info(self, **kwargs): + return {} + + def get_tool_info(self,tool_name): + tool_dict = {tool.name: tool for tool in self.tools} + if tool_name in tool_dict: + + return f'tool \'{tool_name}\' description is: {tool_dict[tool_name].description}\t' + f'parameters: {str(tool_dict[tool_name].input_schema)}' + else: + return '' + + def get_tools_info(self): + tool_dict = {tool.name: tool for tool in self.tools} + return {tool_name:self.get_tool_info(tool_name=tool_name) for tool_name in tool_dict} \ No newline at end of file diff --git a/experiencescope/module/evaluator/__init__.py b/experiencescope/module/evaluator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/module/evaluator/base_evaluator.py b/experiencescope/module/evaluator/base_evaluator.py new file mode 100644 index 00000000..1ae5b515 --- /dev/null +++ b/experiencescope/module/evaluator/base_evaluator.py @@ -0,0 +1,19 @@ +from abc import ABC + +from pydantic import BaseModel, Field + +from beyondagent.core.module.agent_wrapper.base_agent_wrapper import BaseAgentWrapper +from beyondagent.core.module.context_generator.base_context_generator import BaseContextGenerator +from beyondagent.core.module.environment.base_environment import BaseEnvironment +from beyondagent.core.module.summarizer.base_summarizer import BaseSummarizer + + +class BaseEvaluator(BaseModel, ABC): + data_path: str = Field(default="") + agent_wrapper: BaseAgentWrapper | None = Field(default=None) + context_generator: BaseContextGenerator | None = Field(default=None) + summarizer: BaseSummarizer | None = Field(default=None) + env: BaseEnvironment | None = Field(default=None) + + def evaluate(self, **kwargs): + raise NotImplementedError diff --git a/experiencescope/module/reward_fn/__init__.py b/experiencescope/module/reward_fn/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/module/reward_fn/base_reward_fn.py b/experiencescope/module/reward_fn/base_reward_fn.py new file mode 100644 index 00000000..5f84b738 --- /dev/null +++ b/experiencescope/module/reward_fn/base_reward_fn.py @@ -0,0 +1,11 @@ +from abc import ABC + +from beyondagent.core.module.base_module import BaseModule +from beyondagent.core.schema.reward import Reward +from beyondagent.core.schema.trajectory import Trajectory + + +class BaseRewardFn(BaseModule, ABC): + + def execute(self, trajectory: Trajectory, ground_truth=None, **kwargs) -> Reward: + raise NotImplementedError diff --git a/experiencescope/module/runner/__init__.py b/experiencescope/module/runner/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/module/runner/base_runner.py b/experiencescope/module/runner/base_runner.py new file mode 100644 index 00000000..7967466a --- /dev/null +++ b/experiencescope/module/runner/base_runner.py @@ -0,0 +1,29 @@ +from typing import List + +from pydantic import BaseModel, Field + +from beyondagent.core.module.agent_wrapper.base_agent_wrapper import BaseAgentWrapper +from beyondagent.core.module.context_generator.base_context_generator import BaseContextGenerator +from beyondagent.core.module.environment.base_environment import BaseEnvironment +from beyondagent.core.module.summarizer.base_summarizer import BaseSummarizer +from beyondagent.core.schema.trajectory import Trajectory + + +class BaseRunner(BaseModel): + agent_wrapper: BaseAgentWrapper | None = Field(default=None) + context_generator: BaseContextGenerator | None = Field(default=None) + summarizer: BaseSummarizer | None = Field(default=None) + env: BaseEnvironment | None = Field(default=None) + traj_buffer: List[Trajectory] = Field(default_factory=list) + + def reset(self): + self.traj_buffer.clear() + + def rollout_trajectory(self, user_query: str, **kwargs): + raise NotImplementedError + + def summary(self): + raise NotImplementedError + + def start_backend_summary(self): + raise NotImplementedError diff --git a/experiencescope/module/summarizer/__init__.py b/experiencescope/module/summarizer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/module/summarizer/base_summarizer.py b/experiencescope/module/summarizer/base_summarizer.py new file mode 100644 index 00000000..1505e846 --- /dev/null +++ b/experiencescope/module/summarizer/base_summarizer.py @@ -0,0 +1,37 @@ +from typing import List + +from pydantic import Field + +from beyondagent.core.module.base_module import BaseModule +from beyondagent.core.schema.trajectory import Trajectory, Sample, SummaryMessage +from beyondagent.core.storage.base_vector_store import BaseVectorStore + + +class BaseSummarizer(BaseModule): + vector_store: BaseVectorStore | None = Field(default=None) + + def extract_samples(self, trajectories: List[Trajectory], **kwargs) -> List[Sample]: + raise NotImplementedError + + def insert_into_vector_store(self, samples: List[Sample], **kwargs): + raise NotImplementedError + + def execute(self, trajectories: List[Trajectory], return_samples: bool = True, **kwargs) -> List[Sample]: + samples: List[Sample] = self.extract_samples(trajectories, **kwargs) + self.insert_into_vector_store(samples, **kwargs) + + if return_samples: + return samples + + return [] + + +class MockSummarizer(BaseSummarizer): + + def execute(self, trajectories: List[Trajectory], return_samples: bool = True, **kwargs) -> List[Sample]: + tip_message = SummaryMessage(content="I am a mock summarizer.") + + if return_samples: + return [Sample(steps=[tip_message])] + + return [] diff --git a/experiencescope/module/trainner/__init__.py b/experiencescope/module/trainner/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/module/trainner/base_trainner.py b/experiencescope/module/trainner/base_trainner.py new file mode 100644 index 00000000..4fd4a2af --- /dev/null +++ b/experiencescope/module/trainner/base_trainner.py @@ -0,0 +1,45 @@ +from abc import ABC +from typing import List + +from pydantic import BaseModel, Field + +from beyondagent.core.schema.trajectory import Trajectory + + +class BaseTrainner(BaseModel, ABC): + """ + load model/prompt + data + env + -> 新cpt + + off policy/onpolicy + """ + traj_buffer: List[Trajectory] = Field() + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def fit(self): + return + + def save_module_state(self): + return + + +class BaseContextTrainner(BaseTrainner): + """ + load model/prompt/db/buffer -> 新cpt + """ + + +class BaseSummaryTrainner(BaseTrainner): + """ + load model/prompt/db/buffer -> 新cpt + """ + + +class BasePolicyTrainner(BaseTrainner): + """ + load model/prompt/db/buffer -> 新cpt + """ diff --git a/experiencescope/schema/__init__.py b/experiencescope/schema/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/schema/module_loader.py b/experiencescope/schema/module_loader.py new file mode 100644 index 00000000..4dde4a2b --- /dev/null +++ b/experiencescope/schema/module_loader.py @@ -0,0 +1,19 @@ +from importlib import import_module + +from pydantic import BaseModel, Field + +from beyondagent.core.utils.file_handler import FileHandler + + +class ModuleLoader(BaseModel): + class_path: str = Field(default=...) + class_name: str = Field(default=...) + config_path: str = Field(default="") + config: dict = Field(default_factory=dict) + + def load_from_config(self): + return getattr(import_module(self.class_path), self.class_name)(**self.config) + + def load_from_path(self): + self.config = FileHandler(file_path=self.config_path).load() + return self.load_from_config() diff --git a/experiencescope/schema/request.py b/experiencescope/schema/request.py new file mode 100644 index 00000000..21a42656 --- /dev/null +++ b/experiencescope/schema/request.py @@ -0,0 +1,24 @@ +from abc import ABC +from typing import List + +from pydantic import Field + +from beyondagent.core.schema.module_loader import ModuleLoader +from beyondagent.core.schema.trajectory import Trajectory + + +class BaseRequest(ModuleLoader, ABC): + metadata: dict = Field(default_factory=dict) + + +class AgentWrapperRequest(BaseRequest): + query: str = Field(default="") + + +class ContextGeneratorRequest(BaseRequest): + trajectory: Trajectory = Field(default_factory=Trajectory) + + +class SummarizerRequest(BaseRequest): + trajectories: List[Trajectory] = Field(default_factory=dict) + return_samples: bool = Field(default=False) diff --git a/experiencescope/schema/response.py b/experiencescope/schema/response.py new file mode 100644 index 00000000..db3c9ae8 --- /dev/null +++ b/experiencescope/schema/response.py @@ -0,0 +1,23 @@ +from abc import ABC +from typing import List + +from pydantic import BaseModel, Field + +from beyondagent.core.schema.trajectory import Trajectory, ContextMessage, Sample + + +class BaseResponse(BaseModel, ABC): + success: bool = Field(default=True) + metadata: dict = Field(default_factory=dict) + + +class AgentWrapperResponse(BaseResponse): + trajectory: Trajectory = Field(default_factory=Trajectory) + + +class ContextGeneratorResponse(BaseResponse): + context_msg: ContextMessage = Field(default_factory=ContextMessage) + + +class SummarizerResponse(BaseResponse): + extract_samples: List[Sample] = Field(default_factory=list) diff --git a/experiencescope/schema/reward.py b/experiencescope/schema/reward.py new file mode 100644 index 00000000..8636e3b6 --- /dev/null +++ b/experiencescope/schema/reward.py @@ -0,0 +1,6 @@ +from pydantic import Field, BaseModel + + +class Reward(BaseModel): + reward_value: float | None = Field(default=None) + metadata: dict = Field(default_factory=dict) diff --git a/experiencescope/schema/trajectory.py b/experiencescope/schema/trajectory.py new file mode 100644 index 00000000..46e93336 --- /dev/null +++ b/experiencescope/schema/trajectory.py @@ -0,0 +1,127 @@ +import datetime +import json +from typing import List, Any +from uuid import uuid4 + +from pydantic import BaseModel, Field, model_validator + +from beyondagent.core.enumeration.role import Role + + +class ToolCall(BaseModel): + index: int = Field(default=...) + id: str = Field(default="") + name: str = Field(default="") + arguments: str = Field(default="") + type: str = Field(default="function") + result: Any = Field(default=None, exclude=True) + + @model_validator(mode="before") # noqa + @classmethod + def init_tool_call(cls, data: dict): + tool_type = data.get("type", "") + tool_type_dict = data.get(tool_type, {}) + + for key in ["name", "arguments"]: + if key not in data: + data[key] = tool_type_dict.get(key, "") + return data + + @property + def argument_dict(self): + return json.loads(self.arguments) + + @property + def simple_dict(self): + return { + "id": self.id, + self.type: { + "arguments": self.arguments, + "name": self.name + }, + "type": self.type, + "index": self.index, + } + + +class Message(BaseModel): + role: Role = Field(default=Role.USER) + content: str | bytes = Field(default="") + reasoning_content: str = Field(default="") + tool_calls: List[ToolCall] = Field(default_factory=list) + timestamp: str = Field( + default_factory=lambda: datetime.datetime.now().strftime( + "%Y-%m-%d %H:%M:%S.%f", + ), + ) + metadata: dict = Field(default_factory=dict) + + @property + def simple_dict(self) -> dict: + result = { + "role": self.role.value, + "content": self.content, + } + if self.tool_calls: + result["tool_calls"] = [x.simple_dict for x in self.tool_calls] + return result + + +class ActionMessage(Message): + role: Role = Field(default=Role.ASSISTANT) + + +class StateMessage(Message): + role: Role = Field(default=Role.TOOL) + tool_call_id: str = Field(default="") + + @property + def simple_dict(self) -> dict: + result = { + "role": self.role.value, + "content": self.content, + } + if self.tool_call_id: + result["tool_call_id"] = self.tool_call_id + return result + + @property + def simple_list(self) -> list: + return [{ + "role": self.role.value, + "content": str(x.result), + "tool_call_id": x.id, + } for x in self.tool_calls] + + +class ContextMessage(Message): + role: Role = Field(default=Role.CONTEXT_ASSISTANT) + + +class SummaryMessage(Message): + role: Role = Field(default=Role.SUMMARY_ASSISTANT) + + +class Sample(BaseModel): + steps: List[Message] = Field(default_factory=list) + metadata: dict = Field(default_factory=dict) + + +class Trajectory(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex) + steps: List[Message] = Field(default_factory=list) + + done: bool = Field(default=False) + query: str = Field(default="") + answer: Any = Field(default=None) + metadata: dict = Field(default_factory=dict) + + def add_step(self, step: Message): + self.steps.append(step) + + def reset(self): + self.steps.clear() + self.done = False + self.query = "" + self.answer = "" + self.metadata.clear() diff --git a/experiencescope/schema/vector_store_node.py b/experiencescope/schema/vector_store_node.py new file mode 100644 index 00000000..55ede7e9 --- /dev/null +++ b/experiencescope/schema/vector_store_node.py @@ -0,0 +1,12 @@ +from typing import List +from uuid import uuid4 + +from pydantic import BaseModel, Field + + +class VectorStoreNode(BaseModel): + unique_id: str = Field(default_factory=lambda: uuid4().hex) + workspace_id: str = Field(default="") + content: str = Field(default="") + vector: List[float] | None = Field(default=None) + metadata: dict = Field(default_factory=dict) diff --git a/experiencescope/service/__init__.py b/experiencescope/service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/service/model_service.py b/experiencescope/service/model_service.py new file mode 100644 index 00000000..4ff0e94e --- /dev/null +++ b/experiencescope/service/model_service.py @@ -0,0 +1,42 @@ +from beyondagent.core.utils.logger import init_logger +init_logger() + +from typing import List +from fastapi import FastAPI +from beyondagent.core.module.agent_wrapper.base_agent_wrapper import BaseAgentWrapper +from beyondagent.core.module.context_generator.base_context_generator import BaseContextGenerator +from beyondagent.core.module.summarizer.base_summarizer import BaseSummarizer +from beyondagent.core.schema.request import AgentWrapperRequest, ContextGeneratorRequest, SummarizerRequest +from beyondagent.core.schema.response import AgentWrapperResponse, ContextGeneratorResponse, SummarizerResponse +from beyondagent.core.schema.trajectory import ContextMessage, Trajectory, Sample +import uvicorn + +app = FastAPI() + + +@app.post('/agent_wrapper', response_model=AgentWrapperResponse) +def call_agent_wrapper(request: AgentWrapperRequest): + module: BaseAgentWrapper = request.load_from_path() + trajectory: Trajectory = module.execute(request.query, **request.metadata) + return AgentWrapperResponse(trajectory=trajectory) + + +@app.post('/context_generator', response_model=ContextGeneratorResponse) +def call_context_generator(request: ContextGeneratorRequest): + module: BaseContextGenerator = request.load_from_path() + context_msg: ContextMessage = module.execute(request.trajectory, **request.metadata) + return ContextGeneratorResponse(context_msg=context_msg) + + +@app.post('/summarizer', response_model=SummarizerResponse) +def call_summarizer(request: SummarizerRequest): + module: BaseSummarizer = request.load_from_path() + samples: List[Sample] = module.execute(request.trajectories, request.return_samples, **request.metadata) + return SummarizerResponse(extract_samples=samples) + + +if __name__ == '__main__': + uvicorn.run(app, host="0.0.0.0", port=8000, timeout_keep_alive=600000, limit_concurrency=32) + +# launch with: +# python -m beyondagent.core.service.model_service \ No newline at end of file diff --git a/experiencescope/service/model_service_client.py b/experiencescope/service/model_service_client.py new file mode 100644 index 00000000..5b1f3c45 --- /dev/null +++ b/experiencescope/service/model_service_client.py @@ -0,0 +1,24 @@ +from pydantic import Field + +from beyondagent.core.schema.request import AgentWrapperRequest, ContextGeneratorRequest, SummarizerRequest +from beyondagent.core.schema.response import AgentWrapperResponse, ContextGeneratorResponse, SummarizerResponse +from beyondagent.core.utils.http_client import HttpClient + + +class ModelServiceClient(HttpClient): + base_url: str = Field(default=...) + + def call_agent_wrapper(self, request: AgentWrapperRequest): + self.url = self.base_url + "/agent_wrapper" + return AgentWrapperResponse(**self.request(json_data=request.model_dump(), + headers={"Content-Type": "application/json"})) + + def call_context_generator(self, request: ContextGeneratorRequest): + self.url = self.base_url + "/context_generator" + return ContextGeneratorResponse(**self.request(json_data=request.model_dump(), + headers={"Content-Type": "application/json"})) + + def call_summarizer(self, request: SummarizerRequest): + self.url = self.base_url + "/summarizer" + return SummarizerResponse(**self.request(json_data=request.model_dump(), + headers={"Content-Type": "application/json"})) diff --git a/experiencescope/storage/__init__.py b/experiencescope/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/storage/base_sample_buffer.py b/experiencescope/storage/base_sample_buffer.py new file mode 100644 index 00000000..969e68f1 --- /dev/null +++ b/experiencescope/storage/base_sample_buffer.py @@ -0,0 +1,17 @@ +from typing import List + +from pydantic import BaseModel + +from beyondagent.core.schema.trajectory import Sample + + +class BaseSampleBuffer(BaseModel): + + def add(self, samples: Sample | List[Sample]): + raise NotImplementedError + + def get_all(self): + raise NotImplementedError + + def clear(self): + raise NotImplementedError diff --git a/experiencescope/storage/base_vector_store.py b/experiencescope/storage/base_vector_store.py new file mode 100644 index 00000000..f49100b9 --- /dev/null +++ b/experiencescope/storage/base_vector_store.py @@ -0,0 +1,26 @@ +from abc import ABC +from typing import List + +from pydantic import BaseModel, Field + +from beyondagent.core.model.base_embedding_model import BaseEmbeddingModel +from beyondagent.core.schema.vector_store_node import VectorStoreNode + + +class BaseVectorStore(BaseModel, ABC): + embedding_model: BaseEmbeddingModel = Field(default=...) + + def insert(self, nodes: VectorStoreNode | List[VectorStoreNode], **kwargs): + raise NotImplementedError + + def update(self, nodes: VectorStoreNode | List[VectorStoreNode], **kwargs): + raise NotImplementedError + + def delete_by_id(self, unique_id: str, **kwargs): + raise NotImplementedError + + def retrieve_by_id(self, unique_id: str, **kwargs) -> VectorStoreNode | None: + raise NotImplementedError + + def retrieve_by_query(self, query: str, top_k: int = 3, **kwargs) -> List[VectorStoreNode]: + raise NotImplementedError diff --git a/experiencescope/storage/es_vector_store.py b/experiencescope/storage/es_vector_store.py new file mode 100644 index 00000000..312bfe5d --- /dev/null +++ b/experiencescope/storage/es_vector_store.py @@ -0,0 +1,169 @@ +import os +from typing import List, Tuple + +from elasticsearch import Elasticsearch +from elasticsearch.helpers import bulk +from loguru import logger +from pydantic import Field, PrivateAttr, model_validator + +from beyondagent.core.schema.vector_store_node import VectorStoreNode +from beyondagent.core.storage.base_vector_store import BaseVectorStore + + +class EsVectorStore(BaseVectorStore): + hosts: str | List[str] = Field(default_factory=lambda: os.getenv("ES_HOSTS", "http://localhost:9200")) + index_name: str = Field(default=...) + basic_auth: str | Tuple[str, str] | None = Field(default=None) + bulk_chunk_size: int = Field(default=512) + retrieve_filters: List[dict] = [] + _client: Elasticsearch = PrivateAttr() + + @model_validator(mode="after") + def init_client(self): + if isinstance(self.hosts, str): + hosts = [self.hosts] + else: + hosts = self.hosts + self._client = Elasticsearch(hosts=hosts, basic_auth=self.basic_auth) + return self + + def delete_index(self): + if self._client.indices.exists(index=self.index_name): + self._client.indices.delete(index=self.index_name) + + def create_index(self): + if self._client.indices.exists(index=self.index_name): + logger.warning(f"index_name={self.index_name} is already exists!") + return None + + index = { + "mappings": { + "properties": { + "workspace_id": {"type": "keyword"}, + "content": {"type": "text"}, + "metadata": {"type": "object"}, + "vector": { + "type": "dense_vector", + "dims": self.embedding_model.dimensions + } + } + } + } + + return self._client.indices.create(index=self.index_name, body=index) + + def refresh_index(self): + self._client.indices.refresh(index=self.index_name) + + @staticmethod + def doc2node(doc) -> VectorStoreNode: + node = VectorStoreNode(**doc["_source"]) + node.unique_id = doc["_id"] + if "_score" in doc: + node.metadata["score"] = doc["_score"] - 1 + return node + + def exist_id(self, doc_id: str): + return self._client.exists(index=self.index_name, id=doc_id) + + def node2doc(self, node: VectorStoreNode, add_op_type: bool = False) -> dict: + doc: dict = { + "_index": self.index_name, + "_id": node.unique_id, + "_source": { + "workspace_id": node.workspace_id, + "content": node.content, + "metadata": node.metadata, + "vector": node.vector + } + } + + if add_op_type: + doc["_op_type"] = "update" if self.exist_id(node.unique_id) else "index", + return doc + + def add_term_filter(self, key: str, value): + if key: + self.retrieve_filters.append({"term": {key: value}}) + + return self + + def add_range_filter(self, key: str, gte=None, lte=None): + if key: + if gte is not None and lte is not None: + self.retrieve_filters.append({"range": {key: {"gte": gte, "lte": lte}}}) + elif gte is not None: + self.retrieve_filters.append({"range": {key: {"gte": gte}}}) + elif lte is not None: + self.retrieve_filters.append({"range": {key: {"lte": lte}}}) + + return self + + def clear_filter(self): + self.retrieve_filters.clear() + return self + + def insert(self, nodes: VectorStoreNode | List[VectorStoreNode], refresh_index: bool = False, **kwargs): + if isinstance(nodes, VectorStoreNode): + nodes = [nodes] + + embedded_nodes = [node for node in nodes if node.vector] + not_embedded_nodes = [node for node in nodes if not node.vector] + now_embedded_nodes = self.embedding_model.get_node_embeddings(not_embedded_nodes) + + docs = [self.node2doc(node, False) for node in embedded_nodes + now_embedded_nodes] + status, error = bulk(self._client, docs, chunk_size=self.bulk_chunk_size, **kwargs) + logger.info(f"insert sample.size={len(nodes)} status={status} error={error}") + + if refresh_index: + self.refresh_index() + + def update(self, nodes: VectorStoreNode | List[VectorStoreNode], refresh_index: bool = False, **kwargs): + if isinstance(nodes, VectorStoreNode): + nodes = [nodes] + + nodes = self.embedding_model.get_node_embeddings(nodes) + docs = [self.node2doc(node, True) for node in nodes] + status, error = bulk(self._client, docs, chunk_size=self.bulk_chunk_size, **kwargs) + update_size = sum([1 if doc["_op_type"] == "update" else 0 for doc in docs]) + insert_size = len(docs) - update_size + logger.info(f"update update_size={update_size} insert_size={insert_size} status={status} error={error}") + + if refresh_index: + self.refresh_index() + + def delete_by_id(self, unique_id: str, **kwargs): + return self._client.delete(index=self.index_name, id=unique_id, **kwargs) + + def retrieve_by_id(self, unique_id: str, **kwargs) -> VectorStoreNode | None: + try: + doc = self._client.get(index=self.index_name, id=unique_id, **kwargs) + return self.doc2node(doc) + + except Exception as e: + logger.warning(f"retrieve_by_id unique_id={unique_id} is not found with error={e.args}") + return None + + def retrieve_by_query(self, query: str, top_k: int = 3, **kwargs) -> List[VectorStoreNode]: + query_vector = self.embedding_model.get_embeddings(query) + + body = { + "query": { + "script_score": { + "query": {"bool": {"must": self.retrieve_filters}}, + "script": { + "source": "cosineSimilarity(params.query_vector, 'vector') + 1.0", + "params": {"query_vector": query_vector}, + } + } + }, + "size": top_k + } + response = self._client.search(index=self.index_name, body=body, **kwargs) + + nodes: List[VectorStoreNode] = [] + for doc in response['hits']['hits']: + nodes.append(self.doc2node(doc)) + + self.retrieve_filters.clear() + return nodes diff --git a/experiencescope/storage/file_vector_store.py b/experiencescope/storage/file_vector_store.py new file mode 100644 index 00000000..4041ef4b --- /dev/null +++ b/experiencescope/storage/file_vector_store.py @@ -0,0 +1,132 @@ +import json +import math +import threading +from pathlib import Path +from typing import List, Any + +from loguru import logger +from pydantic import Field, model_validator, PrivateAttr + +from beyondagent.core.schema.vector_store_node import VectorStoreNode +from beyondagent.core.storage.base_vector_store import BaseVectorStore + + +class FileVectorStore(BaseVectorStore): + store_dir: str = Field(default="./") + index_name: str = Field(default=...) + index_path: Path | None = Field(default=None) + _thread_lock: Any = PrivateAttr() + + @model_validator(mode="after") + def init_client(self): + self._thread_lock = threading.Lock() + + store_path = Path(self.store_dir) + store_path.mkdir(parents=True, exist_ok=True) + self.index_path = store_path / f"{self.index_name}.jsonl" + if not self.index_path.exists(): + self.index_path.touch(exist_ok=True) + return self + + def delete_index(self): + with self._thread_lock: + if self.index_path.exists() and self.index_path.is_file(): + self.index_path.unlink() + + def create_index(self): + with self._thread_lock: + if not self.index_path.exists(): + self.index_path.touch(exist_ok=True) + + def load(self) -> List[VectorStoreNode]: + with self._thread_lock: + nodes = [] + with open(self.index_path) as f: + for line in f: + if line.strip(): + nodes.append(VectorStoreNode(**json.loads(line))) + return nodes + + def _load(self) -> List[VectorStoreNode]: + with self._thread_lock: + nodes = [] + with open(self.index_path) as f: + for line in f: + if line.strip(): + nodes.append(VectorStoreNode(**json.loads(line))) + return nodes + + def _dump(self, nodes: List[VectorStoreNode]): + with self._thread_lock: + with open(self.index_path, "w") as f: + for doc in nodes: + f.write(doc.model_dump_json() + "\n") + + def exist_id(self, unique_id: str): + nodes = self._load() + for node in nodes: + if node.unique_id == unique_id: + return True + return False + + def insert(self, nodes: VectorStoreNode | List[VectorStoreNode], **kwargs): + return self.update(nodes, **kwargs) + + def update(self, nodes: VectorStoreNode | List[VectorStoreNode], **kwargs): + if isinstance(nodes, VectorStoreNode): + nodes = [nodes] + + all_node_dict = {} + nodes: List[VectorStoreNode] = self.embedding_model.get_node_embeddings(nodes) + exist_nodes: List[VectorStoreNode] = self._load() + for node in exist_nodes: + all_node_dict[node.unique_id] = node + + update_cnt = 0 + for node in nodes: + if node.unique_id in all_node_dict: + update_cnt += 1 + + all_node_dict[node.unique_id] = node + + self._dump(list(all_node_dict.values())) + logger.info(f"update nodes.size={len(nodes)} all.size={len(all_node_dict)} update_cnt={update_cnt}") + + def delete_by_id(self, unique_id: str, **kwargs): + nodes = self._load() + dump_nodes: List[VectorStoreNode] = [] + for node in nodes: + if node.unique_id != unique_id: + dump_nodes.append(node) + + if len(dump_nodes) < len(nodes): + self._dump(dump_nodes) + logger.info(f"delete_by_id unique_id={unique_id}") + + def retrieve_by_id(self, unique_id: str, **kwargs) -> VectorStoreNode | None: + nodes = self._load() + for node in nodes: + if node.unique_id == unique_id: + return node + return None + + @staticmethod + def calculate_similarity(query_vector: List[float], node_vector: List[float]): + assert query_vector, f"query_vector is empty!" + assert node_vector, f"node_vector is empty!" + assert len(query_vector) == len(node_vector), \ + f"query_vector.size={len(query_vector)} node_vector.size={len(node_vector)}" + + dot_product = sum(x * y for x, y in zip(query_vector, node_vector)) + norm_v1 = math.sqrt(sum(x ** 2 for x in query_vector)) + norm_v2 = math.sqrt(sum(y ** 2 for y in node_vector)) + return dot_product / (norm_v1 * norm_v2) + + def retrieve_by_query(self, query: str, top_k: int = 3, **kwargs) -> List[VectorStoreNode]: + query_vector = self.embedding_model.get_embeddings(query) + nodes: List[VectorStoreNode] = self._load() + for node in nodes: + node.metadata["score"] = self.calculate_similarity(query_vector, node.vector) + + nodes = sorted(nodes, key=lambda x: x.metadata["score"], reverse=True) + return nodes[:top_k] diff --git a/experiencescope/tool/__init__.py b/experiencescope/tool/__init__.py new file mode 100644 index 00000000..ebbcbd5d --- /dev/null +++ b/experiencescope/tool/__init__.py @@ -0,0 +1,9 @@ +from beyondagent.core.tool.python_tools.code_tool import CodeTool +from beyondagent.core.tool.python_tools.dashscope_search_tool import DashscopeSearchTool +from beyondagent.core.tool.python_tools.terminate_tool import TerminateTool +from beyondagent.core.utils.registry import Registry + +TOOL_REGISTRY = Registry("tools") +TOOL_REGISTRY.register(CodeTool) +TOOL_REGISTRY.register(DashscopeSearchTool) +TOOL_REGISTRY.register(TerminateTool) diff --git a/experiencescope/tool/base_tool.py b/experiencescope/tool/base_tool.py new file mode 100644 index 00000000..4b3ffae5 --- /dev/null +++ b/experiencescope/tool/base_tool.py @@ -0,0 +1,79 @@ +from abc import ABC + +from loguru import logger +from pydantic import BaseModel, Field + + +class BaseTool(BaseModel, ABC): + tool_id: str = Field(default="") + name: str = Field(..., description="tool name") + description: str = Field(..., description="tool description") + tool_type: str = Field(default="function") + parameters: dict = Field(default_factory=dict, description="tool parameters") + arguments: dict = Field(default_factory=dict, description="execute arguments") + + enable_cache: bool = Field(default=False, description="whether to cache the tool result") + # TODO add cache expire + cached_result: dict = Field(default_factory=dict, description="tool execution result") + + max_retries: int = Field(default=3, description="max retries") + raise_exception: bool = Field(default=True, description="raise exception") + success: bool = Field(default=True, description="whether the tool executed successfully") + + def reset(self): + self.arguments.clear() + self.success = True + + def _execute(self, **kwargs): + raise NotImplementedError + + def execute(self, **kwargs): + cache_id = "" + if self.enable_cache: + cache_id = self.get_cache_id(**kwargs) + if cache_id in self.cached_result: + return self.cached_result[cache_id] + + for i in range(self.max_retries): + try: + if self.enable_cache: + self.cached_result[cache_id] = self._execute(**kwargs) + return self.cached_result[cache_id] + + else: + return self._execute(**kwargs) + + except Exception as e: + logger.exception(f"using tool.name={self.name} encounter error with e={e.args}") + if i == self.max_retries - 1 and self.raise_exception: + raise e + + return None + + # It may be in other different tool params formats; different versions are completed here. + + @property + def simple_dict(self) -> dict: + return { + "type": self.tool_type, + self.tool_type: { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + } + + @property + def input_schema(self) -> dict: + return self.parameters.get("properties", {}) + + @property + def output_schema(self) -> dict: + raise NotImplementedError + + def refresh(self): + # for mcp + raise NotImplementedError + + def get_cache_id(self, **kwargs) -> str: + raise NotImplementedError diff --git a/experiencescope/tool/code_tool.py b/experiencescope/tool/code_tool.py new file mode 100644 index 00000000..6bdfd195 --- /dev/null +++ b/experiencescope/tool/code_tool.py @@ -0,0 +1,42 @@ +import sys +from io import StringIO + +from beyondagent.core.tool.base_tool import BaseTool + + +class CodeTool(BaseTool): + name: str = "python_execute" + description: str = "Execute python code can be used in scenarios such as analysis or calculation, and the final result can be printed using the `print` function." + parameters: dict = { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "code to be executed. Please do not execute any matplotlib code here.", + } + }, + "required": ["code"] + } + + def _execute(self, code: str, **kwargs): + old_stdout = sys.stdout + redirected_output = sys.stdout = StringIO() + + try: + exec(code) + result = redirected_output.getvalue() + + except Exception as e: + self.success = False + result = str(e) + + sys.stdout = old_stdout + + return result + + + +if __name__ == '__main__': + tool = CodeTool() + print(tool.execute(code="print('Hello World')")) + print(tool.execute(code="print('Hello World!'")) diff --git a/experiencescope/tool/dashscope_search_tool.py b/experiencescope/tool/dashscope_search_tool.py new file mode 100644 index 00000000..f42b2b6f --- /dev/null +++ b/experiencescope/tool/dashscope_search_tool.py @@ -0,0 +1,159 @@ +import os +from typing import Literal + +import dashscope +from dashscope.api_entities.dashscope_response import Message +from loguru import logger +from pydantic import Field + +from beyondagent.core.tool.base_tool import BaseTool + + +class DashscopeSearchTool(BaseTool): + name: str = "web_search" + description: str = "Use search keywords to retrieve relevant information from the internet. " \ + "If there are multiple search keywords, please use each keyword separately to call this tool." + parameters: dict = { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "search keyword", + } + }, + "required": ["query"] + } + + model_name: Literal["qwen-plus-2025-04-28", "qwq-plus-latest", "qwen-max-2025-01-25"] = \ + Field(default="qwen-plus-2025-04-28") + api_key: str = Field(default_factory=lambda: os.environ["DASHSCOPE_API_KEY"]) + stream_print: bool = Field(default=False) + temperature: float = Field(default=0.0000001) + use_role_prompt: bool = Field(default=True) + role_prompt: str = """ +# user's question +{question} + +# task +Extract the original content related to the user's question directly from the context, maintain accuracy, and avoid excessive processing. """.strip() + return_only_content: bool = Field(default=True) + + def parse_reasoning_response(self, response, result: dict): + is_answering = False + is_first_chunk = True + + for chunk in response: + if is_first_chunk: + result["search_results"] = chunk.output.search_info["search_results"] + + if self.stream_print: + print("=" * 20 + "search result" + "=" * 20) + for web in result["search_results"]: + print(f"[{web['index']}]: [{web['title']}]({web['url']})") + print("=" * 20 + "thinking process" + "=" * 20) + result["reasoning_content"] += chunk.output.choices[0].message.reasoning_content + + if self.stream_print: + print(chunk.output.choices[0].message.reasoning_content, end="", flush=True) + is_first_chunk = False + + else: + if chunk.output.choices[0].message.content == "" \ + and chunk.output.choices[0].message.reasoning_content == "": + pass + + else: + if chunk.output.choices[0].message.reasoning_content != "" and \ + chunk.output.choices[0].message.content == "": + + if self.stream_print: + print(chunk.output.choices[0].message.reasoning_content, end="", flush=True) + result["reasoning_content"] += chunk.output.choices[0].message.reasoning_content + + elif chunk.output.choices[0].message.content != "": + if not is_answering: + if self.stream_print: + print("\n" + "=" * 20 + "complete answer" + "=" * 20) + is_answering = True + + if self.stream_print: + print(chunk.output.choices[0].message.content, end="", flush=True) + result["answer_content"] += chunk.output.choices[0].message.content + + def parse_response(self, response, result: dict): + is_first_chunk = True + + for chunk in response: + if is_first_chunk: + result["search_results"] = chunk.output.search_info["search_results"] + + if self.stream_print: + print("=" * 20 + "search result" + "=" * 20) + for web in result["search_results"]: + print(f"[{web['index']}]: [{web['title']}]({web['url']})") + print("\n" + "=" * 20 + "complete answer" + "=" * 20) + is_first_chunk = False + + else: + if chunk.output.choices[0].message.content == "": + pass + + else: + if chunk.output.choices[0].message.content != "": + if self.stream_print: + print(chunk.output.choices[0].message.content, end="", flush=True) + result["answer_content"] += chunk.output.choices[0].message.content + + def execute(self, query: str = "", **kwargs): + result = { + "search_results": [], + "reasoning_content": "", + "answer_content": "" + } + user_query = self.role_prompt.format(question=query) if self.use_role_prompt else query + messages = [Message(role="user", content=user_query)] + + response = dashscope.Generation.call( + api_key=self.api_key, + model=self.model_name, + messages=messages, + enable_thinking=True, + enable_search=True, + search_options={ + "forced_search": True, + "enable_source": True, + "enable_citation": False, + "search_strategy": "pro" + }, + stream=True, + incremental_output=True, + result_format="message", + ) + + if self.model_name != "qwen-max-2025-01-25": + self.parse_reasoning_response(response, result) + else: + self.parse_response(response, result) + + if self.return_only_content: + return result["answer_content"] + else: + return result + +def main(): + from beyondagent.core.utils.test_key import set_key + set_key() + query = "What is artificial intelligence?" + + tool = DashscopeSearchTool(stream_print=True) + logger.info(tool.execute(query=query)) + + tool = DashscopeSearchTool(stream_print=False) + logger.info(tool.execute(query=query)) + + tool = DashscopeSearchTool(stream_print=True, model_name="qwen-max-2025-01-25") + logger.info(tool.execute(query=query)) + + +if __name__ == '__main__': + main() diff --git a/experiencescope/tool/mcp_tool.py b/experiencescope/tool/mcp_tool.py new file mode 100644 index 00000000..4e0e45a8 --- /dev/null +++ b/experiencescope/tool/mcp_tool.py @@ -0,0 +1,90 @@ + +from mcp.client.sse import sse_client +from mcp import ClientSession +import asyncio +from pydantic import Field +from typing import List,Optional +from loguru import logger + + +from beyondagent.core.tool.base_tool import BaseTool + +class MCPTool(BaseTool): + server_url: str = Field(..., description="MCP server URL") + tool_name_list: List[str] = Field(default_factory=list) + cache_tools: dict = Field(default_factory=dict, alias="cache_tools") + cache_tools_info: Optional[dict] = Field(default=None, alias="cache_tools_info") + + class Config: + underscore_attrs_are_private = True + + def __init__(self, **data): + super().__init__(**data) + self.refresh() + + def get_tool_name_list(self) -> List[str]: + return self.tool_name_list + + def get_server_info(self): + return self.cache_tools_info + + def refresh(self): + self.cache_tools.clear() + self.tool_name_list.clear() + + if "sse" in self.server_url: + original_tool_list = asyncio.run(self._get_tools()) + self.cache_tools_info = original_tool_list.tools + + for tool in self.cache_tools_info: + self.cache_tools[tool.name] = tool + self.tool_name_list.append(tool.name) + else: + # TODO: Implement non-SSE refresh logic + logger.warning("Non-SSE refresh not implemented yet") + + async def _get_tools(self): + async with sse_client(url=self.server_url) as streams: + async with ClientSession(streams[0], streams[1]) as session: + await session.initialize() + tools = await session.list_tools() + return tools + + def input_schema(self, tool_name: str) -> dict: + return self.cache_tools.get(tool_name, {}).inputSchema + + def output_schema(self, tool_name: str) -> dict: + # TODO: Implement output schema logic + return {} + + def get_tool_description(self, tool_name: str, schema: bool = False) -> str: + tool = self.cache_tools.get(tool_name) + if not tool: + return "" + + description = f'tool \'{tool_name}\' description is:'+ tool.description + if schema: + description += f"\nInput Schema: {self.input_schema(tool_name)}" + description += f"\nOutput Schema: {self.output_schema(tool_name)}" + return description + + async def _execute(self, **kwargs): + tool_name = kwargs.get('tool_name') + args = kwargs.get('args', {}) + + if "sse" in self.server_url: + async with sse_client(url=self.server_url) as streams: + async with ClientSession(streams[0], streams[1]) as session: + await session.initialize() + results = await session.call_tool(tool_name, args) + return results.content[0].text, results.isError + else: + return "Failed to connect to the tool", False + + def execute(self, **kwargs): + return asyncio.run(self._execute(**kwargs)) + + def get_cache_id(self, **kwargs) -> str: + # Implement a method to generate a unique cache ID based on the input + return f"{kwargs.get('tool_name')}_{hash(frozenset(kwargs.get('args', {}).items()))}" + diff --git a/experiencescope/tool/terminate_tool.py b/experiencescope/tool/terminate_tool.py new file mode 100644 index 00000000..c29c8931 --- /dev/null +++ b/experiencescope/tool/terminate_tool.py @@ -0,0 +1,23 @@ +from beyondagent.core.tool.base_tool import BaseTool + + +class TerminateTool(BaseTool): + name: str = "terminate" + description: str = "If you can answer the user's question based on the context, be sure to use the **terminate** tool." + parameters: dict = { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Please determine whether the user's question has been completed. (success / failure)", + "enum": ["success", "failure"], + } + }, + "required": ["status"], + } + + def execute(self, status: str): + self.success = status in ["success", "failure"] + return f"The interaction has been completed with status: {status}" + + diff --git a/experiencescope/utils/__init__.py b/experiencescope/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/experiencescope/utils/file_handler.py b/experiencescope/utils/file_handler.py new file mode 100644 index 00000000..66850ac9 --- /dev/null +++ b/experiencescope/utils/file_handler.py @@ -0,0 +1,29 @@ +import json +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, Field, PrivateAttr + + +class FileHandler(BaseModel): + file_path: str = Field(default=...) + _obj: Any = PrivateAttr() + + def __init__(self, **kwargs): + super().__init__(**kwargs) + suffix = Path(self.file_path).suffix + if suffix == ".json": + self._obj = json + elif suffix == ".yaml": + self._obj = yaml + else: + raise ValueError(f"unsupported file type={suffix}") + + def dump(self, config, **kwargs): + with open(self.file_path, "w") as f: + self._obj.dump(config, f, **kwargs) + + def load(self, **kwargs): + with open(self.file_path, "r") as f: + return self._obj.load(f, **kwargs) diff --git a/experiencescope/utils/http_client.py b/experiencescope/utils/http_client.py new file mode 100644 index 00000000..9b70ee00 --- /dev/null +++ b/experiencescope/utils/http_client.py @@ -0,0 +1,152 @@ +import http +import time +from typing import Any + +import requests +from loguru import logger +from pydantic import BaseModel, Field, PrivateAttr + +from beyondagent.core.enumeration.http_enum import HttpEnum + + +class HttpClient(BaseModel): + url: str = Field(default="") + keep_alive: bool = Field(default=False, description="if true, use session to keep long connection") + timeout: int = Field(default=300, description="request timeout, second") + + return_default_if_error: bool = Field(default=True) + request_start_time: float = Field(default_factory=time.time) + request_time_cost: float = Field(default=0.0, description="request time cost") + + retry_sleep_time: float = Field(0.5, description="interval time for retry") + retry_time_multiplier: float = Field(2.0, description="retry time multiplier") + retry_max_count: int = Field(1, description="maximum number of retries") + + _client: Any = PrivateAttr() + + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._client = requests.Session() if self.keep_alive else requests + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + self.request_time_cost: float = time.time() - self.request_start_time + + def close(self): + if isinstance(self._client, requests.Session): + self._client.close() + + def _request(self, + data: str = None, + json_data: dict = None, + headers: dict = None, + stream: bool = False, + http_enum: HttpEnum | str = HttpEnum.POST): + + if isinstance(http_enum, str): + http_enum = HttpEnum(http_enum) + + if http_enum is HttpEnum.POST: + response: requests.Response = self._client.post(url=self.url, + data=data, + json=json_data, + headers=headers, + stream=stream, + timeout=self.timeout) + + elif http_enum is HttpEnum.GET: + response: requests.Response = self._client.get(url=self.url, + data=data, + json=json_data, + headers=headers, + stream=stream, + timeout=self.timeout) + + else: + raise NotImplementedError + + if response.status_code != http.HTTPStatus.OK: + raise RuntimeError(f"request failed! content={response.json()}") + + return response + + def parse_result(self, response: requests.Response | Any = None, **kwargs): + return response.json() + + def return_default(self, **kwargs): + return None + + def request(self, + data: str | Any = None, + json_data: dict = None, + headers: dict = None, + http_enum: HttpEnum | str = HttpEnum.POST, + **kwargs): + + retry_sleep_time = self.retry_sleep_time + for i in range(self.retry_max_count): + try: + response = self._request(data=data, json_data=json_data, headers=headers, http_enum=http_enum) + result = self.parse_result(response=response, + data=data, + json_data=json_data, + headers=headers, + http_enum=http_enum, + **kwargs) + return result + + except Exception as e: + logger.exception(f"{self.__class__.__name__} {i}th request failed with args={e.args}") + + if i == self.retry_max_count - 1: + if self.return_default_if_error: + return self.return_default() + else: + raise e + + retry_sleep_time *= self.retry_time_multiplier + time.sleep(retry_sleep_time) + + def request_stream(self, + data: str = None, + json_data: dict = None, + headers: dict = None, + http_enum: HttpEnum | str = HttpEnum.POST, + **kwargs): + + retry_sleep_time = self.retry_sleep_time + for i in range(self.retry_max_count): + try: + response = self._request(data=data, + json_data=json_data, + headers=headers, + stream=True, + http_enum=http_enum) + request_context = {} + for iter_idx, line in enumerate(response.iter_lines()): + yield self.parse_result(line=line, + request_context=request_context, + index=iter_idx, + data=data, + json_data=json_data, + headers=headers, + http_enum=http_enum, + **kwargs) + + return + + except Exception as e: + logger.exception(f"{self.__class__.__name__} {i}th request failed with args={e.args}") + + if i == self.retry_max_count - 1: + if self.return_default_if_error: + return self.return_default() + else: + raise e + + retry_sleep_time *= self.retry_time_multiplier + time.sleep(retry_sleep_time) diff --git a/experiencescope/utils/logger.py b/experiencescope/utils/logger.py new file mode 100644 index 00000000..36c4f69f --- /dev/null +++ b/experiencescope/utils/logger.py @@ -0,0 +1,11 @@ +from best_logger import register_logger + +def init_logger(): + register_logger( + mods=["agent", "context", "summary"], + non_console_mods=[], + auto_clean_mods=[], + base_log_path=f"logs/default" + ) + + diff --git a/experiencescope/utils/prompt_handler.py b/experiencescope/utils/prompt_handler.py new file mode 100644 index 00000000..fe68956e --- /dev/null +++ b/experiencescope/utils/prompt_handler.py @@ -0,0 +1,70 @@ +import os + +import yaml +from loguru import logger +from pydantic import BaseModel, Field + + +class PromptHandler(BaseModel): + dir_path: str = Field(default="") + prompt_dict: dict = Field(default_factory=dict) + + def add_prompt_file(self, file_name: str): + prompt_path = os.path.join(self.dir_path, file_name + ".yaml") + self._add_prompt_file(prompt_path) + + def _add_prompt_file(self, prompt_path: str): + if os.path.exists(prompt_path): + with open(prompt_path) as f: + prompt_dict: dict = yaml.load(f, yaml.FullLoader) + self.update_prompt_dict(prompt_dict) + else: + logger.warning(f"prompt_path={prompt_path} not exists!") + + def update_prompt_dict(self, prompt_dict: dict): + self.prompt_dict.update(prompt_dict) + + def __getitem__(self, key: str): + return self.prompt_dict[key] + + def __setitem__(self, key: str, value: str): + self.prompt_dict[key] = value + + def __getattr__(self, key: str): + if key in self.prompt_dict: + return self.prompt_dict[key] + + return super().__getattr__(key) + + def prompt_format(self, prompt_name: str, **kwargs): + prompt = self.prompt_dict[prompt_name] + + flag_kwargs = {k: v for k, v in kwargs.items() if isinstance(v, bool)} + other_kwargs = {k: v for k, v in kwargs.items() if not isinstance(v, bool)} + + if flag_kwargs: + split_prompt = [] + for line in prompt.strip().split("\n"): + hit = False + hit_flag = True + for key, flag in kwargs.items(): + if not line.startswith(f"[{key}]"): + continue + + else: + hit = True + hit_flag = flag + line = line.strip(f"[{key}]") + break + + if not hit: + split_prompt.append(line) + elif hit_flag: + split_prompt.append(line) + + prompt = "\n".join(split_prompt) + + if other_kwargs: + prompt = prompt.format(**other_kwargs) + + return prompt diff --git a/experiencescope/utils/registry.py b/experiencescope/utils/registry.py new file mode 100644 index 00000000..65199cda --- /dev/null +++ b/experiencescope/utils/registry.py @@ -0,0 +1,32 @@ +from typing import Dict, Any, List + + +class Registry(object): + + def __init__(self, name: str): + self.name: str = name + self.module_dict: Dict[str, Any] = {} + + def register(self, module, module_name: str = None): + if module_name is None: + module_name = module.__name__ + + if module_name in self.module_dict: + raise KeyError(f'{module_name} is already registered in {self.name}') + + self.module_dict[module_name] = module + + def batch_register(self, modules: List[Any] | Dict[str, Any]): + if isinstance(modules, list): + module_name_dict = {m.__name__: m for m in modules} + + elif isinstance(modules, dict): + module_name_dict = modules + + else: + raise NotImplementedError("Input must be a list or a dictionary.") + self.module_dict.update(module_name_dict) + + def __getitem__(self, module_name: str): + assert module_name in self.module_dict, f"{module_name} not found in {self.name}" + return self.module_dict[module_name] diff --git a/experiencescope/utils/test_key.py b/experiencescope/utils/test_key.py new file mode 100644 index 00000000..de2c5d07 --- /dev/null +++ b/experiencescope/utils/test_key.py @@ -0,0 +1,33 @@ +import json +import os + +from beyondagent.core.schema.module_loader import ModuleLoader + + +def load_env_keys(): + if os.path.exists(".env"): + with open(".env") as f: + config = json.load(f) + for k, v in config.items(): + os.environ[k] = v + + +agent_wrapper_loader = ModuleLoader( + class_path="beyondagent.core.module.agent_wrapper.naive_agent_wrapper", + class_name="NaiveAgentWrapper", + config_path="beyondagent/config/agent_wrapper/naive_agent_wrapper.json") + +context_generator_loader = ModuleLoader( + class_path="beyondagent.core.module.context_generator.simple_context_generator", + class_name="SimpleContextGenerator", + config_path="beyondagent/config/context_generator/simple_context_generator.json") + +summarizer_loader = ModuleLoader( + class_path="beyondagent.core.module.summarizer.simple_summarizer", + class_name="SimpleSummarizer", + config_path="beyondagent/config/summarizer/simple_summarizer.json") + +env_loader = ModuleLoader( + class_path="beyondagent.core.module.environment.simple_environment", + class_name="SimpleEnvironment", + config_path="beyondagent/config/environment/simple_environment.json") \ No newline at end of file diff --git a/experiencescope/utils/trajectory_utils.py b/experiencescope/utils/trajectory_utils.py new file mode 100644 index 00000000..33cecf5f --- /dev/null +++ b/experiencescope/utils/trajectory_utils.py @@ -0,0 +1,18 @@ +from typing import List + + +from beyondagent.core.schema.trajectory import Message, StateMessage, ActionMessage + +def format_trajectory_steps(steps: List[Message]) -> str: + format_steps = [] + step_idx = 0 + single_step = [] + for idx, step in enumerate(steps): + if isinstance(step, ActionMessage): + step_idx += 1 + single_step.append(f"** STEP {step_idx} **\n{step.content}") + elif isinstance(step, StateMessage): + single_step.append(f"{step.content}") + format_steps.append("\n".join(single_step)) + single_step = [] + return "\n\n".join(format_steps) \ No newline at end of file diff --git a/experiencescope/utils/util_function.py b/experiencescope/utils/util_function.py new file mode 100644 index 00000000..3427cf26 --- /dev/null +++ b/experiencescope/utils/util_function.py @@ -0,0 +1,9 @@ +import re + + +def get_html_match_content(content: str, key: str): + pattern = rf"<{key}>(.*?)" + match = re.search(pattern, content, re.DOTALL) + if match: + return match.group(1) + return None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..7f2199a7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "beyond-agent" +version = "0.1.1" +description = "Agent " +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "beast-logger", + "anthropic>=0.45.1", + "argparse>=1.4.0", + "httpx>=0.28.1", + "mcp[cli]>=1.2.1", + "python-dotenv>=1.0.1", + "firecrawl-py>=1.14.1", + "loguru", + "pillow", + "regex", + "fastapi", + "arxiv", + "tqdm", + "requests_oauthlib", + "teamwork-mcp>=0.2.1", + "agentscope", + "beyond @ file:///${PROJECT_ROOT}/beyondagent/third_party/beyond", + "astor", +] + + +[tool.setuptools.packages.find] +include = ["beyondagent"] \ No newline at end of file