mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac8a35811 | |||
| 9d3c6d87fb | |||
| 4c935a84e7 | |||
| 2ad0831560 | |||
| d865f1e4a7 | |||
| a38c45cf75 |
@@ -286,6 +286,7 @@ jobs:
|
||||
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
|
||||
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
SKIP_CONTAINER_LOGS=true \
|
||||
TEST_RUNTIME=eventstream \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
@@ -363,6 +364,7 @@ jobs:
|
||||
image_name=ghcr.io/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
|
||||
image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
SKIP_CONTAINER_LOGS=true \
|
||||
TEST_RUNTIME=eventstream \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
|
||||
@@ -59,7 +59,7 @@ docker run # ...
|
||||
-e RUNTIME=remote \
|
||||
-e SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.app.all-hands.dev" \
|
||||
-e SANDBOX_API_KEY="your-all-hands-api-key" \
|
||||
-e SANDBOX_KEEP_RUNTIME_ALIVE="true" \
|
||||
-e SANDBOX_KEEP_REMOTE_RUNTIME_ALIVE="true" \
|
||||
# ...
|
||||
```
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def get_config(
|
||||
browsergym_eval_env=env_id,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
keep_remote_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -72,7 +72,7 @@ def get_config(
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
keep_remote_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -145,7 +145,7 @@ def get_config(
|
||||
platform='linux/amd64',
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
keep_remote_runtime_alive=False,
|
||||
remote_runtime_init_timeout=1800,
|
||||
),
|
||||
# do not mount workspace
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"APP_MODE": "oss",
|
||||
"GITHUB_CLIENT_ID": "",
|
||||
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
|
||||
"GITHUB_CLIENT_ID": ""
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ export function InteractiveChatBox({
|
||||
"bg-neutral-700 border border-neutral-600 rounded-lg px-2 py-[10px]",
|
||||
"transition-colors duration-200",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
"group relative",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
|
||||
"before:border-2 before:border-dashed before:border-transparent",
|
||||
"[&:has(*:focus-within)]:before:border-neutral-500/50",
|
||||
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
|
||||
)}
|
||||
>
|
||||
<UploadImageInput onUpload={handleUpload} />
|
||||
|
||||
@@ -15,14 +15,10 @@ import store from "./store";
|
||||
|
||||
function PosthogInit() {
|
||||
React.useEffect(() => {
|
||||
fetch("/config.json")
|
||||
.then((response) => response.json())
|
||||
.then((config) => {
|
||||
posthog.init(config.POSTHOG_CLIENT_KEY, {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
});
|
||||
});
|
||||
posthog.init("phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA", {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -71,6 +71,8 @@ const openHandsHandlers = [
|
||||
export const handlers = [
|
||||
...openHandsHandlers,
|
||||
http.get("https://api.github.com/user/repos", async ({ request }) => {
|
||||
if (import.meta.env.MODE !== "test") await delay(3500);
|
||||
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
?.replace("Bearer", "")
|
||||
|
||||
@@ -29,7 +29,7 @@ const generateAgentResponse = (message: string): AssistantMessageAction => ({
|
||||
action: "message",
|
||||
args: {
|
||||
content: message,
|
||||
image_urls: [],
|
||||
images_urls: [],
|
||||
wait_for_response: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,6 +70,11 @@ export function TaskForm() {
|
||||
"border border-neutral-600 px-4 py-[17px] rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
|
||||
inputIsFocused ? "bg-neutral-600" : "bg-neutral-700",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
"group relative",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:transition-colors",
|
||||
"before:border-2 before:border-dashed before:border-transparent",
|
||||
"[&:has(*:focus-within)]:before:border-neutral-500/50",
|
||||
"[&:has(*[data-dragging-over='true'])]:before:border-neutral-500/50",
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
|
||||
@@ -2,12 +2,12 @@ import ActionType from "#/types/ActionType";
|
||||
|
||||
export function createChatMessage(
|
||||
message: string,
|
||||
image_urls: string[],
|
||||
images_urls: string[],
|
||||
timestamp: string,
|
||||
) {
|
||||
const event = {
|
||||
action: ActionType.MESSAGE,
|
||||
args: { content: message, image_urls, timestamp },
|
||||
args: { content: message, images_urls, timestamp },
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
|
||||
source: "user";
|
||||
args: {
|
||||
content: string;
|
||||
image_urls: string[];
|
||||
images_urls: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface AssistantMessageAction
|
||||
source: "agent";
|
||||
args: {
|
||||
content: string;
|
||||
image_urls: string[] | null;
|
||||
images_urls: string[] | null;
|
||||
wait_for_response: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ interface LocalUserMessageAction {
|
||||
action: "message";
|
||||
args: {
|
||||
content: string;
|
||||
image_urls: string[];
|
||||
images_urls: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ const KEY_2 = "Auto-merge Dependabot PRs";
|
||||
const VALUE_2 = `Please add a GitHub action to this repository which automatically merges pull requests from Dependabot so long as the tests are passing.`;
|
||||
|
||||
const KEY_3 = "Fix up my README";
|
||||
const VALUE_3 = `Please look at the README and make the following improvements, if they make sense:
|
||||
const VALUE_3 = `"Please look at the README and make the following improvements, if they make sense:
|
||||
* correct any typos that you find
|
||||
* add missing language annotations on codeblocks
|
||||
* if there are references to other files or other sections of the README, turn them into links
|
||||
* make sure the readme has an h1 title towards the top
|
||||
* make sure any existing sections in the readme are appropriately separated with headings
|
||||
|
||||
If there are no obvious ways to improve the README, make at least one small change to make the wording clearer or friendlier`;
|
||||
If there are no obvious ways to improve the README, make at least one small change to make the wording clearer or friendlier"`;
|
||||
|
||||
const KEY_4 = "Clean up my dependencies";
|
||||
const VALUE_4 = `Examine the dependencies of the current codebase. Make sure you can run the code and any tests.
|
||||
|
||||
@@ -103,17 +103,15 @@ class CodeActAgent(Agent):
|
||||
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
|
||||
)
|
||||
self.prompt_manager = PromptManager(
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
|
||||
disabled_microagents=self.config.disabled_microagents,
|
||||
)
|
||||
else:
|
||||
self.action_parser = CodeActResponseParser()
|
||||
self.prompt_manager = PromptManager(
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro') if self.config.use_microagents else None,
|
||||
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
|
||||
agent_skills_docs=AgentSkillsRequirement.documentation,
|
||||
disabled_microagents=self.config.disabled_microagents,
|
||||
)
|
||||
|
||||
self.pending_actions: deque[Action] = deque()
|
||||
@@ -198,8 +196,8 @@ class CodeActAgent(Agent):
|
||||
elif isinstance(action, MessageAction):
|
||||
role = 'user' if action.source == 'user' else 'assistant'
|
||||
content = [TextContent(text=action.content or '')]
|
||||
if self.llm.vision_is_active() and action.image_urls:
|
||||
content.append(ImageContent(image_urls=action.image_urls))
|
||||
if self.llm.vision_is_active() and action.images_urls:
|
||||
content.append(ImageContent(image_urls=action.images_urls))
|
||||
return [
|
||||
Message(
|
||||
role=role,
|
||||
|
||||
@@ -95,9 +95,9 @@ class CodeActSWEAgent(Agent):
|
||||
if (
|
||||
self.llm.vision_is_active()
|
||||
and isinstance(action, MessageAction)
|
||||
and action.image_urls
|
||||
and action.images_urls
|
||||
):
|
||||
content.append(ImageContent(image_urls=action.image_urls))
|
||||
content.append(ImageContent(image_urls=action.images_urls))
|
||||
|
||||
return Message(
|
||||
role='user' if action.source == 'user' else 'assistant', content=content
|
||||
|
||||
@@ -149,7 +149,7 @@ class State:
|
||||
for event in reversed(self.history):
|
||||
if isinstance(event, MessageAction) and event.source == 'user':
|
||||
last_user_message = event.content
|
||||
last_user_message_image_urls = event.image_urls
|
||||
last_user_message_image_urls = event.images_urls
|
||||
elif isinstance(event, AgentFinishAction):
|
||||
if last_user_message is not None:
|
||||
return last_user_message, None
|
||||
|
||||
@@ -16,8 +16,6 @@ class AgentConfig:
|
||||
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.
|
||||
use_microagents: Whether to use microagents at all. Default is True.
|
||||
disabled_microagents: A list of microagents to disable. Default is None.
|
||||
"""
|
||||
|
||||
function_calling: bool = True
|
||||
@@ -28,8 +26,6 @@ class AgentConfig:
|
||||
memory_enabled: bool = False
|
||||
memory_max_threads: int = 3
|
||||
llm_config: str | None = None
|
||||
use_microagents: bool = True
|
||||
disabled_microagents: list[str] | 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."""
|
||||
|
||||
@@ -36,7 +36,7 @@ class SandboxConfig:
|
||||
|
||||
remote_runtime_api_url: str = 'http://localhost:8000'
|
||||
local_runtime_url: str = 'http://localhost'
|
||||
keep_runtime_alive: bool = True
|
||||
keep_remote_runtime_alive: bool = True
|
||||
api_key: str | None = None
|
||||
base_container_image: str = 'nikolaik/python-nodejs:python3.12-nodejs22' # default to nikolaik/python-nodejs:python3.12-nodejs22 for eventstream runtime
|
||||
runtime_container_image: str | None = None
|
||||
|
||||
@@ -98,13 +98,6 @@ class Message(BaseModel):
|
||||
content.extend(d)
|
||||
|
||||
ret: dict = {'content': content, 'role': self.role}
|
||||
# pop content if it's empty
|
||||
if not content or (
|
||||
len(content) == 1
|
||||
and content[0]['type'] == 'text'
|
||||
and content[0]['text'] == ''
|
||||
):
|
||||
ret.pop('content')
|
||||
|
||||
if role_tool_with_prompt_caching:
|
||||
ret['cache_control'] = {'type': 'ephemeral'}
|
||||
|
||||
@@ -7,7 +7,7 @@ from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
@dataclass
|
||||
class MessageAction(Action):
|
||||
content: str
|
||||
image_urls: list[str] | None = None
|
||||
images_urls: list[str] | None = None
|
||||
wait_for_response: bool = False
|
||||
action: str = ActionType.MESSAGE
|
||||
security_risk: ActionSecurityRisk | None = None
|
||||
@@ -16,18 +16,10 @@ class MessageAction(Action):
|
||||
def message(self) -> str:
|
||||
return self.content
|
||||
|
||||
@property
|
||||
def images_urls(self):
|
||||
# Deprecated alias for backward compatibility
|
||||
return self.image_urls
|
||||
|
||||
@images_urls.setter
|
||||
def images_urls(self, value):
|
||||
self.image_urls = value
|
||||
def __str__(self) -> str:
|
||||
ret = f'**MessageAction** (source={self.source})\n'
|
||||
ret += f'CONTENT: {self.content}'
|
||||
if self.image_urls:
|
||||
for url in self.image_urls:
|
||||
if self.images_urls:
|
||||
for url in self.images_urls:
|
||||
ret += f'\nIMAGE_URL: {url}'
|
||||
return ret
|
||||
|
||||
@@ -66,10 +66,6 @@ def action_from_dict(action: dict) -> Action:
|
||||
if is_confirmed is not None:
|
||||
args['confirmation_state'] = is_confirmed
|
||||
|
||||
# images_urls has been renamed to image_urls
|
||||
if 'images_urls' in args:
|
||||
args['image_urls'] = args.pop('images_urls')
|
||||
|
||||
try:
|
||||
decoded_action = action_class(**args)
|
||||
if 'timeout' in action:
|
||||
|
||||
@@ -101,7 +101,7 @@ def event_to_memory(event: 'Event', max_message_chars: int) -> dict:
|
||||
d.pop('cause', None)
|
||||
d.pop('timestamp', None)
|
||||
d.pop('message', None)
|
||||
d.pop('image_urls', None)
|
||||
d.pop('images_urls', None)
|
||||
|
||||
# runnable actions have some extra fields used in the BE/FE, which should not be sent to the LLM
|
||||
if 'args' in d:
|
||||
|
||||
@@ -14,9 +14,7 @@ class DebugMixin:
|
||||
|
||||
messages = messages if isinstance(messages, list) else [messages]
|
||||
debug_message = MESSAGE_SEPARATOR.join(
|
||||
self._format_message_content(msg)
|
||||
for msg in messages
|
||||
if msg.get('content', None)
|
||||
self._format_message_content(msg) for msg in messages if msg['content']
|
||||
)
|
||||
|
||||
if debug_message:
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import docker
|
||||
|
||||
|
||||
def remove_all_containers(prefix: str):
|
||||
docker_client = docker.from_env()
|
||||
|
||||
try:
|
||||
containers = docker_client.containers.list(all=True)
|
||||
for container in containers:
|
||||
try:
|
||||
if container.name.startswith(prefix):
|
||||
container.remove(force=True)
|
||||
except docker.errors.APIError:
|
||||
pass
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
except docker.errors.NotFound: # yes, this can happen!
|
||||
pass
|
||||
@@ -1,9 +1,8 @@
|
||||
import atexit
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from zipfile import ZipFile
|
||||
|
||||
@@ -36,7 +35,6 @@ from openhands.events.serialization import event_to_dict, observation_from_dict
|
||||
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.builder import DockerRuntimeBuilder
|
||||
from openhands.runtime.impl.eventstream.containers import remove_all_containers
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.runtime.utils.request import send_request
|
||||
@@ -44,15 +42,6 @@ from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
|
||||
|
||||
def remove_all_runtime_containers():
|
||||
remove_all_containers(CONTAINER_NAME_PREFIX)
|
||||
|
||||
|
||||
atexit.register(remove_all_runtime_containers)
|
||||
|
||||
|
||||
class LogBuffer:
|
||||
"""Synchronous buffer for Docker container logs.
|
||||
@@ -125,6 +114,8 @@ class EventStreamRuntime(Runtime):
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
|
||||
container_name_prefix = 'openhands-runtime-'
|
||||
|
||||
# Need to provide this method to allow inheritors to init the Runtime
|
||||
# without initting the EventStreamRuntime.
|
||||
def init_base_runtime(
|
||||
@@ -167,7 +158,7 @@ class EventStreamRuntime(Runtime):
|
||||
self.docker_client: docker.DockerClient = self._init_docker_client()
|
||||
self.base_container_image = self.config.sandbox.base_container_image
|
||||
self.runtime_container_image = self.config.sandbox.runtime_container_image
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
self.container_name = self.container_name_prefix + sid
|
||||
self.container = None
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
|
||||
@@ -182,6 +173,10 @@ class EventStreamRuntime(Runtime):
|
||||
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
|
||||
)
|
||||
|
||||
self.skip_container_logs = (
|
||||
os.environ.get('SKIP_CONTAINER_LOGS', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
self.init_base_runtime(
|
||||
config,
|
||||
event_stream,
|
||||
@@ -194,15 +189,7 @@ class EventStreamRuntime(Runtime):
|
||||
|
||||
async def connect(self):
|
||||
self.send_status_message('STATUS$STARTING_RUNTIME')
|
||||
try:
|
||||
await call_sync_from_async(self._attach_to_container)
|
||||
except docker.errors.NotFound as e:
|
||||
if self.attach_to_existing:
|
||||
self.log(
|
||||
'error',
|
||||
f'Container {self.container_name} not found.',
|
||||
)
|
||||
raise e
|
||||
if not self.attach_to_existing:
|
||||
if self.runtime_container_image is None:
|
||||
if self.base_container_image is None:
|
||||
raise ValueError(
|
||||
@@ -223,12 +210,13 @@ class EventStreamRuntime(Runtime):
|
||||
await call_sync_from_async(self._init_container)
|
||||
self.log('info', f'Container started: {self.container_name}')
|
||||
|
||||
else:
|
||||
await call_sync_from_async(self._attach_to_container)
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', f'Waiting for client to become ready at {self.api_url}...')
|
||||
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
|
||||
|
||||
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
|
||||
await call_sync_from_async(self._wait_until_alive)
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', 'Runtime is ready.')
|
||||
|
||||
@@ -239,8 +227,7 @@ class EventStreamRuntime(Runtime):
|
||||
'debug',
|
||||
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}',
|
||||
)
|
||||
if not self.attach_to_existing:
|
||||
self.send_status_message(' ')
|
||||
self.send_status_message(' ')
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -345,12 +332,13 @@ class EventStreamRuntime(Runtime):
|
||||
self.log('debug', f'Container started. Server url: {self.api_url}')
|
||||
self.send_status_message('STATUS$CONTAINER_STARTED')
|
||||
except docker.errors.APIError as e:
|
||||
# check 409 error
|
||||
if '409' in str(e):
|
||||
self.log(
|
||||
'warning',
|
||||
f'Container {self.container_name} already exists. Removing...',
|
||||
)
|
||||
remove_all_containers(self.container_name)
|
||||
self._close_containers(rm_all_containers=True)
|
||||
return self._init_container()
|
||||
|
||||
else:
|
||||
@@ -426,18 +414,42 @@ class EventStreamRuntime(Runtime):
|
||||
Parameters:
|
||||
- rm_all_containers (bool): Whether to remove all containers with the 'openhands-sandbox-' prefix
|
||||
"""
|
||||
|
||||
if self.log_buffer:
|
||||
self.log_buffer.close()
|
||||
|
||||
if self.session:
|
||||
self.session.close()
|
||||
|
||||
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
|
||||
if self.attach_to_existing:
|
||||
return
|
||||
close_prefix = (
|
||||
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
|
||||
)
|
||||
remove_all_containers(close_prefix)
|
||||
self._close_containers(rm_all_containers)
|
||||
|
||||
def _close_containers(self, rm_all_containers: bool = True):
|
||||
try:
|
||||
containers = self.docker_client.containers.list(all=True)
|
||||
for container in containers:
|
||||
try:
|
||||
# If the app doesn't shut down properly, it can leave runtime containers on the system. This ensures
|
||||
# that all 'openhands-sandbox-' containers are removed as well.
|
||||
if rm_all_containers and container.name.startswith(
|
||||
self.container_name_prefix
|
||||
):
|
||||
container.remove(force=True)
|
||||
elif container.name == self.container_name:
|
||||
if not self.skip_container_logs:
|
||||
logs = container.logs(tail=1000).decode('utf-8')
|
||||
self.log(
|
||||
'debug',
|
||||
f'==== Container logs on close ====\n{logs}\n==== End of container logs ====',
|
||||
)
|
||||
container.remove(force=True)
|
||||
except docker.errors.APIError:
|
||||
pass
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
except docker.errors.NotFound: # yes, this can happen!
|
||||
pass
|
||||
|
||||
def run_action(self, action: Action) -> Observation:
|
||||
if isinstance(action, FileEditAction):
|
||||
|
||||
@@ -138,7 +138,6 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
|
||||
is_retry=False,
|
||||
timeout=5,
|
||||
)
|
||||
except requests.HTTPError as e:
|
||||
@@ -169,7 +168,6 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/registry_prefix',
|
||||
is_retry=False,
|
||||
timeout=10,
|
||||
)
|
||||
response_json = response.json()
|
||||
@@ -200,7 +198,6 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/image_exists',
|
||||
is_retry=False,
|
||||
params={'image': self.container_image},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -237,7 +234,6 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/start',
|
||||
is_retry=False,
|
||||
json=start_request,
|
||||
)
|
||||
self._parse_runtime_response(response)
|
||||
@@ -250,7 +246,6 @@ class RemoteRuntime(Runtime):
|
||||
self._send_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/resume',
|
||||
is_retry=False,
|
||||
json={'runtime_id': self.runtime_id},
|
||||
timeout=30,
|
||||
)
|
||||
@@ -288,11 +283,14 @@ class RemoteRuntime(Runtime):
|
||||
assert runtime_data['runtime_id'] == self.runtime_id
|
||||
assert 'pod_status' in runtime_data
|
||||
pod_status = runtime_data['pod_status']
|
||||
self.log('debug', f'Pod status: {pod_status}')
|
||||
|
||||
# FIXME: We should fix it at the backend of /start endpoint, make sure
|
||||
# the pod is created before returning the response.
|
||||
# Retry a period of time to give the cluster time to start the pod
|
||||
if pod_status == 'Not Found':
|
||||
raise RuntimeNotReadyError(
|
||||
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
|
||||
)
|
||||
if pod_status == 'Ready':
|
||||
try:
|
||||
self._send_request(
|
||||
@@ -307,23 +305,12 @@ class RemoteRuntime(Runtime):
|
||||
f'Runtime /alive failed to respond with 200: {e}'
|
||||
)
|
||||
return
|
||||
elif (
|
||||
pod_status == 'Not Found'
|
||||
or pod_status == 'Pending'
|
||||
or pod_status == 'Running'
|
||||
): # nb: Running is not yet Ready
|
||||
raise RuntimeNotReadyError(
|
||||
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
|
||||
)
|
||||
elif pod_status in ('Failed', 'Unknown'):
|
||||
if pod_status in ('Failed', 'Unknown'):
|
||||
# clean up the runtime
|
||||
self.close()
|
||||
raise RuntimeError(
|
||||
f'Runtime (ID={self.runtime_id}) failed to start. Current status: {pod_status}'
|
||||
)
|
||||
else:
|
||||
# Maybe this should be a hard failure, but passing through in case the API changes
|
||||
self.log('warning', f'Unknown pod status: {pod_status}')
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
@@ -332,7 +319,7 @@ class RemoteRuntime(Runtime):
|
||||
raise RuntimeNotReadyError()
|
||||
|
||||
def close(self, timeout: int = 10):
|
||||
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
|
||||
if self.config.sandbox.keep_remote_runtime_alive or self.attach_to_existing:
|
||||
self.session.close()
|
||||
return
|
||||
if self.runtime_id and self.session:
|
||||
@@ -340,7 +327,6 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/stop',
|
||||
is_retry=False,
|
||||
json={'runtime_id': self.runtime_id},
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -356,7 +342,7 @@ class RemoteRuntime(Runtime):
|
||||
finally:
|
||||
self.session.close()
|
||||
|
||||
def run_action(self, action: Action, is_retry: bool = False) -> Observation:
|
||||
def run_action(self, action: Action) -> Observation:
|
||||
if action.timeout is None:
|
||||
action.timeout = self.config.sandbox.timeout
|
||||
if isinstance(action, FileEditAction):
|
||||
@@ -381,7 +367,6 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.runtime_url}/execute_action',
|
||||
is_retry=False,
|
||||
json=request_body,
|
||||
# wait a few more seconds to get the timeout error from client side
|
||||
timeout=action.timeout + 5,
|
||||
@@ -395,7 +380,7 @@ class RemoteRuntime(Runtime):
|
||||
)
|
||||
return obs
|
||||
|
||||
def _send_request(self, method, url, is_retry=False, **kwargs):
|
||||
def _send_request(self, method, url, **kwargs):
|
||||
is_runtime_request = self.runtime_url and self.runtime_url in url
|
||||
try:
|
||||
return send_request(self.session, method, url, **kwargs)
|
||||
@@ -407,15 +392,6 @@ class RemoteRuntime(Runtime):
|
||||
raise RuntimeDisconnectedError(
|
||||
f'404 error while connecting to {self.runtime_url}'
|
||||
)
|
||||
elif is_runtime_request and e.response.status_code == 503:
|
||||
if not is_retry:
|
||||
self.log('warning', 'Runtime appears to be paused. Resuming...')
|
||||
self._resume_runtime()
|
||||
self._wait_until_alive()
|
||||
return self._send_request(method, url, True, **kwargs)
|
||||
else:
|
||||
raise e
|
||||
|
||||
else:
|
||||
raise e
|
||||
|
||||
@@ -468,7 +444,6 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.runtime_url}/upload_file',
|
||||
is_retry=False,
|
||||
files=upload_data,
|
||||
params=params,
|
||||
timeout=300,
|
||||
@@ -492,7 +467,6 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'POST',
|
||||
f'{self.runtime_url}/list_files',
|
||||
is_retry=False,
|
||||
json=data,
|
||||
timeout=30,
|
||||
)
|
||||
@@ -506,7 +480,6 @@ class RemoteRuntime(Runtime):
|
||||
response = self._send_request(
|
||||
'GET',
|
||||
f'{self.runtime_url}/download_files',
|
||||
is_retry=False,
|
||||
params=params,
|
||||
stream=True,
|
||||
timeout=30,
|
||||
|
||||
@@ -21,8 +21,6 @@ from openhands.runtime.utils.command import get_remote_startup_command
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
|
||||
|
||||
class RunloopLogBuffer(LogBuffer):
|
||||
"""Synchronous buffer for Runloop devbox logs.
|
||||
@@ -117,7 +115,7 @@ class RunloopRuntime(EventStreamRuntime):
|
||||
bearer_token=config.runloop_api_key,
|
||||
)
|
||||
self.session = requests.Session()
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
self.container_name = self.container_name_prefix + sid
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
self.init_base_runtime(
|
||||
config,
|
||||
@@ -192,7 +190,7 @@ class RunloopRuntime(EventStreamRuntime):
|
||||
prebuilt='openhands',
|
||||
launch_parameters=LaunchParameters(
|
||||
available_ports=[self._sandbox_port],
|
||||
resource_size_request='LARGE',
|
||||
resource_size_request="LARGE",
|
||||
),
|
||||
metadata={'container-name': self.container_name},
|
||||
)
|
||||
|
||||
@@ -1,8 +1,60 @@
|
||||
"""This file imports a global singleton of the `EditTool` class as well as raw functions that expose
|
||||
its __call__.
|
||||
The implementation of the `EditTool` class can be found at: https://github.com/All-Hands-AI/openhands-aci/.
|
||||
"""
|
||||
"""This file contains a global singleton of the `EditTool` class as well as raw functions that expose its __call__."""
|
||||
|
||||
from .base import CLIResult, ToolError, ToolResult
|
||||
from .impl import Command, EditTool
|
||||
|
||||
_GLOBAL_EDITOR = EditTool()
|
||||
|
||||
|
||||
def _make_api_tool_result(
|
||||
result: ToolResult,
|
||||
) -> str:
|
||||
"""Convert an agent ToolResult to an API ToolResultBlockParam."""
|
||||
tool_result_content: str = ''
|
||||
is_error = False
|
||||
if result.error:
|
||||
is_error = True
|
||||
tool_result_content = _maybe_prepend_system_tool_result(result, result.error)
|
||||
else:
|
||||
assert result.output, 'Expecting output in file_editor'
|
||||
tool_result_content = _maybe_prepend_system_tool_result(result, result.output)
|
||||
assert (
|
||||
not result.base64_image
|
||||
), 'Not expecting base64_image as output in file_editor'
|
||||
if is_error:
|
||||
return f'ERROR:\n{tool_result_content}'
|
||||
else:
|
||||
return tool_result_content
|
||||
|
||||
|
||||
def _maybe_prepend_system_tool_result(result: ToolResult, result_text: str) -> str:
|
||||
if result.system:
|
||||
result_text = f'<system>{result.system}</system>\n{result_text}'
|
||||
return result_text
|
||||
|
||||
|
||||
def file_editor(
|
||||
command: Command,
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
result: CLIResult = _GLOBAL_EDITOR(
|
||||
command=command,
|
||||
path=path,
|
||||
file_text=file_text,
|
||||
view_range=view_range,
|
||||
old_str=old_str,
|
||||
new_str=new_str,
|
||||
insert_line=insert_line,
|
||||
)
|
||||
except ToolError as e:
|
||||
return _make_api_tool_result(ToolResult(error=e.message))
|
||||
return _make_api_tool_result(result)
|
||||
|
||||
from openhands_aci.editor import file_editor
|
||||
|
||||
__all__ = ['file_editor']
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from dataclasses import dataclass, fields, replace
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ToolResult:
|
||||
"""Represents the result of a tool execution."""
|
||||
|
||||
output: str | None = None
|
||||
error: str | None = None
|
||||
base64_image: str | None = None
|
||||
system: str | None = None
|
||||
|
||||
def __bool__(self):
|
||||
return any(getattr(self, field.name) for field in fields(self))
|
||||
|
||||
def __add__(self, other: 'ToolResult'):
|
||||
def combine_fields(
|
||||
field: str | None, other_field: str | None, concatenate: bool = True
|
||||
):
|
||||
if field and other_field:
|
||||
if concatenate:
|
||||
return field + other_field
|
||||
raise ValueError('Cannot combine tool results')
|
||||
return field or other_field
|
||||
|
||||
return ToolResult(
|
||||
output=combine_fields(self.output, other.output),
|
||||
error=combine_fields(self.error, other.error),
|
||||
base64_image=combine_fields(self.base64_image, other.base64_image, False),
|
||||
system=combine_fields(self.system, other.system),
|
||||
)
|
||||
|
||||
def replace(self, **kwargs):
|
||||
"""Returns a new ToolResult with the given fields replaced."""
|
||||
return replace(self, **kwargs)
|
||||
|
||||
|
||||
class CLIResult(ToolResult):
|
||||
"""A ToolResult that can be rendered as a CLI output."""
|
||||
|
||||
|
||||
class ToolFailure(ToolResult):
|
||||
"""A ToolResult that represents a failure."""
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""Raised when a tool encounters an error."""
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
@@ -0,0 +1,279 @@
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Literal, get_args
|
||||
|
||||
from .base import CLIResult, ToolError, ToolResult
|
||||
from .run import maybe_truncate, run
|
||||
|
||||
Command = Literal[
|
||||
'view',
|
||||
'create',
|
||||
'str_replace',
|
||||
'insert',
|
||||
'undo_edit',
|
||||
]
|
||||
SNIPPET_LINES: int = 4
|
||||
|
||||
|
||||
class EditTool:
|
||||
"""
|
||||
An filesystem editor tool that allows the agent to view, create, and edit files.
|
||||
The tool parameters are defined by Anthropic and are not editable.
|
||||
|
||||
Original implementation: https://github.com/anthropics/anthropic-quickstarts/blob/main/computer-use-demo/computer_use_demo/tools/edit.py
|
||||
"""
|
||||
|
||||
_file_history: dict[Path, list[str]]
|
||||
|
||||
def __init__(self):
|
||||
self._file_history = defaultdict(list)
|
||||
super().__init__()
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
command: Command,
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
_path = Path(path)
|
||||
self.validate_path(command, _path)
|
||||
if command == 'view':
|
||||
return self.view(_path, view_range)
|
||||
elif command == 'create':
|
||||
if file_text is None:
|
||||
raise ToolError('Parameter `file_text` is required for command: create')
|
||||
self.write_file(_path, file_text)
|
||||
self._file_history[_path].append(file_text)
|
||||
return ToolResult(output=f'File created successfully at: {_path}')
|
||||
elif command == 'str_replace':
|
||||
if old_str is None:
|
||||
raise ToolError(
|
||||
'Parameter `old_str` is required for command: str_replace'
|
||||
)
|
||||
return self.str_replace(_path, old_str, new_str)
|
||||
elif command == 'insert':
|
||||
if insert_line is None:
|
||||
raise ToolError(
|
||||
'Parameter `insert_line` is required for command: insert'
|
||||
)
|
||||
if new_str is None:
|
||||
raise ToolError('Parameter `new_str` is required for command: insert')
|
||||
return self.insert(_path, insert_line, new_str)
|
||||
elif command == 'undo_edit':
|
||||
return self.undo_edit(_path)
|
||||
raise ToolError(
|
||||
f'Unrecognized command {command}. The allowed commands for the {self.name} tool are: {", ".join(get_args(Command))}'
|
||||
)
|
||||
|
||||
def validate_path(self, command: str, path: Path):
|
||||
"""
|
||||
Check that the path/command combination is valid.
|
||||
"""
|
||||
# Check if its an absolute path
|
||||
if not path.is_absolute():
|
||||
suggested_path = Path('') / path
|
||||
raise ToolError(
|
||||
f'The path {path} is not an absolute path, it should start with `/`. Maybe you meant {suggested_path}?'
|
||||
)
|
||||
# Check if path exists
|
||||
if not path.exists() and command != 'create':
|
||||
raise ToolError(
|
||||
f'The path {path} does not exist. Please provide a valid path.'
|
||||
)
|
||||
if path.exists() and command == 'create':
|
||||
raise ToolError(
|
||||
f'File already exists at: {path}. Cannot overwrite files using command `create`.'
|
||||
)
|
||||
# Check if the path points to a directory
|
||||
if path.is_dir():
|
||||
if command != 'view':
|
||||
raise ToolError(
|
||||
f'The path {path} is a directory and only the `view` command can be used on directories'
|
||||
)
|
||||
|
||||
def view(self, path: Path, view_range: list[int] | None = None):
|
||||
"""Implement the view command"""
|
||||
if path.is_dir():
|
||||
if view_range:
|
||||
raise ToolError(
|
||||
'The `view_range` parameter is not allowed when `path` points to a directory.'
|
||||
)
|
||||
|
||||
_, stdout, stderr = run(rf"find {path} -maxdepth 2 -not -path '*/\.*'")
|
||||
if not stderr:
|
||||
stdout = f"Here's the files and directories up to 2 levels deep in {path}, excluding hidden items:\n{stdout}\n"
|
||||
return CLIResult(output=stdout, error=stderr)
|
||||
|
||||
file_content = self.read_file(path)
|
||||
init_line = 1
|
||||
if view_range:
|
||||
if len(view_range) != 2 or not all(isinstance(i, int) for i in view_range):
|
||||
raise ToolError(
|
||||
'Invalid `view_range`. It should be a list of two integers.'
|
||||
)
|
||||
file_lines = file_content.split('\n')
|
||||
n_lines_file = len(file_lines)
|
||||
init_line, final_line = view_range
|
||||
if init_line < 1 or init_line > n_lines_file:
|
||||
raise ToolError(
|
||||
f"Invalid `view_range`: {view_range}. It's first element `{init_line}` should be within the range of lines of the file: {[1, n_lines_file]}"
|
||||
)
|
||||
if final_line > n_lines_file:
|
||||
raise ToolError(
|
||||
f"Invalid `view_range`: {view_range}. It's second element `{final_line}` should be smaller than the number of lines in the file: `{n_lines_file}`"
|
||||
)
|
||||
if final_line != -1 and final_line < init_line:
|
||||
raise ToolError(
|
||||
f"Invalid `view_range`: {view_range}. It's second element `{final_line}` should be larger or equal than its first `{init_line}`"
|
||||
)
|
||||
|
||||
if final_line == -1:
|
||||
file_content = '\n'.join(file_lines[init_line - 1 :])
|
||||
else:
|
||||
file_content = '\n'.join(file_lines[init_line - 1 : final_line])
|
||||
|
||||
return CLIResult(
|
||||
output=self._make_output(file_content, str(path), init_line=init_line)
|
||||
)
|
||||
|
||||
def str_replace(self, path: Path, old_str: str, new_str: str | None):
|
||||
"""Implement the str_replace command, which replaces old_str with new_str in the file content"""
|
||||
# Read the file content
|
||||
file_content = self.read_file(path).expandtabs()
|
||||
old_str = old_str.expandtabs()
|
||||
new_str = new_str.expandtabs() if new_str is not None else ''
|
||||
|
||||
# Check if old_str is unique in the file
|
||||
occurrences = file_content.count(old_str)
|
||||
if occurrences == 0:
|
||||
raise ToolError(
|
||||
f'No replacement was performed, old_str `{old_str}` did not appear verbatim in {path}.'
|
||||
)
|
||||
elif occurrences > 1:
|
||||
file_content_lines = file_content.split('\n')
|
||||
lines = [
|
||||
idx + 1
|
||||
for idx, line in enumerate(file_content_lines)
|
||||
if old_str in line
|
||||
]
|
||||
raise ToolError(
|
||||
f'No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines {lines}. Please ensure it is unique'
|
||||
)
|
||||
|
||||
# Replace old_str with new_str
|
||||
new_file_content = file_content.replace(old_str, new_str)
|
||||
|
||||
# Write the new content to the file
|
||||
self.write_file(path, new_file_content)
|
||||
|
||||
# Save the content to history
|
||||
self._file_history[path].append(file_content)
|
||||
|
||||
# Create a snippet of the edited section
|
||||
replacement_line = file_content.split(old_str)[0].count('\n')
|
||||
start_line = max(0, replacement_line - SNIPPET_LINES)
|
||||
end_line = replacement_line + SNIPPET_LINES + new_str.count('\n')
|
||||
snippet = '\n'.join(new_file_content.split('\n')[start_line : end_line + 1])
|
||||
|
||||
# Prepare the success message
|
||||
success_msg = f'The file {path} has been edited. '
|
||||
success_msg += self._make_output(
|
||||
snippet, f'a snippet of {path}', start_line + 1
|
||||
)
|
||||
success_msg += 'Review the changes and make sure they are as expected. Edit the file again if necessary.'
|
||||
|
||||
return CLIResult(output=success_msg)
|
||||
|
||||
def insert(self, path: Path, insert_line: int, new_str: str):
|
||||
"""Implement the insert command, which inserts new_str at the specified line in the file content."""
|
||||
file_text = self.read_file(path).expandtabs()
|
||||
new_str = new_str.expandtabs()
|
||||
file_text_lines = file_text.split('\n')
|
||||
n_lines_file = len(file_text_lines)
|
||||
|
||||
if insert_line < 0 or insert_line > n_lines_file:
|
||||
raise ToolError(
|
||||
f'Invalid `insert_line` parameter: {insert_line}. It should be within the range of lines of the file: {[0, n_lines_file]}'
|
||||
)
|
||||
|
||||
new_str_lines = new_str.split('\n')
|
||||
new_file_text_lines = (
|
||||
file_text_lines[:insert_line]
|
||||
+ new_str_lines
|
||||
+ file_text_lines[insert_line:]
|
||||
)
|
||||
snippet_lines = (
|
||||
file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
|
||||
+ new_str_lines
|
||||
+ file_text_lines[insert_line : insert_line + SNIPPET_LINES]
|
||||
)
|
||||
|
||||
new_file_text = '\n'.join(new_file_text_lines)
|
||||
snippet = '\n'.join(snippet_lines)
|
||||
|
||||
self.write_file(path, new_file_text)
|
||||
self._file_history[path].append(file_text)
|
||||
|
||||
success_msg = f'The file {path} has been edited. '
|
||||
success_msg += self._make_output(
|
||||
snippet,
|
||||
'a snippet of the edited file',
|
||||
max(1, insert_line - SNIPPET_LINES + 1),
|
||||
)
|
||||
success_msg += 'Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary.'
|
||||
return CLIResult(output=success_msg)
|
||||
|
||||
def undo_edit(self, path: Path):
|
||||
"""Implement the undo_edit command."""
|
||||
if not self._file_history[path]:
|
||||
raise ToolError(f'No edit history found for {path}.')
|
||||
|
||||
old_text = self._file_history[path].pop()
|
||||
self.write_file(path, old_text)
|
||||
|
||||
return CLIResult(
|
||||
output=f'Last edit to {path} undone successfully. {self._make_output(old_text, str(path))}'
|
||||
)
|
||||
|
||||
def read_file(self, path: Path):
|
||||
"""Read the content of a file from a given path; raise a ToolError if an error occurs."""
|
||||
try:
|
||||
return path.read_text()
|
||||
except Exception as e:
|
||||
raise ToolError(f'Ran into {e} while trying to read {path}') from None
|
||||
|
||||
def write_file(self, path: Path, file: str):
|
||||
"""Write the content of a file to a given path; raise a ToolError if an error occurs."""
|
||||
try:
|
||||
path.write_text(file)
|
||||
except Exception as e:
|
||||
raise ToolError(f'Ran into {e} while trying to write to {path}') from None
|
||||
|
||||
def _make_output(
|
||||
self,
|
||||
file_content: str,
|
||||
file_descriptor: str,
|
||||
init_line: int = 1,
|
||||
expand_tabs: bool = True,
|
||||
):
|
||||
"""Generate output for the CLI based on the content of a file."""
|
||||
file_content = maybe_truncate(file_content)
|
||||
if expand_tabs:
|
||||
file_content = file_content.expandtabs()
|
||||
file_content = '\n'.join(
|
||||
[
|
||||
f'{i + init_line:6}\t{line}'
|
||||
for i, line in enumerate(file_content.split('\n'))
|
||||
]
|
||||
)
|
||||
return (
|
||||
f"Here's the result of running `cat -n` on {file_descriptor}:\n"
|
||||
+ file_content
|
||||
+ '\n'
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Utility to run shell commands asynchronously with a timeout."""
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
TRUNCATED_MESSAGE: str = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
|
||||
MAX_RESPONSE_LEN: int = 16000
|
||||
|
||||
|
||||
def maybe_truncate(content: str, truncate_after: int | None = MAX_RESPONSE_LEN):
|
||||
"""Truncate content and append a notice if content exceeds the specified length."""
|
||||
return (
|
||||
content
|
||||
if not truncate_after or len(content) <= truncate_after
|
||||
else content[:truncate_after] + TRUNCATED_MESSAGE
|
||||
)
|
||||
|
||||
|
||||
def run(
|
||||
cmd: str,
|
||||
timeout: float | None = 120.0, # seconds
|
||||
truncate_after: int | None = MAX_RESPONSE_LEN,
|
||||
):
|
||||
"""Run a shell command synchronously with a timeout."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
|
||||
return (
|
||||
process.returncode or 0,
|
||||
maybe_truncate(stdout, truncate_after=truncate_after),
|
||||
maybe_truncate(stderr, truncate_after=truncate_after),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
elapsed_time = time.time() - start_time
|
||||
raise TimeoutError(
|
||||
f"Command '{cmd}' timed out after {elapsed_time:.2f} seconds"
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
@@ -73,7 +74,14 @@ file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
session_manager = SessionManager(config, file_store)
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global session_manager
|
||||
async with session_manager:
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
LocalhostCORSMiddleware,
|
||||
allow_credentials=True,
|
||||
@@ -268,7 +276,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
```
|
||||
- Send a message:
|
||||
```json
|
||||
{"action": "message", "args": {"content": "Hello, how are you?", "image_urls": ["base64_url1", "base64_url2"]}}
|
||||
{"action": "message", "args": {"content": "Hello, how are you?", "images_urls": ["base64_url1", "base64_url2"]}}
|
||||
```
|
||||
- Write contents to a file:
|
||||
```json
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.stream import session_exists
|
||||
from openhands.runtime.utils.shutdown_listener import should_continue
|
||||
from openhands.server.session.conversation import Conversation
|
||||
from openhands.server.session.session import Session
|
||||
from openhands.storage.files import FileStore
|
||||
@@ -15,23 +18,78 @@ from openhands.storage.files import FileStore
|
||||
class SessionManager:
|
||||
config: AppConfig
|
||||
file_store: FileStore
|
||||
cleanup_interval: int = 300
|
||||
session_timeout: int = 600
|
||||
_sessions: dict[str, Session] = field(default_factory=dict)
|
||||
_session_cleanup_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
if not self._session_cleanup_task:
|
||||
self._session_cleanup_task = asyncio.create_task(self._cleanup_sessions())
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
if self._session_cleanup_task:
|
||||
self._session_cleanup_task.cancel()
|
||||
self._session_cleanup_task = None
|
||||
|
||||
def add_or_restart_session(self, sid: str, ws_conn: WebSocket) -> Session:
|
||||
return Session(
|
||||
if sid in self._sessions:
|
||||
self._sessions[sid].close()
|
||||
self._sessions[sid] = Session(
|
||||
sid=sid, file_store=self.file_store, ws=ws_conn, config=self.config
|
||||
)
|
||||
return self._sessions[sid]
|
||||
|
||||
def get_session(self, sid: str) -> Session | None:
|
||||
if sid not in self._sessions:
|
||||
return None
|
||||
return self._sessions.get(sid)
|
||||
|
||||
async def attach_to_conversation(self, sid: str) -> Conversation | None:
|
||||
start_time = time.time()
|
||||
if not await session_exists(sid, self.file_store):
|
||||
return None
|
||||
c = Conversation(sid, file_store=self.file_store, config=self.config)
|
||||
await c.connect()
|
||||
end_time = time.time()
|
||||
logger.info(
|
||||
f'Conversation {c.sid} connected in {end_time - start_time} seconds'
|
||||
)
|
||||
return c
|
||||
|
||||
async def detach_from_conversation(self, conversation: Conversation):
|
||||
await conversation.disconnect()
|
||||
|
||||
async def send(self, sid: str, data: dict[str, object]) -> bool:
|
||||
"""Sends data to the client."""
|
||||
session = self.get_session(sid)
|
||||
if session is None:
|
||||
logger.error(f'*** No session found for {sid}, skipping message ***')
|
||||
return False
|
||||
return await session.send(data)
|
||||
|
||||
async def send_error(self, sid: str, message: str) -> bool:
|
||||
"""Sends an error message to the client."""
|
||||
return await self.send(sid, {'error': True, 'message': message})
|
||||
|
||||
async def send_message(self, sid: str, message: str) -> bool:
|
||||
"""Sends a message to the client."""
|
||||
return await self.send(sid, {'message': message})
|
||||
|
||||
async def _cleanup_sessions(self):
|
||||
while should_continue():
|
||||
current_time = time.time()
|
||||
session_ids_to_remove = []
|
||||
for sid, session in list(self._sessions.items()):
|
||||
# if session inactive for a long time, remove it
|
||||
if (
|
||||
not session.is_alive
|
||||
and current_time - session.last_active_ts > self.session_timeout
|
||||
):
|
||||
session_ids_to_remove.append(sid)
|
||||
|
||||
for sid in session_ids_to_remove:
|
||||
to_del_session: Session | None = self._sessions.pop(sid, None)
|
||||
if to_del_session is not None:
|
||||
to_del_session.close()
|
||||
logger.debug(
|
||||
f'Session {sid} and related resource have been removed due to inactivity.'
|
||||
)
|
||||
|
||||
await asyncio.sleep(self.cleanup_interval)
|
||||
|
||||
@@ -163,7 +163,7 @@ class Session:
|
||||
return
|
||||
event = event_from_dict(data.copy())
|
||||
# This checks if the model supports images
|
||||
if isinstance(event, MessageAction) and event.image_urls:
|
||||
if isinstance(event, MessageAction) and event.images_urls:
|
||||
controller = self.agent_session.controller
|
||||
if controller:
|
||||
if controller.agent.llm.config.disable_vision:
|
||||
|
||||
@@ -19,16 +19,13 @@ class PromptManager:
|
||||
Attributes:
|
||||
prompt_dir (str): Directory containing prompt templates.
|
||||
agent_skills_docs (str): Documentation of agent skills.
|
||||
microagent_dir (str): Directory containing microagent specifications.
|
||||
disabled_microagents (list[str] | None): List of microagents to disable. If None, all microagents are enabled.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prompt_dir: str,
|
||||
microagent_dir: str | None = None,
|
||||
microagent_dir: str = '',
|
||||
agent_skills_docs: str = '',
|
||||
disabled_microagents: list[str] | None = None,
|
||||
):
|
||||
self.prompt_dir: str = prompt_dir
|
||||
self.agent_skills_docs: str = agent_skills_docs
|
||||
@@ -46,15 +43,9 @@ class PromptManager:
|
||||
]
|
||||
for microagent_file in microagent_files:
|
||||
microagent = MicroAgent(microagent_file)
|
||||
if (
|
||||
disabled_microagents is None
|
||||
or microagent.name not in disabled_microagents
|
||||
):
|
||||
self.microagents[microagent.name] = microagent
|
||||
self.microagents[microagent.name] = microagent
|
||||
|
||||
def _load_template(self, template_name: str) -> Template:
|
||||
if self.prompt_dir is None:
|
||||
raise ValueError('Prompt directory is not set')
|
||||
template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
|
||||
if not os.path.exists(template_path):
|
||||
raise FileNotFoundError(f'Prompt file {template_path} not found')
|
||||
|
||||
Generated
+4
-37
@@ -1562,17 +1562,6 @@ files = [
|
||||
{file = "dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diskcache"
|
||||
version = "5.6.3"
|
||||
description = "Disk Cache -- Disk and file backed persistent cache."
|
||||
optional = false
|
||||
python-versions = ">=3"
|
||||
files = [
|
||||
{file = "diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19"},
|
||||
{file = "diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.3.9"
|
||||
@@ -3945,13 +3934,13 @@ types-tqdm = "*"
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.52.5"
|
||||
version = "1.52.3"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
optional = false
|
||||
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
|
||||
files = [
|
||||
{file = "litellm-1.52.5-py3-none-any.whl", hash = "sha256:38c0f30a849b80c99cfc56f96c4c7563d5ced83f08fd7fc2129011ddc4414ac5"},
|
||||
{file = "litellm-1.52.5.tar.gz", hash = "sha256:9708c02983c7ed22fc18c96e167bf1c4ed9672de397d413e7957c216dfc911e6"},
|
||||
{file = "litellm-1.52.3-py3-none-any.whl", hash = "sha256:fc8d5d53ba184cd570ae50d9acefa53c521225b62244adedea129794e98828b6"},
|
||||
{file = "litellm-1.52.3.tar.gz", hash = "sha256:4718235cbd6dea8db99b08e884a07f7ac7fad4a4b12597e20d8ff622295e1e05"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5640,28 +5629,6 @@ files = [
|
||||
[package.dependencies]
|
||||
numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""}
|
||||
|
||||
[[package]]
|
||||
name = "openhands-aci"
|
||||
version = "0.1.0"
|
||||
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.12"
|
||||
files = [
|
||||
{file = "openhands_aci-0.1.0-py3-none-any.whl", hash = "sha256:f28e5a32e394d1e643f79bf8af27fe44d039cb71729d590f9f3ee0c23c075f00"},
|
||||
{file = "openhands_aci-0.1.0.tar.gz", hash = "sha256:babc55f516efbb27eb7e528662e14b75c902965c48a110408fda824b83ea4461"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
diskcache = ">=5.6.3,<6.0.0"
|
||||
gitpython = "*"
|
||||
grep-ast = "0.3.3"
|
||||
litellm = "*"
|
||||
networkx = "*"
|
||||
numpy = "*"
|
||||
pandas = "*"
|
||||
scipy = "*"
|
||||
tree-sitter = "0.21.3"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.25.0"
|
||||
@@ -10211,4 +10178,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "a552f630dfdb9221eda6932e71e67a935c52ebfe4388ec9ef4b3245e7df2f82b"
|
||||
content-hash = "245fd4cd56a3c95b2dd4f3a06251f7de82ad0300de7349f0710aac1f92a151b7"
|
||||
|
||||
@@ -63,7 +63,6 @@ opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
|
||||
modal = "^0.64.145"
|
||||
runloop-api-client = "0.7.0"
|
||||
pygithub = "^2.5.0"
|
||||
openhands-aci = "^0.1.0"
|
||||
|
||||
[tool.poetry.group.llama-index.dependencies]
|
||||
llama-index = "*"
|
||||
|
||||
@@ -224,7 +224,6 @@ def _load_runtime(
|
||||
config = load_app_config()
|
||||
config.run_as_openhands = run_as_openhands
|
||||
config.sandbox.force_rebuild_runtime = force_rebuild_runtime
|
||||
config.sandbox.keep_runtime_alive = False
|
||||
# Folder where all tests create their own folder
|
||||
global test_mount_path
|
||||
if use_workspace:
|
||||
|
||||
@@ -64,7 +64,7 @@ def get_config(
|
||||
timeout=300,
|
||||
api_key=os.environ.get('ALLHANDS_API_KEY', None),
|
||||
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
|
||||
keep_runtime_alive=False,
|
||||
keep_remote_runtime_alive=False,
|
||||
),
|
||||
# do not mount workspace
|
||||
workspace_base=None,
|
||||
|
||||
@@ -65,7 +65,7 @@ def test_event_props_serialization_deserialization():
|
||||
'action': 'message',
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'image_urls': None,
|
||||
'images_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
@@ -77,7 +77,7 @@ def test_message_action_serialization_deserialization():
|
||||
'action': 'message',
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'image_urls': None,
|
||||
'images_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import sys
|
||||
import docx
|
||||
import pytest
|
||||
|
||||
from openhands.runtime.plugins.agent_skills.agentskills import file_editor
|
||||
from openhands.runtime.plugins.agent_skills.file_ops.file_ops import (
|
||||
WINDOW,
|
||||
_print_window,
|
||||
@@ -780,7 +781,7 @@ def test_file_editor_create(tmp_path):
|
||||
assert result is not None
|
||||
assert (
|
||||
result
|
||||
== f'ERROR:\nInvalid `path` parameter: {random_file}. The path {random_file} does not exist. Please provide a valid path.'
|
||||
== f'ERROR:\nThe path {random_file} does not exist. Please provide a valid path.'
|
||||
)
|
||||
|
||||
# create a file
|
||||
@@ -799,3 +800,218 @@ def test_file_editor_create(tmp_path):
|
||||
1\tLine 6
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_file(tmp_path):
|
||||
random_dir = tmp_path / 'dir_1'
|
||||
random_dir.mkdir()
|
||||
random_file = random_dir / 'a.txt'
|
||||
return random_file
|
||||
|
||||
|
||||
def test_file_editor_create_and_view(setup_file):
|
||||
random_file = setup_file
|
||||
|
||||
# Test create command
|
||||
result = file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
print(result)
|
||||
assert result == f'File created successfully at: {random_file}'
|
||||
|
||||
# Test view command for file
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
print(result)
|
||||
assert (
|
||||
result.strip().split('\n')
|
||||
== f"""Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tLine 3
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
# Test view command for directory
|
||||
result = file_editor(command='view', path=str(random_file.parent))
|
||||
assert f'{random_file.parent}' in result
|
||||
assert f'{random_file.name}' in result
|
||||
|
||||
|
||||
def test_file_editor_view_nonexistent(setup_file):
|
||||
random_file = setup_file
|
||||
|
||||
# Test view command for non-existent file
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
assert (
|
||||
result
|
||||
== f'ERROR:\nThe path {random_file} does not exist. Please provide a valid path.'
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_str_replace(setup_file):
|
||||
random_file = setup_file
|
||||
file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
|
||||
# Test str_replace command
|
||||
result = file_editor(
|
||||
command='str_replace',
|
||||
path=str(random_file),
|
||||
old_str='Line 2',
|
||||
new_str='New Line 2',
|
||||
)
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f"""The file {random_file} has been edited. Here's the result of running `cat -n` on a snippet of {random_file}:
|
||||
1\tLine 1
|
||||
2\tNew Line 2
|
||||
3\tLine 3
|
||||
Review the changes and make sure they are as expected. Edit the file again if necessary."""
|
||||
)
|
||||
|
||||
# View the file after str_replace
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
print(result)
|
||||
assert (
|
||||
result.strip().split('\n')
|
||||
== f"""Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tNew Line 2
|
||||
3\tLine 3
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_str_replace_non_existent(setup_file):
|
||||
random_file = setup_file
|
||||
file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
|
||||
# Test str_replace with non-existent string
|
||||
result = file_editor(
|
||||
command='str_replace',
|
||||
path=str(random_file),
|
||||
old_str='Non-existent Line',
|
||||
new_str='New Line',
|
||||
)
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f'ERROR:\nNo replacement was performed, old_str `Non-existent Line` did not appear verbatim in {random_file}.'
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_insert(setup_file):
|
||||
random_file = setup_file
|
||||
file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
|
||||
# Test insert command
|
||||
result = file_editor(
|
||||
command='insert', path=str(random_file), insert_line=2, new_str='Inserted Line'
|
||||
)
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f"""The file {random_file} has been edited. Here's the result of running `cat -n` on a snippet of the edited file:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tInserted Line
|
||||
4\tLine 3
|
||||
Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary."""
|
||||
)
|
||||
|
||||
# View the file after insert
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
assert (
|
||||
result.strip().split('\n')
|
||||
== f"""Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tInserted Line
|
||||
4\tLine 3
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_insert_invalid_line(setup_file):
|
||||
random_file = setup_file
|
||||
file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
|
||||
# Test insert with invalid line number
|
||||
result = file_editor(
|
||||
command='insert',
|
||||
path=str(random_file),
|
||||
insert_line=10,
|
||||
new_str='Invalid Insert',
|
||||
)
|
||||
assert (
|
||||
result
|
||||
== 'ERROR:\nInvalid `insert_line` parameter: 10. It should be within the range of lines of the file: [0, 3]'
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_undo_edit(setup_file):
|
||||
random_file = setup_file
|
||||
result = file_editor(
|
||||
command='create', path=str(random_file), file_text='Line 1\nLine 2\nLine 3'
|
||||
)
|
||||
print(result)
|
||||
assert result == f"""File created successfully at: {random_file}"""
|
||||
|
||||
# Make an edit
|
||||
result = file_editor(
|
||||
command='str_replace',
|
||||
path=str(random_file),
|
||||
old_str='Line 2',
|
||||
new_str='New Line 2',
|
||||
)
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f"""The file {random_file} has been edited. Here's the result of running `cat -n` on a snippet of {random_file}:
|
||||
1\tLine 1
|
||||
2\tNew Line 2
|
||||
3\tLine 3
|
||||
Review the changes and make sure they are as expected. Edit the file again if necessary."""
|
||||
)
|
||||
|
||||
# Test undo_edit command
|
||||
result = file_editor(command='undo_edit', path=str(random_file))
|
||||
print(result)
|
||||
assert (
|
||||
result
|
||||
== f"""Last edit to {random_file} undone successfully. Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tLine 3
|
||||
"""
|
||||
)
|
||||
|
||||
# View the file after undo_edit
|
||||
result = file_editor(command='view', path=str(random_file))
|
||||
assert (
|
||||
result.strip().split('\n')
|
||||
== f"""Here's the result of running `cat -n` on {random_file}:
|
||||
1\tLine 1
|
||||
2\tLine 2
|
||||
3\tLine 3
|
||||
""".strip().split('\n')
|
||||
)
|
||||
|
||||
|
||||
def test_file_editor_undo_edit_no_edits(tmp_path):
|
||||
random_file = tmp_path / 'a.txt'
|
||||
random_file.touch()
|
||||
|
||||
# Test undo_edit when no edits have been made
|
||||
result = file_editor(command='undo_edit', path=str(random_file))
|
||||
print(result)
|
||||
assert result == f'ERROR:\nNo edit history found for {random_file}.'
|
||||
|
||||
@@ -17,7 +17,7 @@ def test_event_serialization_deserialization():
|
||||
'message': 'This is a test.',
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'image_urls': None,
|
||||
'images_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
@@ -38,7 +38,7 @@ def test_array_serialization_deserialization():
|
||||
'message': 'This is a test.',
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'image_urls': None,
|
||||
'images_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -119,63 +119,3 @@ def test_prompt_manager_template_rendering(prompt_dir, agent_skills_docs):
|
||||
# Clean up temporary files
|
||||
os.remove(os.path.join(prompt_dir, 'system_prompt.j2'))
|
||||
os.remove(os.path.join(prompt_dir, 'user_prompt.j2'))
|
||||
|
||||
|
||||
def test_prompt_manager_disabled_microagents(prompt_dir, agent_skills_docs):
|
||||
# Create test microagent files
|
||||
microagent1_name = 'test_microagent1'
|
||||
microagent2_name = 'test_microagent2'
|
||||
microagent1_content = """
|
||||
---
|
||||
name: Test Microagent 1
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- test1
|
||||
---
|
||||
|
||||
Test microagent 1 content
|
||||
"""
|
||||
microagent2_content = """
|
||||
---
|
||||
name: Test Microagent 2
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- test2
|
||||
---
|
||||
|
||||
Test microagent 2 content
|
||||
"""
|
||||
|
||||
# Create temporary micro agent files
|
||||
os.makedirs(os.path.join(prompt_dir, 'micro'), exist_ok=True)
|
||||
with open(os.path.join(prompt_dir, 'micro', f'{microagent1_name}.md'), 'w') as f:
|
||||
f.write(microagent1_content)
|
||||
with open(os.path.join(prompt_dir, 'micro', f'{microagent2_name}.md'), 'w') as f:
|
||||
f.write(microagent2_content)
|
||||
|
||||
# Test that specific microagents can be disabled
|
||||
manager = PromptManager(
|
||||
prompt_dir=prompt_dir,
|
||||
microagent_dir=os.path.join(prompt_dir, 'micro'),
|
||||
agent_skills_docs=agent_skills_docs,
|
||||
disabled_microagents=['Test Microagent 1'],
|
||||
)
|
||||
|
||||
assert len(manager.microagents) == 1
|
||||
assert 'Test Microagent 2' in manager.microagents
|
||||
assert 'Test Microagent 1' not in manager.microagents
|
||||
|
||||
# Test that all microagents are enabled by default
|
||||
manager = PromptManager(
|
||||
prompt_dir=prompt_dir,
|
||||
microagent_dir=os.path.join(prompt_dir, 'micro'),
|
||||
agent_skills_docs=agent_skills_docs,
|
||||
)
|
||||
|
||||
assert len(manager.microagents) == 2
|
||||
assert 'Test Microagent 1' in manager.microagents
|
||||
assert 'Test Microagent 2' in manager.microagents
|
||||
|
||||
# Clean up temporary files
|
||||
os.remove(os.path.join(prompt_dir, 'micro', f'{microagent1_name}.md'))
|
||||
os.remove(os.path.join(prompt_dir, 'micro', f'{microagent2_name}.md'))
|
||||
|
||||
Reference in New Issue
Block a user