mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
Support list_files and get_trajectory for nested conversation managers (#12850)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: OpenHands Bot <contact@all-hands.dev>
This commit is contained in:
@@ -1139,6 +1139,71 @@ class SaasNestedConversationManager(ConversationManager):
|
|||||||
}
|
}
|
||||||
update_conversation_metadata(conversation_id, metadata_content)
|
update_conversation_metadata(conversation_id, metadata_content)
|
||||||
|
|
||||||
|
async def list_files(self, sid: str, path: str | None = None) -> list[str]:
|
||||||
|
"""List files in the workspace for a conversation.
|
||||||
|
|
||||||
|
Delegates to the nested container's list-files endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID.
|
||||||
|
path: Optional path to list files from. If None, lists from workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of file paths.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the conversation is not running.
|
||||||
|
httpx.HTTPError: If there's an error communicating with the nested runtime.
|
||||||
|
"""
|
||||||
|
runtime = await self._get_runtime(sid)
|
||||||
|
if runtime is None or runtime.get('status') != 'running':
|
||||||
|
raise ValueError(f'Conversation {sid} is not running')
|
||||||
|
|
||||||
|
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
|
||||||
|
session_api_key = runtime.get('session_api_key')
|
||||||
|
|
||||||
|
return await self._fetch_list_files_from_nested(
|
||||||
|
sid, nested_url, session_api_key, path
|
||||||
|
)
|
||||||
|
|
||||||
|
async def select_file(self, sid: str, file: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Read a file from the workspace via nested container.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the conversation is not running.
|
||||||
|
httpx.HTTPError: If there's an error communicating with the nested runtime.
|
||||||
|
"""
|
||||||
|
runtime = await self._get_runtime(sid)
|
||||||
|
if runtime is None or runtime.get('status') != 'running':
|
||||||
|
raise ValueError(f'Conversation {sid} is not running')
|
||||||
|
|
||||||
|
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
|
||||||
|
session_api_key = runtime.get('session_api_key')
|
||||||
|
|
||||||
|
return await self._fetch_select_file_from_nested(
|
||||||
|
sid, nested_url, session_api_key, file
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upload_files(
|
||||||
|
self, sid: str, files: list[tuple[str, bytes]]
|
||||||
|
) -> tuple[list[str], list[dict[str, str]]]:
|
||||||
|
"""Upload files to the workspace via nested container.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the conversation is not running.
|
||||||
|
httpx.HTTPError: If there's an error communicating with the nested runtime.
|
||||||
|
"""
|
||||||
|
runtime = await self._get_runtime(sid)
|
||||||
|
if runtime is None or runtime.get('status') != 'running':
|
||||||
|
raise ValueError(f'Conversation {sid} is not running')
|
||||||
|
|
||||||
|
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
|
||||||
|
session_api_key = runtime.get('session_api_key')
|
||||||
|
|
||||||
|
return await self._fetch_upload_files_to_nested(
|
||||||
|
sid, nested_url, session_api_key, files
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
|
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
|
||||||
last_updated_at = conversation.last_updated_at
|
last_updated_at = conversation.last_updated_at
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
import httpx
|
||||||
import socketio
|
import socketio
|
||||||
|
|
||||||
from openhands.core.config import OpenHandsConfig
|
from openhands.core.config import OpenHandsConfig
|
||||||
from openhands.core.config.llm_config import LLMConfig
|
from openhands.core.config.llm_config import LLMConfig
|
||||||
|
from openhands.core.logger import openhands_logger as logger
|
||||||
from openhands.events.action import MessageAction
|
from openhands.events.action import MessageAction
|
||||||
from openhands.server.config.server_config import ServerConfig
|
from openhands.server.config.server_config import ServerConfig
|
||||||
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
|
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
|
||||||
@@ -23,6 +25,7 @@ from openhands.server.session.conversation import ServerConversation
|
|||||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||||
from openhands.storage.data_models.settings import Settings
|
from openhands.storage.data_models.settings import Settings
|
||||||
from openhands.storage.files import FileStore
|
from openhands.storage.files import FileStore
|
||||||
|
from openhands.utils.http_session import httpx_verify_option
|
||||||
|
|
||||||
|
|
||||||
class ConversationManager(ABC):
|
class ConversationManager(ABC):
|
||||||
@@ -155,6 +158,208 @@ class ConversationManager(ABC):
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Request extraneous llm completions for a conversation"""
|
"""Request extraneous llm completions for a conversation"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_files(self, sid: str, path: str | None = None) -> list[str]:
|
||||||
|
"""List files in the workspace for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID.
|
||||||
|
path: Optional path to list files from. If None, lists from workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of file paths.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the conversation is not running (for nested managers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def select_file(self, sid: str, file: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Read a file from the workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID.
|
||||||
|
file: The file path relative to the workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (content, error). If successful, content is the file content
|
||||||
|
and error is None. If failed, content is None and error is the error message.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the conversation is not running (for nested managers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def upload_files(
|
||||||
|
self, sid: str, files: list[tuple[str, bytes]]
|
||||||
|
) -> tuple[list[str], list[dict[str, str]]]:
|
||||||
|
"""Upload files to the workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID.
|
||||||
|
files: List of (filename, content) tuples to upload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (uploaded_files, skipped_files) where uploaded_files is a list
|
||||||
|
of successfully uploaded file paths and skipped_files is a list of dicts
|
||||||
|
with 'name' and 'reason' keys for files that failed to upload.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the conversation is not running (for nested managers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _fetch_list_files_from_nested(
|
||||||
|
self,
|
||||||
|
sid: str,
|
||||||
|
nested_url: str,
|
||||||
|
session_api_key: str | None,
|
||||||
|
path: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Fetch file list from a nested runtime container.
|
||||||
|
|
||||||
|
This is a helper method used by nested conversation managers to make HTTP
|
||||||
|
requests to the nested runtime's list-files endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID (for logging).
|
||||||
|
nested_url: The base URL of the nested runtime.
|
||||||
|
session_api_key: The session API key for authentication.
|
||||||
|
path: Optional path to list files from.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of file paths.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.TimeoutException: If the request times out.
|
||||||
|
httpx.ConnectError: If unable to connect to the nested runtime.
|
||||||
|
httpx.HTTPStatusError: If the nested runtime returns an error status.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
verify=httpx_verify_option(),
|
||||||
|
headers={'X-Session-API-Key': session_api_key} if session_api_key else {},
|
||||||
|
) as client:
|
||||||
|
params = {'path': path} if path else {}
|
||||||
|
try:
|
||||||
|
response = await client.get(f'{nested_url}/list-files', params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error(
|
||||||
|
'Timeout fetching files from nested runtime',
|
||||||
|
extra={'session_id': sid},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
logger.error(
|
||||||
|
f'Failed to connect to nested runtime: {e}',
|
||||||
|
extra={'session_id': sid},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f'Nested runtime returned error: {e.response.status_code}',
|
||||||
|
extra={'session_id': sid},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _fetch_select_file_from_nested(
|
||||||
|
self,
|
||||||
|
sid: str,
|
||||||
|
nested_url: str,
|
||||||
|
session_api_key: str | None,
|
||||||
|
file: str,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Fetch file content from a nested runtime container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID (for logging).
|
||||||
|
nested_url: The base URL of the nested runtime.
|
||||||
|
session_api_key: The session API key for authentication.
|
||||||
|
file: The file path to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (content, error).
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
verify=httpx_verify_option(),
|
||||||
|
headers={'X-Session-API-Key': session_api_key} if session_api_key else {},
|
||||||
|
) as client:
|
||||||
|
params = {'file': file}
|
||||||
|
try:
|
||||||
|
response = await client.get(f'{nested_url}/select-file', params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get('code'), None
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 415:
|
||||||
|
return None, f'BINARY_FILE:{file}'
|
||||||
|
error_data = e.response.json() if e.response.content else {}
|
||||||
|
return None, error_data.get('error', str(e))
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error(
|
||||||
|
'Timeout fetching file from nested runtime',
|
||||||
|
extra={'session_id': sid},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
logger.error(
|
||||||
|
f'Failed to connect to nested runtime: {e}',
|
||||||
|
extra={'session_id': sid},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _fetch_upload_files_to_nested(
|
||||||
|
self,
|
||||||
|
sid: str,
|
||||||
|
nested_url: str,
|
||||||
|
session_api_key: str | None,
|
||||||
|
files: list[tuple[str, bytes]],
|
||||||
|
) -> tuple[list[str], list[dict[str, str]]]:
|
||||||
|
"""Upload files to a nested runtime container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID (for logging).
|
||||||
|
nested_url: The base URL of the nested runtime.
|
||||||
|
session_api_key: The session API key for authentication.
|
||||||
|
files: List of (filename, content) tuples to upload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (uploaded_files, skipped_files).
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
verify=httpx_verify_option(),
|
||||||
|
headers={'X-Session-API-Key': session_api_key} if session_api_key else {},
|
||||||
|
) as client:
|
||||||
|
try:
|
||||||
|
# Build multipart form data
|
||||||
|
multipart_files = [
|
||||||
|
('files', (filename, content)) for filename, content in files
|
||||||
|
]
|
||||||
|
response = await client.post(
|
||||||
|
f'{nested_url}/upload-files', files=multipart_files
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get('uploaded_files', []), data.get('skipped_files', [])
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error(
|
||||||
|
'Timeout uploading files to nested runtime',
|
||||||
|
extra={'session_id': sid},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
logger.error(
|
||||||
|
f'Failed to connect to nested runtime: {e}',
|
||||||
|
extra={'session_id': sid},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f'Nested runtime returned error: {e.response.status_code}',
|
||||||
|
extra={'session_id': sid},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_instance(
|
def get_instance(
|
||||||
|
|||||||
@@ -644,6 +644,68 @@ class DockerNestedConversationManager(ConversationManager):
|
|||||||
except docker.errors.NotFound:
|
except docker.errors.NotFound:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def list_files(self, sid: str, path: str | None = None) -> list[str]:
|
||||||
|
"""List files in the workspace for a conversation.
|
||||||
|
|
||||||
|
Delegates to the nested container's list-files endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID.
|
||||||
|
path: Optional path to list files from. If None, lists from workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of file paths.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the conversation is not running.
|
||||||
|
httpx.HTTPError: If there's an error communicating with the nested runtime.
|
||||||
|
"""
|
||||||
|
if not await self.is_agent_loop_running(sid):
|
||||||
|
raise ValueError(f'Conversation {sid} is not running')
|
||||||
|
|
||||||
|
nested_url = self._get_nested_url(sid)
|
||||||
|
session_api_key = self._get_session_api_key_for_conversation(sid)
|
||||||
|
|
||||||
|
return await self._fetch_list_files_from_nested(
|
||||||
|
sid, nested_url, session_api_key, path
|
||||||
|
)
|
||||||
|
|
||||||
|
async def select_file(self, sid: str, file: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Read a file from the workspace via nested container.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the conversation is not running.
|
||||||
|
httpx.HTTPError: If there's an error communicating with the nested runtime.
|
||||||
|
"""
|
||||||
|
if not await self.is_agent_loop_running(sid):
|
||||||
|
raise ValueError(f'Conversation {sid} is not running')
|
||||||
|
|
||||||
|
nested_url = self._get_nested_url(sid)
|
||||||
|
session_api_key = self._get_session_api_key_for_conversation(sid)
|
||||||
|
|
||||||
|
return await self._fetch_select_file_from_nested(
|
||||||
|
sid, nested_url, session_api_key, file
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upload_files(
|
||||||
|
self, sid: str, files: list[tuple[str, bytes]]
|
||||||
|
) -> tuple[list[str], list[dict[str, str]]]:
|
||||||
|
"""Upload files to the workspace via nested container.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the conversation is not running.
|
||||||
|
httpx.HTTPError: If there's an error communicating with the nested runtime.
|
||||||
|
"""
|
||||||
|
if not await self.is_agent_loop_running(sid):
|
||||||
|
raise ValueError(f'Conversation {sid} is not running')
|
||||||
|
|
||||||
|
nested_url = self._get_nested_url(sid)
|
||||||
|
session_api_key = self._get_session_api_key_for_conversation(sid)
|
||||||
|
|
||||||
|
return await self._fetch_upload_files_to_nested(
|
||||||
|
sid, nested_url, session_api_key, files
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
|
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
|
||||||
last_updated_at = conversation.last_updated_at
|
last_updated_at = conversation.last_updated_at
|
||||||
|
|||||||
@@ -7,12 +7,15 @@
|
|||||||
# Tag: Legacy-V0
|
# Tag: Legacy-V0
|
||||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Callable, Iterable
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
import socketio
|
import socketio
|
||||||
|
from pathspec import PathSpec
|
||||||
|
from pathspec.patterns import GitWildMatchPattern
|
||||||
|
|
||||||
from openhands.core.config.llm_config import LLMConfig
|
from openhands.core.config.llm_config import LLMConfig
|
||||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||||
@@ -20,7 +23,7 @@ from openhands.core.exceptions import AgentRuntimeUnavailableError
|
|||||||
from openhands.core.logger import openhands_logger as logger
|
from openhands.core.logger import openhands_logger as logger
|
||||||
from openhands.core.schema.agent import AgentState
|
from openhands.core.schema.agent import AgentState
|
||||||
from openhands.core.schema.observation import ObservationType
|
from openhands.core.schema.observation import ObservationType
|
||||||
from openhands.events.action import MessageAction
|
from openhands.events.action import FileReadAction, MessageAction
|
||||||
from openhands.events.observation.commands import CmdOutputObservation
|
from openhands.events.observation.commands import CmdOutputObservation
|
||||||
from openhands.events.stream import EventStreamSubscriber, session_exists
|
from openhands.events.stream import EventStreamSubscriber, session_exists
|
||||||
from openhands.llm.llm_registry import LLMRegistry
|
from openhands.llm.llm_registry import LLMRegistry
|
||||||
@@ -40,6 +43,7 @@ from openhands.storage.files import FileStore
|
|||||||
from openhands.utils.async_utils import (
|
from openhands.utils.async_utils import (
|
||||||
GENERAL_TIMEOUT,
|
GENERAL_TIMEOUT,
|
||||||
call_async_from_sync,
|
call_async_from_sync,
|
||||||
|
call_sync_from_async,
|
||||||
run_in_loop,
|
run_in_loop,
|
||||||
wait_all,
|
wait_all,
|
||||||
)
|
)
|
||||||
@@ -434,6 +438,136 @@ class StandaloneConversationManager(ConversationManager):
|
|||||||
return session.agent_session
|
return session.agent_session
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def list_files(self, sid: str, path: str | None = None) -> list[str]:
|
||||||
|
"""List files in the workspace for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID.
|
||||||
|
path: Optional path to list files from. If None, lists from workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of file paths, filtered by .gitignore rules if present.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the runtime is not available.
|
||||||
|
"""
|
||||||
|
agent_session = self.get_agent_session(sid)
|
||||||
|
if not agent_session or not agent_session.runtime:
|
||||||
|
raise ValueError(f'Runtime not available for conversation {sid}')
|
||||||
|
|
||||||
|
runtime = agent_session.runtime
|
||||||
|
file_list = await call_sync_from_async(runtime.list_files, path)
|
||||||
|
|
||||||
|
# runtime.list_files returns relative filenames within the specified directory,
|
||||||
|
# so we need to join with the path to get paths relative to workspace root
|
||||||
|
if path:
|
||||||
|
file_list = [os.path.join(path, f) for f in file_list]
|
||||||
|
|
||||||
|
file_list = await self._filter_for_gitignore(runtime, file_list)
|
||||||
|
return file_list
|
||||||
|
|
||||||
|
async def _filter_for_gitignore(
|
||||||
|
self, runtime: Any, file_list: list[str]
|
||||||
|
) -> list[str]:
|
||||||
|
"""Filter file list based on root-level .gitignore rules.
|
||||||
|
|
||||||
|
Note: Only the .gitignore file at the workspace root is supported.
|
||||||
|
Nested .gitignore files in subdirectories are not processed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
runtime: The runtime to read the .gitignore file from.
|
||||||
|
file_list: List of file paths to filter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list of files excluding those matching .gitignore patterns.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
read_action = FileReadAction('.gitignore')
|
||||||
|
observation = await call_sync_from_async(runtime.run_action, read_action)
|
||||||
|
spec = PathSpec.from_lines(
|
||||||
|
GitWildMatchPattern, observation.content.splitlines()
|
||||||
|
)
|
||||||
|
file_list = [entry for entry in file_list if not spec.match_file(entry)]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'Could not read .gitignore for filtering: {e}')
|
||||||
|
return file_list
|
||||||
|
|
||||||
|
async def select_file(self, sid: str, file: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Read a file from the workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID.
|
||||||
|
file: The file path relative to the workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (content, error). If successful, content is the file content
|
||||||
|
and error is None. If failed, content is None and error is the error message.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the runtime is not available.
|
||||||
|
"""
|
||||||
|
from openhands.events.observation import ErrorObservation, FileReadObservation
|
||||||
|
|
||||||
|
agent_session = self.get_agent_session(sid)
|
||||||
|
if not agent_session or not agent_session.runtime:
|
||||||
|
raise ValueError(f'Runtime not available for conversation {sid}')
|
||||||
|
|
||||||
|
runtime = agent_session.runtime
|
||||||
|
file_path = os.path.join(runtime.config.workspace_mount_path_in_sandbox, file)
|
||||||
|
read_action = FileReadAction(file_path)
|
||||||
|
|
||||||
|
observation = await call_sync_from_async(runtime.run_action, read_action)
|
||||||
|
|
||||||
|
if isinstance(observation, FileReadObservation):
|
||||||
|
return observation.content, None
|
||||||
|
elif isinstance(observation, ErrorObservation):
|
||||||
|
if 'ERROR_BINARY_FILE' in observation.message:
|
||||||
|
return None, f'BINARY_FILE:{file}'
|
||||||
|
return None, str(observation)
|
||||||
|
else:
|
||||||
|
return None, f'Unexpected observation type: {type(observation)}'
|
||||||
|
|
||||||
|
async def upload_files(
|
||||||
|
self, sid: str, files: list[tuple[str, bytes]]
|
||||||
|
) -> tuple[list[str], list[dict[str, str]]]:
|
||||||
|
"""Upload files to the workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sid: The session/conversation ID.
|
||||||
|
files: List of (filename, content) tuples to upload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (uploaded_files, skipped_files).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the runtime is not available.
|
||||||
|
"""
|
||||||
|
from openhands.events.action.files import FileWriteAction
|
||||||
|
|
||||||
|
agent_session = self.get_agent_session(sid)
|
||||||
|
if not agent_session or not agent_session.runtime:
|
||||||
|
raise ValueError(f'Runtime not available for conversation {sid}')
|
||||||
|
|
||||||
|
runtime = agent_session.runtime
|
||||||
|
uploaded_files: list[str] = []
|
||||||
|
skipped_files: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
for filename, content in files:
|
||||||
|
file_path = os.path.join(
|
||||||
|
runtime.config.workspace_mount_path_in_sandbox, filename
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
write_action = FileWriteAction(
|
||||||
|
path=file_path,
|
||||||
|
content=content.decode('utf-8', errors='replace'),
|
||||||
|
)
|
||||||
|
await call_sync_from_async(runtime.run_action, write_action)
|
||||||
|
uploaded_files.append(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
skipped_files.append({'name': filename, 'reason': str(e)})
|
||||||
|
|
||||||
|
return uploaded_files, skipped_files
|
||||||
|
|
||||||
async def _close_session(self, sid: str):
|
async def _close_session(self, sid: str):
|
||||||
logger.info(f'_close_session:{sid}', extra={'session_id': sid})
|
logger.info(f'_close_session:{sid}', extra={'session_id': sid})
|
||||||
|
|
||||||
|
|||||||
@@ -9,30 +9,27 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from pathspec import PathSpec
|
|
||||||
from pathspec.patterns import GitWildMatchPattern
|
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from openhands.core.exceptions import AgentRuntimeUnavailableError
|
from openhands.core.exceptions import AgentRuntimeUnavailableError
|
||||||
from openhands.core.logger import openhands_logger as logger
|
from openhands.core.logger import openhands_logger as logger
|
||||||
from openhands.events.action import (
|
|
||||||
FileReadAction,
|
|
||||||
)
|
|
||||||
from openhands.events.action.files import FileWriteAction
|
|
||||||
from openhands.events.observation import (
|
|
||||||
ErrorObservation,
|
|
||||||
FileReadObservation,
|
|
||||||
)
|
|
||||||
from openhands.runtime.base import Runtime
|
from openhands.runtime.base import Runtime
|
||||||
from openhands.server.dependencies import get_dependencies
|
from openhands.server.dependencies import get_dependencies
|
||||||
from openhands.server.file_config import FILES_TO_IGNORE
|
from openhands.server.file_config import FILES_TO_IGNORE
|
||||||
from openhands.server.files import POSTUploadFilesModel
|
from openhands.server.files import POSTUploadFilesModel
|
||||||
from openhands.server.session.conversation import ServerConversation
|
from openhands.server.session.conversation import ServerConversation
|
||||||
|
from openhands.server.shared import conversation_manager
|
||||||
from openhands.server.user_auth import get_user_id
|
from openhands.server.user_auth import get_user_id
|
||||||
from openhands.server.utils import get_conversation, get_conversation_store
|
from openhands.server.utils import (
|
||||||
|
get_conversation,
|
||||||
|
get_conversation_metadata,
|
||||||
|
get_conversation_store,
|
||||||
|
)
|
||||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||||
|
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||||
from openhands.utils.async_utils import call_sync_from_async
|
from openhands.utils.async_utils import call_sync_from_async
|
||||||
|
|
||||||
app = APIRouter(
|
app = APIRouter(
|
||||||
@@ -50,7 +47,7 @@ app = APIRouter(
|
|||||||
deprecated=True,
|
deprecated=True,
|
||||||
)
|
)
|
||||||
async def list_files(
|
async def list_files(
|
||||||
conversation: ServerConversation = Depends(get_conversation),
|
metadata: ConversationMetadata = Depends(get_conversation_metadata),
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
) -> list[str] | JSONResponse:
|
) -> list[str] | JSONResponse:
|
||||||
"""List files in the specified path.
|
"""List files in the specified path.
|
||||||
@@ -64,7 +61,7 @@ async def list_files(
|
|||||||
```
|
```
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request (Request): The incoming request object.
|
metadata: The conversation metadata (provides conversation_id and user access validation).
|
||||||
path (str, optional): The path to list files from. Defaults to None.
|
path (str, optional): The path to list files from. Defaults to None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -76,49 +73,50 @@ async def list_files(
|
|||||||
For V1 conversations, file operations are handled through the agent server.
|
For V1 conversations, file operations are handled through the agent server.
|
||||||
Use the sandbox's exposed agent server URL to access file operations.
|
Use the sandbox's exposed agent server URL to access file operations.
|
||||||
"""
|
"""
|
||||||
if not conversation.runtime:
|
conversation_id = metadata.conversation_id
|
||||||
|
try:
|
||||||
|
file_list = await conversation_manager.list_files(conversation_id, path)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f'Error listing files: {e}')
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
content={'error': 'Runtime not yet initialized'},
|
content={'error': str(e)},
|
||||||
)
|
)
|
||||||
|
|
||||||
runtime: Runtime = conversation.runtime
|
|
||||||
try:
|
|
||||||
file_list = await call_sync_from_async(runtime.list_files, path)
|
|
||||||
except AgentRuntimeUnavailableError as e:
|
except AgentRuntimeUnavailableError as e:
|
||||||
logger.error(f'Error listing files: {e}')
|
logger.error(f'Error listing files: {e}')
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content={'error': f'Error listing files: {e}'},
|
content={'error': f'Error listing files: {e}'},
|
||||||
)
|
)
|
||||||
if path:
|
except httpx.TimeoutException:
|
||||||
file_list = [os.path.join(path, f) for f in file_list]
|
logger.error(f'Timeout listing files for conversation {conversation_id}')
|
||||||
|
return JSONResponse(
|
||||||
file_list = [f for f in file_list if f not in FILES_TO_IGNORE]
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
content={'error': 'Request to runtime timed out'},
|
||||||
async def filter_for_gitignore(file_list: list[str], base_path: str) -> list[str]:
|
)
|
||||||
gitignore_path = os.path.join(base_path, '.gitignore')
|
except httpx.ConnectError:
|
||||||
try:
|
logger.error(
|
||||||
read_action = FileReadAction(gitignore_path)
|
f'Connection error listing files for conversation {conversation_id}'
|
||||||
observation = await call_sync_from_async(runtime.run_action, read_action)
|
)
|
||||||
spec = PathSpec.from_lines(
|
return JSONResponse(
|
||||||
GitWildMatchPattern, observation.content.splitlines()
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
)
|
content={'error': 'Unable to connect to runtime'},
|
||||||
except Exception as e:
|
)
|
||||||
logger.warning(e)
|
except httpx.HTTPStatusError as e:
|
||||||
return file_list
|
logger.error(f'HTTP error listing files: {e.response.status_code}')
|
||||||
file_list = [entry for entry in file_list if not spec.match_file(entry)]
|
return JSONResponse(
|
||||||
return file_list
|
status_code=e.response.status_code,
|
||||||
|
content={'error': f'Runtime returned error: {e.response.status_code}'},
|
||||||
try:
|
)
|
||||||
file_list = await filter_for_gitignore(file_list, '')
|
except Exception as e:
|
||||||
except AgentRuntimeUnavailableError as e:
|
logger.error(f'Error listing files: {e}')
|
||||||
logger.error(f'Error filtering files: {e}')
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content={'error': f'Error filtering files: {e}'},
|
content={'error': f'Error listing files: {e}'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
file_list = [f for f in file_list if f not in FILES_TO_IGNORE]
|
||||||
|
|
||||||
return file_list
|
return file_list
|
||||||
|
|
||||||
|
|
||||||
@@ -138,19 +136,19 @@ async def list_files(
|
|||||||
deprecated=True,
|
deprecated=True,
|
||||||
)
|
)
|
||||||
async def select_file(
|
async def select_file(
|
||||||
file: str, conversation: ServerConversation = Depends(get_conversation)
|
file: str,
|
||||||
|
metadata: ConversationMetadata = Depends(get_conversation_metadata),
|
||||||
) -> FileResponse | JSONResponse:
|
) -> FileResponse | JSONResponse:
|
||||||
"""Retrieve the content of a specified file.
|
"""Retrieve the content of a specified file.
|
||||||
|
|
||||||
To select a file:
|
To select a file:
|
||||||
```sh
|
```sh
|
||||||
curl http://localhost:3000/api/conversations/{conversation_id}select-file?file=<file_path>
|
curl http://localhost:3000/api/conversations/{conversation_id}/select-file?file=<file_path>
|
||||||
```
|
```
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file (str): The path of the file to be retrieved.
|
file (str): The path of the file to be retrieved (relative to workspace root).
|
||||||
Expect path to be absolute inside the runtime.
|
metadata: The conversation metadata (provides conversation_id and user access validation).
|
||||||
request (Request): The incoming request object.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary containing the file content.
|
dict: A dictionary containing the file content.
|
||||||
@@ -161,40 +159,53 @@ async def select_file(
|
|||||||
For V1 conversations, file operations are handled through the agent server.
|
For V1 conversations, file operations are handled through the agent server.
|
||||||
Use the sandbox's exposed agent server URL to access file operations.
|
Use the sandbox's exposed agent server URL to access file operations.
|
||||||
"""
|
"""
|
||||||
runtime: Runtime = conversation.runtime
|
conversation_id = metadata.conversation_id
|
||||||
|
|
||||||
file = os.path.join(runtime.config.workspace_mount_path_in_sandbox, file)
|
|
||||||
read_action = FileReadAction(file)
|
|
||||||
try:
|
try:
|
||||||
observation = await call_sync_from_async(runtime.run_action, read_action)
|
content, error = await conversation_manager.select_file(conversation_id, file)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f'Error opening file {file}: {e}')
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
content={'error': str(e)},
|
||||||
|
)
|
||||||
except AgentRuntimeUnavailableError as e:
|
except AgentRuntimeUnavailableError as e:
|
||||||
logger.error(f'Error opening file {file}: {e}')
|
logger.error(f'Error opening file {file}: {e}')
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content={'error': f'Error opening file: {e}'},
|
content={'error': f'Error opening file: {e}'},
|
||||||
)
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
if isinstance(observation, FileReadObservation):
|
logger.error(f'Timeout reading file for conversation {conversation_id}')
|
||||||
content = observation.content
|
return JSONResponse(
|
||||||
return JSONResponse(content={'code': content})
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
elif isinstance(observation, ErrorObservation):
|
content={'error': 'Request to runtime timed out'},
|
||||||
logger.error(f'Error opening file {file}: {observation}')
|
)
|
||||||
|
except httpx.ConnectError:
|
||||||
if 'ERROR_BINARY_FILE' in observation.message:
|
logger.error(
|
||||||
return JSONResponse(
|
f'Connection error reading file for conversation {conversation_id}'
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
)
|
||||||
content={'error': f'Unable to open binary file: {file}'},
|
return JSONResponse(
|
||||||
)
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
content={'error': 'Unable to connect to runtime'},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error opening file {file}: {e}')
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content={'error': f'Error opening file: {observation}'},
|
content={'error': f'Error opening file: {e}'},
|
||||||
|
)
|
||||||
|
|
||||||
|
if content is not None:
|
||||||
|
return JSONResponse(content={'code': content})
|
||||||
|
elif error and error.startswith('BINARY_FILE:'):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
content={'error': f'Unable to open binary file: {file}'},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Handle unexpected observation types
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content={'error': f'Unexpected observation type: {type(observation)}'},
|
content={'error': f'Error opening file: {error}'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -321,33 +332,58 @@ async def git_diff(
|
|||||||
@app.post('/upload-files', response_model=POSTUploadFilesModel, deprecated=True)
|
@app.post('/upload-files', response_model=POSTUploadFilesModel, deprecated=True)
|
||||||
async def upload_files(
|
async def upload_files(
|
||||||
files: list[UploadFile],
|
files: list[UploadFile],
|
||||||
conversation: ServerConversation = Depends(get_conversation),
|
metadata: ConversationMetadata = Depends(get_conversation_metadata),
|
||||||
):
|
):
|
||||||
"""Upload files to the workspace.
|
"""Upload files to the workspace.
|
||||||
|
|
||||||
For V1 conversations, file operations are handled through the agent server.
|
For V1 conversations, file operations are handled through the agent server.
|
||||||
Use the sandbox's exposed agent server URL to access file operations.
|
Use the sandbox's exposed agent server URL to access file operations.
|
||||||
"""
|
"""
|
||||||
uploaded_files = []
|
conversation_id = metadata.conversation_id
|
||||||
skipped_files = []
|
|
||||||
runtime: Runtime = conversation.runtime
|
|
||||||
|
|
||||||
|
# Read all file contents
|
||||||
|
file_data: list[tuple[str, bytes]] = []
|
||||||
for file in files:
|
for file in files:
|
||||||
file_path = os.path.join(
|
content = await file.read()
|
||||||
runtime.config.workspace_mount_path_in_sandbox, str(file.filename)
|
file_data.append((str(file.filename), content))
|
||||||
|
|
||||||
|
try:
|
||||||
|
uploaded_files, skipped_files = await conversation_manager.upload_files(
|
||||||
|
conversation_id, file_data
|
||||||
)
|
)
|
||||||
try:
|
except ValueError as e:
|
||||||
file_content = await file.read()
|
logger.error(f'Error uploading files: {e}')
|
||||||
write_action = FileWriteAction(
|
return JSONResponse(
|
||||||
# TODO: DISCUSS UTF8 encoding here
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
path=file_path,
|
content={'error': str(e)},
|
||||||
content=file_content.decode('utf-8', errors='replace'),
|
)
|
||||||
)
|
except httpx.TimeoutException:
|
||||||
# TODO: DISCUSS file name unique issues
|
logger.error(f'Timeout uploading files for conversation {conversation_id}')
|
||||||
await call_sync_from_async(runtime.run_action, write_action)
|
return JSONResponse(
|
||||||
uploaded_files.append(file_path)
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
except Exception as e:
|
content={'error': 'Request to runtime timed out'},
|
||||||
skipped_files.append({'name': file.filename, 'reason': str(e)})
|
)
|
||||||
|
except httpx.ConnectError:
|
||||||
|
logger.error(
|
||||||
|
f'Connection error uploading files for conversation {conversation_id}'
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
content={'error': 'Unable to connect to runtime'},
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f'HTTP error uploading files: {e.response.status_code}')
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
content={'error': f'Runtime returned error: {e.response.status_code}'},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error uploading files: {e}')
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={'error': f'Error uploading files: {e}'},
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
content={
|
content={
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ from fastapi.responses import JSONResponse
|
|||||||
from openhands.core.logger import openhands_logger as logger
|
from openhands.core.logger import openhands_logger as logger
|
||||||
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
|
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
|
||||||
from openhands.events.event_filter import EventFilter
|
from openhands.events.event_filter import EventFilter
|
||||||
|
from openhands.events.event_store import EventStore
|
||||||
from openhands.events.serialization import event_to_trajectory
|
from openhands.events.serialization import event_to_trajectory
|
||||||
from openhands.server.dependencies import get_dependencies
|
from openhands.server.dependencies import get_dependencies
|
||||||
from openhands.server.session.conversation import ServerConversation
|
from openhands.server.shared import file_store
|
||||||
from openhands.server.utils import get_conversation
|
from openhands.server.utils import get_conversation_metadata
|
||||||
|
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||||
|
|
||||||
app = APIRouter(
|
app = APIRouter(
|
||||||
prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies()
|
prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies()
|
||||||
@@ -24,22 +26,29 @@ app = APIRouter(
|
|||||||
|
|
||||||
@app.get('/trajectory')
|
@app.get('/trajectory')
|
||||||
async def get_trajectory(
|
async def get_trajectory(
|
||||||
conversation: ServerConversation = Depends(get_conversation),
|
metadata: ConversationMetadata = Depends(get_conversation_metadata),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""Get trajectory.
|
"""Get trajectory.
|
||||||
|
|
||||||
This function retrieves the current trajectory and returns it.
|
This function retrieves the current trajectory and returns it.
|
||||||
|
Uses the local EventStore which reads events from the file store,
|
||||||
|
so it works with both standalone and nested conversation managers.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request (Request): The incoming request object.
|
metadata: The conversation metadata (provides conversation_id and user access validation).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSONResponse: A JSON response containing the trajectory as a list of
|
JSONResponse: A JSON response containing the trajectory as a list of
|
||||||
events.
|
events.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
event_store = EventStore(
|
||||||
|
sid=metadata.conversation_id,
|
||||||
|
file_store=file_store,
|
||||||
|
user_id=metadata.user_id,
|
||||||
|
)
|
||||||
async_store = AsyncEventStoreWrapper(
|
async_store = AsyncEventStoreWrapper(
|
||||||
conversation.event_stream, filter=EventFilter(exclude_hidden=True)
|
event_store, filter=EventFilter(exclude_hidden=True)
|
||||||
)
|
)
|
||||||
trajectory = []
|
trajectory = []
|
||||||
async for event in async_store:
|
async for event in async_store:
|
||||||
|
|||||||
Reference in New Issue
Block a user