Przeglądaj źródła

Refactor config to dataclasses (#1552)

* mypy is invaluable

* fix config, add test

* Add new-style toml support

* add singleton, small doc fixes

* fix some cases of loading toml, clean up, try to make it clearer

* Add defaults_dict for UI

* allow config to be mutable
error handling
fix toml parsing

* remove debug stuff

* Adapt Makefile

* Add defaults for temperature and top_p

* update to CodeActAgent

* comments

* fix unit tests

* implement groups of llm settings (CLI)

* fix merge issue

* small fix sandboxes, small refactoring

* adapt LLM init to accept overrides at runtime

* reading config is enough

* Encapsulate minimally embeddings initialization

* agent bug fix; fix tests

* fix sandboxes tests

* refactor globals in sandboxes to properties
Engel Nyst 1 rok temu
rodzic
commit
446eaec1e6
49 zmienionych plików z 979 dodań i 548 usunięć
  1. 22 16
      Makefile
  2. 3 4
      agenthub/monologue_agent/agent.py
  3. 58 61
      agenthub/monologue_agent/utils/memory.py
  4. 4 7
      agenthub/monologue_agent/utils/prompts.py
  5. 1 1
      evaluation/regression/run_tests.py
  6. 6 7
      opendevin/controller/action_manager.py
  7. 3 4
      opendevin/controller/agent_controller.py
  8. 318 159
      opendevin/core/config.py
  9. 5 6
      opendevin/core/logger.py
  10. 24 5
      opendevin/core/main.py
  11. 3 0
      opendevin/core/utils/__init__.py
  12. 28 0
      opendevin/core/utils/singleton.py
  13. 4 4
      opendevin/events/action/commands.py
  14. 4 9
      opendevin/events/action/files.py
  15. 5 6
      opendevin/events/action/github.py
  16. 7 8
      opendevin/llm/bedrock.py
  17. 63 34
      opendevin/llm/llm.py
  18. 38 28
      opendevin/runtime/docker/exec_box.py
  19. 8 9
      opendevin/runtime/docker/local_box.py
  20. 60 47
      opendevin/runtime/docker/ssh_box.py
  21. 2 3
      opendevin/runtime/e2b/sandbox.py
  22. 0 0
      opendevin/runtime/utils/singleton.py
  23. 7 20
      opendevin/server/agent/agent.py
  24. 9 7
      opendevin/server/listen.py
  25. 7 1
      tests/integration/mock/PlannerAgent/test_write_simple_script/response_001.log
  26. 1 1
      tests/integration/mock/SWEAgent/test_write_simple_script/prompt_002.log
  27. 2 2
      tests/integration/mock/SWEAgent/test_write_simple_script/prompt_003.log
  28. 3 3
      tests/integration/mock/SWEAgent/test_write_simple_script/prompt_004.log
  29. 4 4
      tests/integration/mock/SWEAgent/test_write_simple_script/prompt_005.log
  30. 4 4
      tests/integration/mock/SWEAgent/test_write_simple_script/prompt_006.log
  31. 4 4
      tests/integration/mock/SWEAgent/test_write_simple_script/prompt_007.log
  32. 4 4
      tests/integration/mock/SWEAgent/test_write_simple_script/prompt_008.log
  33. 4 4
      tests/integration/mock/SWEAgent/test_write_simple_script/prompt_009.log
  34. 8 7
      tests/integration/mock/SWEAgent/test_write_simple_script/prompt_010.log
  35. 1 1
      tests/integration/mock/SWEAgent/test_write_simple_script/response_001.log
  36. 1 1
      tests/integration/mock/SWEAgent/test_write_simple_script/response_002.log
  37. 1 1
      tests/integration/mock/SWEAgent/test_write_simple_script/response_003.log
  38. 1 1
      tests/integration/mock/SWEAgent/test_write_simple_script/response_004.log
  39. 1 1
      tests/integration/mock/SWEAgent/test_write_simple_script/response_005.log
  40. 1 1
      tests/integration/mock/SWEAgent/test_write_simple_script/response_006.log
  41. 1 1
      tests/integration/mock/SWEAgent/test_write_simple_script/response_007.log
  42. 1 1
      tests/integration/mock/SWEAgent/test_write_simple_script/response_008.log
  43. 2 2
      tests/integration/mock/SWEAgent/test_write_simple_script/response_009.log
  44. 2 2
      tests/integration/mock/SWEAgent/test_write_simple_script/response_010.log
  45. 30 13
      tests/test_fileops.py
  46. 7 8
      tests/unit/test_action_github.py
  47. 7 2
      tests/unit/test_arg_parser.py
  48. 173 0
      tests/unit/test_config.py
  49. 27 34
      tests/unit/test_sandbox.py

+ 22 - 16
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:

+ 3 - 4
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

+ 58 - 61
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
-
-    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
-
-    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
-
-    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
-
-    embed_model = HuggingFaceEmbedding(model_name='BAAI/bge-small-en-v1.5')
 
-
-sema = threading.Semaphore(value=config.get(ConfigType.AGENT_MEMORY_MAX_THREADS))
+class EmbeddingsLoader:
+    """Loader for embedding model initialization."""
+
+    @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
+
+            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
+
+            return OpenAIEmbedding(
+                model='text-embedding-ada-002',
+                api_key=config.llm.api_key,
+            )
+        elif strategy == 'azureopenai':
+            from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding
+
+            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.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 = []
 

+ 4 - 7
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,
     }
 
 

+ 1 - 1
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.

+ 6 - 7
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}')
 

+ 3 - 4
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:

+ 318 - 159
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')
-
-
-def str_to_bool(value):
-    if isinstance(value, bool):
-        return value
-    return value.lower() in [
-        'true',
-        '1',
-    ]
-
-
-def int_value(value, default, config_key):
-    # FIXME use a library
+
+@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
+
+
+@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
+
+
+@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,
+        )
+
+
+def finalize_config(config: AppConfig):
+    """
+    More tweaks to the config after it's been loaded.
+    """
+
+    # 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 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.'
         )
-        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]
-
-    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)
-
-    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
-    ]
 
+    # 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)

+ 5 - 6
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())

+ 24 - 5
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,

+ 3 - 0
opendevin/core/utils/__init__.py

@@ -0,0 +1,3 @@
+from .singleton import Singleton
+
+__all__ = ['Singleton']

+ 28 - 0
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)

+ 4 - 4
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',
         )

+ 4 - 9
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
 

+ 5 - 6
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'

+ 7 - 8
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():

+ 63 - 34
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

+ 38 - 28
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__':

+ 8 - 9
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__':

+ 60 - 47
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',
                     },

+ 2 - 3
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}'),

+ 0 - 0
opendevin/runtime/utils/singleton.py


+ 7 - 20
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)

+ 9 - 7
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')

+ 7 - 1
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'"
+  }
+}

+ 1 - 1
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:
 

+ 2 - 2
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:
 

+ 3 - 3
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:
 

+ 4 - 4
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:
 

+ 4 - 4
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:
 

+ 4 - 4
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:
 

+ 4 - 4
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:
 

+ 4 - 4
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:
 

+ 8 - 7
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 =======

+ 1 - 1
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:
 ```

+ 1 - 1
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:
 ```

+ 1 - 1
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:
 ```

+ 1 - 1
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:
 ```

+ 1 - 1
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:
 ```

+ 1 - 1
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:
 ```

+ 1 - 1
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:
 ```

+ 1 - 1
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:
 ```

+ 2 - 2
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
 ```

+ 2 - 2
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
 ```

+ 30 - 13
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'
+    )

+ 7 - 8
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

+ 7 - 2
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')
 

+ 173 - 0
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
+    )

+ 27 - 34
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()