Merge branch 'main' into migrate-org-db-litellm-from-deploy

This commit is contained in:
Rohit Malhotra 2025-10-22 09:29:44 -04:00 committed by GitHub
commit ec3c33afac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1251 additions and 91 deletions

16
.vscode/settings.json vendored
View File

@ -3,4 +3,20 @@
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"python.defaultInterpreterPath": "./.venv/bin/python",
"python.terminal.activateEnvironment": true,
"python.analysis.autoImportCompletions": true,
"python.analysis.autoSearchPaths": true,
"python.analysis.extraPaths": [
"./.venv/lib/python3.12/site-packages"
],
"python.analysis.packageIndexDepths": [
{
"name": "openhands",
"depth": 10,
"includeAllSymbols": true
}
],
"python.analysis.stubPath": "./.venv/lib/python3.12/site-packages",
}

View File

@ -5,6 +5,3 @@ DOCKER_IMAGE=runtime
# These variables will be appended by the runtime_build.py script
# DOCKER_IMAGE_TAG=
# DOCKER_IMAGE_SOURCE_TAG=
DOCKER_IMAGE_TAG=oh_v0.59.0_image_nikolaik_s_python-nodejs_tag_python3.12-nodejs22
DOCKER_IMAGE_SOURCE_TAG=oh_v0.59.0_cwpsf0pego28lacp_p73ruf86qxiulkou

View File

@ -0,0 +1,274 @@
# Instructions for developing SAAS locally
You have a few options here, which are expanded on below:
- A simple local development setup, with live reloading for both OSS and this repo
- A more complex setup that includes Redis
- An even more complex setup that includes GitHub events
## Prerequisites
Before starting, make sure you have the following tools installed:
### Required for all options:
- [gcloud CLI](https://cloud.google.com/sdk/docs/install) - For authentication and secrets management
- [sops](https://github.com/mozilla/sops) - For secrets decryption
- macOS: `brew install sops`
- Linux: `sudo apt-get install sops` or download from GitHub releases
- Windows: Install via Chocolatey `choco install sops` or download from GitHub releases
### Additional requirements for enabling GitHub webhook events
- make
- Python development tools (build-essential, python3-dev)
- [ngrok](https://ngrok.com/download) - For creating tunnels to localhost
## Option 1: Simple local development
This option will allow you to modify the both the OSS code and the code in this repo,
and see the changes in real-time.
This option works best for most scenarios. The only thing it's missing is
the GitHub events webhook, which is not necessary for most development.
### 1. OpenHands location
The open source OpenHands repo should be cloned as a sibling directory,
in `../OpenHands`. This is hard-coded in the pyproject.toml (edit if necessary)
If you're doing this the first time, you may need to run
```
poetry update openhands-ai
```
### 2. Set up env
First run this to retrieve Github App secrets
```
gcloud auth application-default login
gcloud config set project global-432717
local/decrypt_env.sh
```
Now run this to generate a `.env` file, which will used to run SAAS locally
```
python -m pip install PyYAML
export LITE_LLM_API_KEY=<your LLM API key>
python enterprise_local/convert_to_env.py
```
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
```
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:main-nikolaik
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
```
By default the application will log in json, you can override.
```
export LOG_PLAIN_TEXT=1
```
### 3. Start the OpenHands frontend
Start the frontend like you normally would in the open source OpenHands repo.
### 4. Start the SaaS backend
```
make build
make start-backend
```
You should have a server running on `localhost:3000`, similar to the open source backend.
Oauth should work properly.
## Option 2: With Redis
Follow all the steps above, then setup redis:
```bash
docker run -p 6379:6379 --name openhands-redis -d redis
export REDIS_HOST=host.docker.internal # you may want this to be localhost
export REDIS_PORT=6379
```
## Option 3: Work with GitHub events
### 1. Setup env file
(see above)
### 2. Build OSS Openhands
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
```
docker build -f containers/app/Dockerfile -t openhands .
```
### 3. Build SAAS Openhands
Build the SAAS image locally inside Deploy repo. Note that `openhands` is the name of the image built in Step 2
```
docker build -t openhands-saas ./app/ --build-arg BASE="openhands"
```
### 4. Create a tunnel
Run in a separate terminal
```
ngrok http 3000
```
There will be a line
```
Forwarding https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app
```
Remember this URL as it will be used in Step 5 and 6
### 5. Setup Staging Github App callback/webhook urls
Using the URL found in Step 4, add another callback URL (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app/oauth/github/callback`)
### 6. Run
This is the last step! Run SAAS openhands locally using
```
docker run --env-file ./app/.env -p 3000:3000 openhands-saas
```
Note `--env-file` is what injects the `.env` file created in Step 1
Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app`)
### Local Debugging with VSCode
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of the OSS OpenHands project running.
#### Redis
A Local redis instance is required for clustered communication between server nodes. The standard docker instance will suffice.
`docker run -it -p 6379:6379 --name my-redis -d redis`
#### Postgres
A Local postgres instance is required. I used the official docker image:
`docker run -p 5432:5432 --name my-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=openhands -d postgres`
Run the alembic migrations:
`poetry run alembic upgrade head `
#### VSCode launch.json
The VSCode launch.json below sets up 2 servers to test clustering, running independently on localhost:3030 and localhost:3031. Running only the server on 3030 is usually sufficient unless tests of the clustered functionality are required. Secrets may be harvested directly from staging by connecting...
`kubectl exec --stdin --tty <POD_NAME> -n <NAMESPACE> -- /bin/bash`
And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by the time you read this, nobody will have access.)
```
{
"configurations": [
{
"name": "Python Debugger: Python File",
"type": "debugpy",
"request": "launch",
"program": "${file}"
},
{
"name": "OpenHands Deploy",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"saas_server:app",
"--reload",
"--host",
"0.0.0.0",
"--port",
"3030"
],
"env": {
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/all-hands-ai/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
{
"name": "OpenHands Deploy 2",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"saas_server:app",
"--reload",
"--host",
"0.0.0.0",
"--port",
"3031"
],
"env": {
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/all-hands-ai/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
{
"name": "Unit Tests",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": [
"./tests/unit",
//"./tests/unit/test_clustered_conversation_manager.py",
"--durations=0"
],
"env": {
"DEBUG": "1"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
// set working directory...
]
}
```

View File

@ -0,0 +1,127 @@
import base64
import os
import sys
import yaml
def convert_yaml_to_env(yaml_file, target_parameters, output_env_file, prefix):
"""Converts a YAML file into .env file format for specified target parameters under 'stringData' and 'data'.
:param yaml_file: Path to the YAML file.
:param target_parameters: List of keys to extract from the YAML file.
:param output_env_file: Path to the output .env file.
:param prefix: Prefix for environment variables.
"""
try:
# Load the YAML file
with open(yaml_file, 'r') as file:
yaml_data = yaml.safe_load(file)
# Extract sections
string_data = yaml_data.get('stringData', None)
data = yaml_data.get('data', None)
if string_data:
env_source = string_data
process_base64 = False
elif data:
env_source = data
process_base64 = True
else:
print(
"Error: Neither 'stringData' nor 'data' section found in the YAML file."
)
return
env_lines = []
for param in target_parameters:
if param in env_source:
value = env_source[param]
if process_base64:
try:
decoded_value = base64.b64decode(value).decode('utf-8')
formatted_value = (
decoded_value.replace('\n', '\\n')
if '\n' in decoded_value
else decoded_value
)
except Exception as decode_error:
print(f"Error decoding base64 for '{param}': {decode_error}")
continue
else:
formatted_value = (
value.replace('\n', '\\n')
if isinstance(value, str) and '\n' in value
else value
)
new_key = prefix + param.upper().replace('-', '_')
env_lines.append(f'{new_key}={formatted_value}')
else:
print(
f"Warning: Parameter '{param}' not found in the selected section."
)
# Write to the .env file
with open(output_env_file, 'a') as env_file:
env_file.write('\n'.join(env_lines) + '\n')
except Exception as e:
print(f'Error: {e}')
lite_llm_api_key = os.getenv('LITE_LLM_API_KEY')
if not lite_llm_api_key:
print('Set the LITE_LLM_API_KEY environment variable to your API key')
sys.exit(1)
yaml_file = 'github_decrypted.yaml'
target_parameters = ['client-id', 'client-secret', 'webhook-secret', 'private-key']
output_env_file = './enterprise/.env'
if os.path.exists(output_env_file):
os.remove(output_env_file)
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'GITHUB_APP_')
os.remove(yaml_file)
yaml_file = 'keycloak_realm_decrypted.yaml'
target_parameters = ['client-id', 'client-secret', 'provider-name', 'realm-name']
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
os.remove(yaml_file)
yaml_file = 'keycloak_admin_decrypted.yaml'
target_parameters = ['admin-password']
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
os.remove(yaml_file)
lines = []
lines.append('KEYCLOAK_SERVER_URL=https://auth.staging.all-hands.dev/')
lines.append('KEYCLOAK_SERVER_URL_EXT=https://auth.staging.all-hands.dev/')
lines.append('OPENHANDS_CONFIG_CLS=server.config.SaaSServerConfig')
lines.append(
'OPENHANDS_GITHUB_SERVICE_CLS=integrations.github.github_service.SaaSGitHubService'
)
lines.append(
'OPENHANDS_GITLAB_SERVICE_CLS=integrations.gitlab.gitlab_service.SaaSGitLabService'
)
lines.append(
'OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService'
)
lines.append(
'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator'
)
lines.append('POSTHOG_CLIENT_KEY=test')
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
lines.append('LITE_LLM_API_URL=https://llm-proxy.eval.all-hands.dev')
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514')
lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}')
lines.append('LOCAL_DEPLOYMENT=true')
lines.append('DB_HOST=localhost')
with open(output_env_file, 'a') as env_file:
env_file.write('\n'.join(lines))
print(f'.env file created at: {output_env_file}')

View File

@ -0,0 +1,27 @@
#!/bin/bash
set -euo pipefail
# Check if DEPLOY_DIR argument was provided
if [ $# -lt 1 ]; then
echo "Usage: $0 <DEPLOY_DIR>"
echo "Example: $0 /path/to/deploy"
exit 1
fi
# Normalize path (remove trailing slash)
DEPLOY_DIR="${DEPLOY_DIR%/}"
# Function to decrypt and rename
decrypt_and_move() {
local secret_path="$1"
local output_name="$2"
${DEPLOY_DIR}/scripts/decrypt.sh "${DEPLOY_DIR}/${secret_path}"
mv decrypted.yaml "${output_name}"
echo "Moved decrypted.yaml to ${output_name}"
}
# Decrypt each secret file
decrypt_and_move "openhands/envs/feature/secrets/github-app.yaml" "github_decrypted.yaml"
decrypt_and_move "openhands/envs/staging/secrets/keycloak-realm.yaml" "keycloak_realm_decrypted.yaml"
decrypt_and_move "openhands/envs/staging/secrets/keycloak-admin.yaml" "keycloak_admin_decrypted.yaml"

View File

@ -14,6 +14,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
@ -190,19 +191,27 @@ class SlackNewConversationView(SlackViewInterface):
user_secrets = await self.saas_user_auth.get_user_secrets()
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_instructions,
conversation_instructions=conversation_instructions
if conversation_instructions
else None,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
self.conversation_id = agent_loop_info.conversation_id

View File

@ -187,7 +187,7 @@ class ConversationService {
static async getRuntimeId(
conversationId: string,
): Promise<{ runtime_id: string }> {
const url = `${this.getConversationUrl(conversationId)}/config`;
const url = `/api/conversations/${conversationId}/config`;
const { data } = await openHands.get<{ runtime_id: string }>(url, {
headers: this.getConversationHeaders(),
});

View File

@ -279,13 +279,15 @@ class LiveStatusAppConversationService(GitAppConversationService):
# Build app_conversation from info
result = [
self._build_conversation(
app_conversation_info,
sandboxes_by_id.get(app_conversation_info.sandbox_id),
conversation_info_by_id.get(app_conversation_info.id),
(
self._build_conversation(
app_conversation_info,
sandboxes_by_id.get(app_conversation_info.sandbox_id),
conversation_info_by_id.get(app_conversation_info.id),
)
if app_conversation_info
else None
)
if app_conversation_info
else None
for app_conversation_info in app_conversation_infos
]
@ -369,7 +371,6 @@ class LiveStatusAppConversationService(GitAppConversationService):
self, task: AppConversationStartTask
) -> AsyncGenerator[AppConversationStartTask, None]:
"""Wait for sandbox to start and return info."""
# Get the sandbox
if not task.request.sandbox_id:
sandbox = await self.sandbox_service.start_sandbox()
@ -472,14 +473,62 @@ class LiveStatusAppConversationService(GitAppConversationService):
conversation_id=conversation_id,
agent=agent,
workspace=workspace,
confirmation_policy=AlwaysConfirm()
if user.confirmation_mode
else NeverConfirm(),
confirmation_policy=(
AlwaysConfirm() if user.confirmation_mode else NeverConfirm()
),
initial_message=initial_message,
secrets=secrets,
)
return start_conversation_request
async def update_agent_server_conversation_title(
self,
conversation_id: str,
new_title: str,
app_conversation_info: AppConversationInfo,
) -> None:
"""Update the conversation title in the agent-server.
Args:
conversation_id: The conversation ID as a string
new_title: The new title to set
app_conversation_info: The app conversation info containing sandbox_id
"""
# Get the sandbox info to find the agent-server URL
sandbox = await self.sandbox_service.get_sandbox(
app_conversation_info.sandbox_id
)
assert sandbox is not None, (
f'Sandbox {app_conversation_info.sandbox_id} not found for conversation {conversation_id}'
)
assert sandbox.exposed_urls is not None, (
f'Sandbox {app_conversation_info.sandbox_id} has no exposed URLs for conversation {conversation_id}'
)
# Use the existing method to get the agent-server URL
agent_server_url = self._get_agent_server_url(sandbox)
# Prepare the request
url = f'{agent_server_url.rstrip("/")}/api/conversations/{conversation_id}'
headers = {}
if sandbox.session_api_key:
headers['X-Session-API-Key'] = sandbox.session_api_key
payload = {'title': new_title}
# Make the PATCH request to the agent-server
response = await self.httpx_client.patch(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
_logger.info(
f'Successfully updated agent-server conversation {conversation_id} title to "{new_title}"'
)
class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
sandbox_startup_timeout: int = Field(

View File

@ -104,7 +104,7 @@ class AppServerConfig(OpenHandsModel):
)
# Services
lifespan: AppLifespanService = Field(default_factory=_get_default_lifespan)
lifespan: AppLifespanService | None = Field(default_factory=_get_default_lifespan)
def config_from_env() -> AppServerConfig:
@ -291,7 +291,7 @@ def get_db_session(
return get_global_config().db_session.context(state, request)
def get_app_lifespan_service() -> AppLifespanService:
def get_app_lifespan_service() -> AppLifespanService | None:
config = get_global_config()
return config.lifespan

View File

@ -1,7 +1,13 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.config import depends_app_conversation_service
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.message import MessageAction
from openhands.events.event_filter import EventFilter
@ -21,24 +27,116 @@ app = APIRouter(
prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies()
)
# Dependency for app conversation service
app_conversation_service_dependency = depends_app_conversation_service()
@app.get('/config')
async def get_remote_runtime_config(
conversation: ServerConversation = Depends(get_conversation),
) -> JSONResponse:
"""Retrieve the runtime configuration.
Currently, this is the session ID and runtime ID (if available).
async def _is_v1_conversation(
conversation_id: str, app_conversation_service: AppConversationService
) -> bool:
"""Check if the given conversation_id corresponds to a V1 conversation.
Args:
conversation_id: The conversation ID to check
app_conversation_service: Service to query V1 conversations
Returns:
True if this is a V1 conversation, False otherwise
"""
try:
conversation_uuid = uuid.UUID(conversation_id)
app_conversation = await app_conversation_service.get_app_conversation(
conversation_uuid
)
return app_conversation is not None
except (ValueError, TypeError):
# Not a valid UUID, so it's not a V1 conversation
return False
except Exception:
# Service error, assume it's not a V1 conversation
return False
async def _get_v1_conversation_config(
conversation_id: str, app_conversation_service: AppConversationService
) -> dict[str, str | None]:
"""Get configuration for a V1 conversation.
Args:
conversation_id: The conversation ID
app_conversation_service: Service to query V1 conversations
Returns:
Dictionary with runtime_id (sandbox_id) and session_id (conversation_id)
"""
conversation_uuid = uuid.UUID(conversation_id)
app_conversation = await app_conversation_service.get_app_conversation(
conversation_uuid
)
if app_conversation is None:
raise ValueError(f'V1 conversation {conversation_id} not found')
return {
'runtime_id': app_conversation.sandbox_id,
'session_id': conversation_id,
}
def _get_v0_conversation_config(
conversation: ServerConversation,
) -> dict[str, str | None]:
"""Get configuration for a V0 conversation.
Args:
conversation: The server conversation object
Returns:
Dictionary with runtime_id and session_id from the runtime
"""
runtime = conversation.runtime
runtime_id = runtime.runtime_id if hasattr(runtime, 'runtime_id') else None
session_id = runtime.sid if hasattr(runtime, 'sid') else None
return JSONResponse(
content={
'runtime_id': runtime_id,
'session_id': session_id,
}
)
return {
'runtime_id': runtime_id,
'session_id': session_id,
}
@app.get('/config')
async def get_remote_runtime_config(
conversation_id: str,
app_conversation_service: AppConversationService = app_conversation_service_dependency,
user_id: str | None = Depends(get_user_id),
) -> JSONResponse:
"""Retrieve the runtime configuration.
For V0 conversations: returns runtime_id and session_id from the runtime.
For V1 conversations: returns sandbox_id as runtime_id and conversation_id as session_id.
"""
# Check if this is a V1 conversation first
if await _is_v1_conversation(conversation_id, app_conversation_service):
# This is a V1 conversation
config = await _get_v1_conversation_config(
conversation_id, app_conversation_service
)
else:
# V0 conversation - get the conversation and use the existing logic
conversation = await conversation_manager.attach_to_conversation(
conversation_id, user_id
)
if not conversation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Conversation {conversation_id} not found',
)
try:
config = _get_v0_conversation_config(conversation)
finally:
await conversation_manager.detach_from_conversation(conversation)
return JSONResponse(content=config)
@app.get('/vscode-url')
@ -279,12 +377,14 @@ async def get_microagents(
content=r_agent.content,
triggers=[],
inputs=r_agent.metadata.inputs,
tools=[
server.name
for server in r_agent.metadata.mcp_tools.stdio_servers
]
if r_agent.metadata.mcp_tools
else [],
tools=(
[
server.name
for server in r_agent.metadata.mcp_tools.stdio_servers
]
if r_agent.metadata.mcp_tools
else []
),
)
)
@ -297,12 +397,14 @@ async def get_microagents(
content=k_agent.content,
triggers=k_agent.triggers,
inputs=k_agent.metadata.inputs,
tools=[
server.name
for server in k_agent.metadata.mcp_tools.stdio_servers
]
if k_agent.metadata.mcp_tools
else [],
tools=(
[
server.name
for server in k_agent.metadata.mcp_tools.stdio_servers
]
if k_agent.metadata.mcp_tools
else []
),
)
)

View File

@ -12,6 +12,9 @@ from fastapi.responses import JSONResponse
from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel, ConfigDict, Field
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
)
@ -19,6 +22,7 @@ from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.config import (
depends_app_conversation_info_service,
depends_app_conversation_service,
)
from openhands.core.config.llm_config import LLMConfig
@ -90,6 +94,7 @@ from openhands.utils.conversation_summary import get_default_conversation_title
app = APIRouter(prefix='/api', dependencies=get_dependencies())
app_conversation_service_dependency = depends_app_conversation_service()
app_conversation_info_service_dependency = depends_app_conversation_info_service()
def _filter_conversations_by_age(
@ -759,23 +764,201 @@ class UpdateConversationRequest(BaseModel):
model_config = ConfigDict(extra='forbid')
async def _update_v1_conversation(
conversation_uuid: uuid.UUID,
new_title: str,
user_id: str | None,
app_conversation_info_service: AppConversationInfoService,
app_conversation_service: AppConversationService,
) -> JSONResponse | bool:
"""Update a V1 conversation title.
Args:
conversation_uuid: The conversation ID as a UUID
new_title: The new title to set
user_id: The authenticated user ID
app_conversation_info_service: The app conversation info service
app_conversation_service: The app conversation service for agent-server communication
Returns:
JSONResponse on error, True on success
"""
conversation_id = str(conversation_uuid)
logger.info(
f'Updating V1 conversation {conversation_uuid}',
extra={'session_id': conversation_id, 'user_id': user_id},
)
# Get the V1 conversation info
app_conversation_info = (
await app_conversation_info_service.get_app_conversation_info(conversation_uuid)
)
if not app_conversation_info:
# Not a V1 conversation
return None
# Validate that the user owns this conversation
if user_id and app_conversation_info.created_by_user_id != user_id:
logger.warning(
f'User {user_id} attempted to update V1 conversation {conversation_uuid} owned by {app_conversation_info.created_by_user_id}',
extra={'session_id': conversation_id, 'user_id': user_id},
)
return JSONResponse(
content={
'status': 'error',
'message': 'Permission denied: You can only update your own conversations',
'msg_id': 'AUTHORIZATION$PERMISSION_DENIED',
},
status_code=status.HTTP_403_FORBIDDEN,
)
# Update the title and timestamp
original_title = app_conversation_info.title
app_conversation_info.title = new_title
app_conversation_info.updated_at = datetime.now(timezone.utc)
# Save the updated conversation info
try:
await app_conversation_info_service.save_app_conversation_info(
app_conversation_info
)
except AssertionError:
# This happens when user doesn't own the conversation
logger.warning(
f'User {user_id} attempted to update V1 conversation {conversation_uuid} - permission denied',
extra={'session_id': conversation_id, 'user_id': user_id},
)
return JSONResponse(
content={
'status': 'error',
'message': 'Permission denied: You can only update your own conversations',
'msg_id': 'AUTHORIZATION$PERMISSION_DENIED',
},
status_code=status.HTTP_403_FORBIDDEN,
)
# Try to update the agent-server as well
try:
if hasattr(app_conversation_service, 'update_agent_server_conversation_title'):
await app_conversation_service.update_agent_server_conversation_title(
conversation_id=conversation_id,
new_title=new_title,
app_conversation_info=app_conversation_info,
)
except Exception as e:
# Log the error but don't fail the database update
logger.warning(
f'Failed to update agent-server for conversation {conversation_uuid}: {e}',
extra={'session_id': conversation_id, 'user_id': user_id},
)
logger.info(
f'Successfully updated V1 conversation {conversation_uuid} title from "{original_title}" to "{app_conversation_info.title}"',
extra={'session_id': conversation_id, 'user_id': user_id},
)
return True
async def _update_v0_conversation(
conversation_id: str,
new_title: str,
user_id: str | None,
conversation_store: ConversationStore,
) -> JSONResponse | bool:
"""Update a V0 conversation title.
Args:
conversation_id: The conversation ID
new_title: The new title to set
user_id: The authenticated user ID
conversation_store: The conversation store
Returns:
JSONResponse on error, True on success
Raises:
FileNotFoundError: If the conversation is not found
"""
logger.info(
f'Updating V0 conversation {conversation_id}',
extra={'session_id': conversation_id, 'user_id': user_id},
)
# Get the existing conversation metadata
metadata = await conversation_store.get_metadata(conversation_id)
# Validate that the user owns this conversation
if user_id and metadata.user_id != user_id:
logger.warning(
f'User {user_id} attempted to update conversation {conversation_id} owned by {metadata.user_id}',
extra={'session_id': conversation_id, 'user_id': user_id},
)
return JSONResponse(
content={
'status': 'error',
'message': 'Permission denied: You can only update your own conversations',
'msg_id': 'AUTHORIZATION$PERMISSION_DENIED',
},
status_code=status.HTTP_403_FORBIDDEN,
)
# Update the conversation metadata
original_title = metadata.title
metadata.title = new_title
metadata.last_updated_at = datetime.now(timezone.utc)
# Save the updated metadata
await conversation_store.save_metadata(metadata)
# Emit a status update to connected clients about the title change
try:
status_update_dict = {
'status_update': True,
'type': 'info',
'message': conversation_id,
'conversation_title': metadata.title,
}
await conversation_manager.sio.emit(
'oh_event',
status_update_dict,
to=f'room:{conversation_id}',
)
except Exception as e:
logger.error(f'Error emitting title update event: {e}')
# Don't fail the update if we can't emit the event
logger.info(
f'Successfully updated conversation {conversation_id} title from "{original_title}" to "{metadata.title}"',
extra={'session_id': conversation_id, 'user_id': user_id},
)
return True
@app.patch('/conversations/{conversation_id}')
async def update_conversation(
data: UpdateConversationRequest,
conversation_id: str = Depends(validate_conversation_id),
user_id: str | None = Depends(get_user_id),
conversation_store: ConversationStore = Depends(get_conversation_store),
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
app_conversation_service: AppConversationService = app_conversation_service_dependency,
) -> bool:
"""Update conversation metadata.
This endpoint allows updating conversation details like title.
Only the conversation owner can update the conversation.
Supports both V0 and V1 conversations.
Args:
conversation_id: The ID of the conversation to update
data: The conversation update data (title, etc.)
user_id: The authenticated user ID
conversation_store: The conversation store dependency
app_conversation_info_service: The app conversation info service for V1 conversations
app_conversation_service: The app conversation service for agent-server communication
Returns:
bool: True if the conversation was updated successfully
@ -788,57 +971,41 @@ async def update_conversation(
extra={'session_id': conversation_id, 'user_id': user_id},
)
new_title = data.title.strip()
# Try to handle as V1 conversation first
try:
# Get the existing conversation metadata
metadata = await conversation_store.get_metadata(conversation_id)
# Validate that the user owns this conversation
if user_id and metadata.user_id != user_id:
logger.warning(
f'User {user_id} attempted to update conversation {conversation_id} owned by {metadata.user_id}',
extra={'session_id': conversation_id, 'user_id': user_id},
)
return JSONResponse(
content={
'status': 'error',
'message': 'Permission denied: You can only update your own conversations',
'msg_id': 'AUTHORIZATION$PERMISSION_DENIED',
},
status_code=status.HTTP_403_FORBIDDEN,
)
# Update the conversation metadata
original_title = metadata.title
metadata.title = data.title.strip()
metadata.last_updated_at = datetime.now(timezone.utc)
# Save the updated metadata
await conversation_store.save_metadata(metadata)
# Emit a status update to connected clients about the title change
try:
status_update_dict = {
'status_update': True,
'type': 'info',
'message': conversation_id,
'conversation_title': metadata.title,
}
await conversation_manager.sio.emit(
'oh_event',
status_update_dict,
to=f'room:{conversation_id}',
)
except Exception as e:
logger.error(f'Error emitting title update event: {e}')
# Don't fail the update if we can't emit the event
logger.info(
f'Successfully updated conversation {conversation_id} title from "{original_title}" to "{metadata.title}"',
extra={'session_id': conversation_id, 'user_id': user_id},
conversation_uuid = uuid.UUID(conversation_id)
result = await _update_v1_conversation(
conversation_uuid=conversation_uuid,
new_title=new_title,
user_id=user_id,
app_conversation_info_service=app_conversation_info_service,
app_conversation_service=app_conversation_service,
)
return True
# If result is not None, it's a V1 conversation (either success or error)
if result is not None:
return result
except (ValueError, TypeError):
# Not a valid UUID, fall through to V0 logic
pass
except Exception as e:
logger.warning(
f'Error checking V1 conversation {conversation_id}: {str(e)}',
extra={'session_id': conversation_id, 'user_id': user_id},
)
# Fall through to V0 logic
# Handle as V0 conversation
try:
return await _update_v0_conversation(
conversation_id=conversation_id,
new_title=new_title,
user_id=user_id,
conversation_store=conversation_store,
)
except FileNotFoundError:
logger.warning(
f'Conversation {conversation_id} not found for update',

View File

@ -115,7 +115,6 @@ pybase62 = "^1.0.0"
# V1 dependencies
openhands-agent-server = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e" }
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands-sdk", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e" }
# This refuses to install
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands-tools", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e" }
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }

View File

@ -1,11 +1,18 @@
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from fastapi import status
from fastapi.responses import JSONResponse
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
from openhands.microagent.microagent import KnowledgeMicroagent, RepoMicroagent
from openhands.microagent.types import MicroagentMetadata, MicroagentType
from openhands.server.routes.conversation import (
@ -625,6 +632,392 @@ async def test_update_conversation_no_user_id_no_metadata_user_id():
mock_conversation_store.save_metadata.assert_called_once()
@pytest.mark.update_conversation
@pytest.mark.asyncio
async def test_update_v1_conversation_success():
"""Test successful V1 conversation update."""
# Mock data
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
user_id = 'test_user_456'
original_title = 'Original V1 Title'
new_title = 'Updated V1 Title'
# Create mock V1 conversation info
mock_app_conversation_info = AppConversationInfo(
id=conversation_uuid,
created_by_user_id=user_id,
sandbox_id='test_sandbox_123',
title=original_title,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Create mock app conversation info service
mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService)
mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
mock_app_conversation_info_service.save_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
# Create mock conversation store (won't be used for V1)
mock_conversation_store = MagicMock(spec=ConversationStore)
# Create update request
update_request = UpdateConversationRequest(title=new_title)
# Call the function
result = await update_conversation(
conversation_id=conversation_id,
data=update_request,
user_id=user_id,
conversation_store=mock_conversation_store,
app_conversation_info_service=mock_app_conversation_info_service,
)
# Verify the result
assert result is True
# Verify V1 service was called
mock_app_conversation_info_service.get_app_conversation_info.assert_called_once_with(
conversation_uuid
)
mock_app_conversation_info_service.save_app_conversation_info.assert_called_once()
# Verify the conversation store was NOT called (V1 doesn't use it)
mock_conversation_store.get_metadata.assert_not_called()
# Verify the saved info has updated title
saved_info = (
mock_app_conversation_info_service.save_app_conversation_info.call_args[0][0]
)
assert saved_info.title == new_title.strip()
assert saved_info.updated_at is not None
@pytest.mark.update_conversation
@pytest.mark.asyncio
async def test_update_v1_conversation_not_found():
"""Test V1 conversation update when conversation doesn't exist."""
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
user_id = 'test_user_456'
# Create mock app conversation info service that returns None
mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService)
mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=None
)
# Create mock conversation store that also raises FileNotFoundError
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.get_metadata = AsyncMock(side_effect=FileNotFoundError())
# Create update request
update_request = UpdateConversationRequest(title='New Title')
# Call the function
result = await update_conversation(
conversation_id=conversation_id,
data=update_request,
user_id=user_id,
conversation_store=mock_conversation_store,
app_conversation_info_service=mock_app_conversation_info_service,
)
# Verify the result is a 404 error response
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_404_NOT_FOUND
# Parse the JSON content
content = json.loads(result.body)
assert content['status'] == 'error'
assert content['message'] == 'Conversation not found'
assert content['msg_id'] == 'CONVERSATION$NOT_FOUND'
@pytest.mark.update_conversation
@pytest.mark.asyncio
async def test_update_v1_conversation_permission_denied():
"""Test V1 conversation update when user doesn't own the conversation."""
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
user_id = 'test_user_456'
owner_id = 'different_user_789'
# Create mock V1 conversation info owned by different user
mock_app_conversation_info = AppConversationInfo(
id=conversation_uuid,
created_by_user_id=owner_id,
sandbox_id='test_sandbox_123',
title='Original Title',
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Create mock app conversation info service
mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService)
mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
# Create mock conversation store (won't be used)
mock_conversation_store = MagicMock(spec=ConversationStore)
# Create update request
update_request = UpdateConversationRequest(title='New Title')
# Call the function
result = await update_conversation(
conversation_id=conversation_id,
data=update_request,
user_id=user_id,
conversation_store=mock_conversation_store,
app_conversation_info_service=mock_app_conversation_info_service,
)
# Verify the result is a 403 error response
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_403_FORBIDDEN
# Parse the JSON content
content = json.loads(result.body)
assert content['status'] == 'error'
assert (
content['message']
== 'Permission denied: You can only update your own conversations'
)
assert content['msg_id'] == 'AUTHORIZATION$PERMISSION_DENIED'
# Verify save was NOT called
mock_app_conversation_info_service.save_app_conversation_info.assert_not_called()
@pytest.mark.update_conversation
@pytest.mark.asyncio
async def test_update_v1_conversation_save_assertion_error():
"""Test V1 conversation update when save raises AssertionError (permission check)."""
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
user_id = 'test_user_456'
# Create mock V1 conversation info
mock_app_conversation_info = AppConversationInfo(
id=conversation_uuid,
created_by_user_id=user_id,
sandbox_id='test_sandbox_123',
title='Original Title',
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Create mock app conversation info service
mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService)
mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
# Simulate AssertionError on save (permission check in service)
mock_app_conversation_info_service.save_app_conversation_info = AsyncMock(
side_effect=AssertionError('User does not own conversation')
)
# Create mock conversation store (won't be used)
mock_conversation_store = MagicMock(spec=ConversationStore)
# Create update request
update_request = UpdateConversationRequest(title='New Title')
# Call the function
result = await update_conversation(
conversation_id=conversation_id,
data=update_request,
user_id=user_id,
conversation_store=mock_conversation_store,
app_conversation_info_service=mock_app_conversation_info_service,
)
# Verify the result is a 403 error response
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_403_FORBIDDEN
# Parse the JSON content
content = json.loads(result.body)
assert content['status'] == 'error'
assert (
content['message']
== 'Permission denied: You can only update your own conversations'
)
assert content['msg_id'] == 'AUTHORIZATION$PERMISSION_DENIED'
@pytest.mark.update_conversation
@pytest.mark.asyncio
async def test_update_v1_conversation_title_whitespace_trimming():
"""Test that V1 conversation title is properly trimmed of whitespace."""
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
user_id = 'test_user_456'
title_with_whitespace = ' Trimmed V1 Title '
expected_title = 'Trimmed V1 Title'
# Create mock V1 conversation info
mock_app_conversation_info = AppConversationInfo(
id=conversation_uuid,
created_by_user_id=user_id,
sandbox_id='test_sandbox_123',
title='Original Title',
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Create mock app conversation info service
mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService)
mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
mock_app_conversation_info_service.save_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
# Create mock conversation store (won't be used)
mock_conversation_store = MagicMock(spec=ConversationStore)
# Create update request with whitespace
update_request = UpdateConversationRequest(title=title_with_whitespace)
# Call the function
result = await update_conversation(
conversation_id=conversation_id,
data=update_request,
user_id=user_id,
conversation_store=mock_conversation_store,
app_conversation_info_service=mock_app_conversation_info_service,
)
# Verify the result
assert result is True
# Verify the saved info has trimmed title
saved_info = (
mock_app_conversation_info_service.save_app_conversation_info.call_args[0][0]
)
assert saved_info.title == expected_title
@pytest.mark.update_conversation
@pytest.mark.asyncio
async def test_update_v1_conversation_invalid_uuid_falls_back_to_v0():
"""Test that invalid UUID conversation_id falls back to V0 logic."""
conversation_id = 'not_a_valid_uuid_123'
user_id = 'test_user_456'
new_title = 'Updated Title'
# Create mock V0 metadata
mock_metadata = ConversationMetadata(
conversation_id=conversation_id,
user_id=user_id,
title='Original Title',
selected_repository=None,
last_updated_at=datetime.now(timezone.utc),
)
# Create mock conversation store for V0
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.get_metadata = AsyncMock(return_value=mock_metadata)
mock_conversation_store.save_metadata = AsyncMock()
# Create mock app conversation info service (won't be called)
mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService)
# Create update request
update_request = UpdateConversationRequest(title=new_title)
# Mock the conversation manager socket
mock_sio = AsyncMock()
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.sio = mock_sio
# Call the function
result = await update_conversation(
conversation_id=conversation_id,
data=update_request,
user_id=user_id,
conversation_store=mock_conversation_store,
app_conversation_info_service=mock_app_conversation_info_service,
)
# Verify the result is successful
assert result is True
# Verify V0 store was used, not V1 service
mock_conversation_store.get_metadata.assert_called_once_with(conversation_id)
mock_conversation_store.save_metadata.assert_called_once()
mock_app_conversation_info_service.get_app_conversation_info.assert_not_called()
@pytest.mark.update_conversation
@pytest.mark.asyncio
async def test_update_v1_conversation_no_socket_emission():
"""Test that V1 conversation update does NOT emit socket.io events."""
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
user_id = 'test_user_456'
new_title = 'Updated V1 Title'
# Create mock V1 conversation info
mock_app_conversation_info = AppConversationInfo(
id=conversation_uuid,
created_by_user_id=user_id,
sandbox_id='test_sandbox_123',
title='Original Title',
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Create mock app conversation info service
mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService)
mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
mock_app_conversation_info_service.save_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
# Create mock conversation store (won't be used)
mock_conversation_store = MagicMock(spec=ConversationStore)
# Create update request
update_request = UpdateConversationRequest(title=new_title)
# Mock the conversation manager socket
mock_sio = AsyncMock()
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.sio = mock_sio
# Call the function
result = await update_conversation(
conversation_id=conversation_id,
data=update_request,
user_id=user_id,
conversation_store=mock_conversation_store,
app_conversation_info_service=mock_app_conversation_info_service,
)
# Verify the result is successful
assert result is True
# Verify socket.io was NOT called for V1 conversation
mock_sio.emit.assert_not_called()
@pytest.mark.asyncio
async def test_add_message_success():
"""Test successful message addition to conversation."""