Compare commits

...

4 Commits

Author SHA1 Message Date
Engel Nyst
15ea9cdc9f Merge branch 'main' into openhands-fix-issue-4706 2024-12-07 07:27:15 +01:00
OpenHands Bot
d4d01b1a27 🤖 Auto-fix Python linting issues 2024-12-07 06:24:50 +00:00
openhands
b7e345aa36 Fix pr #5435: Fix issue #4706: [Feature]: Give descriptive name to downloaded zip file 2024-12-07 06:05:33 +00:00
openhands
567ce9b049 Fix issue #4706: [Feature]: Give descriptive name to downloaded zip file 2024-12-06 16:29:00 +00:00
5 changed files with 209 additions and 2 deletions

View File

@@ -12,6 +12,7 @@ from openhands.events.observation.files import (
FileReadObservation,
FileWriteObservation,
)
from openhands.events.observation.observation import Observation
from openhands.events.observation.reject import UserRejectObservation
from openhands.events.observation.success import SuccessObservation
@@ -30,4 +31,5 @@ __all__ = [
'AgentDelegateObservation',
'SuccessObservation',
'UserRejectObservation',
]

View File

@@ -323,14 +323,23 @@ async def zip_current_workspace(request: Request, background_tasks: BackgroundTa
status_code=500,
content={'error': f'Error zipping workspace: {e}'},
)
# Get a descriptive name for the zip file
try:
summary = request.state.conversation.summarize_actions(request.state.llm)
filename = f"{summary}.zip"
except Exception as e:
logger.warning(f'Error generating descriptive filename: {e}', exc_info=True)
filename = 'workspace.zip'
response = FileResponse(
path=zip_file,
filename='workspace.zip',
filename=filename,
media_type='application/x-zip-compressed',
)
# This will execute after the response is sent (So the file is not deleted before being sent)
background_tasks.add_task(zip_file.unlink)
background_tasks.add_task(os.unlink, zip_file)
return response
except Exception as e:

View File

@@ -1,7 +1,10 @@
import asyncio
import re
from openhands.core.config import AppConfig
from openhands.events.stream import EventStream
from openhands.llm.llm import LLM
from openhands.memory.condenser import MemoryCondenser
from openhands.runtime import get_runtime_cls
from openhands.runtime.base import Runtime
from openhands.security import SecurityAnalyzer, options
@@ -44,3 +47,38 @@ class Conversation:
async def disconnect(self):
asyncio.create_task(call_sync_from_async(self.runtime.close))
def summarize_actions(self, llm: LLM) -> str:
"""Summarize the agent's actions into a short descriptive phrase.
Args:
llm (LLM): The language model to use for summarization.
Returns:
str: A short descriptive phrase summarizing the agent's actions.
"""
events = self.event_stream.get_events()
if not events:
return "empty-workspace"
# Create a prompt that asks for a short descriptive phrase
prompt = (
"Please summarize the following conversation into a short descriptive phrase "
"that can be used as a filename (e.g. 'fixing-bug-in-code' or 'adding-new-feature'). "
"The summary should be lowercase, use hyphens instead of spaces, and not include special characters.\n\n"
"Conversation:\n"
)
for event in events:
prompt += f"{event.__class__.__name__}: {str(event)}\n"
# Use the memory condenser to get a summary
condenser = MemoryCondenser()
summary = condenser.condense(prompt, llm)
# Clean up the summary to make it suitable for a filename
summary = summary.strip().lower()
summary = re.sub(r'[^a-z0-9-]', '-', summary) # Replace special chars with hyphens
summary = re.sub(r'-+', '-', summary) # Replace multiple hyphens with single hyphen
summary = summary.strip('-') # Remove leading/trailing hyphens
return summary

View File

@@ -0,0 +1,48 @@
from unittest.mock import MagicMock, patch
import pytest
from openhands.core.config import AppConfig
from openhands.events.action import MessageAction
from openhands.events.observation import NullObservation
from openhands.server.session.conversation import Conversation
from openhands.storage.memory import InMemoryFileStore
@pytest.fixture
def conversation():
config = AppConfig()
file_store = InMemoryFileStore()
with patch(
'openhands.runtime.impl.eventstream.eventstream_runtime.docker'
) as mock_docker:
mock_client = MagicMock()
mock_client.version.return_value = {'Version': '20.10.0'}
mock_docker.from_env.return_value = mock_client
return Conversation('test_sid', file_store, config)
def test_summarize_actions(conversation):
# Mock the event stream
conversation.event_stream.get_events = MagicMock(
return_value=[
MessageAction('Hello'),
NullObservation(content='Hi there'),
MessageAction('Fix the bug'),
NullObservation(content="I'll help fix the bug"),
]
)
# Mock the LLM
with patch('openhands.memory.condenser.LLM') as mock_llm:
mock_llm.completion.return_value = {
'choices': [{'message': {'content': 'fixing-bug-in-code'}}]
}
summary = conversation.summarize_actions(mock_llm)
assert summary == 'fixing-bug-in-code'
# Verify the prompt sent to the LLM
messages = mock_llm.completion.call_args[1]['messages']
assert len(messages) == 1
assert messages[0]['role'] == 'user'
assert 'Please summarize' in messages[0]['content']

110
tests/unit/test_files.py Normal file
View File

@@ -0,0 +1,110 @@
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from openhands.core.config import AppConfig
from openhands.events.action import MessageAction
from openhands.events.observation import NullObservation
from openhands.server.routes.files import app
from openhands.server.session.conversation import Conversation
from openhands.storage.memory import InMemoryFileStore
@pytest.fixture
def client():
app_instance = FastAPI()
app_instance.include_router(app)
# Add middleware to inject conversation and LLM into request state
@app_instance.middleware('http')
async def inject_conversation(request: Request, call_next):
request.state.conversation = request.app.state.conversation
request.state.llm = request.app.state.llm
response = await call_next(request)
return response
test_client = TestClient(app_instance)
return test_client
@pytest.fixture
def conversation():
config = AppConfig()
file_store = InMemoryFileStore()
with patch(
'openhands.runtime.impl.eventstream.eventstream_runtime.docker'
) as mock_docker:
mock_client = MagicMock()
mock_client.version.return_value = {'Version': '20.10.0'}
mock_docker.from_env.return_value = mock_client
return Conversation('test_sid', file_store, config)
def test_zip_directory_with_descriptive_name(client, conversation):
# Create a temporary file to simulate the workspace
with tempfile.NamedTemporaryFile() as temp_file:
# Mock the runtime to return our temp file
mock_runtime = MagicMock()
mock_runtime.copy_from.return_value = temp_file.name
mock_runtime.config.workspace_mount_path_in_sandbox = '/workspace'
conversation.runtime = mock_runtime
# Mock the event stream to have some events
conversation.event_stream.get_events = MagicMock(
return_value=[
MessageAction('Fix the bug'),
NullObservation(content="I'll help fix the bug"),
]
)
# Mock the LLM
mock_llm = MagicMock()
mock_llm.completion.return_value = {
'choices': [{'message': {'content': 'fixing-bug-in-code'}}]
}
# Set up the app state
client.app.state.conversation = conversation
client.app.state.llm = mock_llm
response = client.get('/api/zip-directory')
# Check that the response is successful
assert response.status_code == 200
# Check that the filename is correct
assert (
response.headers['content-disposition']
== 'attachment; filename="fixing-bug-in-code.zip"'
)
def test_zip_directory_fallback_name(client, conversation):
# Create a temporary file to simulate the workspace
with tempfile.NamedTemporaryFile() as temp_file:
# Mock the runtime to return our temp file
mock_runtime = MagicMock()
mock_runtime.copy_from.return_value = temp_file.name
mock_runtime.config.workspace_mount_path_in_sandbox = '/workspace'
conversation.runtime = mock_runtime
# Mock the event stream to have no events
conversation.event_stream.get_events = MagicMock(return_value=[])
# Set up the app state
client.app.state.conversation = conversation
client.app.state.llm = None # No LLM available
response = client.get('/api/zip-directory')
# Check that the response is successful
assert response.status_code == 200
# Check that the filename falls back to empty-workspace.zip
assert (
response.headers['content-disposition']
== 'attachment; filename="empty-workspace.zip"'
)