mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 13:52:43 +08:00
Merge branch 'main' into migrate-org-db-litellm-from-deploy
This commit is contained in:
commit
ec3c33afac
16
.vscode/settings.json
vendored
16
.vscode/settings.json
vendored
@ -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",
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
274
enterprise/enterprise_local/README.md
Normal file
274
enterprise/enterprise_local/README.md
Normal 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...
|
||||
]
|
||||
}
|
||||
```
|
||||
127
enterprise/enterprise_local/convert_to_env.py
Normal file
127
enterprise/enterprise_local/convert_to_env.py
Normal 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}')
|
||||
27
enterprise/enterprise_local/decrypt_env.sh
Normal file
27
enterprise/enterprise_local/decrypt_env.sh
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 []
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user