Explorar el Código

feat: specialize CodeAct into micro agents by providing markdown files (#3511)

* update microagent name and update template.toml

* substitute actual micro_agent_name for prompt manager

* add python-frontmatter

* support micro agent in codeact

* add test cases

* add instruction from require env var

* add draft gh micro agent

* update poetry lock

* update poetry lock
Xingyao Wang hace 1 año
padre
commit
d9a8b53bc2

+ 13 - 1
agenthub/codeact_agent/codeact_agent.py

@@ -27,6 +27,7 @@ from openhands.runtime.plugins import (
     JupyterRequirement,
     PluginRequirement,
 )
+from openhands.utils.microagent import MicroAgent
 from openhands.utils.prompt import PromptManager
 
 
@@ -73,10 +74,21 @@ class CodeActAgent(Agent):
         """
         super().__init__(llm, config)
         self.reset()
+
+        self.micro_agent = (
+            MicroAgent(
+                os.path.join(
+                    os.path.dirname(__file__), 'micro', f'{config.micro_agent_name}.md'
+                )
+            )
+            if config.micro_agent_name
+            else None
+        )
+
         self.prompt_manager = PromptManager(
             prompt_dir=os.path.join(os.path.dirname(__file__)),
             agent_skills_docs=AgentSkillsRequirement.documentation,
-            micro_agent_name=None,  # TODO: implement micro-agent
+            micro_agent=self.micro_agent,
         )
 
     def action_to_str(self, action: Action) -> str:

+ 59 - 0
agenthub/codeact_agent/micro/github.md

@@ -0,0 +1,59 @@
+---
+name: github
+agent: CodeActAgent
+require_env_var:
+    SANDBOX_ENV_GITHUB_TOKEN: "Create a GitHub Personal Access Token (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and set it as SANDBOX_GITHUB_TOKEN in your environment variables."
+---
+
+# How to Interact with Github
+
+## Environment Variable Available
+
+1. `GITHUB_TOKEN`: A read-only token for Github.
+
+## Using GitHub's RESTful API
+
+Use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API. Here are some common operations:
+
+1. View an issue:
+   ```
+   curl -H "Authorization: token $GITHUB_TOKEN" \
+        https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}
+   ```
+
+2. List repository issues:
+   ```
+   curl -H "Authorization: token $GITHUB_TOKEN" \
+        https://api.github.com/repos/{owner}/{repo}/issues
+   ```
+
+3. Get repository details:
+   ```
+   curl -H "Authorization: token $GITHUB_TOKEN" \
+        https://api.github.com/repos/{owner}/{repo}
+   ```
+
+4. List pull requests:
+   ```
+   curl -H "Authorization: token $GITHUB_TOKEN" \
+        https://api.github.com/repos/{owner}/{repo}/pulls
+   ```
+
+5. Get user information:
+   ```
+   curl -H "Authorization: token $GITHUB_TOKEN" \
+        https://api.github.com/user
+   ```
+
+Replace `{owner}`, `{repo}`, and `{issue_number}` with appropriate values.
+
+## Important Notes
+
+1. Always use the GitHub API for operations instead of a web browser.
+2. The `GITHUB_TOKEN` is read-only. Avoid operations that require write access.
+3. Git config (username and email) is pre-set. Do not modify.
+4. Edit and test code locally. Never push directly to remote.
+5. Verify correct branch before committing.
+6. Commit changes frequently.
+7. If the issue or task is ambiguous or lacks sufficient detail, always request clarification from the user before proceeding.
+8. You should avoid using command line tools like `sed` for file editing.

+ 33 - 0
config.template.toml

@@ -64,6 +64,15 @@ workspace_base = "./workspace"
 # Name of the default agent
 #default_agent = "CodeActAgent"
 
+# JWT secret for authentication
+#jwt_secret = ""
+
+# Restrict file types for file uploads
+#file_uploads_restrict_file_types = false
+
+# List of allowed file extensions for uploads
+#file_uploads_allowed_extensions = [".*"]
+
 #################################### LLM #####################################
 # Configuration for LLM models (group name starts with 'llm')
 # use 'llm' for the default LLM config
@@ -126,6 +135,15 @@ model = "gpt-4o"
 # Retry minimum wait time
 #retry_min_wait = 3
 
+# Retry multiplier for exponential backoff
+#retry_multiplier = 2.0
+
+# Drop any unmapped (unsupported) params without causing an exception
+#drop_params = false
+
+# Base URL for the OLLAMA API
+#ollama_base_url = ""
+
 # Temperature for the API
 #temperature = 0.0
 
@@ -149,6 +167,9 @@ model = "gpt-3.5"
 # agent.CodeActAgent
 ##############################################################################
 [agent]
+# Name of the micro agent to use for this agent
+#micro_agent_name = ""
+
 # Memory enabled
 #memory_enabled = false
 
@@ -182,6 +203,18 @@ llm_config = 'gpt3'
 # Enable auto linting after editing
 #enable_auto_lint = false
 
+# Whether to initialize plugins
+#initialize_plugins = true
+
+# Extra dependencies to install in the runtime image
+#runtime_extra_deps = ""
+
+# Environment variables to set at the launch of the runtime
+#runtime_startup_env_vars = {}
+
+# BrowserGym environment to use for evaluation
+#browsergym_eval_env = ""
+
 #################################### Security ###################################
 # Configuration for security features
 ##############################################################################

+ 2 - 0
openhands/core/config.py

@@ -123,11 +123,13 @@ class AgentConfig:
     """Configuration for the agent.
 
     Attributes:
+        micro_agent_name: The name of the micro agent to use for this agent.
         memory_enabled: Whether long-term memory (embeddings) is enabled.
         memory_max_threads: The maximum number of threads indexing at the same time for embeddings.
         llm_config: The name of the llm config to use. If specified, this will override global llm config.
     """
 
+    micro_agent_name: str | None = None
     memory_enabled: bool = False
     memory_max_threads: int = 2
     llm_config: str | None = None

+ 5 - 0
openhands/core/exceptions.py

@@ -72,3 +72,8 @@ class LLMResponseError(Exception):
 class UserCancelledError(Exception):
     def __init__(self, message='User cancelled the request'):
         super().__init__(message)
+
+
+class MicroAgentValidationError(Exception):
+    def __init__(self, message='Micro agent validation failed'):
+        super().__init__(message)

+ 44 - 0
openhands/utils/microagent.py

@@ -0,0 +1,44 @@
+import os
+
+import frontmatter
+import pydantic
+
+from openhands.controller.agent import Agent
+from openhands.core.exceptions import MicroAgentValidationError
+from openhands.core.logger import openhands_logger as logger
+
+
+class MicroAgentMetadata(pydantic.BaseModel):
+    name: str
+    agent: str
+    require_env_var: dict[str, str]
+
+
+class MicroAgent:
+    def __init__(self, path: str):
+        self.path = path
+        if not os.path.exists(path):
+            raise FileNotFoundError(f'Micro agent file {path} is not found')
+        with open(path, 'r') as file:
+            self._loaded = frontmatter.load(file)
+            self._content = self._loaded.content
+            self._metadata = MicroAgentMetadata(**self._loaded.metadata)
+        self._validate_micro_agent()
+
+    @property
+    def content(self) -> str:
+        return self._content
+
+    def _validate_micro_agent(self):
+        logger.info(
+            f'Loading and validating micro agent [{self._metadata.name}] based on [{self._metadata.agent}]'
+        )
+        # Make sure the agent is registered
+        agent_cls = Agent.get_cls(self._metadata.agent)
+        assert agent_cls is not None
+        # Make sure the environment variables are set
+        for env_var, instruction in self._metadata.require_env_var.items():
+            if env_var not in os.environ:
+                raise MicroAgentValidationError(
+                    f'Environment variable [{env_var}] is required by micro agent [{self._metadata.name}] but not set. {instruction}'
+                )

+ 8 - 15
openhands/utils/prompt.py

@@ -2,6 +2,8 @@ import os
 
 from jinja2 import Template
 
+from openhands.utils.microagent import MicroAgent
+
 
 class PromptManager:
     """
@@ -14,23 +16,21 @@ class PromptManager:
     Attributes:
         prompt_dir (str): Directory containing prompt templates.
         agent_skills_docs (str): Documentation of agent skills.
-        micro_agent (str | None): Content of the micro-agent definition file, if specified.
+        micro_agent (MicroAgent | None): Micro-agent, if specified.
     """
 
     def __init__(
         self,
         prompt_dir: str,
         agent_skills_docs: str,
-        micro_agent_name: str | None = None,
+        micro_agent: MicroAgent | None = None,
     ):
         self.prompt_dir: str = prompt_dir
         self.agent_skills_docs: str = agent_skills_docs
 
         self.system_template: Template = self._load_template('system_prompt')
         self.user_template: Template = self._load_template('user_prompt')
-        self.micro_agent: str | None = (
-            self._load_micro_agent(micro_agent_name) if micro_agent_name else None
-        )
+        self.micro_agent: MicroAgent | None = micro_agent
 
     def _load_template(self, template_name: str) -> Template:
         template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
@@ -39,15 +39,6 @@ class PromptManager:
         with open(template_path, 'r') as file:
             return Template(file.read())
 
-    def _load_micro_agent(self, micro_agent_name: str) -> str:
-        micro_agent_path = os.path.join(self.prompt_dir, f'micro/{micro_agent_name}.md')
-        if not os.path.exists(micro_agent_path):
-            raise FileNotFoundError(
-                f'Micro agent file {micro_agent_path} for {micro_agent_name} is not found'
-            )
-        with open(micro_agent_path, 'r') as file:
-            return file.read()
-
     @property
     def system_message(self) -> str:
         rendered = self.system_template.render(
@@ -66,5 +57,7 @@ class PromptManager:
         These additional context will convert the current generic agent
         into a more specialized agent that is tailored to the user's task.
         """
-        rendered = self.user_template.render(micro_agent=self.micro_agent)
+        rendered = self.user_template.render(
+            micro_agent=self.micro_agent.content if self.micro_agent else None
+        )
         return rendered.strip()

+ 20 - 2
poetry.lock

@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
 
 [[package]]
 name = "aenum"
@@ -6585,6 +6585,24 @@ files = [
 [package.extras]
 cli = ["click (>=5.0)"]
 
+[[package]]
+name = "python-frontmatter"
+version = "1.1.0"
+description = "Parse and manage posts with YAML (or other) frontmatter"
+optional = false
+python-versions = "*"
+files = [
+    {file = "python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d"},
+    {file = "python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1"},
+]
+
+[package.dependencies]
+PyYAML = "*"
+
+[package.extras]
+docs = ["sphinx"]
+test = ["mypy", "pyaml", "pytest", "toml", "types-PyYAML", "types-toml"]
+
 [[package]]
 name = "python-json-logger"
 version = "2.0.7"
@@ -9459,4 +9477,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "0e95b8afa4826171ad0b57a46690c8dc4317e1d5a642388e9be9352eac7b3cdc"
+content-hash = "ca8ef3dbc1eed207bca42c98c3cbf1fc085548977994ebc28283bc5ddbfa0101"

+ 3 - 0
pyproject.toml

@@ -47,6 +47,7 @@ tree-sitter = "0.21.3"
 bashlex = "^0.18"
 pyjwt = "^2.9.0"
 dirhash = "*"
+python-frontmatter = "^1.1.0"
 python-docx = "*"
 PyPDF2 = "*"
 python-pptx = "*"
@@ -83,6 +84,7 @@ reportlab = "*"
 [tool.coverage.run]
 concurrency = ["gevent"]
 
+
 [tool.poetry.group.runtime.dependencies]
 jupyterlab = "*"
 notebook = "*"
@@ -113,6 +115,7 @@ ignore = ["D1"]
 [tool.ruff.lint.pydocstyle]
 convention = "google"
 
+
 [tool.poetry.group.evaluation.dependencies]
 streamlit = "*"
 whatthepatch = "*"

+ 73 - 0
tests/unit/test_microagent_utils.py

@@ -0,0 +1,73 @@
+import os
+
+import pytest
+from pytest import MonkeyPatch
+
+import agenthub  # noqa: F401
+from openhands.core.exceptions import (
+    AgentNotRegisteredError,
+    MicroAgentValidationError,
+)
+from openhands.utils.microagent import MicroAgent
+
+CONTENT = (
+    '# dummy header\n' 'dummy content\n' '## dummy subheader\n' 'dummy subcontent\n'
+)
+
+
+def test_micro_agent_load(tmp_path, monkeypatch: MonkeyPatch):
+    with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
+        f.write(
+            (
+                '---\n'
+                'name: dummy\n'
+                'agent: CodeActAgent\n'
+                'require_env_var:\n'
+                '  SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
+                '---\n' + CONTENT
+            )
+        )
+
+    # Patch the required environment variable
+    monkeypatch.setenv('SANDBOX_OPENHANDS_TEST_ENV_VAR', 'dummy_value')
+
+    micro_agent = MicroAgent(os.path.join(tmp_path, 'dummy.md'))
+    assert micro_agent is not None
+    assert micro_agent.content == CONTENT.strip()
+
+
+def test_not_existing_agent(tmp_path, monkeypatch: MonkeyPatch):
+    with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
+        f.write(
+            (
+                '---\n'
+                'name: dummy\n'
+                'agent: NotExistingAgent\n'
+                'require_env_var:\n'
+                '  SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
+                '---\n' + CONTENT
+            )
+        )
+    monkeypatch.setenv('SANDBOX_OPENHANDS_TEST_ENV_VAR', 'dummy_value')
+
+    with pytest.raises(AgentNotRegisteredError):
+        MicroAgent(os.path.join(tmp_path, 'dummy.md'))
+
+
+def test_not_existing_env_var(tmp_path):
+    with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
+        f.write(
+            (
+                '---\n'
+                'name: dummy\n'
+                'agent: CodeActAgent\n'
+                'require_env_var:\n'
+                '  SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
+                '---\n' + CONTENT
+            )
+        )
+
+    with pytest.raises(MicroAgentValidationError) as excinfo:
+        MicroAgent(os.path.join(tmp_path, 'dummy.md'))
+
+    assert 'Set this environment variable for testing purposes' in str(excinfo.value)

+ 13 - 3
tests/unit/test_prompt_manager.py

@@ -1,8 +1,10 @@
 import os
 import shutil
+from unittest.mock import Mock
 
 import pytest
 
+from openhands.utils.microagent import MicroAgent
 from openhands.utils.prompt import PromptManager
 
 
@@ -56,11 +58,19 @@ def test_prompt_manager_with_micro_agent(prompt_dir, agent_skills_docs):
     with open(os.path.join(prompt_dir, 'micro', f'{micro_agent_name}.md'), 'w') as f:
         f.write(micro_agent_content)
 
-    manager = PromptManager(prompt_dir, agent_skills_docs, micro_agent_name)
+    # Mock MicroAgent
+    mock_micro_agent = Mock(spec=MicroAgent)
+    mock_micro_agent.content = micro_agent_content
+
+    manager = PromptManager(
+        prompt_dir=prompt_dir,
+        agent_skills_docs=agent_skills_docs,
+        micro_agent=mock_micro_agent,
+    )
 
     assert manager.prompt_dir == prompt_dir
     assert manager.agent_skills_docs == agent_skills_docs
-    assert manager.micro_agent == micro_agent_content
+    assert manager.micro_agent == mock_micro_agent
 
     assert isinstance(manager.system_message, str)
     assert (
@@ -86,7 +96,7 @@ def test_prompt_manager_with_micro_agent(prompt_dir, agent_skills_docs):
 
 def test_prompt_manager_file_not_found(prompt_dir, agent_skills_docs):
     with pytest.raises(FileNotFoundError):
-        PromptManager(prompt_dir, agent_skills_docs, 'non_existent_micro_agent')
+        MicroAgent(os.path.join(prompt_dir, 'micro', 'non_existent_micro_agent.md'))
 
 
 def test_prompt_manager_template_rendering(prompt_dir, agent_skills_docs):