diff --git a/Makefile b/Makefile index 04927fffa0..912a3e7150 100644 --- a/Makefile +++ b/Makefile @@ -219,15 +219,24 @@ setup-config: @echo "$(GREEN)Config.toml setup completed.$(RESET)" setup-config-prompts: - @read -p "Enter your LLM Model name, used for running without UI. Set the model in the UI after you start the app. (see https://docs.litellm.ai/docs/providers for full list) [default: $(DEFAULT_MODEL)]: " llm_model; \ + @echo "[core]" > $(CONFIG_FILE).tmp + + @read -p "Enter your workspace directory [default: $(DEFAULT_WORKSPACE_DIR)]: " workspace_dir; \ + workspace_dir=$${workspace_dir:-$(DEFAULT_WORKSPACE_DIR)}; \ + echo "workspace_base=\"$$workspace_dir\"" >> $(CONFIG_FILE).tmp + + @echo "" >> $(CONFIG_FILE).tmp + + @echo "[llm]" >> $(CONFIG_FILE).tmp + @read -p "Enter your LLM model name, used for running without UI. Set the model in the UI after you start the app. (see https://docs.litellm.ai/docs/providers for full list) [default: $(DEFAULT_MODEL)]: " llm_model; \ llm_model=$${llm_model:-$(DEFAULT_MODEL)}; \ - echo "LLM_MODEL=\"$$llm_model\"" > $(CONFIG_FILE).tmp + echo "model=\"$$llm_model\"" >> $(CONFIG_FILE).tmp - @read -p "Enter your LLM API key: " llm_api_key; \ - echo "LLM_API_KEY=\"$$llm_api_key\"" >> $(CONFIG_FILE).tmp + @read -p "Enter your LLM api key: " llm_api_key; \ + echo "api_key=\"$$llm_api_key\"" >> $(CONFIG_FILE).tmp - @read -p "Enter your LLM Base URL [mostly used for local LLMs, leave blank if not needed - example: http://localhost:5001/v1/]: " llm_base_url; \ - if [[ ! -z "$$llm_base_url" ]]; then echo "LLM_BASE_URL=\"$$llm_base_url\"" >> $(CONFIG_FILE).tmp; fi + @read -p "Enter your LLM base URL [mostly used for local LLMs, leave blank if not needed - example: http://localhost:5001/v1/]: " llm_base_url; \ + if [[ ! -z "$$llm_base_url" ]]; then echo "base_url=\"$$llm_base_url\"" >> $(CONFIG_FILE).tmp; fi @echo "Enter your LLM Embedding Model"; \ echo "Choices are:"; \ @@ -241,22 +250,19 @@ setup-config-prompts: echo " - stable-code"; \ echo " - Leave blank to default to 'BAAI/bge-small-en-v1.5' via huggingface"; \ read -p "> " llm_embedding_model; \ - echo "LLM_EMBEDDING_MODEL=\"$$llm_embedding_model\"" >> $(CONFIG_FILE).tmp; \ + echo "embedding_model=\"$$llm_embedding_model\"" >> $(CONFIG_FILE).tmp; \ if [ "$$llm_embedding_model" = "llama2" ] || [ "$$llm_embedding_model" = "mxbai-embed-large" ] || [ "$$llm_embedding_model" = "nomic-embed-text" ] || [ "$$llm_embedding_model" = "all-minilm" ] || [ "$$llm_embedding_model" = "stable-code" ]; then \ - read -p "Enter the local model URL for the embedding model (will set LLM_EMBEDDING_BASE_URL): " llm_embedding_base_url; \ - echo "LLM_EMBEDDING_BASE_URL=\"$$llm_embedding_base_url\"" >> $(CONFIG_FILE).tmp; \ + read -p "Enter the local model URL for the embedding model (will set llm.embedding_base_url): " llm_embedding_base_url; \ + echo "embedding_base_url=\"$$llm_embedding_base_url\"" >> $(CONFIG_FILE).tmp; \ elif [ "$$llm_embedding_model" = "azureopenai" ]; then \ - read -p "Enter the Azure endpoint URL (will overwrite LLM_BASE_URL): " llm_base_url; \ - echo "LLM_BASE_URL=\"$$llm_base_url\"" >> $(CONFIG_FILE).tmp; \ + read -p "Enter the Azure endpoint URL (will overwrite llm.base_url): " llm_base_url; \ + echo "base_url=\"$$llm_base_url\"" >> $(CONFIG_FILE).tmp; \ read -p "Enter the Azure LLM Embedding Deployment Name: " llm_embedding_deployment_name; \ - echo "LLM_EMBEDDING_DEPLOYMENT_NAME=\"$$llm_embedding_deployment_name\"" >> $(CONFIG_FILE).tmp; \ + echo "embedding_deployment_name=\"$$llm_embedding_deployment_name\"" >> $(CONFIG_FILE).tmp; \ read -p "Enter the Azure API Version: " llm_api_version; \ - echo "LLM_API_VERSION=\"$$llm_api_version\"" >> $(CONFIG_FILE).tmp; \ + echo "api_version=\"$$llm_api_version\"" >> $(CONFIG_FILE).tmp; \ fi - @read -p "Enter your workspace directory [default: $(DEFAULT_WORKSPACE_DIR)]: " workspace_dir; \ - workspace_dir=$${workspace_dir:-$(DEFAULT_WORKSPACE_DIR)}; \ - echo "WORKSPACE_BASE=\"$$workspace_dir\"" >> $(CONFIG_FILE).tmp # Clean up all caches clean: diff --git a/agenthub/monologue_agent/agent.py b/agenthub/monologue_agent/agent.py index 04ffcc2934..8088b3ffdd 100644 --- a/agenthub/monologue_agent/agent.py +++ b/agenthub/monologue_agent/agent.py @@ -4,10 +4,9 @@ import agenthub.monologue_agent.utils.prompts as prompts from agenthub.monologue_agent.utils.monologue import Monologue from opendevin.controller.agent import Agent from opendevin.controller.state.state import State -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.exceptions import AgentNoInstructionError from opendevin.core.schema import ActionType -from opendevin.core.schema.config import ConfigType from opendevin.events.action import ( Action, AgentRecallAction, @@ -29,7 +28,7 @@ from opendevin.events.observation import ( ) from opendevin.llm.llm import LLM -if config.get(ConfigType.AGENT_MEMORY_ENABLED): +if config.agent.memory_enabled: from agenthub.monologue_agent.utils.memory import LongTermMemory MAX_TOKEN_COUNT_PADDING = 512 @@ -160,7 +159,7 @@ class MonologueAgent(Agent): raise AgentNoInstructionError() self.monologue = Monologue() - if config.get(ConfigType.AGENT_MEMORY_ENABLED): + if config.agent.memory_enabled: self.memory = LongTermMemory() else: self.memory = None diff --git a/agenthub/monologue_agent/utils/memory.py b/agenthub/monologue_agent/utils/memory.py index d0d16e5a0f..3922cdb7df 100644 --- a/agenthub/monologue_agent/utils/memory.py +++ b/agenthub/monologue_agent/utils/memory.py @@ -13,15 +13,14 @@ from tenacity import ( wait_random_exponential, ) -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema.config import ConfigType from . import json -num_retries = config.get(ConfigType.LLM_NUM_RETRIES) -retry_min_wait = config.get(ConfigType.LLM_RETRY_MIN_WAIT) -retry_max_wait = config.get(ConfigType.LLM_RETRY_MAX_WAIT) +num_retries = config.llm.num_retries +retry_min_wait = config.llm.retry_min_wait +retry_max_wait = config.llm.retry_max_wait # llama-index includes a retry decorator around openai.get_embeddings() function # it is initialized with hard-coded values and errors @@ -31,7 +30,7 @@ retry_max_wait = config.get(ConfigType.LLM_RETRY_MAX_WAIT) if hasattr(llama_openai.get_embeddings, '__wrapped__'): original_get_embeddings = llama_openai.get_embeddings.__wrapped__ else: - logger.warning('Cannot set custom retry limits.') # warn + logger.warning('Cannot set custom retry limits.') num_retries = 1 original_get_embeddings = llama_openai.get_embeddings @@ -59,63 +58,61 @@ def wrapper_get_embeddings(*args, **kwargs): llama_openai.get_embeddings = wrapper_get_embeddings -embedding_strategy = config.get(ConfigType.LLM_EMBEDDING_MODEL) -# TODO: More embeddings: https://docs.llamaindex.ai/en/stable/examples/embeddings/OpenAI/ -# There's probably a more programmatic way to do this. -supported_ollama_embed_models = [ - 'llama2', - 'mxbai-embed-large', - 'nomic-embed-text', - 'all-minilm', - 'stable-code', -] -if embedding_strategy in supported_ollama_embed_models: - from llama_index.embeddings.ollama import OllamaEmbedding +class EmbeddingsLoader: + """Loader for embedding model initialization.""" - embed_model = OllamaEmbedding( - model_name=embedding_strategy, - base_url=config.get(ConfigType.LLM_EMBEDDING_BASE_URL, required=True), - ollama_additional_kwargs={'mirostat': 0}, - ) -elif embedding_strategy == 'openai': - from llama_index.embeddings.openai import OpenAIEmbedding + @staticmethod + def get_embedding_model(strategy: str): + supported_ollama_embed_models = [ + 'llama2', + 'mxbai-embed-large', + 'nomic-embed-text', + 'all-minilm', + 'stable-code', + ] + if strategy in supported_ollama_embed_models: + from llama_index.embeddings.ollama import OllamaEmbedding - embed_model = OpenAIEmbedding( - model='text-embedding-ada-002', - api_key=config.get(ConfigType.LLM_API_KEY, required=True), - ) -elif embedding_strategy == 'azureopenai': - # Need to instruct to set these env variables in documentation - from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding + return OllamaEmbedding( + model_name=strategy, + base_url=config.llm.embedding_base_url, + ollama_additional_kwargs={'mirostat': 0}, + ) + elif strategy == 'openai': + from llama_index.embeddings.openai import OpenAIEmbedding - embed_model = AzureOpenAIEmbedding( - model='text-embedding-ada-002', - deployment_name=config.get( - ConfigType.LLM_EMBEDDING_DEPLOYMENT_NAME, required=True - ), - api_key=config.get(ConfigType.LLM_API_KEY, required=True), - azure_endpoint=config.get(ConfigType.LLM_BASE_URL, required=True), - api_version=config.get(ConfigType.LLM_API_VERSION, required=True), - ) -elif (embedding_strategy is not None) and (embedding_strategy.lower() == 'none'): - # TODO: this works but is not elegant enough. The incentive is when - # monologue agent is not used, there is no reason we need to initialize an - # embedding model - embed_model = None -else: - from llama_index.embeddings.huggingface import HuggingFaceEmbedding + return OpenAIEmbedding( + model='text-embedding-ada-002', + api_key=config.llm.api_key, + ) + elif strategy == 'azureopenai': + from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding - embed_model = HuggingFaceEmbedding(model_name='BAAI/bge-small-en-v1.5') + return AzureOpenAIEmbedding( + model='text-embedding-ada-002', + deployment_name=config.llm.embedding_deployment_name, + api_key=config.llm.api_key, + azure_endpoint=config.llm.base_url, + api_version=config.llm.api_version, + ) + elif (strategy is not None) and (strategy.lower() == 'none'): + # TODO: this works but is not elegant enough. The incentive is when + # monologue agent is not used, there is no reason we need to initialize an + # embedding model + return None + else: + from llama_index.embeddings.huggingface import HuggingFaceEmbedding + + return HuggingFaceEmbedding(model_name='BAAI/bge-small-en-v1.5') -sema = threading.Semaphore(value=config.get(ConfigType.AGENT_MEMORY_MAX_THREADS)) +sema = threading.Semaphore(value=config.agent.memory_max_threads) class LongTermMemory: """ - Responsible for storing information that the agent can call on later for better insights and context. - Uses chromadb to store and search through memories. + Handles storing information for the agent to access later, using chromadb. """ def __init__(self): @@ -125,9 +122,9 @@ class LongTermMemory: db = chromadb.Client() self.collection = db.get_or_create_collection(name='memories') vector_store = ChromaVectorStore(chroma_collection=self.collection) - self.index = VectorStoreIndex.from_vector_store( - vector_store, embed_model=embed_model - ) + embedding_strategy = config.llm.embedding_model + embed_model = EmbeddingsLoader.get_embedding_model(embedding_strategy) + self.index = VectorStoreIndex.from_vector_store(vector_store, embed_model) self.thought_idx = 0 self._add_threads = [] diff --git a/agenthub/monologue_agent/utils/prompts.py b/agenthub/monologue_agent/utils/prompts.py index 9ddb173788..920603d323 100644 --- a/agenthub/monologue_agent/utils/prompts.py +++ b/agenthub/monologue_agent/utils/prompts.py @@ -2,9 +2,8 @@ import re from json import JSONDecodeError from typing import List -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.exceptions import LLMOutputError -from opendevin.core.schema.config import ConfigType from opendevin.events.action import ( Action, action_from_dict, @@ -149,7 +148,7 @@ def get_request_action_prompt( ) bg_commands_message += '\nYou can end any process by sending a `kill` action with the numerical `id` above.' - user = 'opendevin' if config.get(ConfigType.RUN_AS_DEVIN) else 'root' + user = 'opendevin' if config.run_as_devin else 'root' return ACTION_PROMPT % { 'task': task, @@ -157,10 +156,8 @@ def get_request_action_prompt( 'background_commands': bg_commands_message, 'hint': hint, 'user': user, - 'timeout': config.get(ConfigType.SANDBOX_TIMEOUT), - 'WORKSPACE_MOUNT_PATH_IN_SANDBOX': config.get( - ConfigType.WORKSPACE_MOUNT_PATH_IN_SANDBOX - ), + 'timeout': config.sandbox_timeout, + 'WORKSPACE_MOUNT_PATH_IN_SANDBOX': config.workspace_mount_path_in_sandbox, } diff --git a/evaluation/regression/run_tests.py b/evaluation/regression/run_tests.py index 2887b44507..245dbcc3b7 100644 --- a/evaluation/regression/run_tests.py +++ b/evaluation/regression/run_tests.py @@ -2,7 +2,7 @@ import argparse import pytest -from opendevin import config +from opendevin.config import config if __name__ == '__main__': """Main entry point of the script. diff --git a/opendevin/controller/action_manager.py b/opendevin/controller/action_manager.py index 96beba5736..0cdfc31935 100644 --- a/opendevin/controller/action_manager.py +++ b/opendevin/controller/action_manager.py @@ -1,7 +1,6 @@ from typing import List -from opendevin.core import config -from opendevin.core.schema import ConfigType +from opendevin.core.config import config from opendevin.events.action import ( Action, ) @@ -28,19 +27,19 @@ class ActionManager: self, sid: str = 'default', ): - sandbox_type = config.get(ConfigType.SANDBOX_TYPE).lower() + sandbox_type = config.sandbox_type.lower() if sandbox_type == 'exec': self.sandbox = DockerExecBox( - sid=(sid or 'default'), timeout=config.get(ConfigType.SANDBOX_TIMEOUT) + sid=(sid or 'default'), timeout=config.sandbox_timeout ) elif sandbox_type == 'local': - self.sandbox = LocalBox(timeout=config.get(ConfigType.SANDBOX_TIMEOUT)) + self.sandbox = LocalBox(timeout=config.sandbox_timeout) elif sandbox_type == 'ssh': self.sandbox = DockerSSHBox( - sid=(sid or 'default'), timeout=config.get(ConfigType.SANDBOX_TIMEOUT) + sid=(sid or 'default'), timeout=config.sandbox_timeout ) elif sandbox_type == 'e2b': - self.sandbox = E2BBox(timeout=config.get(ConfigType.SANDBOX_TIMEOUT)) + self.sandbox = E2BBox(timeout=config.sandbox_timeout) else: raise ValueError(f'Invalid sandbox type: {sandbox_type}') diff --git a/opendevin/controller/agent_controller.py b/opendevin/controller/agent_controller.py index c5e536f87f..50eaa1078f 100644 --- a/opendevin/controller/agent_controller.py +++ b/opendevin/controller/agent_controller.py @@ -6,7 +6,7 @@ from opendevin.controller.action_manager import ActionManager from opendevin.controller.agent import Agent from opendevin.controller.state.plan import Plan from opendevin.controller.state.state import State -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.exceptions import ( AgentMalformedActionError, AgentNoActionError, @@ -15,7 +15,6 @@ from opendevin.core.exceptions import ( ) from opendevin.core.logger import opendevin_logger as logger from opendevin.core.schema import AgentState -from opendevin.core.schema.config import ConfigType from opendevin.events.action import ( Action, AgentDelegateAction, @@ -37,8 +36,8 @@ from opendevin.events.stream import EventSource, EventStream, EventStreamSubscri from opendevin.runtime import DockerSSHBox from opendevin.runtime.browser.browser_env import BrowserEnv -MAX_ITERATIONS = config.get(ConfigType.MAX_ITERATIONS) -MAX_CHARS = config.get(ConfigType.MAX_CHARS) +MAX_ITERATIONS = config.max_iterations +MAX_CHARS = config.llm.max_chars class AgentController: diff --git a/opendevin/core/config.py b/opendevin/core/config.py index f78735da58..6ce79e3b07 100644 --- a/opendevin/core/config.py +++ b/opendevin/core/config.py @@ -3,126 +3,323 @@ import logging import os import pathlib import platform +from dataclasses import dataclass, field, fields, is_dataclass +from types import UnionType +from typing import Any, ClassVar, get_args, get_origin import toml from dotenv import load_dotenv -from opendevin.core.schema import ConfigType +from opendevin.core.utils import Singleton logger = logging.getLogger(__name__) -DEFAULT_CONTAINER_IMAGE = 'ghcr.io/opendevin/sandbox' -if os.getenv('OPEN_DEVIN_BUILD_VERSION'): - DEFAULT_CONTAINER_IMAGE += ':' + (os.getenv('OPEN_DEVIN_BUILD_VERSION') or '') -else: - DEFAULT_CONTAINER_IMAGE += ':main' - load_dotenv() -DEFAULT_CONFIG: dict = { - ConfigType.LLM_API_KEY: None, - ConfigType.LLM_BASE_URL: None, - ConfigType.LLM_CUSTOM_LLM_PROVIDER: None, - ConfigType.AWS_ACCESS_KEY_ID: None, - ConfigType.AWS_SECRET_ACCESS_KEY: None, - ConfigType.AWS_REGION_NAME: None, - ConfigType.WORKSPACE_BASE: os.getcwd(), - ConfigType.WORKSPACE_MOUNT_PATH: None, - ConfigType.WORKSPACE_MOUNT_PATH_IN_SANDBOX: '/workspace', - ConfigType.WORKSPACE_MOUNT_REWRITE: None, - ConfigType.CACHE_DIR: '/tmp/cache', # '/tmp/cache' is the default cache directory - ConfigType.LLM_MODEL: 'gpt-3.5-turbo-1106', - ConfigType.SANDBOX_CONTAINER_IMAGE: DEFAULT_CONTAINER_IMAGE, - ConfigType.RUN_AS_DEVIN: True, - ConfigType.LLM_EMBEDDING_MODEL: 'local', - ConfigType.LLM_EMBEDDING_BASE_URL: None, - ConfigType.LLM_EMBEDDING_DEPLOYMENT_NAME: None, - ConfigType.LLM_API_VERSION: None, - ConfigType.LLM_NUM_RETRIES: 5, - ConfigType.LLM_RETRY_MIN_WAIT: 3, - ConfigType.LLM_RETRY_MAX_WAIT: 60, - ConfigType.MAX_ITERATIONS: 100, - ConfigType.LLM_MAX_INPUT_TOKENS: None, - ConfigType.LLM_MAX_OUTPUT_TOKENS: None, - ConfigType.AGENT_MEMORY_MAX_THREADS: 2, - ConfigType.AGENT_MEMORY_ENABLED: False, - ConfigType.LLM_TIMEOUT: None, - ConfigType.LLM_TEMPERATURE: None, - ConfigType.LLM_TOP_P: None, - # GPT-4 pricing is $10 per 1M input tokens. Since tokenization happens on LLM side, - # we cannot easily count number of tokens, but we can count characters. - # Assuming 5 characters per token, 5 million is a reasonable default limit. - ConfigType.MAX_CHARS: 5_000_000, - ConfigType.AGENT: 'CodeActAgent', - ConfigType.E2B_API_KEY: '', - ConfigType.SANDBOX_TYPE: 'ssh', # Can be 'ssh', 'exec', or 'e2b' - ConfigType.USE_HOST_NETWORK: False, - ConfigType.SSH_HOSTNAME: 'localhost', - ConfigType.DISABLE_COLOR: False, - ConfigType.SANDBOX_USER_ID: os.getuid() if hasattr(os, 'getuid') else None, - ConfigType.SANDBOX_TIMEOUT: 120, - ConfigType.GITHUB_TOKEN: None, - ConfigType.SANDBOX_USER_ID: None, - ConfigType.DEBUG: False, -} -config_str = '' -if os.path.exists('config.toml'): - with open('config.toml', 'rb') as f: - config_str = f.read().decode('utf-8') +@dataclass +class LLMConfig(metaclass=Singleton): + model: str = 'gpt-3.5-turbo-1106' + api_key: str | None = None + base_url: str | None = None + api_version: str | None = None + embedding_model: str = 'local' + embedding_base_url: str | None = None + embedding_deployment_name: str | None = None + aws_access_key_id: str | None = None + aws_secret_access_key: str | None = None + aws_region_name: str | None = None + num_retries: int = 5 + retry_min_wait: int = 3 + retry_max_wait: int = 60 + timeout: int | None = None + max_chars: int = 5_000_000 # fallback for token counting + temperature: float = 0 + top_p: float = 0.5 + custom_llm_provider: str | None = None + max_input_tokens: int | None = None + max_output_tokens: int | None = None + + def defaults_to_dict(self) -> dict: + """ + Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional. + """ + dict = {} + for f in fields(self): + dict[f.name] = get_field_info(f) + return dict -def str_to_bool(value): - if isinstance(value, bool): - return value - return value.lower() in [ - 'true', - '1', - ] +@dataclass +class AgentConfig(metaclass=Singleton): + name: str = 'CodeActAgent' + memory_enabled: bool = False + memory_max_threads: int = 2 + + def defaults_to_dict(self) -> dict: + """ + Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional. + """ + dict = {} + for f in fields(self): + dict[f.name] = get_field_info(f) + return dict -def int_value(value, default, config_key): - # FIXME use a library +@dataclass +class AppConfig(metaclass=Singleton): + llm: LLMConfig = field(default_factory=LLMConfig) + agent: AgentConfig = field(default_factory=AgentConfig) + workspace_base: str = os.getcwd() + workspace_mount_path: str = os.getcwd() + workspace_mount_path_in_sandbox: str = '/workspace' + workspace_mount_rewrite: str | None = None + cache_dir: str = '/tmp/cache' + sandbox_container_image: str = 'ghcr.io/opendevin/sandbox' + ( + f':{os.getenv("OPEN_DEVIN_BUILD_VERSION")}' + if os.getenv('OPEN_DEVIN_BUILD_VERSION') + else ':main' + ) + run_as_devin: bool = True + max_iterations: int = 100 + e2b_api_key: str = '' + sandbox_type: str = 'ssh' # Can be 'ssh', 'exec', or 'e2b' + use_host_network: bool = False + ssh_hostname: str = 'localhost' + disable_color: bool = False + sandbox_user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000 + sandbox_timeout: int = 120 + github_token: str | None = None + debug: bool = False + + defaults_dict: ClassVar[dict] = {} + + def __post_init__(self): + """ + Post-initialization hook, called when the instance is created with only default values. + """ + AppConfig.defaults_dict = self.defaults_to_dict() + + def defaults_to_dict(self) -> dict: + """ + Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional. + """ + dict = {} + for f in fields(self): + field_value = getattr(self, f.name) + + # dataclasses compute their defaults themselves + if is_dataclass(type(field_value)): + dict[f.name] = field_value.defaults_to_dict() + else: + dict[f.name] = get_field_info(f) + return dict + + +def get_field_info(field): + """ + Extract information about a dataclass field: type, optional, and default. + + Args: + field: The field to extract information from. + + Returns: A dict with the field's type, whether it's optional, and its default value. + """ + field_type = field.type + optional = False + + # for types like str | None, find the non-None type and set optional to True + # this is useful for the frontend to know if a field is optional + # and to show the correct type in the UI + # Note: this only works for UnionTypes with None as one of the types + if get_origin(field_type) is UnionType: + types = get_args(field_type) + non_none_arg = next((t for t in types if t is not type(None)), None) + if non_none_arg is not None: + field_type = non_none_arg + optional = True + + # type name in a pretty format + type_name = ( + field_type.__name__ if hasattr(field_type, '__name__') else str(field_type) + ) + + # default is always present + default = field.default + + # return a schema with the useful info for frontend + return {'type': type_name.lower(), 'optional': optional, 'default': default} + + +def load_from_env(config: AppConfig, env_or_toml_dict: dict | os._Environ): + """Reads the env-style vars and sets config attributes based on env vars or a config.toml dict. + Compatibility with vars like LLM_BASE_URL, AGENT_MEMORY_ENABLED and others. + + Args: + config: The AppConfig object to set attributes on. + env_or_toml_dict: The environment variables or a config.toml dict. + """ + + def get_optional_type(union_type: UnionType) -> Any: + """Returns the non-None type from an Union.""" + types = get_args(union_type) + return next((t for t in types if t is not type(None)), None) + + # helper function to set attributes based on env vars + def set_attr_from_env(sub_config: Any, prefix=''): + """Set attributes of a config dataclass based on environment variables.""" + for field_name, field_type in sub_config.__annotations__.items(): + # compute the expected env var name from the prefix and field name + # e.g. LLM_BASE_URL + env_var_name = (prefix + field_name).upper() + + if is_dataclass(field_type): + # nested dataclass + nested_sub_config = getattr(sub_config, field_name) + + # the agent field: the env var for agent.name is just 'AGENT' + if field_name == 'agent' and 'AGENT' in env_or_toml_dict: + setattr(nested_sub_config, 'name', env_or_toml_dict[env_var_name]) + + set_attr_from_env(nested_sub_config, prefix=field_name + '_') + elif env_var_name in env_or_toml_dict: + # convert the env var to the correct type and set it + value = env_or_toml_dict[env_var_name] + try: + # if it's an optional type, get the non-None type + if get_origin(field_type) is UnionType: + field_type = get_optional_type(field_type) + + # Attempt to cast the env var to type hinted in the dataclass + cast_value = field_type(value) + setattr(sub_config, field_name, cast_value) + except (ValueError, TypeError): + logger.error( + f'Error setting env var {env_var_name}={value}: check that the value is of the right type' + ) + + # Start processing from the root of the config object + set_attr_from_env(config) + + +def load_from_toml(config: AppConfig, toml_file: str = 'config.toml'): + """Load the config from the toml file. Supports both styles of config vars. + + Args: + config: The AppConfig object to update attributes of. + """ + + # try to read the config.toml file into the config object + toml_config = {} + try: - return int(value) - except ValueError: + with open(toml_file, 'r', encoding='utf-8') as toml_contents: + toml_config = toml.load(toml_contents) + except FileNotFoundError: + # the file is optional, we don't need to do anything + return + except toml.TomlDecodeError: logger.warning( - f'Invalid value for {config_key}: {value} not applied. Using default value {default}' + 'Cannot parse config from toml, toml values have not been applied.', + exc_info=False, + ) + return + + # if there was an exception or core is not in the toml, try to use the old-style toml + if 'core' not in toml_config: + # re-use the env loader to set the config from env-style vars + load_from_env(config, toml_config) + return + + core_config = toml_config['core'] + + try: + # set llm config from the toml file + llm_config = config.llm + if 'llm' in toml_config: + llm_config = LLMConfig(**toml_config['llm']) + + # set agent config from the toml file + agent_config = config.agent + if 'agent' in toml_config: + agent_config = AgentConfig(**toml_config['agent']) + + # update the config object with the new values + config = AppConfig(llm=llm_config, agent=agent_config, **core_config) + except (TypeError, KeyError): + logger.warning( + 'Cannot parse config from toml, toml values have not been applied.', + exc_info=False, ) - return default -tomlConfig = toml.loads(config_str) -config = DEFAULT_CONFIG.copy() -for k, v in config.items(): - if k in os.environ: - config[k] = os.environ[k] - elif k in tomlConfig: - config[k] = tomlConfig[k] +def finalize_config(config: AppConfig): + """ + More tweaks to the config after it's been loaded. + """ - if k in [ - ConfigType.LLM_NUM_RETRIES, - ConfigType.LLM_RETRY_MIN_WAIT, - ConfigType.LLM_RETRY_MAX_WAIT, - ]: - config[k] = int_value(config[k], v, config_key=k) + # In local there is no sandbox, the workspace will have the same pwd as the host + if config.sandbox_type == 'local': + config.workspace_mount_path_in_sandbox = config.workspace_mount_path - if k in [ - ConfigType.RUN_AS_DEVIN, - ConfigType.USE_HOST_NETWORK, - ConfigType.AGENT_MEMORY_ENABLED, - ConfigType.DISABLE_COLOR, - ConfigType.DEBUG, - ]: - config[k] = str_to_bool(config[k]) -# In local there is no sandbox, the workspace will have the same pwd as the host -if config[ConfigType.SANDBOX_TYPE] == 'local': - config[ConfigType.WORKSPACE_MOUNT_PATH_IN_SANDBOX] = config[ - ConfigType.WORKSPACE_MOUNT_PATH - ] + if config.workspace_mount_rewrite: # and not config.workspace_mount_path: + # TODO why do we need to check if workspace_mount_path is None? + base = config.workspace_base or os.getcwd() + parts = config.workspace_mount_rewrite.split(':') + config.workspace_mount_path = base.replace(parts[0], parts[1]) + + if config.llm.embedding_base_url is None: + config.llm.embedding_base_url = config.llm.base_url + + if config.use_host_network and platform.system() == 'Darwin': + logger.warning( + 'Please upgrade to Docker Desktop 4.29.0 or later to use host network mode on macOS. ' + 'See https://github.com/docker/roadmap/issues/238#issuecomment-2044688144 for more information.' + ) + + # make sure cache dir exists + if config.cache_dir: + pathlib.Path(config.cache_dir).mkdir(parents=True, exist_ok=True) +config = AppConfig() +load_from_toml(config) +load_from_env(config, os.environ) +finalize_config(config) + + +# Utility function for command line --group argument +def get_llm_config_arg(llm_config_arg: str): + """ + Get a group of llm settings from the config file. + """ + + # keep only the name, just in case + llm_config_arg = llm_config_arg.strip('[]') + logger.info(f'Loading llm config from {llm_config_arg}') + + # load the toml file + try: + with open('config.toml', 'r', encoding='utf-8') as toml_file: + toml_config = toml.load(toml_file) + except FileNotFoundError: + return None + except toml.TomlDecodeError as e: + logger.error(f'Cannot parse llm group from {llm_config_arg}. Exception: {e}') + return None + + # update the llm config with the specified section + if llm_config_arg in toml_config: + return LLMConfig(**toml_config[llm_config_arg]) + logger.debug(f'Loading from toml failed for {llm_config_arg}') + return None + + +# Command line arguments def get_parser(): + """ + Get the parser for the command line arguments. + """ parser = argparse.ArgumentParser(description='Run an agent with a specific task') parser.add_argument( '-d', @@ -142,89 +339,51 @@ def get_parser(): parser.add_argument( '-c', '--agent-cls', - default=config.get(ConfigType.AGENT), + default=config.agent.name, type=str, help='The agent class to use', ) parser.add_argument( '-m', '--model-name', - default=config.get(ConfigType.LLM_MODEL), + default=config.llm.model, type=str, help='The (litellm) model name to use', ) parser.add_argument( '-i', '--max-iterations', - default=config.get(ConfigType.MAX_ITERATIONS), + default=config.max_iterations, type=int, help='The maximum number of iterations to run the agent', ) parser.add_argument( '-n', '--max-chars', - default=config.get(ConfigType.MAX_CHARS), + default=config.llm.max_chars, type=int, help='The maximum number of characters to send to and receive from LLM per task', ) + parser.add_argument( + '-l', + '--llm-config', + default=None, + type=str, + help='The group of llm settings, e.g. a [llama3] section in the toml file. Overrides model if both are provided.', + ) return parser def parse_arguments(): + """ + Parse the command line arguments. + """ parser = get_parser() args, _ = parser.parse_known_args() if args.directory: - config[ConfigType.WORKSPACE_BASE] = os.path.abspath(args.directory) - print(f'Setting workspace base to {config[ConfigType.WORKSPACE_BASE]}') + config.workspace_base = os.path.abspath(args.directory) + print(f'Setting workspace base to {config.workspace_base}') return args args = parse_arguments() - - -def finalize_config(): - if config.get(ConfigType.WORKSPACE_MOUNT_REWRITE) and not config.get( - ConfigType.WORKSPACE_MOUNT_PATH - ): - base = config.get(ConfigType.WORKSPACE_BASE) or os.getcwd() - parts = config[ConfigType.WORKSPACE_MOUNT_REWRITE].split(':') - config[ConfigType.WORKSPACE_MOUNT_PATH] = base.replace(parts[0], parts[1]) - - if config.get(ConfigType.WORKSPACE_MOUNT_PATH) is None: - config[ConfigType.WORKSPACE_MOUNT_PATH] = os.path.abspath( - config[ConfigType.WORKSPACE_BASE] - ) - - if config.get(ConfigType.LLM_EMBEDDING_BASE_URL) is None: - config[ConfigType.LLM_EMBEDDING_BASE_URL] = config.get(ConfigType.LLM_BASE_URL) - - USE_HOST_NETWORK = config[ConfigType.USE_HOST_NETWORK] - if USE_HOST_NETWORK and platform.system() == 'Darwin': - logger.warning( - 'Please upgrade to Docker Desktop 4.29.0 or later to use host network mode on macOS. ' - 'See https://github.com/docker/roadmap/issues/238#issuecomment-2044688144 for more information.' - ) - config[ConfigType.USE_HOST_NETWORK] = USE_HOST_NETWORK - - if config.get(ConfigType.WORKSPACE_MOUNT_PATH) is None: - config[ConfigType.WORKSPACE_MOUNT_PATH] = config.get(ConfigType.WORKSPACE_BASE) - - -finalize_config() - - -def get(key: ConfigType, required: bool = False): - """ - Get a key from the environment variables or config.toml or default configs. - """ - if not isinstance(key, ConfigType): - raise ValueError(f"key '{key}' must be an instance of ConfigType Enum") - value = config.get(key) - if not value and required: - raise KeyError(f"Please set '{key}' in `config.toml` or `.env`.") - return value - - -_cache_dir = config.get(ConfigType.CACHE_DIR) -if _cache_dir: - pathlib.Path(_cache_dir).mkdir(parents=True, exist_ok=True) diff --git a/opendevin/core/logger.py b/opendevin/core/logger.py index ecca191f81..0ebe6691a6 100644 --- a/opendevin/core/logger.py +++ b/opendevin/core/logger.py @@ -7,10 +7,9 @@ from typing import Literal, Mapping from termcolor import colored -from opendevin.core import config -from opendevin.core.schema.config import ConfigType +from opendevin.core.config import config -DISABLE_COLOR_PRINTING = config.get(ConfigType.DISABLE_COLOR) +DISABLE_COLOR_PRINTING = config.disable_color ColorType = Literal[ 'red', @@ -91,7 +90,7 @@ def get_file_handler(): timestamp = datetime.now().strftime('%Y-%m-%d') file_name = f'opendevin_{timestamp}.log' file_handler = logging.FileHandler(os.path.join(log_dir, file_name)) - if config.get(ConfigType.DEBUG): + if config.debug: file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(file_formatter) return file_handler @@ -197,12 +196,12 @@ def get_llm_response_file_handler(): llm_prompt_logger = logging.getLogger('prompt') llm_prompt_logger.propagate = False -if config.get(ConfigType.DEBUG): +if config.debug: llm_prompt_logger.setLevel(logging.DEBUG) llm_prompt_logger.addHandler(get_llm_prompt_file_handler()) llm_response_logger = logging.getLogger('response') llm_response_logger.propagate = False -if config.get(ConfigType.DEBUG): +if config.debug: llm_response_logger.setLevel(logging.DEBUG) llm_response_logger.addHandler(get_llm_response_file_handler()) diff --git a/opendevin/core/main.py b/opendevin/core/main.py index 2ad6a8ab1c..d05790668e 100644 --- a/opendevin/core/main.py +++ b/opendevin/core/main.py @@ -5,7 +5,7 @@ from typing import Type import agenthub # noqa F401 (we import this to get the agents registered) from opendevin.controller import AgentController from opendevin.controller.agent import Agent -from opendevin.core.config import args +from opendevin.core.config import args, get_llm_config_arg from opendevin.core.schema import AgentState from opendevin.events.action import ChangeAgentStateAction, MessageAction from opendevin.events.event import Event @@ -40,12 +40,31 @@ async def main(task_str: str = ''): else: raise ValueError('No task provided. Please specify a task through -t, -f.') - print( - f'Running agent {args.agent_cls} (model: {args.model_name}) with task: "{task}"' - ) - llm = LLM(args.model_name) + # only one of model_name or llm_config is required + if args.llm_config: + # --llm_config + # llm_config can contain any of the attributes of LLMConfig + llm_config = get_llm_config_arg(args.llm_config) + + if llm_config is None: + raise ValueError(f'Invalid toml file, cannot read {args.llm_config}') + + print( + f'Running agent {args.agent_cls} (model: {llm_config.model}, llm_config: {llm_config}) with task: "{task}"' + ) + + # create LLM instance with the given config + llm = LLM(llm_config=llm_config) + else: + # --model-name model_name + print( + f'Running agent {args.agent_cls} (model: {args.model_name}), with task: "{task}"' + ) + llm = LLM(args.model_name) + AgentCls: Type[Agent] = Agent.get_cls(args.agent_cls) agent = AgentCls(llm=llm) + event_stream = EventStream() controller = AgentController( agent=agent, diff --git a/opendevin/core/utils/__init__.py b/opendevin/core/utils/__init__.py new file mode 100644 index 0000000000..33012ec028 --- /dev/null +++ b/opendevin/core/utils/__init__.py @@ -0,0 +1,3 @@ +from .singleton import Singleton + +__all__ = ['Singleton'] diff --git a/opendevin/core/utils/singleton.py b/opendevin/core/utils/singleton.py new file mode 100644 index 0000000000..fba57ddc69 --- /dev/null +++ b/opendevin/core/utils/singleton.py @@ -0,0 +1,28 @@ +import dataclasses + + +class Singleton(type): + _instances: dict = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + else: + # allow updates, just update existing instance + # perhaps not the most orthodox way to do it, though it simplifies client code + # useful for pre-defined groups of settings + instance = cls._instances[cls] + for key, value in kwargs.items(): + setattr(instance, key, value) + return cls._instances[cls] + + @classmethod + def reset(cls): + # used by pytest to reset the state of the singleton instances + for instance_type, instance in cls._instances.items(): + print('resetting... ', instance_type) + for field in dataclasses.fields(instance_type): + if dataclasses.is_dataclass(field.type): + setattr(instance, field.name, field.type()) + else: + setattr(instance, field.name, field.default) diff --git a/opendevin/events/action/commands.py b/opendevin/events/action/commands.py index e3d1d5cc37..5cb72760e3 100644 --- a/opendevin/events/action/commands.py +++ b/opendevin/events/action/commands.py @@ -3,8 +3,8 @@ import pathlib from dataclasses import dataclass from typing import TYPE_CHECKING -from opendevin.core import config -from opendevin.core.schema import ActionType, ConfigType +from opendevin.core.config import config +from opendevin.core.schema import ActionType from .action import Action @@ -64,14 +64,14 @@ class IPythonRunCellAction(Action): # echo "import math" | execute_cli # write code to a temporary file and pass it to `execute_cli` via stdin tmp_filepath = os.path.join( - config.get(ConfigType.WORKSPACE_BASE), '.tmp', '.ipython_execution_tmp.py' + config.workspace_base, '.tmp', '.ipython_execution_tmp.py' ) pathlib.Path(os.path.dirname(tmp_filepath)).mkdir(parents=True, exist_ok=True) with open(tmp_filepath, 'w') as tmp_file: tmp_file.write(self.code) tmp_filepath_inside_sandbox = os.path.join( - config.get(ConfigType.WORKSPACE_MOUNT_PATH_IN_SANDBOX), + config.workspace_mount_path_in_sandbox, '.tmp', '.ipython_execution_tmp.py', ) diff --git a/opendevin/events/action/files.py b/opendevin/events/action/files.py index 6498886b8c..defd91013f 100644 --- a/opendevin/events/action/files.py +++ b/opendevin/events/action/files.py @@ -2,9 +2,8 @@ import os from dataclasses import dataclass from pathlib import Path -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.schema import ActionType -from opendevin.core.schema.config import ConfigType from opendevin.events.observation import ( ErrorObservation, FileReadObservation, @@ -28,20 +27,16 @@ def resolve_path(file_path, working_directory): abs_path_in_sandbox = path_in_sandbox.resolve() # If the path is outside the workspace, deny it - if not abs_path_in_sandbox.is_relative_to( - config.get(ConfigType.WORKSPACE_MOUNT_PATH_IN_SANDBOX) - ): + if not abs_path_in_sandbox.is_relative_to(config.workspace_mount_path_in_sandbox): raise PermissionError(f'File access not permitted: {file_path}') # Get path relative to the root of the workspace inside the sandbox path_in_workspace = abs_path_in_sandbox.relative_to( - Path(config.get(ConfigType.WORKSPACE_MOUNT_PATH_IN_SANDBOX)) + Path(config.workspace_mount_path_in_sandbox) ) # Get path relative to host - path_in_host_workspace = ( - Path(config.get(ConfigType.WORKSPACE_BASE)) / path_in_workspace - ) + path_in_host_workspace = Path(config.workspace_base) / path_in_workspace return path_in_host_workspace diff --git a/opendevin/events/action/github.py b/opendevin/events/action/github.py index 57e560ba6d..d7878573a0 100644 --- a/opendevin/events/action/github.py +++ b/opendevin/events/action/github.py @@ -5,9 +5,8 @@ from typing import TYPE_CHECKING import requests -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.schema import ActionType -from opendevin.core.schema.config import ConfigType from opendevin.events.observation import ( CmdOutputObservation, ErrorObservation, @@ -42,9 +41,9 @@ class GitHubPushAction(Action): action: str = ActionType.PUSH async def run(self, controller: 'AgentController') -> Observation: - github_token = config.get(ConfigType.GITHUB_TOKEN) + github_token = config.github_token if not github_token: - return ErrorObservation('GITHUB_TOKEN is not set') + return ErrorObservation('github_token is not set') # Create a random short string to use as a temporary remote random_remote = ''.join( @@ -111,9 +110,9 @@ class GitHubSendPRAction(Action): action: str = ActionType.SEND_PR async def run(self, controller: 'AgentController') -> Observation: - github_token = config.get(ConfigType.GITHUB_TOKEN) + github_token = config.github_token if not github_token: - return ErrorObservation('GITHUB_TOKEN is not set') + return ErrorObservation('github_token is not set') # API URL to create the pull request url = f'https://api.github.com/repos/{self.owner}/{self.repo}/pulls' diff --git a/opendevin/llm/bedrock.py b/opendevin/llm/bedrock.py index fa91455fe9..62413a8fcb 100644 --- a/opendevin/llm/bedrock.py +++ b/opendevin/llm/bedrock.py @@ -2,21 +2,20 @@ import os import boto3 -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema import ConfigType -AWS_ACCESS_KEY_ID = config.get(ConfigType.AWS_ACCESS_KEY_ID) -AWS_SECRET_ACCESS_KEY = config.get(ConfigType.AWS_SECRET_ACCESS_KEY) -AWS_REGION_NAME = config.get(ConfigType.AWS_REGION_NAME) +AWS_ACCESS_KEY_ID = config.llm.aws_access_key_id +AWS_SECRET_ACCESS_KEY = config.llm.aws_secret_access_key +AWS_REGION_NAME = config.llm.aws_region_name # It needs to be set as an environment variable, if the variable is configured in the Config file. if AWS_ACCESS_KEY_ID is not None: - os.environ[ConfigType.AWS_ACCESS_KEY_ID] = AWS_ACCESS_KEY_ID + os.environ['AWS_ACCESS_KEY_ID'] = AWS_ACCESS_KEY_ID if AWS_SECRET_ACCESS_KEY is not None: - os.environ[ConfigType.AWS_SECRET_ACCESS_KEY] = AWS_SECRET_ACCESS_KEY + os.environ['AWS_SECRET_ACCESS_KEY'] = AWS_SECRET_ACCESS_KEY if AWS_REGION_NAME is not None: - os.environ[ConfigType.AWS_REGION_NAME] = AWS_REGION_NAME + os.environ['AWS_REGION_NAME'] = AWS_REGION_NAME def list_foundation_models(): diff --git a/opendevin/llm/llm.py b/opendevin/llm/llm.py index fdae4281da..7f6333a22a 100644 --- a/opendevin/llm/llm.py +++ b/opendevin/llm/llm.py @@ -15,50 +15,50 @@ from tenacity import ( wait_random_exponential, ) -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.logger import llm_prompt_logger, llm_response_logger from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema import ConfigType __all__ = ['LLM', 'completion_cost'] -DEFAULT_API_KEY = config.get(ConfigType.LLM_API_KEY) -DEFAULT_BASE_URL = config.get(ConfigType.LLM_BASE_URL) -DEFAULT_MODEL_NAME = config.get(ConfigType.LLM_MODEL) -DEFAULT_API_VERSION = config.get(ConfigType.LLM_API_VERSION) -LLM_NUM_RETRIES = config.get(ConfigType.LLM_NUM_RETRIES) -LLM_RETRY_MIN_WAIT = config.get(ConfigType.LLM_RETRY_MIN_WAIT) -LLM_RETRY_MAX_WAIT = config.get(ConfigType.LLM_RETRY_MAX_WAIT) -LLM_MAX_INPUT_TOKENS = config.get(ConfigType.LLM_MAX_INPUT_TOKENS) -LLM_MAX_OUTPUT_TOKENS = config.get(ConfigType.LLM_MAX_OUTPUT_TOKENS) -LLM_CUSTOM_LLM_PROVIDER = config.get(ConfigType.LLM_CUSTOM_LLM_PROVIDER) -LLM_TIMEOUT = config.get(ConfigType.LLM_TIMEOUT) -LLM_TEMPERATURE = config.get(ConfigType.LLM_TEMPERATURE) -LLM_TOP_P = config.get(ConfigType.LLM_TOP_P) - class LLM: """ The LLM class represents a Language Model instance. + + Attributes: + model_name (str): The name of the language model. + api_key (str): The API key for accessing the language model. + base_url (str): The base URL for the language model API. + api_version (str): The version of the API to use. + max_input_tokens (int): The maximum number of tokens to send to the LLM per task. + max_output_tokens (int): The maximum number of tokens to receive from the LLM per task. + llm_timeout (int): The maximum time to wait for a response in seconds. + custom_llm_provider (str): A custom LLM provider. """ def __init__( self, - model=DEFAULT_MODEL_NAME, - api_key=DEFAULT_API_KEY, - base_url=DEFAULT_BASE_URL, - api_version=DEFAULT_API_VERSION, - num_retries=LLM_NUM_RETRIES, - retry_min_wait=LLM_RETRY_MIN_WAIT, - retry_max_wait=LLM_RETRY_MAX_WAIT, - max_input_tokens=LLM_MAX_INPUT_TOKENS, - max_output_tokens=LLM_MAX_OUTPUT_TOKENS, - custom_llm_provider=LLM_CUSTOM_LLM_PROVIDER, - llm_timeout=LLM_TIMEOUT, - llm_temperature=LLM_TEMPERATURE, - llm_top_p=LLM_TOP_P, + model=None, + api_key=None, + base_url=None, + api_version=None, + num_retries=None, + retry_min_wait=None, + retry_max_wait=None, + llm_timeout=None, + llm_temperature=None, + llm_top_p=None, + custom_llm_provider=None, + max_input_tokens=None, + max_output_tokens=None, + llm_config=None, ): """ + Initializes the LLM. If LLMConfig is passed, its values will be the fallback. + + Passing simple parameters always overrides config. + Args: model (str, optional): The name of the language model. Defaults to LLM_MODEL. api_key (str, optional): The API key for accessing the language model. Defaults to LLM_API_KEY. @@ -73,12 +73,41 @@ class LLM: llm_timeout (int, optional): The maximum time to wait for a response in seconds. Defaults to LLM_TIMEOUT. llm_temperature (float, optional): The temperature for LLM sampling. Defaults to LLM_TEMPERATURE. - Attributes: - model_name (str): The name of the language model. - api_key (str): The API key for accessing the language model. - base_url (str): The base URL for the language model API. - api_version (str): The version of the API to use. """ + if llm_config is None: + llm_config = config.llm + model = model if model is not None else llm_config.model + api_key = api_key if api_key is not None else llm_config.api_key + base_url = base_url if base_url is not None else llm_config.base_url + api_version = api_version if api_version is not None else llm_config.api_version + num_retries = num_retries if num_retries is not None else llm_config.num_retries + retry_min_wait = ( + retry_min_wait if retry_min_wait is not None else llm_config.retry_min_wait + ) + retry_max_wait = ( + retry_max_wait if retry_max_wait is not None else llm_config.retry_max_wait + ) + llm_timeout = llm_timeout if llm_timeout is not None else llm_config.timeout + llm_temperature = ( + llm_temperature if llm_temperature is not None else llm_config.temperature + ) + llm_top_p = llm_top_p if llm_top_p is not None else llm_config.top_p + custom_llm_provider = ( + custom_llm_provider + if custom_llm_provider is not None + else llm_config.custom_llm_provider + ) + max_input_tokens = ( + max_input_tokens + if max_input_tokens is not None + else llm_config.max_input_tokens + ) + max_output_tokens = ( + max_output_tokens + if max_output_tokens is not None + else llm_config.max_output_tokens + ) + logger.info(f'Initializing LLM with model: {model}') self.model_name = model self.api_key = api_key diff --git a/opendevin/runtime/docker/exec_box.py b/opendevin/runtime/docker/exec_box.py index c2676a0503..1de97d217f 100644 --- a/opendevin/runtime/docker/exec_box.py +++ b/opendevin/runtime/docker/exec_box.py @@ -12,28 +12,16 @@ from typing import Dict, List, Tuple import docker from opendevin.const.guide_url import TROUBLESHOOTING_URL -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.exceptions import SandboxInvalidBackgroundCommandError from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema import ConfigType from opendevin.runtime.docker.process import DockerProcess, Process from opendevin.runtime.sandbox import Sandbox +# FIXME these are not used, should we remove them? InputType = namedtuple('InputType', ['content']) OutputType = namedtuple('OutputType', ['content']) -CONTAINER_IMAGE = config.get(ConfigType.SANDBOX_CONTAINER_IMAGE) -SANDBOX_WORKSPACE_DIR = config.get(ConfigType.WORKSPACE_MOUNT_PATH_IN_SANDBOX) - -# FIXME: On some containers, the devin user doesn't have enough permission, e.g. to install packages -# How do we make this more flexible? -RUN_AS_DEVIN = config.get(ConfigType.RUN_AS_DEVIN) -USER_ID = 1000 -if SANDBOX_USER_ID := config.get(ConfigType.SANDBOX_USER_ID): - USER_ID = int(SANDBOX_USER_ID) -elif hasattr(os, 'getuid'): - USER_ID = os.getuid() - class DockerExecBox(Sandbox): instance_id: str @@ -72,35 +60,43 @@ class DockerExecBox(Sandbox): # if it is too long, the user may have to wait for a unnecessary long time self.timeout = timeout self.container_image = ( - CONTAINER_IMAGE if container_image is None else container_image + config.sandbox_container_image + if container_image is None + else container_image ) self.container_name = self.container_name_prefix + self.instance_id + logger.info( + 'Starting Docker container with image %s, sandbox workspace dir=%s', + self.container_image, + self.sandbox_workspace_dir, + ) + # always restart the container, cuz the initial be regarded as a new session self.restart_docker_container() - if RUN_AS_DEVIN: + if self.run_as_devin: self.setup_devin_user() atexit.register(self.close) super().__init__() def setup_devin_user(self): cmds = [ - f'useradd --shell /bin/bash -u {USER_ID} -o -c "" -m devin', + f'useradd --shell /bin/bash -u {self.user_id} -o -c "" -m devin', r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers", 'sudo adduser devin sudo', ] for cmd in cmds: exit_code, logs = self.container.exec_run( ['/bin/bash', '-c', cmd], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: raise Exception(f'Failed to setup devin user: {logs}') def get_exec_cmd(self, cmd: str) -> List[str]: - if RUN_AS_DEVIN: + if self.run_as_devin: return ['su', 'devin', '-c', cmd] else: return ['/bin/bash', '-c', cmd] @@ -115,7 +111,7 @@ class DockerExecBox(Sandbox): # TODO: each execute is not stateful! We need to keep track of the current working directory def run_command(container, command): return container.exec_run( - command, workdir=SANDBOX_WORKSPACE_DIR, environment=self._env + command, workdir=self.sandbox_workspace_dir, environment=self._env ) # Use ThreadPoolExecutor to control command and set timeout @@ -133,7 +129,7 @@ class DockerExecBox(Sandbox): if pid is not None: self.container.exec_run( f'kill -9 {pid}', - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) return -1, f'Command: "{cmd}" timed out' @@ -146,7 +142,7 @@ class DockerExecBox(Sandbox): # mkdir -p sandbox_dest if it doesn't exist exit_code, logs = self.container.exec_run( ['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: @@ -185,7 +181,7 @@ class DockerExecBox(Sandbox): result = self.container.exec_run( self.get_exec_cmd(cmd), socket=True, - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) result.output._sock.setblocking(0) @@ -213,7 +209,7 @@ class DockerExecBox(Sandbox): if bg_cmd.pid is not None: self.container.exec_run( f'kill -9 {bg_cmd.pid}', - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) assert isinstance(bg_cmd, DockerProcess) @@ -256,15 +252,15 @@ class DockerExecBox(Sandbox): try: # start the container - mount_dir = config.get(ConfigType.WORKSPACE_MOUNT_PATH) + mount_dir = config.workspace_mount_path self.container = self.docker_client.containers.run( self.container_image, command='tail -f /dev/null', network_mode='host', - working_dir=SANDBOX_WORKSPACE_DIR, + working_dir=self.sandbox_workspace_dir, name=self.container_name, detach=True, - volumes={mount_dir: {'bind': SANDBOX_WORKSPACE_DIR, 'mode': 'rw'}}, + volumes={mount_dir: {'bind': self.sandbox_workspace_dir, 'mode': 'rw'}}, ) logger.info('Container started') except Exception as ex: @@ -298,7 +294,21 @@ class DockerExecBox(Sandbox): pass def get_working_directory(self): - return SANDBOX_WORKSPACE_DIR + return self.sandbox_workspace_dir + + @property + def user_id(self): + return config.sandbox_user_id + + @property + def run_as_devin(self): + # FIXME: On some containers, the devin user doesn't have enough permission, e.g. to install packages + # How do we make this more flexible? + return config.run_as_devin + + @property + def sandbox_workspace_dir(self): + return config.workspace_mount_path_in_sandbox if __name__ == '__main__': diff --git a/opendevin/runtime/docker/local_box.py b/opendevin/runtime/docker/local_box.py index 44b70e2e0b..f122f35971 100644 --- a/opendevin/runtime/docker/local_box.py +++ b/opendevin/runtime/docker/local_box.py @@ -4,9 +4,8 @@ import subprocess import sys from typing import Dict, Tuple -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema.config import ConfigType from opendevin.runtime.docker.process import DockerProcess, Process from opendevin.runtime.sandbox import Sandbox @@ -28,7 +27,7 @@ from opendevin.runtime.sandbox import Sandbox class LocalBox(Sandbox): def __init__(self, timeout: int = 120): - os.makedirs(config.get(ConfigType.WORKSPACE_BASE), exist_ok=True) + os.makedirs(config.workspace_base, exist_ok=True) self.timeout = timeout self.background_commands: Dict[int, Process] = {} self.cur_background_id = 0 @@ -43,7 +42,7 @@ class LocalBox(Sandbox): text=True, capture_output=True, timeout=self.timeout, - cwd=config.get(ConfigType.WORKSPACE_BASE), + cwd=config.workspace_base, env=self._env, ) return completed_process.returncode, completed_process.stdout.strip() @@ -56,7 +55,7 @@ class LocalBox(Sandbox): f'mkdir -p {sandbox_dest}', shell=True, text=True, - cwd=config.get(ConfigType.WORKSPACE_BASE), + cwd=config.workspace_base, env=self._env, ) if res.returncode != 0: @@ -67,7 +66,7 @@ class LocalBox(Sandbox): f'cp -r {host_src} {sandbox_dest}', shell=True, text=True, - cwd=config.get(ConfigType.WORKSPACE_BASE), + cwd=config.workspace_base, env=self._env, ) if res.returncode != 0: @@ -79,7 +78,7 @@ class LocalBox(Sandbox): f'cp {host_src} {sandbox_dest}', shell=True, text=True, - cwd=config.get(ConfigType.WORKSPACE_BASE), + cwd=config.workspace_base, env=self._env, ) if res.returncode != 0: @@ -94,7 +93,7 @@ class LocalBox(Sandbox): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - cwd=config.get(ConfigType.WORKSPACE_BASE), + cwd=config.workspace_base, ) bg_cmd = DockerProcess( id=self.cur_background_id, command=cmd, result=process, pid=process.pid @@ -128,7 +127,7 @@ class LocalBox(Sandbox): self.close() def get_working_directory(self): - return config.get(ConfigType.WORKSPACE_BASE) + return config.workspace_base if __name__ == '__main__': diff --git a/opendevin/runtime/docker/ssh_box.py b/opendevin/runtime/docker/ssh_box.py index 0e1ffa006f..eddb44b92d 100644 --- a/opendevin/runtime/docker/ssh_box.py +++ b/opendevin/runtime/docker/ssh_box.py @@ -13,10 +13,9 @@ import docker from pexpect import pxssh from opendevin.const.guide_url import TROUBLESHOOTING_URL -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.exceptions import SandboxInvalidBackgroundCommandError from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema import ConfigType from opendevin.runtime.docker.process import DockerProcess, Process from opendevin.runtime.plugins import ( JupyterRequirement, @@ -25,26 +24,10 @@ from opendevin.runtime.plugins import ( from opendevin.runtime.sandbox import Sandbox from opendevin.runtime.utils import find_available_tcp_port +# FIXME: these are not used, can we remove them? InputType = namedtuple('InputType', ['content']) OutputType = namedtuple('OutputType', ['content']) -SANDBOX_WORKSPACE_DIR = config.get(ConfigType.WORKSPACE_MOUNT_PATH_IN_SANDBOX) - -CONTAINER_IMAGE = config.get(ConfigType.SANDBOX_CONTAINER_IMAGE) - -SSH_HOSTNAME = config.get(ConfigType.SSH_HOSTNAME) - -USE_HOST_NETWORK = config.get(ConfigType.USE_HOST_NETWORK) - -# FIXME: On some containers, the devin user doesn't have enough permission, e.g. to install packages -# How do we make this more flexible? -RUN_AS_DEVIN = config.get(ConfigType.RUN_AS_DEVIN) -USER_ID = 1000 -if SANDBOX_USER_ID := config.get(ConfigType.SANDBOX_USER_ID): - USER_ID = int(SANDBOX_USER_ID) -elif hasattr(os, 'getuid'): - USER_ID = os.getuid() - class DockerSSHBox(Sandbox): instance_id: str @@ -67,7 +50,7 @@ class DockerSSHBox(Sandbox): sid: str | None = None, ): logger.info( - f'SSHBox is running as {"opendevin" if RUN_AS_DEVIN else "root"} user with USER_ID={USER_ID} in the sandbox' + f'SSHBox is running as {"opendevin" if self.run_as_devin else "root"} user with USER_ID={self.user_id} in the sandbox' ) # Initialize docker client. Throws an exception if Docker is not reachable. try: @@ -89,7 +72,9 @@ class DockerSSHBox(Sandbox): # if it is too long, the user may have to wait for a unnecessary long time self.timeout = timeout self.container_image = ( - CONTAINER_IMAGE if container_image is None else container_image + config.sandbox_container_image + if container_image is None + else container_image ) self.container_name = self.container_name_prefix + self.instance_id @@ -115,7 +100,7 @@ class DockerSSHBox(Sandbox): # TODO(sandbox): add this line in the Dockerfile for next minor version of docker image exit_code, logs = self.container.exec_run( ['/bin/bash', '-c', r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: @@ -126,28 +111,28 @@ class DockerSSHBox(Sandbox): # Check if the opendevin user exists exit_code, logs = self.container.exec_run( ['/bin/bash', '-c', 'id -u opendevin'], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code == 0: # User exists, delete it exit_code, logs = self.container.exec_run( ['/bin/bash', '-c', 'userdel -r opendevin'], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: raise Exception(f'Failed to remove opendevin user in sandbox: {logs}') - if RUN_AS_DEVIN: + if self.run_as_devin: # Create the opendevin user exit_code, logs = self.container.exec_run( [ '/bin/bash', '-c', - f'useradd -rm -d /home/opendevin -s /bin/bash -g root -G sudo -u {USER_ID} opendevin', + f'useradd -rm -d /home/opendevin -s /bin/bash -g root -G sudo -u {self.user_id} opendevin', ], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: @@ -158,7 +143,7 @@ class DockerSSHBox(Sandbox): '-c', f"echo 'opendevin:{self._ssh_password}' | chpasswd", ], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: @@ -167,7 +152,7 @@ class DockerSSHBox(Sandbox): # chown the home directory exit_code, logs = self.container.exec_run( ['/bin/bash', '-c', 'chown opendevin:root /home/opendevin'], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: @@ -175,35 +160,39 @@ class DockerSSHBox(Sandbox): f'Failed to chown home directory for opendevin in sandbox: {logs}' ) exit_code, logs = self.container.exec_run( - ['/bin/bash', '-c', f'chown opendevin:root {SANDBOX_WORKSPACE_DIR}'], - workdir=SANDBOX_WORKSPACE_DIR, + [ + '/bin/bash', + '-c', + f'chown opendevin:root {self.sandbox_workspace_dir}', + ], + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: # This is not a fatal error, just a warning logger.warning( - f'Failed to chown workspace directory for opendevin in sandbox: {logs}. But this should be fine if the {SANDBOX_WORKSPACE_DIR=} is mounted by the app docker container.' + f'Failed to chown workspace directory for opendevin in sandbox: {logs}. But this should be fine if the {self.sandbox_workspace_dir=} is mounted by the app docker container.' ) else: exit_code, logs = self.container.exec_run( # change password for root ['/bin/bash', '-c', f"echo 'root:{self._ssh_password}' | chpasswd"], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: raise Exception(f'Failed to set password for root in sandbox: {logs}') exit_code, logs = self.container.exec_run( ['/bin/bash', '-c', "echo 'opendevin-sandbox' > /etc/hostname"], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) def start_ssh_session(self): # start ssh session at the background self.ssh = pxssh.pxssh() - hostname = SSH_HOSTNAME - if RUN_AS_DEVIN: + hostname = self.ssh_hostname + if self.run_as_devin: username = 'opendevin' else: username = 'root' @@ -218,11 +207,11 @@ class DockerSSHBox(Sandbox): self.ssh.sendline("bind 'set enable-bracketed-paste off'") self.ssh.prompt() # cd to workspace - self.ssh.sendline(f'cd {SANDBOX_WORKSPACE_DIR}') + self.ssh.sendline(f'cd {self.sandbox_workspace_dir}') self.ssh.prompt() def get_exec_cmd(self, cmd: str) -> List[str]: - if RUN_AS_DEVIN: + if self.run_as_devin: return ['su', 'opendevin', '-c', cmd] else: return ['/bin/bash', '-c', cmd] @@ -283,7 +272,7 @@ class DockerSSHBox(Sandbox): # mkdir -p sandbox_dest if it doesn't exist exit_code, logs = self.container.exec_run( ['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'], - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) if exit_code != 0: @@ -322,7 +311,7 @@ class DockerSSHBox(Sandbox): result = self.container.exec_run( self.get_exec_cmd(cmd), socket=True, - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) result.output._sock.setblocking(0) @@ -350,7 +339,7 @@ class DockerSSHBox(Sandbox): if bg_cmd.pid is not None: self.container.exec_run( f'kill -9 {bg_cmd.pid}', - workdir=SANDBOX_WORKSPACE_DIR, + workdir=self.sandbox_workspace_dir, environment=self._env, ) assert isinstance(bg_cmd, DockerProcess) @@ -379,6 +368,30 @@ class DockerSSHBox(Sandbox): raise Exception('Failed to get working directory') return result.strip() + @property + def user_id(self): + return config.sandbox_user_id + + @property + def sandbox_user_id(self): + return config.sandbox_user_id + + @property + def run_as_devin(self): + return config.run_as_devin + + @property + def sandbox_workspace_dir(self): + return config.workspace_mount_path_in_sandbox + + @property + def ssh_hostname(self): + return config.ssh_hostname + + @property + def use_host_network(self): + return config.use_host_network + def is_container_running(self): try: container = self.docker_client.containers.get(self.container_name) @@ -399,7 +412,7 @@ class DockerSSHBox(Sandbox): try: network_kwargs: Dict[str, Union[str, Dict[str, int]]] = {} - if USE_HOST_NETWORK: + if self.use_host_network: network_kwargs['network_mode'] = 'host' else: # FIXME: This is a temporary workaround for Mac OS @@ -412,7 +425,7 @@ class DockerSSHBox(Sandbox): ) ) - mount_dir = config.get(ConfigType.WORKSPACE_MOUNT_PATH) + mount_dir = config.workspace_mount_path logger.info(f'Mounting workspace directory: {mount_dir}') # start the container self.container = self.docker_client.containers.run( @@ -420,15 +433,15 @@ class DockerSSHBox(Sandbox): # allow root login command=f"/usr/sbin/sshd -D -p {self._ssh_port} -o 'PermitRootLogin=yes'", **network_kwargs, - working_dir=SANDBOX_WORKSPACE_DIR, + working_dir=self.sandbox_workspace_dir, name=self.container_name, detach=True, volumes={ - mount_dir: {'bind': SANDBOX_WORKSPACE_DIR, 'mode': 'rw'}, + mount_dir: {'bind': self.sandbox_workspace_dir, 'mode': 'rw'}, # mount cache directory to /home/opendevin/.cache for pip cache reuse - config.get(ConfigType.CACHE_DIR): { + config.cache_dir: { 'bind': '/home/opendevin/.cache' - if RUN_AS_DEVIN + if self.run_as_devin else '/root/.cache', 'mode': 'rw', }, diff --git a/opendevin/runtime/e2b/sandbox.py b/opendevin/runtime/e2b/sandbox.py index 9e1d4506da..3210d232fa 100644 --- a/opendevin/runtime/e2b/sandbox.py +++ b/opendevin/runtime/e2b/sandbox.py @@ -8,9 +8,8 @@ from e2b.sandbox.exception import ( TimeoutException, ) -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema.config import ConfigType from opendevin.runtime.e2b.process import E2BProcess from opendevin.runtime.process import Process from opendevin.runtime.sandbox import Sandbox @@ -28,7 +27,7 @@ class E2BBox(Sandbox): timeout: int = 120, ): self.sandbox = E2BSandbox( - api_key=config.get(ConfigType.E2B_API_KEY), + api_key=config.e2b_api_key, template=template, # It's possible to stream stdout and stderr from sandbox and from each process on_stderr=lambda x: logger.info(f'E2B sandbox stderr: {x}'), diff --git a/opendevin/runtime/utils/singleton.py b/opendevin/runtime/utils/singleton.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/opendevin/server/agent/agent.py b/opendevin/server/agent/agent.py index 28fe935d52..8e89cea611 100644 --- a/opendevin/server/agent/agent.py +++ b/opendevin/server/agent/agent.py @@ -3,7 +3,7 @@ from typing import Optional from opendevin.const.guide_url import TROUBLESHOOTING_URL from opendevin.controller import AgentController from opendevin.controller.agent import Agent -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.logger import opendevin_logger as logger from opendevin.core.schema import ActionType, AgentState, ConfigType from opendevin.events.action import ( @@ -91,19 +91,6 @@ class AgentUnit: action_obj = action_from_dict(action_dict) await self.event_stream.add_event(action_obj, EventSource.USER) - def get_arg_or_default(self, _args: dict, key: ConfigType) -> str: - """Gets an argument from the args dictionary or the default value. - - Args: - _args: The args dictionary. - key: The key to get. - - Returns: - The value of the key or the default value. - """ - - return _args.get(key, config.get(key)) - async def create_controller(self, start_event: dict): """Creates an AgentController instance. @@ -115,12 +102,12 @@ class AgentUnit: for key, value in start_event.get('args', {}).items() if value != '' } # remove empty values, prevent FE from sending empty strings - agent_cls = self.get_arg_or_default(args, ConfigType.AGENT) - model = self.get_arg_or_default(args, ConfigType.LLM_MODEL) - api_key = self.get_arg_or_default(args, ConfigType.LLM_API_KEY) - api_base = config.get(ConfigType.LLM_BASE_URL) - max_iterations = self.get_arg_or_default(args, ConfigType.MAX_ITERATIONS) - max_chars = self.get_arg_or_default(args, ConfigType.MAX_CHARS) + agent_cls = args.get(ConfigType.AGENT, config.agent.name) + model = args.get(ConfigType.LLM_MODEL, config.llm.model) + api_key = args.get(ConfigType.LLM_API_KEY, config.llm.api_key) + api_base = config.llm.base_url + max_iterations = args.get(ConfigType.MAX_ITERATIONS, config.max_iterations) + max_chars = args.get(ConfigType.MAX_CHARS, config.llm.max_chars) logger.info(f'Creating agent {agent_cls} using LLM {model}') llm = LLM(model=model, api_key=api_key, base_url=api_base) diff --git a/opendevin/server/listen.py b/opendevin/server/listen.py index b6cf4dbf4d..1eb1cdde5c 100644 --- a/opendevin/server/listen.py +++ b/opendevin/server/listen.py @@ -12,9 +12,8 @@ from fastapi.staticfiles import StaticFiles import agenthub # noqa F401 (we import this to get the agents registered) from opendevin.controller.agent import Agent -from opendevin.core import config +from opendevin.core.config import config from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema.config import ConfigType from opendevin.llm import bedrock from opendevin.runtime import files from opendevin.server.agent import agent_manager @@ -124,16 +123,14 @@ async def del_messages( @app.get('/api/refresh-files') def refresh_files(): - structure = files.get_folder_structure( - Path(str(config.get(ConfigType.WORKSPACE_BASE))) - ) + structure = files.get_folder_structure(Path(str(config.workspace_base))) return structure.to_dict() @app.get('/api/select-file') def select_file(file: str): try: - workspace_base = config.get(ConfigType.WORKSPACE_BASE) + workspace_base = config.workspace_base file_path = Path(workspace_base, file) # The following will check if the file is within the workspace base and throw an exception if not file_path.resolve().relative_to(Path(workspace_base).resolve()) @@ -152,7 +149,7 @@ def select_file(file: str): @app.post('/api/upload-file') async def upload_file(file: UploadFile): try: - workspace_base = config.get(ConfigType.WORKSPACE_BASE) + workspace_base = config.workspace_base file_path = Path(workspace_base, file.filename) # The following will check if the file is within the workspace base and throw an exception if not file_path.resolve().relative_to(Path(workspace_base).resolve()) @@ -189,6 +186,11 @@ def get_plan( return Response(status_code=status.HTTP_204_NO_CONTENT) +@app.get('/api/defaults') +async def appconfig_defaults(): + return config.defaults_dict + + @app.get('/') async def docs_redirect(): response = RedirectResponse(url='/index.html') diff --git a/tests/integration/mock/PlannerAgent/test_write_simple_script/response_001.log b/tests/integration/mock/PlannerAgent/test_write_simple_script/response_001.log index d81b0d2189..c768a2a63d 100644 --- a/tests/integration/mock/PlannerAgent/test_write_simple_script/response_001.log +++ b/tests/integration/mock/PlannerAgent/test_write_simple_script/response_001.log @@ -1 +1,7 @@ -{"action": "write", "args": {"path": "hello.sh", "content": "#!/bin/bash\n\necho 'hello'"}} +{ + "action": "write", + "args": { + "path": "hello.sh", + "content": "#!/bin/bash\n\necho 'hello'" + } +} diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_002.log b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_002.log index 53921cdc77..4ab9c83550 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_002.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_002.log @@ -67,7 +67,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to create a shell script named 'hello.sh' that prints 'hello' when executed. I will use the 'write' command to create the script with the required content. + I need to create a shell script named 'hello.sh' that prints 'hello' without asking for confirmation. I will use the echo command to print 'hello' to the console. Action: diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_003.log b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_003.log index 4c3b3a1339..094e3f4db4 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_003.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_003.log @@ -67,7 +67,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to create a shell script named 'hello.sh' that prints 'hello' when executed. I will use the 'write' command to create the script with the required content. + I need to create a shell script named 'hello.sh' that prints 'hello' without asking for confirmation. I will use the echo command to print 'hello' to the console. Action: @@ -85,7 +85,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_004.log b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_004.log index 1b29de0906..20add1573e 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_004.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_004.log @@ -67,7 +67,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to create a shell script named 'hello.sh' that prints 'hello' when executed. I will use the 'write' command to create the script with the required content. + I need to create a shell script named 'hello.sh' that prints 'hello' without asking for confirmation. I will use the echo command to print 'hello' to the console. Action: @@ -85,7 +85,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -103,7 +103,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_005.log b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_005.log index 369cdd1a9c..af38a778cb 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_005.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_005.log @@ -67,7 +67,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to create a shell script named 'hello.sh' that prints 'hello' when executed. I will use the 'write' command to create the script with the required content. + I need to create a shell script named 'hello.sh' that prints 'hello' without asking for confirmation. I will use the echo command to print 'hello' to the console. Action: @@ -85,7 +85,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -103,7 +103,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -121,7 +121,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_006.log b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_006.log index 36dc58e7d7..0486a93615 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_006.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_006.log @@ -67,7 +67,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -85,7 +85,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -103,7 +103,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -121,7 +121,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_007.log b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_007.log index df876316f0..826ff471cb 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_007.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_007.log @@ -67,7 +67,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -85,7 +85,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -103,7 +103,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -121,7 +121,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_008.log b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_008.log index eeb4caa103..08ed4d4f92 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_008.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_008.log @@ -67,7 +67,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -85,7 +85,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -103,7 +103,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -121,7 +121,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_009.log b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_009.log index 8b0747ecaa..6e08e0646f 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_009.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_009.log @@ -67,7 +67,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -85,7 +85,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -103,7 +103,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -121,7 +121,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_010.log b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_010.log index 36dc58e7d7..0473367c11 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_010.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/prompt_010.log @@ -67,7 +67,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -85,7 +85,7 @@ args: start: 0 end: -1 thought: Thoughts: - I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -103,7 +103,7 @@ args: start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: @@ -115,19 +115,20 @@ extras: Memory 3: Previous Action: -action: write +action: read args: path: hello.sh start: 0 end: -1 thought: Thoughts: - I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. + I have already written the shell script 'hello.sh' to print 'hello' without asking for confirmation. I need to verify the content of the file to ensure that the script is correctly implemented. Action: Output from Action: -observation: write -content: +observation: read +content: echo 'hello' + extras: path: hello.sh ======= End Actions ======= diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_001.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_001.log index d396d5d16e..33c064e3fc 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_001.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_001.log @@ -1,5 +1,5 @@ Thoughts: -I need to create a shell script named 'hello.sh' that prints 'hello' when executed. I will use the 'write' command to create the script with the required content. +I need to create a shell script named 'hello.sh' that prints 'hello' without asking for confirmation. I will use the echo command to print 'hello' to the console. Action: ``` diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_002.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_002.log index 581088f3b8..c91f1c337c 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_002.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_002.log @@ -1,5 +1,5 @@ Thoughts: -I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. +I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: ``` diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_003.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_003.log index 581088f3b8..c91f1c337c 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_003.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_003.log @@ -1,5 +1,5 @@ Thoughts: -I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. +I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: ``` diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_004.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_004.log index d2bc897518..c91f1c337c 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_004.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_004.log @@ -1,5 +1,5 @@ Thoughts: -I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. +I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: ``` diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_005.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_005.log index d2bc897518..c91f1c337c 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_005.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_005.log @@ -1,5 +1,5 @@ Thoughts: -I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. +I need to add the command to the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: ``` diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_006.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_006.log index 581088f3b8..3abac05a69 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_006.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_006.log @@ -1,5 +1,5 @@ Thoughts: -I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. +I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: ``` diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_007.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_007.log index 581088f3b8..3abac05a69 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_007.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_007.log @@ -1,5 +1,5 @@ Thoughts: -I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. +I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: ``` diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_008.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_008.log index d2bc897518..3abac05a69 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_008.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_008.log @@ -1,5 +1,5 @@ Thoughts: -I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. +I need to modify the 'hello.sh' script to print 'hello' without asking for confirmation. I will use the echo command to achieve this. Action: ``` diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_009.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_009.log index d2bc897518..9b051dea70 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_009.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_009.log @@ -1,7 +1,7 @@ Thoughts: -I have already created the 'hello.sh' file, and now I need to add the code to the file that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. +I have already written the shell script 'hello.sh' to print 'hello' without asking for confirmation. I need to verify the content of the file to ensure that the script is correctly implemented. Action: ``` -edit 0 -1 "echo 'hello'" +read hello.sh ``` diff --git a/tests/integration/mock/SWEAgent/test_write_simple_script/response_010.log b/tests/integration/mock/SWEAgent/test_write_simple_script/response_010.log index 581088f3b8..f5d842205a 100644 --- a/tests/integration/mock/SWEAgent/test_write_simple_script/response_010.log +++ b/tests/integration/mock/SWEAgent/test_write_simple_script/response_010.log @@ -1,7 +1,7 @@ Thoughts: -I need to add the code to the 'hello.sh' script that will print 'hello' when executed. I will use the 'edit' command to add the necessary code to the file. +I have already written the shell script 'hello.sh' to print 'hello' without asking for confirmation. I have verified the content of the file and it contains the correct command 'echo 'hello''. The script should work as expected. Action: ``` -edit 0 -1 "echo 'hello'" +exit ``` diff --git a/tests/test_fileops.py b/tests/test_fileops.py index 0a6c3d3f0c..d14a2d42d7 100644 --- a/tests/test_fileops.py +++ b/tests/test_fileops.py @@ -2,27 +2,44 @@ from pathlib import Path import pytest -from opendevin import config +from opendevin.core.config import config from opendevin.events.action import files -from opendevin.schema import ConfigType SANDBOX_PATH_PREFIX = '/workspace' + def test_resolve_path(): - assert files.resolve_path('test.txt', '/workspace') == Path(config.get(ConfigType.WORKSPACE_BASE)) / 'test.txt' - assert files.resolve_path('subdir/test.txt', '/workspace') == \ - Path(config.get(ConfigType.WORKSPACE_BASE)) / 'subdir' / 'test.txt' - assert files.resolve_path(Path(SANDBOX_PATH_PREFIX) / 'test.txt', '/workspace') == \ - Path(config.get(ConfigType.WORKSPACE_BASE)) / 'test.txt' - assert files.resolve_path(Path(SANDBOX_PATH_PREFIX) / 'subdir' / 'test.txt', - '/workspace') == Path(config.get(ConfigType.WORKSPACE_BASE)) / 'subdir' / 'test.txt' - assert files.resolve_path(Path(SANDBOX_PATH_PREFIX) / 'subdir' / '..' / 'test.txt', - '/workspace') == Path(config.get(ConfigType.WORKSPACE_BASE)) / 'test.txt' + assert ( + files.resolve_path('test.txt', '/workspace') + == Path(config.workspace_base) / 'test.txt' + ) + assert ( + files.resolve_path('subdir/test.txt', '/workspace') + == Path(config.workspace_base) / 'subdir' / 'test.txt' + ) + assert ( + files.resolve_path(Path(SANDBOX_PATH_PREFIX) / 'test.txt', '/workspace') + == Path(config.workspace_base) / 'test.txt' + ) + assert ( + files.resolve_path( + Path(SANDBOX_PATH_PREFIX) / 'subdir' / 'test.txt', '/workspace' + ) + == Path(config.workspace_base) / 'subdir' / 'test.txt' + ) + assert ( + files.resolve_path( + Path(SANDBOX_PATH_PREFIX) / 'subdir' / '..' / 'test.txt', '/workspace' + ) + == Path(config.workspace_base) / 'test.txt' + ) with pytest.raises(PermissionError): files.resolve_path(Path(SANDBOX_PATH_PREFIX) / '..' / 'test.txt', '/workspace') with pytest.raises(PermissionError): files.resolve_path(Path('..') / 'test.txt', '/workspace') with pytest.raises(PermissionError): files.resolve_path(Path('/') / 'test.txt', '/workspace') - assert files.resolve_path('test.txt', '/workspace/test') == \ - Path(config.get(ConfigType.WORKSPACE_BASE)) / 'test' / 'test.txt' + assert ( + files.resolve_path('test.txt', '/workspace/test') + == Path(config.workspace_base) / 'test' / 'test.txt' + ) diff --git a/tests/unit/test_action_github.py b/tests/unit/test_action_github.py index 1f115a99de..c34f84c09a 100644 --- a/tests/unit/test_action_github.py +++ b/tests/unit/test_action_github.py @@ -4,8 +4,7 @@ import pytest from agenthub.dummy_agent.agent import DummyAgent from opendevin.controller.agent_controller import AgentController -from opendevin.core import config -from opendevin.core.schema.config import ConfigType +from opendevin.core.config import config from opendevin.events.action.github import GitHubPushAction, GitHubSendPRAction from opendevin.events.observation.commands import CmdOutputObservation from opendevin.events.observation.error import ErrorObservation @@ -16,7 +15,7 @@ from opendevin.llm.llm import LLM @pytest.fixture def agent_controller(): # Setup the environment variable - config.config[ConfigType.SANDBOX_TYPE] = 'local' + config.sandbox_type = 'local' llm = LLM() agent = DummyAgent(llm=llm) event_stream = EventStream() @@ -25,7 +24,7 @@ def agent_controller(): @pytest.mark.asyncio -@patch.dict(config.config, {'GITHUB_TOKEN': 'fake_token'}, clear=True) +@patch.object(config, 'github_token', 'fake_token') @patch('random.choices') @patch('opendevin.controller.action_manager.ActionManager.run_command') async def test_run_push_successful( @@ -74,11 +73,11 @@ async def test_run_push_error_missing_token( # Verify the result is an error due to missing token assert isinstance(result, ErrorObservation) - assert result.message == 'GITHUB_TOKEN is not set' + assert result.message == 'github_token is not set' @pytest.mark.asyncio -@patch.dict(config.config, {'GITHUB_TOKEN': 'fake_token'}, clear=True) +@patch.object(config, 'github_token', 'fake_token') @patch('requests.post') async def test_run_pull_request_created_successfully(mock_post, agent_controller): # Set up the mock for the requests.post call to simulate a successful pull request creation @@ -106,7 +105,7 @@ async def test_run_pull_request_created_successfully(mock_post, agent_controller @pytest.mark.asyncio @patch('requests.post') -@patch.dict(config.config, {'GITHUB_TOKEN': 'fake_token'}, clear=True) +@patch.object(config, 'github_token', 'fake_token') async def test_run_pull_request_creation_failed(mock_post, agent_controller): # Set up the mock for the requests.post call to simulate a failed pull request creation mock_response = MagicMock() @@ -149,4 +148,4 @@ async def test_run_error_missing_token(agent_controller): # Verify the result is an error due to missing token assert isinstance(result, ErrorObservation) - assert 'GITHUB_TOKEN is not set' in result.message + assert 'github_token is not set' in result.message diff --git a/tests/unit/test_arg_parser.py b/tests/unit/test_arg_parser.py index 1b332c8669..281e555453 100644 --- a/tests/unit/test_arg_parser.py +++ b/tests/unit/test_arg_parser.py @@ -10,7 +10,8 @@ def test_help_message(capsys): captured = capsys.readouterr() expected_help_message = """ usage: pytest [-h] [-d DIRECTORY] [-t TASK] [-f FILE] [-c AGENT_CLS] -[-m MODEL_NAME] [-i MAX_ITERATIONS] [-n MAX_CHARS] + [-m MODEL_NAME] [-i MAX_ITERATIONS] [-n MAX_CHARS] + [-l LLM_CONFIG] Run an agent with a specific task @@ -20,7 +21,7 @@ options: The working directory for the agent -t TASK, --task TASK The task for the agent to perform -f FILE, --file FILE Path to a file containing the task. Overrides -t if - both are provided. + both are provided. -c AGENT_CLS, --agent-cls AGENT_CLS The agent class to use -m MODEL_NAME, --model-name MODEL_NAME @@ -30,7 +31,11 @@ options: -n MAX_CHARS, --max-chars MAX_CHARS The maximum number of characters to send to and receive from LLM per task + -l LLM_CONFIG, --llm-config LLM_CONFIG + The group of llm settings, e.g. a [llama3] section in + the toml file. Overrides model if both are provided. """ + actual_lines = captured.out.strip().split('\n') expected_lines = expected_help_message.strip().split('\n') diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000000..84c8b2af57 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,173 @@ +import os + +import pytest + +from opendevin.core.config import ( + AgentConfig, + AppConfig, + LLMConfig, + finalize_config, + load_from_env, + load_from_toml, +) + + +@pytest.fixture +def setup_env(): + # Create old-style and new-style TOML files + with open('old_style_config.toml', 'w') as f: + f.write('[default]\nLLM_MODEL="GPT-4"\n') + + with open('new_style_config.toml', 'w') as f: + f.write('[app]\nLLM_MODEL="GPT-3"\n') + + yield + + # Cleanup TOML files after the test + os.remove('old_style_config.toml') + os.remove('new_style_config.toml') + + +def test_compat_env_to_config(monkeypatch, setup_env): + # Use `monkeypatch` to set environment variables for this specific test + monkeypatch.setenv('WORKSPACE_BASE', '/repos/opendevin/workspace') + monkeypatch.setenv('LLM_API_KEY', 'sk-proj-rgMV0...') + monkeypatch.setenv('LLM_MODEL', 'gpt-3.5-turbo') + monkeypatch.setenv('AGENT_MEMORY_MAX_THREADS', '4') + monkeypatch.setenv('AGENT_MEMORY_ENABLED', 'True') + monkeypatch.setenv('AGENT', 'CodeActAgent') + + config = AppConfig() + load_from_env(config, os.environ) + + assert config.workspace_base == '/repos/opendevin/workspace' + assert isinstance(config.llm, LLMConfig) + assert config.llm.api_key == 'sk-proj-rgMV0...' + assert config.llm.model == 'gpt-3.5-turbo' + assert isinstance(config.agent, AgentConfig) + assert isinstance(config.agent.memory_max_threads, int) + assert config.agent.memory_max_threads == 4 + + +@pytest.fixture +def temp_toml_file(tmp_path): + # Fixture to create a temporary directory and TOML file for testing + tmp_toml_file = os.path.join(tmp_path, 'config.toml') + yield tmp_toml_file + + +@pytest.fixture +def default_config(monkeypatch): + # Fixture to provide a default AppConfig instance + AppConfig.reset() + yield AppConfig() + + +def test_load_from_old_style_env(monkeypatch, default_config): + # Test loading configuration from old-style environment variables using monkeypatch + monkeypatch.setenv('LLM_API_KEY', 'test-api-key') + monkeypatch.setenv('AGENT_MEMORY_ENABLED', 'True') + monkeypatch.setenv('AGENT_NAME', 'PlannerAgent') + monkeypatch.setenv('WORKSPACE_BASE', '/opt/files/workspace') + + load_from_env(default_config, os.environ) + + assert default_config.llm.api_key == 'test-api-key' + assert default_config.agent.memory_enabled is True + assert default_config.agent.name == 'PlannerAgent' + assert default_config.workspace_base == '/opt/files/workspace' + + +def test_load_from_new_style_toml(default_config, temp_toml_file): + # Test loading configuration from a new-style TOML file + with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: + toml_file.write(""" +[llm] +model = "test-model" +api_key = "toml-api-key" + +[agent] +name = "TestAgent" +memory_enabled = true + +[core] +workspace_base = "/opt/files2/workspace" +""") + + load_from_toml(default_config, temp_toml_file) + + assert default_config.llm.model == 'test-model' + assert default_config.llm.api_key == 'toml-api-key' + assert default_config.agent.name == 'TestAgent' + assert default_config.agent.memory_enabled is True + assert default_config.workspace_base == '/opt/files2/workspace' + + +def test_env_overrides_toml(monkeypatch, default_config, temp_toml_file): + # Test that environment variables override TOML values using monkeypatch + with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: + toml_file.write(""" +[llm] +model = "test-model" +api_key = "toml-api-key" + +[core] +workspace_base = "/opt/files3/workspace" +sandbox_type = "local" +disable_color = true +""") + + monkeypatch.setenv('LLM_API_KEY', 'env-api-key') + monkeypatch.setenv('WORKSPACE_BASE', '/opt/files4/workspace') + monkeypatch.setenv('SANDBOX_TYPE', 'ssh') + + load_from_toml(default_config, temp_toml_file) + load_from_env(default_config, os.environ) + + assert os.environ.get('LLM_MODEL') is None + assert default_config.llm.model == 'test-model' + assert default_config.llm.api_key == 'env-api-key' + assert default_config.workspace_base == '/opt/files4/workspace' + assert default_config.sandbox_type == 'ssh' + assert default_config.disable_color is True + + +def test_defaults_dict_after_updates(default_config): + # Test that `defaults_dict` retains initial values after updates. + initial_defaults = default_config.defaults_dict + updated_config = AppConfig() + updated_config.llm.api_key = 'updated-api-key' + updated_config.agent.name = 'MonologueAgent' + + defaults_after_updates = updated_config.defaults_dict + assert defaults_after_updates['llm']['api_key']['default'] is None + assert defaults_after_updates['agent']['name']['default'] == 'CodeActAgent' + assert defaults_after_updates == initial_defaults + + AppConfig.reset() + + +def test_invalid_toml_format(monkeypatch, temp_toml_file, default_config): + # Invalid TOML format doesn't break the configuration + monkeypatch.setenv('LLM_MODEL', 'gpt-5-turbo-1106') + monkeypatch.delenv('LLM_API_KEY', raising=False) + with open(temp_toml_file, 'w', encoding='utf-8') as toml_file: + toml_file.write('INVALID TOML CONTENT') + + load_from_toml(default_config) + load_from_env(default_config, os.environ) + assert default_config.llm.model == 'gpt-5-turbo-1106' + assert default_config.llm.custom_llm_provider is None + assert default_config.github_token is None + assert default_config.llm.api_key is None + + +def test_finalize_config(default_config): + # Test finalize config + default_config.sandbox_type = 'local' + finalize_config(default_config) + + assert ( + default_config.workspace_mount_path_in_sandbox + == default_config.workspace_mount_path + ) diff --git a/tests/unit/test_sandbox.py b/tests/unit/test_sandbox.py index 57fef0da2b..7a9826931e 100644 --- a/tests/unit/test_sandbox.py +++ b/tests/unit/test_sandbox.py @@ -5,19 +5,27 @@ from unittest.mock import patch import pytest -from opendevin.core import config +from opendevin.core.config import AppConfig, config from opendevin.runtime.docker.exec_box import DockerExecBox from opendevin.runtime.docker.local_box import LocalBox from opendevin.runtime.docker.ssh_box import DockerSSHBox @pytest.fixture -def temp_dir(): +def temp_dir(monkeypatch): # get a temporary directory with tempfile.TemporaryDirectory() as temp_dir: pathlib.Path().mkdir(parents=True, exist_ok=True) yield temp_dir + # make sure os.environ is clean + monkeypatch.delenv('RUN_AS_DEVIN', raising=False) + monkeypatch.delenv('SANDBOX_TYPE', raising=False) + monkeypatch.delenv('WORKSPACE_BASE', raising=False) + + # make sure config is clean + AppConfig.reset() + def test_env_vars(temp_dir): os.environ['SANDBOX_ENV_FOOBAR'] = 'BAZ' @@ -33,18 +41,15 @@ def test_env_vars(temp_dir): def test_ssh_box_run_as_devin(temp_dir): # get a temporary directory - with patch.dict( - config.config, - { - config.ConfigType.WORKSPACE_BASE: temp_dir, - config.ConfigType.RUN_AS_DEVIN: 'true', - config.ConfigType.SANDBOX_TYPE: 'ssh', - }, - clear=True, + with patch.object(config, 'workspace_base', new=temp_dir), patch.object( + config, 'workspace_mount_path', new=temp_dir + ), patch.object(config, 'run_as_devin', new='true'), patch.object( + config, 'sandbox_type', new='ssh' ): ssh_box = DockerSSHBox() # test the ssh box + assert config.workspace_base == temp_dir exit_code, output = ssh_box.execute('ls -l') assert exit_code == 0, 'The exit code should be 0.' assert output.strip() == 'total 0' @@ -69,14 +74,10 @@ def test_ssh_box_run_as_devin(temp_dir): def test_ssh_box_multi_line_cmd_run_as_devin(temp_dir): # get a temporary directory - with patch.dict( - config.config, - { - config.ConfigType.WORKSPACE_BASE: temp_dir, - config.ConfigType.RUN_AS_DEVIN: 'true', - config.ConfigType.SANDBOX_TYPE: 'ssh', - }, - clear=True, + with patch.object(config, 'workspace_base', new=temp_dir), patch.object( + config, 'workspace_mount_path', new=temp_dir + ), patch.object(config, 'run_as_devin', new='true'), patch.object( + config, 'sandbox_type', new='ssh' ): ssh_box = DockerSSHBox() @@ -89,14 +90,10 @@ def test_ssh_box_multi_line_cmd_run_as_devin(temp_dir): def test_ssh_box_stateful_cmd_run_as_devin(temp_dir): # get a temporary directory - with patch.dict( - config.config, - { - config.ConfigType.WORKSPACE_BASE: temp_dir, - config.ConfigType.RUN_AS_DEVIN: 'true', - config.ConfigType.SANDBOX_TYPE: 'ssh', - }, - clear=True, + with patch.object(config, 'workspace_base', new=temp_dir), patch.object( + config, 'workspace_mount_path', new=temp_dir + ), patch.object(config, 'run_as_devin', new='true'), patch.object( + config, 'sandbox_type', new='ssh' ): ssh_box = DockerSSHBox() @@ -116,14 +113,10 @@ def test_ssh_box_stateful_cmd_run_as_devin(temp_dir): def test_ssh_box_failed_cmd_run_as_devin(temp_dir): # get a temporary directory - with patch.dict( - config.config, - { - config.ConfigType.WORKSPACE_BASE: temp_dir, - config.ConfigType.RUN_AS_DEVIN: 'true', - config.ConfigType.SANDBOX_TYPE: 'ssh', - }, - clear=True, + with patch.object(config, 'workspace_base', new=temp_dir), patch.object( + config, 'workspace_mount_path', new=temp_dir + ), patch.object(config, 'run_as_devin', new='true'), patch.object( + config, 'sandbox_type', new='ssh' ): ssh_box = DockerSSHBox()