Compare commits

..

29 Commits

Author SHA1 Message Date
Alona King c126b042f4 fix: add newline at end of init.md file
Fixed linting issue - added required newline at end of file.
2025-10-24 14:23:04 -04:00
Alona King 76bb89b0cf feat: add /init microagent for repository initialization
Creates a new '/init' microagent that automatically generates a comprehensive
repo.md file containing repository documentation, structure, and CI/CD setup.

Users can now simply type '/init' to create a .openhands/microagents/repo.md
file that captures essential repository context, reducing repetitive searches
and improving OpenHands' understanding of the codebase.

The slash prefix makes it clear this is a command, consistent with other
task microagents like /codereview and /fix_test.
2025-10-24 14:15:54 -04:00
Samuel Akerele e450a3a603 fix(llm): Support nested paths in litellm_proxy/ model names (#11430)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2025-10-24 17:41:25 +00:00
softpudding 17e32af6fe Enhance dead-loop recovery by pausing agent and reprompting (#11439)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-24 11:25:14 +00:00
Tim O'Farrell 4b303ec9b4 Fixes to unblock frontend (#11488)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2025-10-23 14:43:45 -06:00
Ray Myers eb954164a5 chore - update ghcr enterprise build to new org 2025-10-23 12:53:01 -05:00
Tim O'Farrell 0c1c2163b1 The AsyncRemoteWorkspace class was moved to the SDK (#11471) 2025-10-23 09:39:56 -06:00
Hiep Le dd2a62c992 refactor(frontend): disable some agent server API until implemented in the server source code (#11476) 2025-10-23 19:38:18 +04:00
Rohit Malhotra f3d9faef34 SAAS: dedup fetching user settings from keycloak id (#11480)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-23 09:56:55 -04:00
Hiep Le 134c122026 fix: disable pro subscription upgrade on LLM page for self-hosted installs (#11479) 2025-10-23 01:11:04 +07:00
Rohit Malhotra 523b40dbfc SAAS: drop deprecated table (#11469)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-22 10:52:10 -04:00
sp.wack 6a5b915088 Add unified file upload support for V0 and V1 conversations (#11457) 2025-10-22 17:44:38 +04:00
sp.wack a5c5133961 Remove queries from cache and do not refetch them after starting a conversation (#11453) 2025-10-22 13:42:09 +00:00
sp.wack eea1e7f4e1 Prevent calling V1 "start tasks” API if feature flag is disabled + always set “start tasks” query cache to stale (#11454) 2025-10-22 20:38:32 +07:00
Hiep Le e2d990f3a0 feat(backend): implement get_remote_runtime_config support for V1 conversations (#11466) 2025-10-22 15:38:25 +07:00
Hiep Le f258eafa37 feat(backend): add support for updating the title in V1 conversations (#11446) 2025-10-22 13:36:56 +07:00
Hiep Le 19634f364e fix(backend): repository pill does not display the selected repository when a conversation is initiated via slack (#11225) 2025-10-22 13:12:32 +07:00
Alona aa6446038c fix: remove accidentally committed Docker image tags from config.sh (#11470) 2025-10-22 04:48:17 +00:00
Tim O'Farrell dbddc1868e Fixes for VSCode code completion (#11449) 2025-10-21 21:39:50 +00:00
Rohit Malhotra cd967ef4bc SAAS: add local development helper scripts (#11459) 2025-10-21 21:26:23 +00:00
Tim O'Farrell e34c13ea3c Set dump mode to json to convert UUIDs to strings (#11467) 2025-10-21 19:20:56 +00:00
Hiep Le 1f35a73cc4 fix(frontend): display repository information after creating a V1 conversation (#11463) 2025-10-21 18:24:26 +00:00
Alona 267528fa82 fix: refresh provider tokens proactively and update git URLs on resume (#11296) 2025-10-22 01:19:08 +07:00
sp.wack 49f360d021 Fix toast dismissal to target specific toast IDs instead of all toasts (#11455) 2025-10-21 17:43:14 +00:00
sp.wack 9520da668c Prevent WebSocket provider remount by defaulting to V1 (#11458) 2025-10-21 17:11:15 +00:00
Rohit Malhotra 9d19292619 V1: Experiment manager (#11388)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-21 16:04:48 +00:00
sp.wack fc9a87550d Fix zero state not showing for V1 conversations (#11452) 2025-10-21 20:04:01 +04:00
sp.wack 490d3dba10 Remove toast notifications for starting/resuming conversation sandbox (#11456) 2025-10-21 20:03:45 +04:00
Rohit Malhotra 5ed1dde2e9 CLI Patch Release 1.0.2 (#11448) 2025-10-21 15:32:00 +00:00
105 changed files with 3730 additions and 826 deletions
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ghcr.io/all-hands-ai/python-nodejs:python3.13-nodejs22-trixie", tag: "trixie" },
{ image: "ghcr.io/openhands/python-nodejs:python3.13-nodejs22-trixie", tag: "trixie" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
@@ -200,7 +200,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/all-hands-ai/enterprise-server
images: ghcr.io/openhands/enterprise-server
tags: |
type=ref,event=branch
type=ref,event=pr
+16
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",
}
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.59-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.59-nikolaik`
## Develop inside Docker container
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.59-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.59-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -1,5 +1,5 @@
ARG OPENHANDS_VERSION=latest
ARG BASE="ghcr.io/all-hands-ai/openhands"
ARG BASE="ghcr.io/openhands/openhands"
FROM ${BASE}:${OPENHANDS_VERSION}
# Datadog labels
+274
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/openhands/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/openhands/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/openhands/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...
]
}
```
@@ -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}')
@@ -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"
@@ -1,18 +1,47 @@
from uuid import UUID
from experiments.constants import (
ENABLE_EXPERIMENT_MANAGER,
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
)
from experiments.experiment_versions import (
handle_condenser_max_step_experiment,
handle_system_prompt_experiment,
)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment__v1,
)
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.experiments.experiment_manager import ExperimentManager
from openhands.sdk import Agent
from openhands.server.session.conversation_init_data import ConversationInitData
class SaaSExperimentManager(ExperimentManager):
@staticmethod
def run_agent_variant_tests__v1(
user_id: str | None, conversation_id: UUID, agent: Agent
) -> Agent:
if not ENABLE_EXPERIMENT_MANAGER:
logger.info(
'experiment_manager:run_conversation_variant_test:skipped',
extra={'reason': 'experiment_manager_disabled'},
)
return agent
agent = handle_condenser_max_step_experiment__v1(
user_id, conversation_id, agent
)
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
)
return agent
@staticmethod
def run_conversation_variant_test(
user_id, conversation_id, conversation_settings
@@ -5,12 +5,18 @@ This module contains the handler for the condenser max step experiment that test
different max_size values for the condenser configuration.
"""
from uuid import UUID
import posthog
from experiments.constants import EXPERIMENT_CONDENSER_MAX_STEP
from server.constants import IS_FEATURE_ENV
from storage.experiment_assignment_store import ExperimentAssignmentStore
from openhands.core.logger import openhands_logger as logger
from openhands.sdk import Agent
from openhands.sdk.context.condenser import (
LLMSummarizingCondenser,
)
from openhands.server.session.conversation_init_data import ConversationInitData
@@ -190,3 +196,37 @@ def handle_condenser_max_step_experiment(
return conversation_settings
return conversation_settings
def handle_condenser_max_step_experiment__v1(
user_id: str | None,
conversation_id: UUID,
agent: Agent,
) -> Agent:
enabled_variant = _get_condenser_max_step_variant(user_id, str(conversation_id))
if enabled_variant is None:
return agent
if enabled_variant == 'control':
condenser_max_size = 120
elif enabled_variant == 'treatment':
condenser_max_size = 80
else:
logger.error(
'condenser_max_step_experiment:unknown_variant',
extra={
'user_id': user_id,
'convo_id': conversation_id,
'variant': enabled_variant,
'reason': 'unknown variant; returning original conversation settings',
},
)
return agent
condenser_llm = agent.llm.model_copy(update={'usage_id': 'condenser'})
condenser = LLMSummarizingCondenser(
llm=condenser_llm, max_size=condenser_max_size, keep_first=4
)
return agent.model_copy(update={'condenser': condenser})
+11 -12
View File
@@ -24,7 +24,7 @@ from server.config import get_config
from storage.database import session_maker
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.user_settings import UserSettings
from storage.saas_settings_store import SaasSettingsStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
@@ -61,20 +61,19 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
if not user_id:
return False
def _get_setting():
with session_maker() as session:
settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
if not settings or settings.enable_proactive_conversation_starters is None:
return False
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
return settings.enable_proactive_conversation_starters
if not settings or settings.enable_proactive_conversation_starters is None:
return False
return await call_sync_from_async(_get_setting)
return settings.enable_proactive_conversation_starters
# =================================================
+12 -3
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,
@@ -188,19 +189,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
@@ -0,0 +1,27 @@
"""drop settings table
Revision ID: 077
Revises: 076
Create Date: 2025-10-21 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '077'
down_revision: Union[str, None] = '076'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Drop the deprecated settings table."""
op.execute('DROP TABLE IF EXISTS settings')
def downgrade() -> None:
"""No-op downgrade since the settings table is deprecated."""
pass
+12 -12
View File
@@ -5737,7 +5737,7 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.0.0a2"
version = "1.0.0a3"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
@@ -5759,8 +5759,8 @@ wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "512399d896521aee3131eea4bb59087fb9dfa243"
resolved_reference = "512399d896521aee3131eea4bb59087fb9dfa243"
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
subdirectory = "openhands-agent-server"
[[package]]
@@ -5805,9 +5805,9 @@ memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
openhands-aci = "0.3.2"
openhands-agent-server = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "512399d896521aee3131eea4bb59087fb9dfa243", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "512399d896521aee3131eea4bb59087fb9dfa243", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "512399d896521aee3131eea4bb59087fb9dfa243", subdirectory = "openhands-tools"}
openhands-agent-server = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e", subdirectory = "openhands-tools"}
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5863,7 +5863,7 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.0.0a2"
version = "1.0.0a3"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
@@ -5887,13 +5887,13 @@ boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "512399d896521aee3131eea4bb59087fb9dfa243"
resolved_reference = "512399d896521aee3131eea4bb59087fb9dfa243"
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
subdirectory = "openhands-sdk"
[[package]]
name = "openhands-tools"
version = "1.0.0a2"
version = "1.0.0a3"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
@@ -5914,8 +5914,8 @@ pydantic = ">=2.11.7"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "512399d896521aee3131eea4bb59087fb9dfa243"
resolved_reference = "512399d896521aee3131eea4bb59087fb9dfa243"
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
subdirectory = "openhands-tools"
[[package]]
+3 -2
View File
@@ -293,11 +293,12 @@ class TokenManager:
refresh_token_expires_at: int,
) -> dict[str, str | int] | None:
current_time = int(time.time())
# expire access_token ten minutes before actual expiration
# expire access_token four hours before actual expiration
# This ensures tokens are refreshed on resume to have at least 4 hours validity
access_expired = (
False
if access_token_expires_at == 0
else access_token_expires_at < current_time + 600
else access_token_expires_at < current_time + 14400
)
refresh_expired = (
False
+18 -17
View File
@@ -3,10 +3,11 @@ from datetime import UTC, datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from server.config import get_config
from server.constants import LITE_LLM_API_KEY, LITE_LLM_API_URL
from storage.api_key_store import ApiKeyStore
from storage.database import session_maker
from storage.user_settings import UserSettings
from storage.saas_settings_store import SaasSettingsStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
@@ -16,30 +17,30 @@ from openhands.utils.async_utils import call_sync_from_async
# Helper functions for BYOR API key management
async def get_byor_key_from_db(user_id: str) -> str | None:
"""Get the BYOR key from the database for a user."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
def _get_byor_key():
with session_maker() as session:
user_db_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
if user_db_settings and user_db_settings.llm_api_key_for_byor:
return user_db_settings.llm_api_key_for_byor
return None
return await call_sync_from_async(_get_byor_key)
user_db_settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if user_db_settings and user_db_settings.llm_api_key_for_byor:
return user_db_settings.llm_api_key_for_byor
return None
async def store_byor_key_in_db(user_id: str, key: str) -> None:
"""Store the BYOR key in the database for a user."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
def _update_user_settings():
with session_maker() as session:
user_db_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
user_db_settings = settings_store.get_user_settings_by_keycloak_id(
user_id, session
)
if user_db_settings:
user_db_settings.llm_api_key_for_byor = key
+10 -11
View File
@@ -16,10 +16,11 @@ from server.auth.constants import (
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from server.config import sign_token
from server.config import get_config, sign_token
from server.constants import IS_FEATURE_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.user_settings import UserSettings
from openhands.core.logger import openhands_logger as logger
@@ -212,16 +213,14 @@ async def keycloak_callback(
f'&state={state}'
)
has_accepted_tos = False
with session_maker() as session:
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
has_accepted_tos = (
user_settings is not None and user_settings.accepted_tos is not None
)
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
user_settings = settings_store.get_user_settings_by_keycloak_id(user_id)
has_accepted_tos = (
user_settings is not None and user_settings.accepted_tos is not None
)
# If the user hasn't accepted the TOS, redirect to the TOS page
if not has_accepted_tos:
+48 -5
View File
@@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from integrations import stripe_service
from pydantic import BaseModel
from server.config import get_config
from server.constants import (
LITE_LLM_API_KEY,
LITE_LLM_API_URL,
@@ -22,8 +23,8 @@ from server.constants import (
from server.logger import logger
from storage.billing_session import BillingSession
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.subscription_access import SubscriptionAccess
from storage.user_settings import UserSettings
from openhands.server.user_auth import get_user_id
@@ -31,6 +32,37 @@ stripe.api_key = STRIPE_API_KEY
billing_router = APIRouter(prefix='/api/billing')
# TODO: Add a new app_mode named "ON_PREM" to support self-hosted customers instead of doing this
# and members should comment out the "validate_saas_environment" function if they are developing and testing locally.
def is_all_hands_saas_environment(request: Request) -> bool:
"""Check if the current domain is an All Hands SaaS environment.
Args:
request: FastAPI Request object
Returns:
True if the current domain contains "all-hands.dev" or "openhands.dev" postfix
"""
hostname = request.url.hostname or ''
return hostname.endswith('all-hands.dev') or hostname.endswith('openhands.dev')
def validate_saas_environment(request: Request) -> None:
"""Validate that the request is coming from an All Hands SaaS environment.
Args:
request: FastAPI Request object
Raises:
HTTPException: If the request is not from an All Hands SaaS environment
"""
if not is_all_hands_saas_environment(request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Checkout sessions are only available for All Hands SaaS environments',
)
class BillingSessionType(Enum):
DIRECT_PAYMENT = 'DIRECT_PAYMENT'
MONTHLY_SUBSCRIPTION = 'MONTHLY_SUBSCRIPTION'
@@ -196,6 +228,8 @@ async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONRespon
async def create_customer_setup_session(
request: Request, user_id: str = Depends(get_user_id)
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
customer_id = await stripe_service.find_or_create_customer(user_id)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
@@ -214,6 +248,8 @@ async def create_checkout_session(
request: Request,
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
customer_id = await stripe_service.find_or_create_customer(user_id)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
@@ -268,6 +304,8 @@ async def create_subscription_checkout_session(
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
# Prevent duplicate subscriptions for the same user
with session_maker() as session:
now = datetime.now(UTC)
@@ -343,6 +381,8 @@ async def create_subscription_checkout_session_via_get(
user_id: str = Depends(get_user_id),
) -> RedirectResponse:
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)."""
validate_saas_environment(request)
response = await create_subscription_checkout_session(
request, billing_session_type, user_id
)
@@ -578,11 +618,14 @@ async def stripe_webhook(request: Request) -> JSONResponse:
def reset_user_to_free_tier_settings(user_id: str) -> None:
"""Reset user settings to free tier defaults when subscription ends."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
with session_maker() as session:
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
user_settings = settings_store.get_user_settings_by_keycloak_id(
user_id, session
)
if user_settings:
+39 -42
View File
@@ -24,7 +24,6 @@ from server.constants import (
from server.logger import logger
from sqlalchemy.orm import sessionmaker
from storage.database import session_maker
from storage.stored_settings import StoredSettings
from storage.user_settings import UserSettings
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -40,15 +39,46 @@ class SaasSettingsStore(SettingsStore):
session_maker: sessionmaker
config: OpenHandsConfig
def get_user_settings_by_keycloak_id(
self, keycloak_user_id: str, session=None
) -> UserSettings | None:
"""
Get UserSettings by keycloak_user_id.
Args:
keycloak_user_id: The keycloak user ID to search for
session: Optional existing database session. If not provided, creates a new one.
Returns:
UserSettings object if found, None otherwise
"""
if not keycloak_user_id:
return None
def _get_settings():
if session:
# Use provided session
return (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == keycloak_user_id)
.first()
)
else:
# Create new session
with self.session_maker() as new_session:
return (
new_session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == keycloak_user_id)
.first()
)
return _get_settings()
async def load(self) -> Settings | None:
if not self.user_id:
return None
with self.session_maker() as session:
settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == self.user_id)
.first()
)
settings = self.get_user_settings_by_keycloak_id(self.user_id, session)
if not settings or settings.user_version != CURRENT_USER_SETTINGS_VERSION:
logger.info(
@@ -72,12 +102,8 @@ class SaasSettingsStore(SettingsStore):
if item:
kwargs = item.model_dump(context={'expose_secrets': True})
self._encrypt_kwargs(kwargs)
query = session.query(UserSettings).filter(
UserSettings.keycloak_user_id == self.user_id
)
# First check if we have an existing entry in the new table
existing = query.first()
existing = self.get_user_settings_by_keycloak_id(self.user_id, session)
kwargs = {
key: value
@@ -144,33 +170,6 @@ class SaasSettingsStore(SettingsStore):
await self.store(settings)
return settings
def load_legacy_db_settings(self, github_user_id: str) -> Settings | None:
if not github_user_id:
return None
with self.session_maker() as session:
settings = (
session.query(StoredSettings)
.filter(StoredSettings.id == github_user_id)
.first()
)
if settings is None:
return None
logger.info(
'saas_settings_store:load_legacy_db_settings:found',
extra={'github_user_id': github_user_id},
)
kwargs = {
c.name: getattr(settings, c.name)
for c in StoredSettings.__table__.columns
if c.name in Settings.model_fields
}
self._decrypt_kwargs(kwargs)
del kwargs['secrets_store']
settings = Settings(**kwargs)
return settings
async def load_legacy_file_store_settings(self, github_user_id: str):
if not github_user_id:
return None
@@ -235,10 +234,8 @@ class SaasSettingsStore(SettingsStore):
spend = user_info.get('spend') or 0
with session_maker() as session:
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == self.user_id)
.first()
user_settings = self.get_user_settings_by_keycloak_id(
self.user_id, session
)
# In upgrade to V4, we no longer use billing margin, but instead apply this directly
# in litellm. The default billing marign was 2 before this (hence the magic numbers below)
-29
View File
@@ -1,29 +0,0 @@
import uuid
from sqlalchemy import JSON, Boolean, Column, Float, Integer, String
from storage.base import Base
class StoredSettings(Base): # type: ignore
"""
Legacy user settings storage. This should be considered deprecated - use UserSettings isntead
"""
__tablename__ = 'settings'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
language = Column(String, nullable=True)
agent = Column(String, nullable=True)
max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
llm_model = Column(String, nullable=True)
llm_api_key = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
user_consents_to_analytics = Column(Boolean, nullable=True)
margin = Column(Float, nullable=True)
enable_sound_notifications = Column(Boolean, nullable=True, default=False)
sandbox_base_container_image = Column(String, nullable=True)
sandbox_runtime_container_image = Column(String, nullable=True)
secrets_store = Column(JSON, nullable=True)
+1 -2
View File
@@ -17,7 +17,6 @@ from storage.github_app_installation import GithubAppInstallation
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_offline_token import StoredOfflineToken
from storage.stored_settings import StoredSettings
from storage.stripe_customer import StripeCustomer
from storage.user_settings import UserSettings
@@ -85,7 +84,7 @@ def add_minimal_fixtures(session_maker):
updated_at=datetime.fromisoformat('2025-03-08'),
)
)
session.add(StoredSettings(id='mock-user-id', user_consents_to_analytics=True))
session.add(
StripeCustomer(
keycloak_user_id='mock-user-id',
@@ -0,0 +1 @@
"""Unit tests for experiments module."""
@@ -0,0 +1,137 @@
# tests/test_condenser_max_step_experiment_v1.py
from unittest.mock import patch
from uuid import uuid4
from experiments.experiment_manager import SaaSExperimentManager
# SUT imports (update the module path if needed)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment__v1,
)
from pydantic import SecretStr
from openhands.sdk import LLM, Agent
from openhands.sdk.context.condenser import LLMSummarizingCondenser
def make_agent() -> Agent:
"""Build a minimal valid Agent."""
llm = LLM(
usage_id='primary-llm',
model='provider/model',
api_key=SecretStr('sk-test'),
)
return Agent(llm=llm)
def _patch_variant(monkeypatch, return_value):
"""Patch the internal variant getter to return a specific value."""
monkeypatch.setattr(
'experiments.experiment_versions._004_condenser_max_step_experiment._get_condenser_max_step_variant',
lambda user_id, conv_id: return_value,
raising=True,
)
def test_control_variant_sets_condenser_with_max_size_120(monkeypatch):
_patch_variant(monkeypatch, 'control')
agent = make_agent()
conv_id = uuid4()
result = handle_condenser_max_step_experiment__v1('user-1', conv_id, agent)
# Should be a new Agent instance with a condenser installed
assert result is not agent
assert isinstance(result.condenser, LLMSummarizingCondenser)
# The condenser should have its own LLM (usage_id overridden to "condenser")
assert result.condenser.llm.usage_id == 'condenser'
# The original agent LLM remains unchanged
assert agent.llm.usage_id == 'primary-llm'
# Control: max_size = 120, keep_first = 4
assert result.condenser.max_size == 120
assert result.condenser.keep_first == 4
def test_treatment_variant_sets_condenser_with_max_size_80(monkeypatch):
_patch_variant(monkeypatch, 'treatment')
agent = make_agent()
conv_id = uuid4()
result = handle_condenser_max_step_experiment__v1('user-2', conv_id, agent)
assert result is not agent
assert isinstance(result.condenser, LLMSummarizingCondenser)
assert result.condenser.llm.usage_id == 'condenser'
assert result.condenser.max_size == 80
assert result.condenser.keep_first == 4
def test_none_variant_returns_original_agent_without_changes(monkeypatch):
_patch_variant(monkeypatch, None)
agent = make_agent()
conv_id = uuid4()
result = handle_condenser_max_step_experiment__v1('user-3', conv_id, agent)
# No changes—same instance and no condenser attribute added
assert result is agent
assert getattr(result, 'condenser', None) is None
def test_unknown_variant_returns_original_agent_without_changes(monkeypatch):
_patch_variant(monkeypatch, 'weird-variant')
agent = make_agent()
conv_id = uuid4()
result = handle_condenser_max_step_experiment__v1('user-4', conv_id, agent)
assert result is agent
assert getattr(result, 'condenser', None) is None
@patch('experiments.experiment_manager.handle_condenser_max_step_experiment__v1')
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', False)
def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
mock_handle_condenser,
):
"""If ENABLE_EXPERIMENT_MANAGER is False, the method returns the exact same agent and does not call the handler."""
agent = make_agent()
conv_id = uuid4()
result = SaaSExperimentManager.run_agent_variant_tests__v1(
user_id='user-123',
conversation_id=conv_id,
agent=agent,
)
# Same object returned (no copy)
assert result is agent
# Handler should not have been called
mock_handle_condenser.assert_not_called()
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
@patch('experiments.experiment_manager.EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT', True)
def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeypatch):
"""When enabled, it should call the condenser experiment handler and set the long-horizon system prompt."""
agent = make_agent()
conv_id = uuid4()
_patch_variant(monkeypatch, 'treatment')
result: Agent = SaaSExperimentManager.run_agent_variant_tests__v1(
user_id='user-abc',
conversation_id=conv_id,
agent=agent,
)
# Should be a different instance than the original (copied after handler runs)
assert result is not agent
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
# The condenser returned by the handler must be preserved after the system-prompt override copy
assert isinstance(result.condenser, LLMSummarizingCondenser)
assert result.condenser.max_size == 80
+63 -25
View File
@@ -36,6 +36,46 @@ def session_maker(engine):
return sessionmaker(bind=engine)
@pytest.fixture
def mock_request():
"""Create a mock request object with proper URL structure for testing."""
return Request(
scope={
'type': 'http',
'path': '/api/billing/test',
'server': ('test.com', 80),
}
)
@pytest.fixture
def mock_checkout_request():
"""Create a mock request object for checkout session tests."""
request = Request(
scope={
'type': 'http',
'path': '/api/billing/create-checkout-session',
'server': ('test.com', 80),
}
)
request._base_url = URL('http://test.com/')
return request
@pytest.fixture
def mock_subscription_request():
"""Create a mock request object for subscription checkout session tests."""
request = Request(
scope={
'type': 'http',
'path': '/api/billing/subscription-checkout-session',
'server': ('test.com', 80),
}
)
request._base_url = URL('http://test.com/')
return request
@pytest.mark.asyncio
async def test_get_credits_lite_llm_error():
mock_request = Request(scope={'type': 'http', 'state': {'user_id': 'mock_user'}})
@@ -90,14 +130,10 @@ async def test_get_credits_success():
@pytest.mark.asyncio
async def test_create_checkout_session_stripe_error(session_maker):
async def test_create_checkout_session_stripe_error(
session_maker, mock_checkout_request
):
"""Test handling of Stripe API errors."""
mock_request = Request(
scope={
'type': 'http',
}
)
mock_request._base_url = URL('http://test.com/')
mock_customer = stripe.Customer(
id='mock-customer', metadata={'user_id': 'mock-user'}
@@ -118,17 +154,16 @@ async def test_create_checkout_session_stripe_error(session_maker):
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
AsyncMock(return_value={'email': 'testy@tester.com'}),
),
patch('server.routes.billing.validate_saas_environment'),
):
await create_checkout_session(
CreateCheckoutSessionRequest(amount=25), mock_request, 'mock_user'
CreateCheckoutSessionRequest(amount=25), mock_checkout_request, 'mock_user'
)
@pytest.mark.asyncio
async def test_create_checkout_session_success(session_maker):
async def test_create_checkout_session_success(session_maker, mock_checkout_request):
"""Test successful creation of checkout session."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_session = MagicMock()
mock_session.url = 'https://checkout.stripe.com/test-session'
@@ -152,12 +187,13 @@ async def test_create_checkout_session_success(session_maker):
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
AsyncMock(return_value={'email': 'testy@tester.com'}),
),
patch('server.routes.billing.validate_saas_environment'),
):
mock_db_session = MagicMock()
mock_session_maker.return_value.__enter__.return_value = mock_db_session
result = await create_checkout_session(
CreateCheckoutSessionRequest(amount=25), mock_request, 'mock_user'
CreateCheckoutSessionRequest(amount=25), mock_checkout_request, 'mock_user'
)
assert isinstance(result, CreateBillingSessionResponse)
@@ -590,7 +626,9 @@ async def test_cancel_subscription_stripe_error():
@pytest.mark.asyncio
async def test_create_subscription_checkout_session_duplicate_prevention():
async def test_create_subscription_checkout_session_duplicate_prevention(
mock_subscription_request,
):
"""Test that creating a subscription when user already has active subscription raises error."""
from datetime import UTC, datetime
@@ -609,11 +647,9 @@ async def test_create_subscription_checkout_session_duplicate_prevention():
cancelled_at=None,
)
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
with (
patch('server.routes.billing.session_maker') as mock_session_maker,
patch('server.routes.billing.validate_saas_environment'),
):
# Setup mock session to return existing active subscription
mock_session = MagicMock()
@@ -623,7 +659,7 @@ async def test_create_subscription_checkout_session_duplicate_prevention():
# Call the function and expect HTTPException
with pytest.raises(HTTPException) as exc_info:
await create_subscription_checkout_session(
mock_request, user_id='test_user'
mock_subscription_request, user_id='test_user'
)
assert exc_info.value.status_code == 400
@@ -634,10 +670,10 @@ async def test_create_subscription_checkout_session_duplicate_prevention():
@pytest.mark.asyncio
async def test_create_subscription_checkout_session_allows_after_cancellation():
async def test_create_subscription_checkout_session_allows_after_cancellation(
mock_subscription_request,
):
"""Test that creating a subscription is allowed when previous subscription was cancelled."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_session_obj = MagicMock()
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
@@ -657,6 +693,7 @@ async def test_create_subscription_checkout_session_allows_after_cancellation():
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
),
patch('server.routes.billing.validate_saas_environment'),
):
# Setup mock session - the query should return None because cancelled subscriptions are filtered out
mock_session = MagicMock()
@@ -665,7 +702,7 @@ async def test_create_subscription_checkout_session_allows_after_cancellation():
# Should succeed
result = await create_subscription_checkout_session(
mock_request, user_id='test_user'
mock_subscription_request, user_id='test_user'
)
assert isinstance(result, CreateBillingSessionResponse)
@@ -673,10 +710,10 @@ async def test_create_subscription_checkout_session_allows_after_cancellation():
@pytest.mark.asyncio
async def test_create_subscription_checkout_session_success_no_existing():
async def test_create_subscription_checkout_session_success_no_existing(
mock_subscription_request,
):
"""Test successful subscription creation when no existing subscription."""
mock_request = Request(scope={'type': 'http'})
mock_request._base_url = URL('http://test.com/')
mock_session_obj = MagicMock()
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
@@ -696,6 +733,7 @@ async def test_create_subscription_checkout_session_success_no_existing():
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
),
patch('server.routes.billing.validate_saas_environment'),
):
# Setup mock session to return no existing subscription
mock_session = MagicMock()
@@ -704,7 +742,7 @@ async def test_create_subscription_checkout_session_success_no_existing():
# Should succeed
result = await create_subscription_checkout_session(
mock_request, user_id='test_user'
mock_subscription_request, user_id='test_user'
)
assert isinstance(result, CreateBillingSessionResponse)
@@ -8,8 +8,8 @@ pytestmark = pytest.mark.asyncio
# Mock the call_sync_from_async function to return the result of the function directly
def mock_call_sync_from_async(func):
return func()
def mock_call_sync_from_async(func, *args, **kwargs):
return func(*args, **kwargs)
@pytest.fixture
@@ -8,7 +8,6 @@ from server.constants import (
LITE_LLM_TEAM_ID,
)
from storage.saas_settings_store import SaasSettingsStore
from storage.stored_settings import StoredSettings
from storage.user_settings import UserSettings
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -303,26 +302,6 @@ async def test_create_default_settings_require_payment_disabled(
assert settings.language == 'en'
@pytest.mark.asyncio
async def test_create_default_settings_with_existing_llm_key(
settings_store, mock_stripe, mock_github_user, mock_litellm_api, session_maker
):
# Test that existing llm_api_key is preserved and not overwritten with litellm default
with (
patch('storage.saas_settings_store.REQUIRE_PAYMENT', False),
patch('storage.saas_settings_store.LITE_LLM_API_KEY', 'mock-api-key'),
patch('storage.saas_settings_store.session_maker', session_maker),
):
with settings_store.session_maker() as session:
kwargs = {'id': '12345', 'language': 'en', 'llm_api_key': 'existing_key'}
settings_store._encrypt_kwargs(kwargs)
session.merge(StoredSettings(**kwargs))
session.commit()
updated_settings = await settings_store.create_default_settings(None)
assert updated_settings is not None
assert updated_settings.llm_api_key.get_secret_value() == 'test_api_key'
@pytest.mark.asyncio
async def test_create_default_lite_llm_settings_no_api_config(settings_store):
with (
@@ -13,7 +13,6 @@ from integrations.stripe_service import (
)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from storage.stored_settings import Base as StoredBase
from storage.stripe_customer import Base as StripeCustomerBase
from storage.stripe_customer import StripeCustomer
from storage.user_settings import Base as UserBase
@@ -22,7 +21,7 @@ from storage.user_settings import Base as UserBase
@pytest.fixture
def engine():
engine = create_engine('sqlite:///:memory:')
StoredBase.metadata.create_all(engine)
UserBase.metadata.create_all(engine)
StripeCustomerBase.metadata.create_all(engine)
return engine
@@ -12,7 +12,7 @@ git clone -b $OH_SWE_BENCH_REPO_BRANCH $OH_SWE_BENCH_REPO_PATH $EVAL_WORKSPACE/O
# 2. Prepare DATA
echo "==== Prepare SWE-bench data ===="
EVAL_IMAGE=ghcr.io/all-hands-ai/eval-swe-bench:builder_with_conda
EVAL_IMAGE=ghcr.io/openhands/eval-swe-bench:builder_with_conda
EVAL_WORKSPACE=$(realpath $EVAL_WORKSPACE)
chmod +x $EVAL_WORKSPACE/OH-SWE-bench/swebench/harness/prepare_data.sh
if [ -d $EVAL_WORKSPACE/eval_data ]; then
@@ -12,7 +12,7 @@ git clone -b $OH_SWE_BENCH_REPO_BRANCH $OH_SWE_BENCH_REPO_PATH $EVAL_WORKSPACE/O
# 2. Prepare DATA
echo "==== Prepare SWE-bench data ===="
EVAL_IMAGE=ghcr.io/all-hands-ai/eval-swe-bench:builder_with_conda
EVAL_IMAGE=ghcr.io/openhands/eval-swe-bench:builder_with_conda
EVAL_WORKSPACE=$(realpath $EVAL_WORKSPACE)
chmod +x $EVAL_WORKSPACE/OH-SWE-bench/swebench/harness/prepare_data.sh
if [ -d $EVAL_WORKSPACE/eval_data ]; then
@@ -12,7 +12,7 @@ git clone -b $OH_SWE_BENCH_REPO_BRANCH $OH_SWE_BENCH_REPO_PATH $EVAL_WORKSPACE/O
# 2. Prepare DATA
echo "==== Prepare SWE-bench data ===="
EVAL_IMAGE=ghcr.io/all-hands-ai/eval-swe-bench:builder_with_conda
EVAL_IMAGE=ghcr.io/openhands/eval-swe-bench:builder_with_conda
EVAL_WORKSPACE=$(realpath $EVAL_WORKSPACE)
chmod +x $EVAL_WORKSPACE/OH-SWE-bench/swebench/harness/prepare_data.sh
if [ -d $EVAL_WORKSPACE/eval_data ]; then
@@ -162,7 +162,7 @@ while IFS= read -r task_image; do
# Prune unused images and volumes
docker image rm "$task_image"
docker images "ghcr.io/all-hands-ai/runtime" -q | xargs -r docker rmi -f
docker images "ghcr.io/openhands/runtime" -q | xargs -r docker rmi -f
docker volume prune -f
docker system prune -f
done < "$temp_file"
@@ -21,7 +21,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useConfig } from "#/hooks/query/use-config";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { OpenHandsAction } from "#/types/core/actions";
import { useEventStore } from "#/stores/use-event-store";
@@ -31,7 +31,7 @@ vi.mock("#/stores/error-message-store");
vi.mock("#/stores/optimistic-user-message-store");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-get-trajectory");
vi.mock("#/hooks/mutation/use-upload-files");
vi.mock("#/hooks/mutation/use-unified-upload-files");
// Mock React Router hooks at the top level
vi.mock("react-router", async () => {
@@ -128,7 +128,7 @@ describe("ChatInterface - Chat Suggestions", () => {
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
@@ -267,7 +267,7 @@ describe("ChatInterface - Empty state", () => {
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
@@ -25,6 +25,12 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => mockUseIsAuthed(),
}));
// Mock useIsAllHandsSaaSEnvironment hook
const mockUseIsAllHandsSaaSEnvironment = vi.fn();
vi.mock("#/hooks/use-is-all-hands-saas-environment", () => ({
useIsAllHandsSaaSEnvironment: () => mockUseIsAllHandsSaaSEnvironment(),
}));
const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
wrapper: ({ children }) => (
@@ -48,6 +54,9 @@ beforeEach(() => {
// Default mock for useIsAuthed - returns authenticated by default
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
// Default mock for useIsAllHandsSaaSEnvironment - returns true for SaaS environment
mockUseIsAllHandsSaaSEnvironment.mockReturnValue(true);
});
describe("Content", () => {
@@ -104,7 +113,6 @@ describe("Content", () => {
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
});
});
});
describe("Advanced form", () => {
@@ -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(),
});
@@ -253,6 +253,44 @@ class V1ConversationService {
);
return data;
}
/**
* Upload a single file to the V1 conversation workspace
* V1 API endpoint: POST /api/file/upload/{path}
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @param file The file to upload
* @param path The absolute path where the file should be uploaded (defaults to /workspace/{file.name})
* @returns void on success, throws on error
*/
static async uploadFile(
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
file: File,
path?: string,
): Promise<void> {
// Default to /workspace/{filename} if no path provided (must be absolute)
const uploadPath = path || `/workspace/${file.name}`;
const encodedPath = encodeURIComponent(uploadPath);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/file/upload/${encodedPath}`,
);
const headers = this.buildSessionHeaders(sessionApiKey);
// Create FormData with the file
const formData = new FormData();
formData.append("file", file);
// Upload file
await axios.post(url, formData, {
headers: {
...headers,
"Content-Type": "multipart/form-data",
},
});
}
}
export default V1ConversationService;
@@ -35,12 +35,17 @@ import {
hasUserEvent as hasV1UserEvent,
shouldRenderEvent as shouldRenderV1Event,
} from "#/components/v1/chat";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
import { useConversationStore } from "#/state/conversation-store";
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
import { isV0Event, isV1Event } from "#/types/v1/type-guards";
import {
isV0Event,
isV1Event,
isSystemPromptEvent,
isConversationStateUpdateEvent,
} from "#/types/v1/type-guards";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
function getEntryPoint(
@@ -81,7 +86,7 @@ export function ChatInterface() {
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const { selectedRepository, replayJson } = useInitialQueryStore();
const params = useParams();
const { mutateAsync: uploadFiles } = useUploadFiles();
const { mutateAsync: uploadFiles } = useUnifiedUploadFiles();
const optimisticUserMessage = getOptimisticUserMessage();
@@ -111,7 +116,14 @@ export function ChatInterface() {
event.source === "agent" &&
event.action !== "system",
) ||
storeEvents.filter(isV1Event).some((event) => event.source === "agent"),
storeEvents
.filter(isV1Event)
.some(
(event) =>
event.source === "agent" &&
!isSystemPromptEvent(event) &&
!isConversationStateUpdateEvent(event),
),
[storeEvents],
);
@@ -237,7 +249,7 @@ export function ChatInterface() {
<div className="flex justify-between relative">
<div className="flex items-center gap-1">
<ConfirmationModeEnabled />
{totalEvents > 0 && (
{totalEvents > 0 && !isV1Conversation && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
@@ -262,7 +274,7 @@ export function ChatInterface() {
<InteractiveChatBox onSubmit={handleSendMessage} />
</div>
{config?.APP_MODE !== "saas" && (
{config?.APP_MODE !== "saas" && !isV1Conversation && (
<FeedbackModal
isOpen={feedbackModalIsOpen}
onClose={() => setFeedbackModalIsOpen(false)}
@@ -5,6 +5,7 @@ import { GitControlBarPullButton } from "./git-control-bar-pull-button";
import { GitControlBarPushButton } from "./git-control-bar-push-button";
import { GitControlBarPrButton } from "./git-control-bar-pr-button";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { Provider } from "#/types/settings";
import { I18nKey } from "#/i18n/declaration";
import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper";
@@ -17,10 +18,16 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const { repositoryInfo } = useTaskPolling();
const selectedRepository = conversation?.selected_repository;
const gitProvider = conversation?.git_provider as Provider;
const selectedBranch = conversation?.selected_branch;
// Priority: conversation data > task data
// This ensures we show repository info immediately from task, then transition to conversation data
const selectedRepository =
conversation?.selected_repository || repositoryInfo?.selectedRepository;
const gitProvider = (conversation?.git_provider ||
repositoryInfo?.gitProvider) as Provider;
const selectedBranch =
conversation?.selected_branch || repositoryInfo?.selectedBranch;
const hasRepository = !!selectedRepository;
@@ -24,6 +24,7 @@ import {
import { AgentState } from "#/types/agent-state";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import MemoryIcon from "#/icons/memory_icon.svg?react";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
@@ -51,6 +52,11 @@ export const Messages: React.FC<MessagesProps> = React.memo(
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
const { conversationId } = useConversationId();
const { data: conversation } = useUserConversation(conversationId);
const { data: activeConversation } = useActiveConversation();
// TODO: Hide microagent actions for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = activeConversation?.conversation_version === "V1";
const optimisticUserMessage = getOptimisticUserMessage();
@@ -236,7 +242,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
)}
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
actions={
conversation?.selected_repository
conversation?.selected_repository && !isV1Conversation
? [
{
icon: (
@@ -259,6 +265,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
<ChatMessage type="user" message={optimisticUserMessage} />
)}
{conversation?.selected_repository &&
!isV1Conversation &&
showLaunchMicroagentModal &&
selectedEventId &&
createPortal(
@@ -14,6 +14,7 @@ import { LoadingMicroagentBody } from "./loading-microagent-body";
import { LoadingMicroagentTextarea } from "./loading-microagent-textarea";
import { useGetMicroagents } from "#/hooks/query/use-get-microagents";
import { Typography } from "#/ui/typography";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
interface LaunchMicroagentModalProps {
onClose: () => void;
@@ -32,6 +33,7 @@ export function LaunchMicroagentModal({
}: LaunchMicroagentModalProps) {
const { t } = useTranslation();
const { runtimeActive } = useHandleRuntimeActive();
const { data: conversation } = useActiveConversation();
const { data: prompt, isLoading: promptIsLoading } =
useMicroagentPrompt(eventId);
@@ -40,6 +42,15 @@ export function LaunchMicroagentModal({
const [triggers, setTriggers] = React.useState<string[]>([]);
// TODO: Hide LaunchMicroagentModal for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Don't render anything for V1 conversations
if (isV1Conversation) {
return null;
}
const formAction = (formData: FormData) => {
const query = formData.get("query-input")?.toString();
const target = formData.get("target-input")?.toString();
@@ -28,17 +28,23 @@ interface ToolsContextMenuProps {
onClose: () => void;
onShowMicroagents: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools: (event: React.MouseEvent<HTMLButtonElement>) => void;
shouldShowAgentTools?: boolean;
}
export function ToolsContextMenu({
onClose,
onShowMicroagents,
onShowAgentTools,
shouldShowAgentTools = true,
}: ToolsContextMenuProps) {
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const { providers } = useUserProviders();
// TODO: Hide microagent menu items for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
const [activeSubmenu, setActiveSubmenu] = useState<"git" | "macros" | null>(
null,
);
@@ -64,7 +70,7 @@ export function ToolsContextMenu({
testId="tools-context-menu"
position="top"
alignment="left"
className="left-[-16px] mb-2 bottom-full overflow-visible"
className="left-[-16px] mb-2 bottom-full overflow-visible min-w-[200px]"
>
{/* Git Tools */}
{showGitTools && (
@@ -122,33 +128,37 @@ export function ToolsContextMenu({
</div>
</div>
<Divider />
{(!isV1Conversation || shouldShowAgentTools) && <Divider />}
{/* Show Available Microagents */}
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
className={contextMenuListItemClassName}
>
<ToolsContextMenuIconText
icon={<RobotIcon width={16} height={16} />}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
{/* Show Available Microagents - Hidden for V1 conversations */}
{!isV1Conversation && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
className={contextMenuListItemClassName}
>
<ToolsContextMenuIconText
icon={<RobotIcon width={16} height={16} />}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
)}
{/* Show Agent Tools and Metadata */}
<ContextMenuListItem
testId="show-agent-tools-button"
onClick={onShowAgentTools}
className={contextMenuListItemClassName}
>
<ToolsContextMenuIconText
icon={<ToolsIcon width={16} height={16} />}
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
{/* Show Agent Tools and Metadata - Only show if system message is available */}
{shouldShowAgentTools && (
<ContextMenuListItem
testId="show-agent-tools-button"
onClick={onShowAgentTools}
className={contextMenuListItemClassName}
>
<ToolsContextMenuIconText
icon={<ToolsIcon width={16} height={16} />}
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
/>
</ContextMenuListItem>
)}
</ContextMenu>
);
}
@@ -23,6 +23,7 @@ export function Tools() {
microagentsModalVisible,
setMicroagentsModalVisible,
systemMessage,
shouldShowAgentTools,
} = useConversationNameContextMenu({
conversationId,
conversationStatus: conversation?.status,
@@ -52,6 +53,7 @@ export function Tools() {
onClose={() => setContextMenuOpen(false)}
onShowMicroagents={handleShowMicroagents}
onShowAgentTools={handleShowAgentTools}
shouldShowAgentTools={shouldShowAgentTools}
/>
)}
@@ -15,6 +15,7 @@ import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { Divider } from "#/ui/divider";
import { I18nKey } from "#/i18n/declaration";
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
interface ConversationCardContextMenuProps {
onClose: () => void;
@@ -41,6 +42,11 @@ export function ConversationCardContextMenu({
}: ConversationCardContextMenuProps) {
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { data: conversation } = useActiveConversation();
// TODO: Hide microagent menu items for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
const hasEdit = Boolean(onEdit);
const hasDownload = Boolean(onDownloadViaVSCode);
@@ -97,7 +103,7 @@ export function ConversationCardContextMenu({
</ContextMenuListItem>
)}
{onShowMicroagents && (
{onShowMicroagents && !isV1Conversation && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
@@ -11,6 +11,7 @@ import { MicroagentsLoadingState } from "./microagents-loading-state";
import { MicroagentsEmptyState } from "./microagents-empty-state";
import { MicroagentItem } from "./microagent-item";
import { useAgentState } from "#/hooks/use-agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
interface MicroagentsModalProps {
onClose: () => void;
@@ -19,6 +20,7 @@ interface MicroagentsModalProps {
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
const { t } = useTranslation();
const { curAgentState } = useAgentState();
const { data: conversation } = useActiveConversation();
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
{},
);
@@ -30,6 +32,15 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
isRefetching,
} = useConversationMicroagents();
// TODO: Hide MicroagentsModal for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Don't render anything for V1 conversations
if (isV1Conversation) {
return null;
}
const toggleAgent = (agentName: string) => {
setExpandedAgents((prev) => ({
...prev,
@@ -6,6 +6,7 @@ import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { Divider } from "#/ui/divider";
import { I18nKey } from "#/i18n/declaration";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import EditIcon from "#/icons/u-edit.svg?react";
import RobotIcon from "#/icons/u-robot.svg?react";
@@ -52,6 +53,11 @@ export function ConversationNameContextMenu({
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { data: conversation } = useActiveConversation();
// TODO: Hide microagent menu items for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
const hasDownload = Boolean(onDownloadViaVSCode);
const hasExport = Boolean(onExportConversation);
@@ -85,7 +91,7 @@ export function ConversationNameContextMenu({
{hasTools && <Divider testId="separator-tools" />}
{onShowMicroagents && (
{onShowMicroagents && !isV1Conversation && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
@@ -113,9 +119,11 @@ export function ConversationNameContextMenu({
</ContextMenuListItem>
)}
{(hasExport || hasDownload) && <Divider testId="separator-export" />}
{(hasExport || hasDownload) && !isV1Conversation && (
<Divider testId="separator-export" />
)}
{onExportConversation && (
{onExportConversation && !isV1Conversation && (
<ContextMenuListItem
testId="export-conversation-button"
onClick={onExportConversation}
@@ -129,7 +137,7 @@ export function ConversationNameContextMenu({
</ContextMenuListItem>
)}
{onDownloadViaVSCode && (
{onDownloadViaVSCode && !isV1Conversation && (
<ContextMenuListItem
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
@@ -143,7 +151,9 @@ export function ConversationNameContextMenu({
</ContextMenuListItem>
)}
{(hasInfo || hasControl) && <Divider testId="separator-info-control" />}
{(hasInfo || hasControl) && !isV1Conversation && (
<Divider testId="separator-info-control" />
)}
{onDisplayCost && (
<ContextMenuListItem
@@ -5,6 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
import { Feedback } from "#/api/open-hands.types";
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
import { BrandButton } from "../settings/brand-button";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
const FEEDBACK_VERSION = "1.0";
const VIEWER_PAGE = "https://www.all-hands.dev/share";
@@ -16,6 +17,7 @@ interface FeedbackFormProps {
export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const copiedToClipboardToast = () => {
hotToast(t(I18nKey.FEEDBACK$PASSWORD_COPIED_MESSAGE), {
@@ -60,6 +62,15 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
const { mutate: submitFeedback, isPending } = useSubmitFeedback();
// TODO: Hide FeedbackForm for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Don't render anything for V1 conversations
if (isV1Conversation) {
return null;
}
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const formData = new FormData(event.currentTarget);
@@ -5,6 +5,7 @@ import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { useSubmitConversationFeedback } from "#/hooks/mutation/use-submit-conversation-feedback";
import { ScrollContext } from "#/context/scroll-context";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
// Global timeout duration in milliseconds
const AUTO_SUBMIT_TIMEOUT = 10000;
@@ -23,6 +24,7 @@ export function LikertScale({
initialReason,
}: LikertScaleProps) {
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const [selectedRating, setSelectedRating] = useState<number | null>(
initialRating || null,
@@ -77,6 +79,56 @@ export function LikertScale({
}
}, [initialReason]);
// Countdown effect
useEffect(() => {
if (countdown > 0 && showReasons && !isSubmitted) {
const timer = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
return () => clearTimeout(timer);
}
return () => {};
}, [countdown, showReasons, isSubmitted]);
// Clean up timeout on unmount
useEffect(
() => () => {
if (reasonTimeout) {
clearTimeout(reasonTimeout);
}
},
[reasonTimeout],
);
// Scroll to bottom when component mounts, but only if user is already at the bottom
useEffect(() => {
if (scrollToBottom && autoScroll && !isSubmitted) {
// Small delay to ensure the component is fully rendered
setTimeout(() => {
scrollToBottom();
}, 100);
}
}, [scrollToBottom, autoScroll, isSubmitted]);
// Scroll to bottom when reasons are shown, but only if user is already at the bottom
useEffect(() => {
if (scrollToBottom && autoScroll && showReasons) {
// Small delay to ensure the reasons are fully rendered
setTimeout(() => {
scrollToBottom();
}, 100);
}
}, [scrollToBottom, autoScroll, showReasons]);
// TODO: Hide LikertScale for V1 conversations
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Don't render anything for V1 conversations
if (isV1Conversation) {
return null;
}
// Submit feedback and disable the component
const submitFeedback = (rating: number, reason?: string) => {
submitConversationFeedback(
@@ -137,47 +189,6 @@ export function LikertScale({
}
};
// Countdown effect
useEffect(() => {
if (countdown > 0 && showReasons && !isSubmitted) {
const timer = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
return () => clearTimeout(timer);
}
return () => {};
}, [countdown, showReasons, isSubmitted]);
// Clean up timeout on unmount
useEffect(
() => () => {
if (reasonTimeout) {
clearTimeout(reasonTimeout);
}
},
[reasonTimeout],
);
// Scroll to bottom when component mounts, but only if user is already at the bottom
useEffect(() => {
if (scrollToBottom && autoScroll && !isSubmitted) {
// Small delay to ensure the component is fully rendered
setTimeout(() => {
scrollToBottom();
}, 100);
}
}, [scrollToBottom, autoScroll, isSubmitted]);
// Scroll to bottom when reasons are shown, but only if user is already at the bottom
useEffect(() => {
if (scrollToBottom && autoScroll && showReasons) {
// Small delay to ensure the reasons are fully rendered
setTimeout(() => {
scrollToBottom();
}, 100);
}
}, [scrollToBottom, autoScroll, showReasons]);
// Helper function to get button class based on state
const getButtonClass = (rating: number) => {
if (isSubmitted) {
@@ -92,7 +92,7 @@ export const useCreateConversation = () => {
query_character_length: query?.length,
has_repository: !!repository,
});
await queryClient.invalidateQueries({
queryClient.removeQueries({
queryKey: ["user", "conversations"],
});
},
@@ -10,6 +10,7 @@ type SubmitFeedbackArgs = {
export const useSubmitFeedback = () => {
const { conversationId } = useConversationId();
return useMutation({
mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
ConversationService.submitFeedback(conversationId, feedback),
@@ -1,10 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { Provider } from "#/types/settings";
import { useErrorMessageStore } from "#/stores/error-message-store";
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import {
getConversationVersionFromQueryCache,
resumeV1ConversationSandbox,
@@ -25,7 +21,6 @@ import {
* startConversation({ conversationId: "some-id", providers: [...] });
*/
export const useUnifiedResumeConversationSandbox = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const removeErrorMessage = useErrorMessageStore(
(state) => state.removeErrorMessage,
@@ -53,8 +48,6 @@ export const useUnifiedResumeConversationSandbox = () => {
return startV0Conversation(variables.conversationId, variables.providers);
},
onMutate: async () => {
toast.loading(t(I18nKey.TOAST$STARTING_CONVERSATION), TOAST_OPTIONS);
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
const previousConversations = queryClient.getQueryData([
"user",
@@ -64,9 +57,6 @@ export const useUnifiedResumeConversationSandbox = () => {
return { previousConversations };
},
onError: (_, __, context) => {
toast.dismiss();
toast.error(t(I18nKey.TOAST$FAILED_TO_START_CONVERSATION), TOAST_OPTIONS);
if (context?.previousConversations) {
queryClient.setQueryData(
["user", "conversations"],
@@ -78,9 +68,6 @@ export const useUnifiedResumeConversationSandbox = () => {
invalidateConversationQueries(queryClient, variables.conversationId);
},
onSuccess: (_, variables) => {
toast.dismiss();
toast.success(t(I18nKey.TOAST$CONVERSATION_STARTED), TOAST_OPTIONS);
// Clear error messages when starting/resuming conversation
removeErrorMessage();
@@ -50,7 +50,10 @@ export const useUnifiedPauseConversationSandbox = () => {
return stopV0Conversation(variables.conversationId);
},
onMutate: async () => {
toast.loading(t(I18nKey.TOAST$STOPPING_CONVERSATION), TOAST_OPTIONS);
const toastId = toast.loading(
t(I18nKey.TOAST$STOPPING_CONVERSATION),
TOAST_OPTIONS,
);
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
const previousConversations = queryClient.getQueryData([
@@ -58,10 +61,12 @@ export const useUnifiedPauseConversationSandbox = () => {
"conversations",
]);
return { previousConversations };
return { previousConversations, toastId };
},
onError: (_, __, context) => {
toast.dismiss();
if (context?.toastId) {
toast.dismiss(context.toastId);
}
toast.error(t(I18nKey.TOAST$FAILED_TO_STOP_CONVERSATION), TOAST_OPTIONS);
if (context?.previousConversations) {
@@ -74,8 +79,10 @@ export const useUnifiedPauseConversationSandbox = () => {
onSettled: (_, __, variables) => {
invalidateConversationQueries(queryClient, variables.conversationId);
},
onSuccess: (_, variables) => {
toast.dismiss();
onSuccess: (_, variables, context) => {
if (context?.toastId) {
toast.dismiss(context.toastId);
}
toast.success(t(I18nKey.TOAST$CONVERSATION_STOPPED), TOAST_OPTIONS);
updateConversationStatusInCache(
@@ -0,0 +1,55 @@
import { useMutation } from "@tanstack/react-query";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useUploadFiles } from "./use-upload-files";
import { useV1UploadFiles } from "./use-v1-upload-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
interface UnifiedUploadFilesVariables {
conversationId: string;
files: File[];
}
/**
* Unified hook that automatically selects the correct file upload method
* based on the conversation version (V0 or V1).
*
* For V0 conversations: Uses the legacy multi-file upload endpoint
* For V1 conversations: Uses parallel single-file uploads
*
* @returns Mutation hook with the same interface as useUploadFiles
*/
export const useUnifiedUploadFiles = () => {
const { data: conversation } = useActiveConversation();
const isV1Conversation = conversation?.conversation_version === "V1";
// Initialize both hooks
const v0Upload = useUploadFiles();
const v1Upload = useV1UploadFiles();
// Create a unified mutation that delegates to the appropriate hook
return useMutation({
mutationKey: ["unified-upload-files"],
mutationFn: async (
variables: UnifiedUploadFilesVariables,
): Promise<FileUploadSuccessResponse> => {
const { conversationId, files } = variables;
if (isV1Conversation) {
// V1: Use conversation URL and session API key
return v1Upload.mutateAsync({
conversationUrl: conversation?.url,
sessionApiKey: conversation?.session_api_key,
files,
});
}
// V0: Use conversation ID
return v0Upload.mutateAsync({
conversationId,
files,
});
},
meta: {
disableToast: true,
},
});
};
@@ -0,0 +1,82 @@
import { useMutation } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
interface V1UploadFilesVariables {
conversationUrl: string | null | undefined;
sessionApiKey: string | null | undefined;
files: File[];
}
/**
* Hook to upload multiple files in parallel to V1 conversations
* Uploads files concurrently using Promise.allSettled and aggregates results
*
* @returns Mutation hook with mutateAsync function
*/
export const useV1UploadFiles = () =>
useMutation({
mutationKey: ["v1-upload-files"],
mutationFn: async (
variables: V1UploadFilesVariables,
): Promise<FileUploadSuccessResponse> => {
const { conversationUrl, sessionApiKey, files } = variables;
// Upload all files in parallel
const uploadPromises = files.map(async (file) => {
try {
// Upload to /workspace/{filename}
const filePath = `/workspace/${file.name}`;
await V1ConversationService.uploadFile(
conversationUrl,
sessionApiKey,
file,
filePath,
);
return { success: true as const, fileName: file.name, filePath };
} catch (error) {
return {
success: false as const,
fileName: file.name,
filePath: `/workspace/${file.name}`,
error: error instanceof Error ? error.message : "Unknown error",
};
}
});
// Wait for all uploads to complete (both successful and failed)
const results = await Promise.allSettled(uploadPromises);
// Aggregate the results
const uploadedFiles: string[] = [];
const skippedFiles: { name: string; reason: string }[] = [];
results.forEach((result) => {
if (result.status === "fulfilled") {
if (result.value.success) {
// Return the absolute file path for V1
uploadedFiles.push(result.value.filePath);
} else {
skippedFiles.push({
name: result.value.fileName,
reason: result.value.error,
});
}
} else {
// Promise was rejected (shouldn't happen since we catch errors above)
skippedFiles.push({
name: "unknown",
reason: result.reason?.message || "Upload failed",
});
}
});
return {
uploaded_files: uploadedFiles,
skipped_files: skippedFiles,
};
},
meta: {
disableToast: true,
},
});
@@ -4,7 +4,6 @@ import { useConversationId } from "../use-conversation-id";
export const useMicroagentPrompt = (eventId: number) => {
const { conversationId } = useConversationId();
return useQuery({
queryKey: ["memory", "prompt", conversationId, eventId],
queryFn: () =>
+2 -2
View File
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
/**
* Hook to fetch in-progress V1 conversation start tasks
@@ -16,10 +17,9 @@ export const useStartTasks = (limit = 10) =>
useQuery({
queryKey: ["start-tasks", "search", limit],
queryFn: () => V1ConversationService.searchStartTasks(limit),
enabled: USE_V1_CONVERSATION_API(),
select: (tasks) =>
tasks.filter(
(task) => task.status !== "READY" && task.status !== "ERROR",
),
staleTime: 1000 * 60 * 1, // 1 minute (short since these are in-progress)
gcTime: 1000 * 60 * 5, // 5 minutes
});
@@ -68,5 +68,11 @@ export const useTaskPolling = () => {
taskDetail: taskQuery.data?.detail,
taskError: taskQuery.error,
isLoadingTask: taskQuery.isLoading,
// Repository information from task request
repositoryInfo: {
selectedRepository: taskQuery.data?.request?.selected_repository,
selectedBranch: taskQuery.data?.request?.selected_branch,
gitProvider: taskQuery.data?.request?.git_provider,
},
};
};
@@ -0,0 +1,13 @@
import { useMemo } from "react";
/**
* Hook to check if the current domain is an All Hands SaaS environment
* @returns True if the current domain contains "all-hands.dev" or "openhands.dev" postfix
*/
export const useIsAllHandsSaaSEnvironment = (): boolean =>
useMemo(() => {
const { hostname } = window.location;
return (
hostname.endsWith("all-hands.dev") || hostname.endsWith("openhands.dev")
);
}, []);
-3
View File
@@ -927,9 +927,6 @@ export enum I18nKey {
CONVERSATION$FAILED_TO_START_FROM_TASK = "CONVERSATION$FAILED_TO_START_FROM_TASK",
CONVERSATION$NOT_EXIST_OR_NO_PERMISSION = "CONVERSATION$NOT_EXIST_OR_NO_PERMISSION",
CONVERSATION$FAILED_TO_START_WITH_ERROR = "CONVERSATION$FAILED_TO_START_WITH_ERROR",
TOAST$STARTING_CONVERSATION = "TOAST$STARTING_CONVERSATION",
TOAST$FAILED_TO_START_CONVERSATION = "TOAST$FAILED_TO_START_CONVERSATION",
TOAST$CONVERSATION_STARTED = "TOAST$CONVERSATION_STARTED",
TOAST$STOPPING_CONVERSATION = "TOAST$STOPPING_CONVERSATION",
TOAST$FAILED_TO_STOP_CONVERSATION = "TOAST$FAILED_TO_STOP_CONVERSATION",
TOAST$CONVERSATION_STOPPED = "TOAST$CONVERSATION_STOPPED",
-48
View File
@@ -14831,54 +14831,6 @@
"de": "Konversation konnte nicht gestartet werden: {{error}}",
"uk": "Не вдалося запустити розмову: {{error}}"
},
"TOAST$STARTING_CONVERSATION": {
"en": "Starting conversation...",
"ja": "会話を開始しています...",
"zh-CN": "正在启动对话...",
"zh-TW": "正在啟動對話...",
"ko-KR": "대화 시작 중...",
"no": "Starter samtale...",
"it": "Avvio della conversazione...",
"pt": "Iniciando conversa...",
"es": "Iniciando conversación...",
"ar": "بدء المحادثة...",
"fr": "Démarrage de la conversation...",
"tr": "Konuşma başlatılıyor...",
"de": "Konversation wird gestartet...",
"uk": "Запуск розмови..."
},
"TOAST$FAILED_TO_START_CONVERSATION": {
"en": "Failed to start conversation",
"ja": "会話の開始に失敗しました",
"zh-CN": "启动对话失败",
"zh-TW": "啟動對話失敗",
"ko-KR": "대화 시작 실패",
"no": "Kunne ikke starte samtale",
"it": "Impossibile avviare la conversazione",
"pt": "Falha ao iniciar conversa",
"es": "No se pudo iniciar la conversación",
"ar": "فشل بدء المحادثة",
"fr": "Échec du démarrage de la conversation",
"tr": "Konuşma başlatılamadı",
"de": "Konversation konnte nicht gestartet werden",
"uk": "Не вдалося запустити розмову"
},
"TOAST$CONVERSATION_STARTED": {
"en": "Conversation started",
"ja": "会話が開始されました",
"zh-CN": "对话已启动",
"zh-TW": "對話已啟動",
"ko-KR": "대화가 시작되었습니다",
"no": "Samtale startet",
"it": "Conversazione avviata",
"pt": "Conversa iniciada",
"es": "Conversación iniciada",
"ar": "بدأت المحادثة",
"fr": "Conversation démarrée",
"tr": "Konuşma başlatıldı",
"de": "Konversation gestartet",
"uk": "Розмову запущено"
},
"TOAST$STOPPING_CONVERSATION": {
"en": "Stopping conversation...",
"ja": "会話を停止しています...",
+4 -8
View File
@@ -154,7 +154,7 @@ function AppContent() {
t,
]);
const isV1Conversation = conversation?.conversation_version === "V1";
const isV0Conversation = conversation?.conversation_version === "V0";
const content = (
<ConversationSubscriptionsProvider>
@@ -174,15 +174,11 @@ function AppContent() {
</ConversationSubscriptionsProvider>
);
// Wait for conversation data to load before rendering WebSocket provider
// This prevents the provider from unmounting/remounting when version changes from 0 to 1
if (!conversation) {
return content;
}
// Render WebSocket provider immediately to avoid mount/remount cycles
// The providers internally handle waiting for conversation data to be ready
return (
<WebSocketProviderWrapper
version={isV1Conversation ? 1 : 0}
version={isV0Conversation ? 0 : 1}
conversationId={conversationId}
>
{content}
+6 -1
View File
@@ -33,6 +33,7 @@ import { UpgradeBannerWithBackdrop } from "#/components/features/settings/upgrad
import { useCreateSubscriptionCheckoutSession } from "#/hooks/mutation/stripe/use-create-subscription-checkout-session";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { cn } from "#/utils/utils";
import { useIsAllHandsSaaSEnvironment } from "#/hooks/use-is-all-hands-saas-environment";
interface OpenHandsApiKeyHelpProps {
testId: string;
@@ -78,6 +79,7 @@ function LlmSettingsScreen() {
const { data: isAuthed } = useIsAuthed();
const { mutate: createSubscriptionCheckoutSession } =
useCreateSubscriptionCheckoutSession();
const isAllHandsSaaSEnvironment = useIsAllHandsSaaSEnvironment();
const [view, setView] = React.useState<"basic" | "advanced">("basic");
@@ -441,8 +443,11 @@ function LlmSettingsScreen() {
if (!settings || isFetching) return <LlmSettingsInputsSkeleton />;
// Show upgrade banner and disable form in SaaS mode when user doesn't have an active subscription
// Exclude self-hosted enterprise customers (those not on all-hands.dev domains)
const shouldShowUpgradeBanner =
config?.APP_MODE === "saas" && !subscriptionAccess;
config?.APP_MODE === "saas" &&
!subscriptionAccess &&
isAllHandsSaaSEnvironment;
const formAction = (formData: FormData) => {
// Prevent form submission for unsubscribed SaaS users
+13
View File
@@ -13,6 +13,7 @@ import {
ConversationStateUpdateEventAgentStatus,
ConversationStateUpdateEventFullState,
} from "./core/events/conversation-state-event";
import { SystemPromptEvent } from "./core/events/system-event";
import type { OpenHandsParsedEvent } from "../core/index";
/**
@@ -108,6 +109,18 @@ export const isExecuteBashObservationEvent = (
isObservationEvent(event) &&
event.observation.kind === "ExecuteBashObservation";
/**
* Type guard function to check if an event is a system prompt event
*/
export const isSystemPromptEvent = (
event: OpenHandsEvent,
): event is SystemPromptEvent =>
event.source === "agent" &&
"system_prompt" in event &&
"tools" in event &&
typeof event.system_prompt === "object" &&
Array.isArray(event.tools);
/**
* Type guard function to check if an event is a conversation state update event
*/
+19
View File
@@ -0,0 +1,19 @@
---
name: init
type: task
version: 1.0.0
agent: CodeActAgent
triggers:
- /init
---
Please browse the repository, look at the documentation and relevant code, and understand the purpose of this repository.
Specifically, I want you to create a `.openhands/microagents/repo.md` file. This file should contain succinct information that summarizes:
1. The purpose of this repository
2. The general setup of this repo
3. A brief description of the structure of this repo
Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.
The repo.md file should be a microagent with type "knowledge" that provides context about the repository for future conversations.
@@ -88,7 +88,10 @@ class AppConversationService(ABC):
@abstractmethod
async def run_setup_scripts(
self, task: AppConversationStartTask, workspace: Workspace
self,
task: AppConversationStartTask,
workspace: Workspace,
working_dir: str,
) -> AsyncGenerator[AppConversationStartTask, None]:
"""Run the setup scripts for the project and yield status updates"""
yield task
@@ -16,7 +16,7 @@ from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.async_remote_workspace import AsyncRemoteWorkspace
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
_logger = logging.getLogger(__name__)
PRE_COMMIT_HOOK = '.git/hooks/pre-commit'
@@ -36,23 +36,25 @@ class GitAppConversationService(AppConversationService, ABC):
self,
task: AppConversationStartTask,
workspace: AsyncRemoteWorkspace,
working_dir: str,
) -> AsyncGenerator[AppConversationStartTask, None]:
task.status = AppConversationStartTaskStatus.PREPARING_REPOSITORY
yield task
await self.clone_or_init_git_repo(task, workspace)
await self.clone_or_init_git_repo(task, workspace, working_dir)
task.status = AppConversationStartTaskStatus.RUNNING_SETUP_SCRIPT
yield task
await self.maybe_run_setup_script(workspace)
await self.maybe_run_setup_script(workspace, working_dir)
task.status = AppConversationStartTaskStatus.SETTING_UP_GIT_HOOKS
yield task
await self.maybe_setup_git_hooks(workspace)
await self.maybe_setup_git_hooks(workspace, working_dir)
async def clone_or_init_git_repo(
self,
task: AppConversationStartTask,
workspace: AsyncRemoteWorkspace,
working_dir: str,
):
request = task.request
@@ -61,7 +63,7 @@ class GitAppConversationService(AppConversationService, ABC):
_logger.debug('Initializing a new git repository in the workspace.')
await workspace.execute_command(
'git init && git config --global --add safe.directory '
+ workspace.working_dir
+ working_dir
)
else:
_logger.info('Not initializing a new git repository.')
@@ -77,7 +79,7 @@ class GitAppConversationService(AppConversationService, ABC):
# Clone the repo - this is the slow part!
clone_command = f'git clone {remote_repo_url} {dir_name}'
await workspace.execute_command(clone_command, workspace.working_dir)
await workspace.execute_command(clone_command, working_dir)
# Checkout the appropriate branch
if request.selected_branch:
@@ -87,14 +89,15 @@ class GitAppConversationService(AppConversationService, ABC):
random_str = base62.encodebytes(os.urandom(16))
openhands_workspace_branch = f'openhands-workspace-{random_str}'
checkout_command = f'git checkout -b {openhands_workspace_branch}'
await workspace.execute_command(checkout_command, workspace.working_dir)
await workspace.execute_command(checkout_command, working_dir)
async def maybe_run_setup_script(
self,
workspace: AsyncRemoteWorkspace,
working_dir: str,
):
"""Run .openhands/setup.sh if it exists in the workspace or repository."""
setup_script = workspace.working_dir + '/.openhands/setup.sh'
setup_script = working_dir + '/.openhands/setup.sh'
await workspace.execute_command(
f'chmod +x {setup_script} && source {setup_script}', timeout=600
@@ -108,10 +111,11 @@ class GitAppConversationService(AppConversationService, ABC):
async def maybe_setup_git_hooks(
self,
workspace: AsyncRemoteWorkspace,
working_dir: str,
):
"""Set up git hooks if .openhands/pre-commit.sh exists in the workspace or repository."""
command = 'mkdir -p .git/hooks && chmod +x .openhands/pre-commit.sh'
result = await workspace.execute_command(command, workspace.working_dir)
result = await workspace.execute_command(command, working_dir)
if result.exit_code:
return
@@ -127,9 +131,7 @@ class GitAppConversationService(AppConversationService, ABC):
f'mv {PRE_COMMIT_HOOK} {PRE_COMMIT_LOCAL} &&'
f'chmod +x {PRE_COMMIT_LOCAL}'
)
result = await workspace.execute_command(
command, workspace.working_dir
)
result = await workspace.execute_command(command, working_dir)
if result.exit_code != 0:
_logger.error(
f'Failed to preserve existing pre-commit hook: {result.stderr}',
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from time import time
from typing import AsyncGenerator, Sequence
from uuid import UUID
from uuid import UUID, uuid4
import httpx
from fastapi import Request
@@ -51,12 +51,13 @@ from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.services.jwt_service import JwtService
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.async_remote_workspace import AsyncRemoteWorkspace
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import ProviderType
from openhands.sdk import LocalWorkspace
from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret
from openhands.sdk.llm import LLM
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.tools.preset.default import get_default_agent
_conversation_info_type_adapter = TypeAdapter(list[ConversationInfo | None])
@@ -180,11 +181,11 @@ class LiveStatusAppConversationService(GitAppConversationService):
# Run setup scripts
workspace = AsyncRemoteWorkspace(
working_dir=sandbox_spec.working_dir,
server_url=agent_server_url,
session_api_key=sandbox.session_api_key,
host=agent_server_url, api_key=sandbox.session_api_key
)
async for updated_task in self.run_setup_scripts(task, workspace):
async for updated_task in self.run_setup_scripts(
task, workspace, sandbox_spec.working_dir
):
yield updated_task
# Build the start request
@@ -205,7 +206,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
response = await self.httpx_client.post(
f'{agent_server_url}/api/conversations',
json=start_conversation_request.model_dump(
context={'expose_secrets': True}
mode='json', context={'expose_secrets': True}
),
headers={'X-Session-API-Key': sandbox.session_api_key},
timeout=self.sandbox_startup_timeout,
@@ -278,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
]
@@ -368,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()
@@ -458,20 +460,75 @@ class LiveStatusAppConversationService(GitAppConversationService):
model=user.llm_model,
base_url=user.llm_base_url,
api_key=user.llm_api_key,
service_id='agent',
usage_id='agent',
)
agent = get_default_agent(llm=llm)
conversation_id = uuid4()
agent = ExperimentManagerImpl.run_agent_variant_tests__v1(
user.id, conversation_id, agent
)
start_conversation_request = StartConversationRequest(
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(
+2 -2
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
@@ -90,7 +90,8 @@ class DockerSandboxService(SandboxService):
status_mapping = {
'running': SandboxStatus.RUNNING,
'paused': SandboxStatus.PAUSED,
'exited': SandboxStatus.MISSING,
# The stop button was pressed in the docker console
'exited': SandboxStatus.PAUSED,
'created': SandboxStatus.STARTING,
'restarting': SandboxStatus.STARTING,
'removing': SandboxStatus.MISSING,
@@ -1,256 +0,0 @@
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
import httpx
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
_logger = logging.getLogger(__name__)
@dataclass
class AsyncRemoteWorkspace:
"""Mixin providing remote workspace operations."""
working_dir: str
server_url: str
session_api_key: str | None = None
client: httpx.AsyncClient = field(default_factory=httpx.AsyncClient)
def __post_init__(self) -> None:
# Set up remote host and API key
self.server_url = self.server_url.rstrip('/')
def _headers(self):
headers = {}
if self.session_api_key:
headers['X-Session-API-Key'] = self.session_api_key
return headers
async def execute_command(
self,
command: str,
cwd: str | Path | None = None,
timeout: float = 30.0,
) -> CommandResult:
"""Execute a bash command on the remote system.
This method starts a bash command via the remote agent server API,
then polls for the output until the command completes.
Args:
command: The bash command to execute
cwd: Working directory (optional)
timeout: Timeout in seconds
Returns:
CommandResult: Result with stdout, stderr, exit_code, and other metadata
"""
_logger.debug(f'Executing remote command: {command}')
# Step 1: Start the bash command
payload = {
'command': command,
'timeout': int(timeout),
}
if cwd is not None:
payload['cwd'] = str(cwd)
try:
# Start the command
response = await self.client.post(
f'{self.server_url}/api/bash/execute_bash_command',
json=payload,
timeout=timeout + 5.0, # Add buffer to HTTP timeout
headers=self._headers(),
)
response.raise_for_status()
bash_command = response.json()
command_id = bash_command['id']
_logger.debug(f'Started command with ID: {command_id}')
# Step 2: Poll for output until command completes
start_time = time.time()
stdout_parts = []
stderr_parts = []
exit_code = None
while time.time() - start_time < timeout:
# Search for all events and filter client-side
# (workaround for bash service filtering bug)
search_response = await self.client.get(
f'{self.server_url}/api/bash/bash_events/search',
params={
'sort_order': 'TIMESTAMP',
'limit': 100,
},
timeout=10.0,
headers=self._headers(),
)
search_response.raise_for_status()
search_result = search_response.json()
# Filter for BashOutput events for this command
for event in search_result.get('items', []):
if (
event.get('kind') == 'BashOutput'
and event.get('command_id') == command_id
):
if event.get('stdout'):
stdout_parts.append(event['stdout'])
if event.get('stderr'):
stderr_parts.append(event['stderr'])
if event.get('exit_code') is not None:
exit_code = event['exit_code']
# If we have an exit code, the command is complete
if exit_code is not None:
break
# Wait a bit before polling again
time.sleep(0.1)
# If we timed out waiting for completion
if exit_code is None:
_logger.warning(f'Command timed out after {timeout} seconds: {command}')
exit_code = -1
stderr_parts.append(f'Command timed out after {timeout} seconds')
# Combine all output parts
stdout = ''.join(stdout_parts)
stderr = ''.join(stderr_parts)
return CommandResult(
command=command,
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
timeout_occurred=exit_code == -1 and 'timed out' in stderr,
)
except Exception as e:
_logger.error(f'Remote command execution failed: {e}')
return CommandResult(
command=command,
exit_code=-1,
stdout='',
stderr=f'Remote execution error: {str(e)}',
timeout_occurred=False,
)
async def file_upload(
self,
source_path: str | Path,
destination_path: str | Path,
) -> FileOperationResult:
"""Upload a file to the remote system.
Reads the local file and sends it to the remote system via HTTP API.
Args:
source_path: Path to the local source file
destination_path: Path where the file should be uploaded on remote system
Returns:
FileOperationResult: Result with success status and metadata
"""
source = Path(source_path)
destination = Path(destination_path)
_logger.debug(f'Remote file upload: {source} -> {destination}')
try:
# Read the file content
with open(source, 'rb') as f:
file_content = f.read()
# Prepare the upload
files = {'file': (source.name, file_content)}
data = {'destination_path': str(destination)}
# Make synchronous HTTP call
response = await self.client.post(
'/api/files/upload',
files=files,
data=data,
timeout=60.0,
)
response.raise_for_status()
result_data = response.json()
# Convert the API response to our model
return FileOperationResult(
success=result_data.get('success', True),
source_path=str(source),
destination_path=str(destination),
file_size=result_data.get('file_size'),
error=result_data.get('error'),
)
except Exception as e:
_logger.error(f'Remote file upload failed: {e}')
return FileOperationResult(
success=False,
source_path=str(source),
destination_path=str(destination),
error=str(e),
)
async def file_download(
self,
source_path: str | Path,
destination_path: str | Path,
) -> FileOperationResult:
"""Download a file from the remote system.
Requests the file from the remote system via HTTP API and saves it locally.
Args:
source_path: Path to the source file on remote system
destination_path: Path where the file should be saved locally
Returns:
FileOperationResult: Result with success status and metadata
"""
source = Path(source_path)
destination = Path(destination_path)
_logger.debug(f'Remote file download: {source} -> {destination}')
try:
# Request the file from remote system
params = {'file_path': str(source)}
# Make synchronous HTTP call
response = await self.client.get(
'/api/files/download',
params=params,
timeout=60.0,
)
response.raise_for_status()
# Ensure destination directory exists
destination.parent.mkdir(parents=True, exist_ok=True)
# Write the file content
with open(destination, 'wb') as f:
f.write(response.content)
return FileOperationResult(
success=True,
source_path=str(source),
destination_path=str(destination),
file_size=len(response.content),
)
except Exception as e:
_logger.error(f'Remote file download failed: {e}')
return FileOperationResult(
success=False,
source_path=str(source),
destination_path=str(destination),
error=str(e),
)
+27 -6
View File
@@ -47,6 +47,7 @@ from openhands.core.schema.exit_reason import ExitReason
from openhands.events import EventSource
from openhands.events.action import (
ChangeAgentStateAction,
LoopRecoveryAction,
MessageAction,
)
from openhands.events.stream import EventStream
@@ -159,9 +160,9 @@ async def handle_commands(
exit_reason = ExitReason.INTENTIONAL
elif command == '/settings':
await handle_settings_command(config, settings_store)
elif command == '/resume':
elif command.startswith('/resume'):
close_repl, new_session_requested = await handle_resume_command(
event_stream, agent_state
command, event_stream, agent_state
)
elif command == '/mcp':
await handle_mcp_command(config)
@@ -294,6 +295,7 @@ async def handle_settings_command(
# Setting the agent state to RUNNING will currently freeze the agent without continuing with the rest of the task.
# This is a workaround to handle the resume command for the time being. Replace user message with the state change event once the issue is fixed.
async def handle_resume_command(
command: str,
event_stream: EventStream,
agent_state: str,
) -> tuple[bool, bool]:
@@ -309,10 +311,29 @@ async def handle_resume_command(
)
return close_repl, new_session_requested
event_stream.add_event(
MessageAction(content='continue'),
EventSource.USER,
)
# Check if this is a loop recovery resume with an option
if command.strip() != '/resume':
# Parse the option from the command (e.g., '/resume 1', '/resume 2')
parts = command.strip().split()
if len(parts) == 2 and parts[1] in ['1', '2']:
option = parts[1]
# Send the option as a message to be handled by the controller
event_stream.add_event(
LoopRecoveryAction(option=int(option)),
EventSource.USER,
)
else:
# Invalid format, send as regular resume
event_stream.add_event(
MessageAction(content='continue'),
EventSource.USER,
)
else:
# Regular resume without loop recovery option
event_stream.add_event(
MessageAction(content='continue'),
EventSource.USER,
)
# event_stream.add_event(
# ChangeAgentStateAction(AgentState.RUNNING),
+19 -3
View File
@@ -430,9 +430,25 @@ async def run_session(
# No session restored, no initial action: prompt for the user's first message
asyncio.create_task(prompt_for_next_task(''))
await run_agent_until_done(
controller, runtime, memory, [AgentState.STOPPED, AgentState.ERROR]
)
skip_set_callback = False
while True:
await run_agent_until_done(
controller,
runtime,
memory,
[AgentState.STOPPED, AgentState.ERROR],
skip_set_callback,
)
# Try loop recovery in CLI app
if (
controller.state.agent_state == AgentState.ERROR
and controller.state.last_error.startswith('AgentStuckInLoopError')
):
controller.attempt_loop_recovery()
skip_set_callback = True
continue
else:
break
await cleanup_session(loop, agent, runtime, controller)
+25
View File
@@ -59,6 +59,7 @@ from openhands.events.observation import (
ErrorObservation,
FileEditObservation,
FileReadObservation,
LoopDetectionObservation,
MCPObservation,
TaskTrackingObservation,
)
@@ -309,6 +310,8 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
display_agent_state_change_message(event.agent_state)
elif isinstance(event, ErrorObservation):
display_error(event.content)
elif isinstance(event, LoopDetectionObservation):
handle_loop_recovery_state_observation(event)
def display_message(message: str, is_agent_message: bool = False) -> None:
@@ -1039,3 +1042,25 @@ class UserCancelledError(Exception):
"""Raised when the user cancels an operation via key binding."""
pass
def handle_loop_recovery_state_observation(
observation: LoopDetectionObservation,
) -> None:
"""Handle loop recovery state observation events.
Updates the global loop recovery state based on the observation.
"""
content = observation.content
container = Frame(
TextArea(
text=content,
read_only=True,
style=COLOR_GREY,
wrap_lines=True,
),
title='Agent Loop Detection',
style=f'fg:{COLOR_GREY}',
)
print_formatted_text('')
print_container(container)
+149
View File
@@ -64,6 +64,7 @@ from openhands.events.action import (
MessageAction,
NullAction,
SystemMessageAction,
LoopRecoveryAction,
)
from openhands.events.action.agent import (
CondensationAction,
@@ -77,6 +78,7 @@ from openhands.events.observation import (
ErrorObservation,
NullObservation,
Observation,
LoopDetectionObservation,
)
from openhands.events.serialization.event import truncate_content
from openhands.llm.metrics import Metrics
@@ -523,6 +525,8 @@ class AgentController:
elif isinstance(action, AgentRejectAction):
self.state.outputs = action.outputs
await self.set_agent_state_to(AgentState.REJECTED)
elif isinstance(action, LoopRecoveryAction):
await self._handle_loop_recovery_action(action)
async def _handle_observation(self, observation: Observation) -> None:
"""Handles observation from the event stream.
@@ -595,6 +599,25 @@ class AgentController:
if action.wait_for_response:
await self.set_agent_state_to(AgentState.AWAITING_USER_INPUT)
async def _handle_loop_recovery_action(self, action: LoopRecoveryAction) -> None:
# Check if this is a loop recovery option
if self._stuck_detector.stuck_analysis:
option = action.option
# Handle the loop recovery option
if option == 1:
# Option 1: Restart from before loop
await self._perform_loop_recovery(self._stuck_detector.stuck_analysis)
elif option == 2:
# Option 2: Restart with last user message
await self._restart_with_last_user_message(
self._stuck_detector.stuck_analysis
)
elif option == 3:
# Option 3: Stop agent completely
await self.set_agent_state_to(AgentState.STOPPED)
return
def _reset(self) -> None:
"""Resets the agent controller."""
# Runnable actions need an Observation
@@ -1084,6 +1107,45 @@ class AgentController:
return self._stuck_detector.is_stuck(self.headless_mode)
def attempt_loop_recovery(self) -> bool:
"""Attempts loop recovery when agent is stuck in a loop.
Only supports CLI for now.
Returns:
bool: True if recovery was successful and agent should continue,
False if recovery failed or was not attempted.
"""
# Check if we're in a loop
if not self._stuck_detector.stuck_analysis:
return False
"""Handle loop recovery in CLI mode by pausing the agent and presenting recovery options."""
recovery_point = self._stuck_detector.stuck_analysis.loop_start_idx
# Present loop detection message
self.event_stream.add_event(
LoopDetectionObservation(
content=f"""⚠️ Agent detected in a loop!
Loop type: {self._stuck_detector.stuck_analysis.loop_type}
Loop detected at iteration {self.state.iteration_flag.current_value}
\nRecovery options:
/resume 1. Restart from before loop (preserves {recovery_point} events)
/resume 2. Restart with last user message (reuses your most recent instruction)
/exit. Quit directly
\nThe agent has been paused. Type '/resume 1', '/resume 2', or '/exit' to choose an option.
"""
),
source=EventSource.ENVIRONMENT,
)
# Pause the agent using the same mechanism as Ctrl+P
# This ensures consistent behavior and avoids event loop conflicts
self.event_stream.add_event(
ChangeAgentStateAction(AgentState.PAUSED),
EventSource.ENVIRONMENT, # Use ENVIRONMENT source to distinguish from user pause
)
return True
def _prepare_metrics_for_frontend(self, action: Action) -> None:
"""Create a minimal metrics object for frontend display and log it.
@@ -1208,5 +1270,92 @@ class AgentController:
)
return self._cached_first_user_message
async def _perform_loop_recovery(
self, stuck_analysis: StuckDetector.StuckAnalysis
) -> None:
"""Perform loop recovery by truncating memory and restarting from before the loop."""
recovery_point = stuck_analysis.loop_start_idx
# Truncate memory to the recovery point
await self._truncate_memory_to_point(recovery_point)
# Set agent state to AWAITING_USER_INPUT to allow user to provide new instructions
await self.set_agent_state_to(AgentState.AWAITING_USER_INPUT)
self.event_stream.add_event(
LoopDetectionObservation(
content="""✅ Loop recovery completed. Agent has been reset to before the loop.
You can now provide new instructions to continue.
"""
),
source=EventSource.ENVIRONMENT,
)
async def _truncate_memory_to_point(self, recovery_point: int) -> None:
"""Truncate memory to the specified recovery point."""
# Get all events from state history
all_events = self.state.history
if recovery_point >= len(all_events):
return
# Keep only events up to the recovery point
events_to_keep = all_events[:recovery_point]
# Update state history
self.state.history = events_to_keep
# Update end_id to reflect the truncation
if events_to_keep:
self.state.end_id = events_to_keep[-1].id
else:
self.state.end_id = -1
# Clear any cached messages
self._cached_first_user_message = None
async def _restart_with_last_user_message(
self, stuck_analysis: StuckDetector.StuckAnalysis
) -> None:
"""Restart the agent using the last user message as the new instruction."""
# Find the last user message in the history
last_user_message = None
for event in reversed(self.state.history):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
last_user_message = event
break
if last_user_message:
# Truncate memory to just before the loop started
recovery_point = stuck_analysis.loop_start_idx
await self._truncate_memory_to_point(recovery_point)
# Set agent state to RUNNING and re-use the last user message
await self.set_agent_state_to(AgentState.RUNNING)
# Re-use the last user message as the new instruction
self.event_stream.add_event(
LoopDetectionObservation(
content=f"""\n✅ Restarting with your last instruction: {last_user_message.content}
Agent is now continuing with the same task...
"""
),
source=EventSource.ENVIRONMENT,
)
# Create a new action with the last user message
new_action = MessageAction(
content=last_user_message.content, wait_for_response=False
)
new_action._source = EventSource.USER # type: ignore [attr-defined]
# Process the action to restart the agent
await self._handle_action(new_action)
else:
# If no user message found, fall back to regular recovery
print('\n⚠️ No previous user message found. Using standard recovery.')
await self._perform_loop_recovery(stuck_analysis)
def save_state(self):
self.state_tracker.save_state()
+85 -12
View File
@@ -1,10 +1,13 @@
from dataclasses import dataclass
from typing import Optional
from openhands.controller.state.state import State
from openhands.core.logger import openhands_logger as logger
from openhands.events import Event, EventSource
from openhands.events.action.action import Action
from openhands.events.action.commands import IPythonRunCellAction
from openhands.events.action.empty import NullAction
from openhands.events.action.message import MessageAction
from openhands.events.event import Event, EventSource
from openhands.events.observation import (
CmdOutputObservation,
IPythonRunCellObservation,
@@ -22,8 +25,15 @@ class StuckDetector:
'SyntaxError: incomplete input',
]
@dataclass
class StuckAnalysis:
loop_type: str
loop_repeat_times: int
loop_start_idx: int # in filtered_history
def __init__(self, state: State):
self.state = state
self.stuck_analysis: Optional[StuckDetector.StuckAnalysis] = None
def is_stuck(self, headless_mode: bool = True) -> bool:
"""Checks if the agent is stuck in a loop.
@@ -36,6 +46,7 @@ class StuckDetector:
Returns:
bool: True if the agent is stuck in a loop, False otherwise.
"""
filtered_history_offset = 0
if not headless_mode:
# In interactive mode, only look at history after the last user message
last_user_msg_idx = -1
@@ -46,7 +57,7 @@ class StuckDetector:
):
last_user_msg_idx = len(self.state.history) - i - 1
break
filtered_history_offset = last_user_msg_idx + 1
history_to_check = self.state.history[last_user_msg_idx + 1 :]
else:
# In headless mode, look at all history
@@ -86,31 +97,45 @@ class StuckDetector:
break
# scenario 1: same action, same observation
if self._is_stuck_repeating_action_observation(last_actions, last_observations):
if self._is_stuck_repeating_action_observation(
last_actions, last_observations, filtered_history, filtered_history_offset
):
return True
# scenario 2: same action, errors
if self._is_stuck_repeating_action_error(last_actions, last_observations):
if self._is_stuck_repeating_action_error(
last_actions, last_observations, filtered_history, filtered_history_offset
):
return True
# scenario 3: monologue
if self._is_stuck_monologue(filtered_history):
if self._is_stuck_monologue(filtered_history, filtered_history_offset):
return True
# scenario 4: action, observation pattern on the last six steps
if len(filtered_history) >= 6:
if self._is_stuck_action_observation_pattern(filtered_history):
if self._is_stuck_action_observation_pattern(
filtered_history, filtered_history_offset
):
return True
# scenario 5: context window error loop
if len(filtered_history) >= 10:
if self._is_stuck_context_window_error(filtered_history):
if self._is_stuck_context_window_error(
filtered_history, filtered_history_offset
):
return True
# Empty stuck_analysis when not stuck
self.stuck_analysis = None
return False
def _is_stuck_repeating_action_observation(
self, last_actions: list[Event], last_observations: list[Event]
self,
last_actions: list[Event],
last_observations: list[Event],
filtered_history: list[Event],
filtered_history_offset: int = 0,
) -> bool:
# scenario 1: same action, same observation
# it takes 4 actions and 4 observations to detect a loop
@@ -128,12 +153,22 @@ class StuckDetector:
if actions_equal and observations_equal:
logger.warning('Action, Observation loop detected')
self.stuck_analysis = StuckDetector.StuckAnalysis(
loop_type='repeating_action_observation',
loop_repeat_times=4,
loop_start_idx=filtered_history.index(last_actions[-1])
+ filtered_history_offset,
)
return True
return False
def _is_stuck_repeating_action_error(
self, last_actions: list[Event], last_observations: list[Event]
self,
last_actions: list[Event],
last_observations: list[Event],
filtered_history: list[Event],
filtered_history_offset: int = 0,
) -> bool:
# scenario 2: same action, errors
# it takes 3 actions and 3 observations to detect a loop
@@ -147,6 +182,12 @@ class StuckDetector:
# and the last three observations are all errors?
if all(isinstance(obs, ErrorObservation) for obs in last_observations[:3]):
logger.warning('Action, ErrorObservation loop detected')
self.stuck_analysis = StuckDetector.StuckAnalysis(
loop_type='repeating_action_error',
loop_repeat_times=3,
loop_start_idx=filtered_history.index(last_actions[-1])
+ filtered_history_offset,
)
return True
# or, are the last three observations all IPythonRunCellObservation with SyntaxError?
elif all(
@@ -167,6 +208,12 @@ class StuckDetector:
error_message,
):
logger.warning(warning)
self.stuck_analysis = StuckDetector.StuckAnalysis(
loop_type='repeating_action_error',
loop_repeat_times=3,
loop_start_idx=filtered_history.index(last_actions[-1])
+ filtered_history_offset,
)
return True
elif error_message in (
'SyntaxError: invalid syntax. Perhaps you forgot a comma?',
@@ -180,6 +227,12 @@ class StuckDetector:
error_message,
):
logger.warning(warning)
self.stuck_analysis = StuckDetector.StuckAnalysis(
loop_type='repeating_action_error',
loop_repeat_times=3,
loop_start_idx=filtered_history.index(last_actions[-1])
+ filtered_history_offset,
)
return True
return False
@@ -255,7 +308,9 @@ class StuckDetector:
# and the 3rd-to-last line is identical across all occurrences
return len(error_lines) == 3 and len(set(error_lines)) == 1
def _is_stuck_monologue(self, filtered_history: list[Event]) -> bool:
def _is_stuck_monologue(
self, filtered_history: list[Event], filtered_history_offset: int = 0
) -> bool:
# scenario 3: monologue
# check for repeated MessageActions with source=AGENT
# see if the agent is engaged in a good old monologue, telling itself the same thing over and over
@@ -286,11 +341,16 @@ class StuckDetector:
if not has_observation_between:
logger.warning('Repeated MessageAction with source=AGENT detected')
self.stuck_analysis = StuckDetector.StuckAnalysis(
loop_type='monologue',
loop_repeat_times=3,
loop_start_idx=start_index + filtered_history_offset,
)
return True
return False
def _is_stuck_action_observation_pattern(
self, filtered_history: list[Event]
self, filtered_history: list[Event], filtered_history_offset: int = 0
) -> bool:
# scenario 4: action, observation pattern on the last six steps
# check if the agent repeats the same (Action, Observation)
@@ -330,10 +390,18 @@ class StuckDetector:
if actions_equal and observations_equal:
logger.warning('Action, Observation pattern detected')
self.stuck_analysis = StuckDetector.StuckAnalysis(
loop_type='repeating_action_observation_pattern',
loop_repeat_times=3,
loop_start_idx=filtered_history.index(last_six_actions[-1])
+ filtered_history_offset,
)
return True
return False
def _is_stuck_context_window_error(self, filtered_history: list[Event]) -> bool:
def _is_stuck_context_window_error(
self, filtered_history: list[Event], filtered_history_offset: int = 0
) -> bool:
"""Detects if we're stuck in a loop of context window errors.
This happens when we repeatedly get context window errors and try to trim,
@@ -377,6 +445,11 @@ class StuckDetector:
logger.warning(
'Context window error loop detected - repeated condensation events'
)
self.stuck_analysis = StuckDetector.StuckAnalysis(
loop_type='context_window_error',
loop_repeat_times=2,
loop_start_idx=start_idx + filtered_history_offset,
)
return True
return False
+13 -11
View File
@@ -13,6 +13,7 @@ async def run_agent_until_done(
runtime: Runtime,
memory: Memory,
end_states: list[AgentState],
skip_set_callback: bool = False,
) -> None:
"""run_agent_until_done takes a controller and a runtime, and will run
the agent until it reaches a terminal state.
@@ -28,18 +29,19 @@ async def run_agent_until_done(
else:
logger.info(msg)
if hasattr(runtime, 'status_callback') and runtime.status_callback:
raise ValueError(
'Runtime status_callback was set, but run_agent_until_done will override it'
)
if hasattr(controller, 'status_callback') and controller.status_callback:
raise ValueError(
'Controller status_callback was set, but run_agent_until_done will override it'
)
if not skip_set_callback:
if hasattr(runtime, 'status_callback') and runtime.status_callback:
raise ValueError(
'Runtime status_callback was set, but run_agent_until_done will override it'
)
if hasattr(controller, 'status_callback') and controller.status_callback:
raise ValueError(
'Controller status_callback was set, but run_agent_until_done will override it'
)
runtime.status_callback = status_callback
controller.status_callback = status_callback
memory.status_callback = status_callback
runtime.status_callback = status_callback
controller.status_callback = status_callback
memory.status_callback = status_callback
while controller.state.agent_state not in end_states:
await asyncio.sleep(1)
+3
View File
@@ -97,3 +97,6 @@ class ActionType(str, Enum):
TASK_TRACKING = 'task_tracking'
"""Views or updates the task list for task management."""
LOOP_RECOVERY = 'loop_recovery'
"""Recover dead loop."""
+3
View File
@@ -58,3 +58,6 @@ class ObservationType(str, Enum):
TASK_TRACKING = 'task_tracking'
"""Result of a task tracking operation"""
LOOP_DETECTION = 'loop_detection'
"""Results of a dead-loop detection"""
+2
View File
@@ -9,6 +9,7 @@ from openhands.events.action.agent import (
AgentRejectAction,
AgentThinkAction,
ChangeAgentStateAction,
LoopRecoveryAction,
RecallAction,
TaskTrackingAction,
)
@@ -45,4 +46,5 @@ __all__ = [
'MCPAction',
'TaskTrackingAction',
'ActionSecurityRisk',
'LoopRecoveryAction',
]
+14
View File
@@ -226,3 +226,17 @@ class TaskTrackingAction(Action):
return 'Managing 1 task item.'
else:
return f'Managing {num_tasks} task items.'
@dataclass
class LoopRecoveryAction(Action):
"""An action that shows three ways to handle dead loop.
The class should be invisible to LLM.
Attributes:
option (int): 1 allow user to prompt again
2 automatically use latest user prompt
3 stop agent
"""
option: int = 1
action: str = ActionType.LOOP_RECOVERY
+2
View File
@@ -22,6 +22,7 @@ from openhands.events.observation.files import (
FileReadObservation,
FileWriteObservation,
)
from openhands.events.observation.loop_recovery import LoopDetectionObservation
from openhands.events.observation.mcp import MCPObservation
from openhands.events.observation.observation import Observation
from openhands.events.observation.reject import UserRejectObservation
@@ -47,6 +48,7 @@ __all__ = [
'AgentCondensationObservation',
'RecallObservation',
'RecallType',
'LoopDetectionObservation',
'MCPObservation',
'FileDownloadObservation',
'TaskTrackingObservation',
@@ -0,0 +1,18 @@
from dataclasses import dataclass
from openhands.core.schema import ObservationType
from openhands.events.observation.observation import Observation
@dataclass
class LoopDetectionObservation(Observation):
"""Observation for loop recovery state changes.
This observation is used to notify the UI layer when agent
is in loop recovery mode.
This observation is CLI-specific and should only be displayed
in CLI/TUI mode, not in GUI or other UI modes.
"""
observation: str = ObservationType.LOOP_DETECTION
+2
View File
@@ -10,6 +10,7 @@ from openhands.events.action.agent import (
ChangeAgentStateAction,
CondensationAction,
CondensationRequestAction,
LoopRecoveryAction,
RecallAction,
TaskTrackingAction,
)
@@ -48,6 +49,7 @@ actions = (
CondensationRequestAction,
MCPAction,
TaskTrackingAction,
LoopRecoveryAction,
)
ACTION_TYPE_TO_CLASS = {action_class.action: action_class for action_class in actions} # type: ignore[attr-defined]
@@ -26,6 +26,7 @@ from openhands.events.observation.files import (
FileReadObservation,
FileWriteObservation,
)
from openhands.events.observation.loop_recovery import LoopDetectionObservation
from openhands.events.observation.mcp import MCPObservation
from openhands.events.observation.observation import Observation
from openhands.events.observation.reject import UserRejectObservation
@@ -51,6 +52,7 @@ observations = (
MCPObservation,
FileDownloadObservation,
TaskTrackingObservation,
LoopDetectionObservation,
)
OBSERVATION_TYPE_TO_CLASS = {
@@ -1,9 +1,11 @@
import os
from uuid import UUID
from pydantic import BaseModel
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.sdk import Agent
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.shared import file_store
from openhands.storage.locations import get_experiment_config_filename
@@ -29,6 +31,12 @@ def load_experiment_config(conversation_id: str) -> ExperimentConfig | None:
class ExperimentManager:
@staticmethod
def run_agent_variant_tests__v1(
user_id: str | None, conversation_id: UUID, agent: Agent
) -> Agent:
return agent
@staticmethod
def run_conversation_variant_test(
user_id: str | None,
+4
View File
@@ -33,6 +33,7 @@ from openhands.events.observation import (
FileEditObservation,
FileReadObservation,
IPythonRunCellObservation,
LoopDetectionObservation,
TaskTrackingObservation,
UserRejectObservation,
)
@@ -524,6 +525,9 @@ class ConversationMemory:
elif isinstance(obs, FileDownloadObservation):
text = truncate_content(obs.content, max_message_chars)
message = Message(role='user', content=[TextContent(text=text)])
elif isinstance(obs, LoopDetectionObservation):
# LoopRecovery should not be observed by llm, handled internally.
return []
elif (
isinstance(obs, RecallObservation)
and self.agent_config.enable_prompt_extensions
+1 -1
View File
@@ -222,7 +222,7 @@ class IssueResolver:
and not is_experimental
):
runtime_container_image = (
f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik'
f'ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik'
)
# Convert container image values to string or None
+31 -2
View File
@@ -4,6 +4,7 @@ import copy
import json
import os
import random
import shlex
import shutil
import string
import tempfile
@@ -447,8 +448,12 @@ class Runtime(FileEditRuntimeMixin):
)
openhands_workspace_branch = f'openhands-workspace-{random_str}'
repo_path = self.workspace_root / dir_name
quoted_repo_path = shlex.quote(str(repo_path))
quoted_remote_repo_url = shlex.quote(remote_repo_url)
# Clone repository command
clone_command = f'git clone {remote_repo_url} {dir_name}'
clone_command = f'git clone {quoted_remote_repo_url} {quoted_repo_path}'
# Checkout to appropriate branch
checkout_command = (
@@ -461,11 +466,35 @@ class Runtime(FileEditRuntimeMixin):
await call_sync_from_async(self.run_action, clone_action)
cd_checkout_action = CmdRunAction(
command=f'cd {dir_name} && {checkout_command}'
command=f'cd {quoted_repo_path} && {checkout_command}'
)
action = cd_checkout_action
self.log('info', f'Cloning repo: {selected_repository}')
await call_sync_from_async(self.run_action, action)
if remote_repo_url:
set_remote_action = CmdRunAction(
command=(
f'cd {quoted_repo_path} && '
f'git remote set-url origin {quoted_remote_repo_url}'
)
)
obs = await call_sync_from_async(self.run_action, set_remote_action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code == 0:
self.log(
'info',
f'Set git remote origin to authenticated URL for {selected_repository}',
)
else:
self.log(
'warning',
(
'Failed to set git remote origin while ensuring fresh token '
f'for {selected_repository}: '
f'{obs.content if isinstance(obs, CmdOutputObservation) else "unknown error"}'
),
)
return dir_name
def maybe_run_setup_script(self):
+1 -1
View File
@@ -25,7 +25,7 @@ class BuildFromImageType(Enum):
def get_runtime_image_repo() -> str:
return os.getenv('OH_RUNTIME_RUNTIME_IMAGE_REPO', 'ghcr.io/all-hands-ai/runtime')
return os.getenv('OH_RUNTIME_RUNTIME_IMAGE_REPO', 'ghcr.io/openhands/runtime')
def _generate_dockerfile(
+126 -24
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 []
),
)
)
+231 -60
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',
@@ -972,25 +1139,29 @@ def _to_conversation_info(app_conversation: AppConversation) -> ConversationInfo
app_conversation.sandbox_status, ConversationStatus.STOPPED
)
runtime_status_mapping = {
AgentExecutionStatus.ERROR: RuntimeStatus.ERROR,
AgentExecutionStatus.IDLE: RuntimeStatus.READY,
AgentExecutionStatus.RUNNING: RuntimeStatus.READY,
AgentExecutionStatus.PAUSED: RuntimeStatus.READY,
AgentExecutionStatus.WAITING_FOR_CONFIRMATION: RuntimeStatus.READY,
AgentExecutionStatus.FINISHED: RuntimeStatus.READY,
AgentExecutionStatus.STUCK: RuntimeStatus.ERROR,
}
runtime_status = runtime_status_mapping.get(
app_conversation.agent_status, RuntimeStatus.ERROR
)
if conversation_status == ConversationStatus.RUNNING:
runtime_status_mapping = {
AgentExecutionStatus.ERROR: RuntimeStatus.ERROR,
AgentExecutionStatus.IDLE: RuntimeStatus.READY,
AgentExecutionStatus.RUNNING: RuntimeStatus.READY,
AgentExecutionStatus.PAUSED: RuntimeStatus.READY,
AgentExecutionStatus.WAITING_FOR_CONFIRMATION: RuntimeStatus.READY,
AgentExecutionStatus.FINISHED: RuntimeStatus.READY,
AgentExecutionStatus.STUCK: RuntimeStatus.ERROR,
}
runtime_status = runtime_status_mapping.get(
app_conversation.agent_status, RuntimeStatus.ERROR
)
else:
runtime_status = None
title = (
app_conversation.title
or f'Conversation {base62.encodebytes(app_conversation.id.bytes)}'
)
return ConversationInfo(
conversation_id=str(app_conversation.id),
conversation_id=app_conversation.id.hex,
title=title,
last_updated_at=app_conversation.updated_at,
status=conversation_status,
Generated
+13 -10
View File
@@ -5711,8 +5711,11 @@ files = [
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
@@ -7272,7 +7275,7 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.0.0a2"
version = "1.0.0a3"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
@@ -7294,13 +7297,13 @@ wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "512399d896521aee3131eea4bb59087fb9dfa243"
resolved_reference = "512399d896521aee3131eea4bb59087fb9dfa243"
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
subdirectory = "openhands-agent-server"
[[package]]
name = "openhands-sdk"
version = "1.0.0a2"
version = "1.0.0a3"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
@@ -7324,13 +7327,13 @@ boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "512399d896521aee3131eea4bb59087fb9dfa243"
resolved_reference = "512399d896521aee3131eea4bb59087fb9dfa243"
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
subdirectory = "openhands-sdk"
[[package]]
name = "openhands-tools"
version = "1.0.0a2"
version = "1.0.0a3"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
@@ -7351,8 +7354,8 @@ pydantic = ">=2.11.7"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "512399d896521aee3131eea4bb59087fb9dfa243"
resolved_reference = "512399d896521aee3131eea4bb59087fb9dfa243"
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
subdirectory = "openhands-tools"
[[package]]
@@ -16521,4 +16524,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "03639ad9782d05163b25c507e7232d797572902ee57408bf999b72c21e3adf5e"
content-hash = "fd68ed845befeb646ee910db46f1ef9c5a1fd2e6d1ac6189c04864e0665f66ed"
+3 -4
View File
@@ -113,10 +113,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
openhands-agent-server = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "512399d896521aee3131eea4bb59087fb9dfa243" }
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands-sdk", rev = "512399d896521aee3131eea4bb59087fb9dfa243" }
# This refuses to install
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands-tools", rev = "512399d896521aee3131eea4bb59087fb9dfa243" }
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" }
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" }
pg8000 = "^1.31.5"
@@ -601,7 +601,7 @@ class TestDockerSandboxService:
service._docker_status_to_sandbox_status('paused') == SandboxStatus.PAUSED
)
assert (
service._docker_status_to_sandbox_status('exited') == SandboxStatus.MISSING
service._docker_status_to_sandbox_status('exited') == SandboxStatus.PAUSED
)
assert (
service._docker_status_to_sandbox_status('created')
+20 -8
View File
@@ -632,7 +632,7 @@ async def test_main_with_session_name_passes_name_to_run_session(
) # For REPL control
@patch('openhands.cli.main.handle_commands', new_callable=AsyncMock) # For REPL control
@patch('openhands.core.setup.State.restore_from_session') # Key mock
@patch('openhands.controller.AgentController.__init__') # To check initial_state
@patch('openhands.cli.main.create_controller') # To check initial_state
@patch('openhands.cli.main.display_runtime_initialization_message') # Cosmetic
@patch('openhands.cli.main.display_initialization_animation') # Cosmetic
@patch('openhands.cli.main.initialize_repository_for_runtime') # Cosmetic / setup
@@ -644,7 +644,7 @@ async def test_run_session_with_name_attempts_state_restore(
mock_initialize_repo,
mock_display_init_anim,
mock_display_runtime_init,
mock_agent_controller_init,
mock_create_controller,
mock_restore_from_session,
mock_handle_commands,
mock_read_prompt_input,
@@ -680,8 +680,20 @@ async def test_run_session_with_name_attempts_state_restore(
mock_loaded_state = MagicMock(spec=State)
mock_restore_from_session.return_value = mock_loaded_state
# AgentController.__init__ should not return a value (it's __init__)
mock_agent_controller_init.return_value = None
# Create a mock controller with state attribute
mock_controller = MagicMock()
mock_controller.state = MagicMock()
mock_controller.state.agent_state = None
mock_controller.state.last_error = None
# Mock create_controller to return the mock controller and loaded state
# but still call the real restore_from_session
def create_controller_side_effect(*args, **kwargs):
# Call the real restore_from_session to verify it's called
mock_restore_from_session(expected_sid, mock_runtime.event_stream.file_store)
return (mock_controller, mock_loaded_state)
mock_create_controller.side_effect = create_controller_side_effect
# To make run_session exit cleanly after one loop
mock_read_prompt_input.return_value = '/exit'
@@ -712,10 +724,10 @@ async def test_run_session_with_name_attempts_state_restore(
expected_sid, mock_runtime.event_stream.file_store
)
# Check that AgentController was initialized with the loaded state
mock_agent_controller_init.assert_called_once()
args, kwargs = mock_agent_controller_init.call_args
assert kwargs.get('initial_state') == mock_loaded_state
# Check that create_controller was called and returned the loaded state
mock_create_controller.assert_called_once()
# The create_controller should have been called with the loaded state
# (this is verified by the fact that restore_from_session was called and returned mock_loaded_state)
@pytest.mark.asyncio
+2 -2
View File
@@ -573,7 +573,7 @@ class TestHandleResumeCommand:
# Call the function with PAUSED state
close_repl, new_session_requested = await handle_resume_command(
event_stream, AgentState.PAUSED
'/resume', event_stream, AgentState.PAUSED
)
# Check that the event stream add_event was called with the correct message action
@@ -604,7 +604,7 @@ class TestHandleResumeCommand:
event_stream = MagicMock(spec=EventStream)
close_repl, new_session_requested = await handle_resume_command(
event_stream, invalid_state
'/resume', event_stream, invalid_state
)
# Check that no event was added to the stream
+143
View File
@@ -0,0 +1,143 @@
"""Tests for CLI loop recovery functionality."""
from unittest.mock import MagicMock, patch
import pytest
from openhands.cli.commands import handle_resume_command
from openhands.controller.agent_controller import AgentController
from openhands.controller.stuck import StuckDetector
from openhands.core.schema import AgentState
from openhands.events import EventSource
from openhands.events.action import LoopRecoveryAction, MessageAction
from openhands.events.stream import EventStream
class TestCliLoopRecoveryIntegration:
"""Integration tests for CLI loop recovery functionality."""
@pytest.mark.asyncio
async def test_loop_recovery_resume_option_1(self):
"""Test that resume option 1 triggers loop recovery with memory truncation."""
# Create a mock agent controller with stuck analysis
mock_controller = MagicMock(spec=AgentController)
mock_controller._stuck_detector = MagicMock(spec=StuckDetector)
mock_controller._stuck_detector.stuck_analysis = MagicMock()
mock_controller._stuck_detector.stuck_analysis.loop_start_idx = 5
# Mock the loop recovery methods
mock_controller._perform_loop_recovery = MagicMock()
mock_controller._restart_with_last_user_message = MagicMock()
mock_controller.set_agent_state_to = MagicMock()
mock_controller._loop_recovery_info = None
# Create a mock event stream
event_stream = MagicMock(spec=EventStream)
# Call handle_resume_command with option 1
close_repl, new_session_requested = await handle_resume_command(
'/resume 1', event_stream, AgentState.PAUSED
)
# Verify that LoopRecoveryAction was added to the event stream
event_stream.add_event.assert_called_once()
args, kwargs = event_stream.add_event.call_args
loop_recovery_action, source = args
assert isinstance(loop_recovery_action, LoopRecoveryAction)
assert loop_recovery_action.option == 1
assert source == EventSource.USER
# Check the return values
assert close_repl is True
assert new_session_requested is False
@pytest.mark.asyncio
async def test_loop_recovery_resume_option_2(self):
"""Test that resume option 2 triggers restart with last user message."""
# Create a mock event stream
event_stream = MagicMock(spec=EventStream)
# Call handle_resume_command with option 2
close_repl, new_session_requested = await handle_resume_command(
'/resume 2', event_stream, AgentState.PAUSED
)
# Verify that LoopRecoveryAction was added to the event stream
event_stream.add_event.assert_called_once()
args, kwargs = event_stream.add_event.call_args
loop_recovery_action, source = args
assert isinstance(loop_recovery_action, LoopRecoveryAction)
assert loop_recovery_action.option == 2
assert source == EventSource.USER
# Check the return values
assert close_repl is True
assert new_session_requested is False
@pytest.mark.asyncio
async def test_regular_resume_without_loop_recovery(self):
"""Test that regular resume without option sends continue message."""
# Create a mock event stream
event_stream = MagicMock(spec=EventStream)
# Call handle_resume_command without loop recovery option
close_repl, new_session_requested = await handle_resume_command(
'/resume', event_stream, AgentState.PAUSED
)
# Verify that MessageAction was added to the event stream
event_stream.add_event.assert_called_once()
args, kwargs = event_stream.add_event.call_args
message_action, source = args
assert isinstance(message_action, MessageAction)
assert message_action.content == 'continue'
assert source == EventSource.USER
# Check the return values
assert close_repl is True
assert new_session_requested is False
@pytest.mark.asyncio
async def test_handle_commands_with_loop_recovery_resume(self):
"""Test that handle_commands properly routes loop recovery resume commands."""
from openhands.cli.commands import handle_commands
# Create mock dependencies
event_stream = MagicMock(spec=EventStream)
usage_metrics = MagicMock()
sid = 'test-session-id'
config = MagicMock()
current_dir = '/test/dir'
settings_store = MagicMock()
agent_state = AgentState.PAUSED
# Mock handle_resume_command
with patch(
'openhands.cli.commands.handle_resume_command'
) as mock_handle_resume:
mock_handle_resume.return_value = (False, False)
# Call handle_commands with loop recovery resume
close_repl, reload_microagents, new_session, _ = await handle_commands(
'/resume 1',
event_stream,
usage_metrics,
sid,
config,
current_dir,
settings_store,
agent_state,
)
# Check that handle_resume_command was called with correct args
mock_handle_resume.assert_called_once_with(
'/resume 1', event_stream, agent_state
)
# Check the return values
assert close_repl is False
assert reload_microagents is False
assert new_session is False
+1 -1
View File
@@ -271,7 +271,7 @@ class TestCliCommandsPauseResume:
)
# Check that handle_resume_command was called with correct args
mock_handle_resume.assert_called_once_with(event_stream, agent_state)
mock_handle_resume.assert_called_once_with(message, event_stream, agent_state)
# Check the return values
assert close_repl is False
@@ -0,0 +1,374 @@
"""Tests for agent controller loop recovery functionality."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from openhands.controller.agent_controller import AgentController
from openhands.controller.stuck import StuckDetector
from openhands.core.schema import AgentState
from openhands.events import EventStream
from openhands.events.action import LoopRecoveryAction, MessageAction
from openhands.events.observation import LoopDetectionObservation
from openhands.server.services.conversation_stats import ConversationStats
from openhands.storage.memory import InMemoryFileStore
class TestAgentControllerLoopRecovery:
"""Tests for agent controller loop recovery functionality."""
@pytest.fixture
def mock_controller(self):
"""Create a mock agent controller for testing."""
# Create mock dependencies
mock_event_stream = MagicMock(
spec=EventStream,
event_stream=EventStream(
sid='test-session-id', file_store=InMemoryFileStore({})
),
)
mock_event_stream.sid = 'test-session-id'
mock_event_stream.get_latest_event_id.return_value = 0
mock_conversation_stats = MagicMock(spec=ConversationStats)
mock_agent = MagicMock()
mock_agent.act = AsyncMock()
# Create controller with correct parameters
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
conversation_stats=mock_conversation_stats,
iteration_delta=100,
headless_mode=True,
)
# Mock state properties
controller.state.history = []
controller.state.agent_state = AgentState.RUNNING
controller.state.iteration_flag = MagicMock()
controller.state.iteration_flag.current_value = 10
# Mock stuck detector
controller._stuck_detector = MagicMock(spec=StuckDetector)
controller._stuck_detector.stuck_analysis = None
controller._stuck_detector.is_stuck = MagicMock(return_value=False)
return controller
@pytest.mark.asyncio
async def test_controller_detects_loop_and_produces_observation(
self, mock_controller
):
"""Test that controller detects loops and produces LoopDetectionObservation."""
# Setup stuck detector to detect a loop
mock_controller._stuck_detector.is_stuck.return_value = True
mock_controller._stuck_detector.stuck_analysis = MagicMock()
mock_controller._stuck_detector.stuck_analysis.loop_type = (
'repeating_action_observation'
)
mock_controller._stuck_detector.stuck_analysis.loop_start_idx = 5
# Call attempt_loop_recovery
result = mock_controller.attempt_loop_recovery()
# Verify that loop recovery was attempted
assert result is True
# Verify that LoopDetectionObservation was added to event stream
mock_controller.event_stream.add_event.assert_called()
# Check that LoopDetectionObservation was created
calls = mock_controller.event_stream.add_event.call_args_list
loop_detection_found = False
pause_action_found = False
for call in calls:
args, _ = call
# add_event only takes one argument (the event)
event = args[0]
if isinstance(event, LoopDetectionObservation):
loop_detection_found = True
assert 'Agent detected in a loop!' in event.content
assert 'repeating_action_observation' in event.content
assert 'Loop detected at iteration 10' in event.content
elif (
hasattr(event, 'agent_state') and event.agent_state == AgentState.PAUSED
):
pause_action_found = True
assert loop_detection_found, 'LoopDetectionObservation should be created'
assert pause_action_found, 'Agent should be paused'
@pytest.mark.asyncio
async def test_controller_handles_loop_recovery_action_option_1(
self, mock_controller
):
"""Test that controller handles LoopRecoveryAction with option 1."""
# Setup stuck analysis
mock_controller._stuck_detector.stuck_analysis = MagicMock()
mock_controller._stuck_detector.stuck_analysis.loop_start_idx = 5
# Mock the _perform_loop_recovery method for this test
mock_controller._perform_loop_recovery = AsyncMock()
# Create LoopRecoveryAction with option 1
action = LoopRecoveryAction(option=1)
# Call _handle_loop_recovery_action
await mock_controller._handle_loop_recovery_action(action)
# Verify that _perform_loop_recovery was called
mock_controller._perform_loop_recovery.assert_called_once_with(
mock_controller._stuck_detector.stuck_analysis
)
@pytest.mark.asyncio
async def test_controller_handles_loop_recovery_action_option_2(
self, mock_controller
):
"""Test that controller handles LoopRecoveryAction with option 2."""
# Setup stuck analysis
mock_controller._stuck_detector.stuck_analysis = MagicMock()
mock_controller._stuck_detector.stuck_analysis.loop_start_idx = 5
# Mock the _restart_with_last_user_message method for this test
mock_controller._restart_with_last_user_message = AsyncMock()
# Create LoopRecoveryAction with option 2
action = LoopRecoveryAction(option=2)
# Call _handle_loop_recovery_action
await mock_controller._handle_loop_recovery_action(action)
# Verify that _restart_with_last_user_message was called
mock_controller._restart_with_last_user_message.assert_called_once_with(
mock_controller._stuck_detector.stuck_analysis
)
@pytest.mark.asyncio
async def test_controller_handles_loop_recovery_action_option_3(
self, mock_controller
):
"""Test that controller handles LoopRecoveryAction with option 3 (stop)."""
# Setup stuck analysis
mock_controller._stuck_detector.stuck_analysis = MagicMock()
# Mock the set_agent_state_to method for this test
mock_controller.set_agent_state_to = AsyncMock()
# Create LoopRecoveryAction with option 3
action = LoopRecoveryAction(option=3)
# Call _handle_loop_recovery_action
await mock_controller._handle_loop_recovery_action(action)
# Verify that set_agent_state_to was called with STOPPED
mock_controller.set_agent_state_to.assert_called_once_with(AgentState.STOPPED)
@pytest.mark.asyncio
async def test_controller_ignores_loop_recovery_without_stuck_analysis(
self, mock_controller
):
"""Test that controller ignores LoopRecoveryAction when no stuck analysis exists."""
# Ensure no stuck analysis
mock_controller._stuck_detector.stuck_analysis = None
# Mock all recovery methods for this test
mock_controller._perform_loop_recovery = AsyncMock()
mock_controller._restart_with_last_user_message = AsyncMock()
mock_controller.set_agent_state_to = AsyncMock()
# Create LoopRecoveryAction
action = LoopRecoveryAction(option=1)
# Call _handle_loop_recovery_action
await mock_controller._handle_loop_recovery_action(action)
# Verify that no recovery methods were called
mock_controller._perform_loop_recovery.assert_not_called()
mock_controller._restart_with_last_user_message.assert_not_called()
mock_controller.set_agent_state_to.assert_not_called()
@pytest.mark.asyncio
async def test_controller_no_loop_recovery_when_not_stuck(self, mock_controller):
"""Test that controller doesn't attempt recovery when not stuck."""
# Setup no stuck analysis
mock_controller._stuck_detector.stuck_analysis = None
# Reset the mock to ignore any previous calls (like system message)
mock_controller.event_stream.add_event.reset_mock()
# Call attempt_loop_recovery
result = mock_controller.attempt_loop_recovery()
# Verify that no recovery was attempted
assert result is False
# Verify that no loop recovery events were added to the stream
# (Note: there might be other events, but no loop recovery specific ones)
calls = mock_controller.event_stream.add_event.call_args_list
loop_recovery_events = [
call
for call in calls
if len(call[0]) > 0
and (
isinstance(call[0][0], LoopDetectionObservation)
or (
hasattr(call[0][0], 'agent_state')
and call[0][0].agent_state == AgentState.PAUSED
)
)
]
assert len(loop_recovery_events) == 0, (
'No loop recovery events should be added when not stuck'
)
@pytest.mark.asyncio
async def test_controller_state_transition_after_loop_recovery(
self, mock_controller
):
"""Test that controller state transitions correctly after loop recovery."""
# Setup initial state
mock_controller.state.agent_state = AgentState.RUNNING
# Setup stuck detector to detect a loop
mock_controller._stuck_detector.is_stuck.return_value = True
mock_controller._stuck_detector.stuck_analysis = MagicMock()
mock_controller._stuck_detector.stuck_analysis.loop_type = 'monologue'
mock_controller._stuck_detector.stuck_analysis.loop_start_idx = 3
# Call attempt_loop_recovery
result = mock_controller.attempt_loop_recovery()
# Verify that recovery was attempted
assert result is True
# Verify that agent was paused
calls = mock_controller.event_stream.add_event.call_args_list
pause_found = False
for call in calls:
args, _ = call
# add_event only takes one argument (the event)
event = args[0]
if hasattr(event, 'agent_state') and event.agent_state == AgentState.PAUSED:
pause_found = True
break
assert pause_found, 'Agent should be paused after loop detection'
@pytest.mark.asyncio
async def test_controller_resumes_after_loop_recovery(self, mock_controller):
"""Test that controller can resume normal operation after loop recovery."""
# Setup stuck analysis
mock_controller._stuck_detector.stuck_analysis = MagicMock()
mock_controller._stuck_detector.stuck_analysis.loop_start_idx = 5
# Mock the _perform_loop_recovery method for this test
mock_controller._perform_loop_recovery = AsyncMock()
# Create LoopRecoveryAction with option 1
action = LoopRecoveryAction(option=1)
# Call _handle_loop_recovery_action
await mock_controller._handle_loop_recovery_action(action)
# Verify that recovery was performed
mock_controller._perform_loop_recovery.assert_called_once()
# Verify that agent can continue normal operation
# (This would be tested in integration tests with actual agent execution)
@pytest.mark.asyncio
async def test_controller_truncates_history_during_loop_recovery(
self, mock_controller
):
"""Test that controller correctly truncates history during loop recovery."""
# Setup mock history with events
from openhands.events.action import CmdRunAction
from openhands.events.observation import CmdOutputObservation, NullObservation
# Create a realistic history with 10 events
mock_history = []
# Add initial user message
user_msg = MessageAction(
content='Hello, help me with this task', wait_for_response=False
)
user_msg._source = 'user'
user_msg._id = 1
mock_history.append(user_msg)
# Add agent response
agent_obs = NullObservation(content='')
agent_obs._id = 2
mock_history.append(agent_obs)
# Add some commands and observations (simulating a loop)
for i in range(3, 11):
if i % 2 == 1: # Action
cmd = CmdRunAction(command='ls -la')
cmd._id = i
mock_history.append(cmd)
else: # Observation
obs = CmdOutputObservation(
content='file1.txt file2.txt', command='ls -la'
)
obs._id = i
obs._cause = i - 1
mock_history.append(obs)
# Set the mock history
mock_controller.state.history = mock_history
mock_controller.state.end_id = 10
# Setup stuck analysis to indicate loop starts at index 5
mock_controller._stuck_detector.stuck_analysis = MagicMock()
mock_controller._stuck_detector.stuck_analysis.loop_start_idx = 5
# Create LoopRecoveryAction with option 1 (truncate memory)
LoopRecoveryAction(option=1)
# Test actual truncation by calling the _perform_loop_recovery method directly
# Reset history for actual truncation test
mock_controller.state.history = mock_history.copy()
mock_controller.state.end_id = 10
# Call the actual _perform_loop_recovery method directly
print(
f'Before truncation: {len(mock_controller.state.history)} events, recovery_point={mock_controller._stuck_detector.stuck_analysis.loop_start_idx}'
)
print(
f'_perform_loop_recovery method: {mock_controller._perform_loop_recovery}'
)
print(
f'_truncate_memory_to_point method: {mock_controller._truncate_memory_to_point}'
)
await mock_controller._perform_loop_recovery(
mock_controller._stuck_detector.stuck_analysis
)
# Debug: print the actual history after truncation
print(f'History after truncation: {len(mock_controller.state.history)} events')
for i, event in enumerate(mock_controller.state.history):
print(f' Event {i}: id={event.id}, type={type(event).__name__}')
# Verify that history was truncated to the recovery point
# The recovery point is index 5, so we should keep events 0-4 (5 events)
assert len(mock_controller.state.history) == 5, (
f'Expected 5 events after truncation, got {len(mock_controller.state.history)}'
)
# Verify the specific events that remain
expected_ids = [1, 2, 3, 4, 5]
for i, event in enumerate(mock_controller.state.history):
assert event.id == expected_ids[i], (
f'Event at index {i} should have id {expected_ids[i]}, got {event.id}'
)
# Verify end_id was updated
assert mock_controller.state.end_id == 5, (
f'Expected end_id to be 5, got {mock_controller.state.end_id}'
)
+24
View File
@@ -116,6 +116,7 @@ class TestStuckDetector:
state.history.append(cmd_observation)
assert stuck_detector.is_stuck(headless_mode=True) is False
assert stuck_detector.stuck_analysis is None
def test_interactive_mode_resets_after_user_message(
self, stuck_detector: StuckDetector
@@ -237,6 +238,11 @@ class TestStuckDetector:
assert stuck_detector.is_stuck(headless_mode=True) is True
mock_warning.assert_called_once_with('Action, Observation loop detected')
# recover to before first loop pattern
assert stuck_detector.stuck_analysis.loop_type == 'repeating_action_observation'
assert stuck_detector.stuck_analysis.loop_repeat_times == 4
assert stuck_detector.stuck_analysis.loop_start_idx == 1
def test_is_stuck_repeating_action_error(self, stuck_detector: StuckDetector):
state = stuck_detector.state
# (action, error_observation), not necessarily the same error
@@ -290,6 +296,9 @@ class TestStuckDetector:
mock_warning.assert_called_once_with(
'Action, ErrorObservation loop detected'
)
assert stuck_detector.stuck_analysis.loop_type == 'repeating_action_error'
assert stuck_detector.stuck_analysis.loop_repeat_times == 3
assert stuck_detector.stuck_analysis.loop_start_idx == 1
def test_is_stuck_invalid_syntax_error(self, stuck_detector: StuckDetector):
state = stuck_detector.state
@@ -494,6 +503,12 @@ class TestStuckDetector:
with patch('logging.Logger.warning') as mock_warning:
assert stuck_detector.is_stuck(headless_mode=True) is True
mock_warning.assert_called_once_with('Action, Observation pattern detected')
assert (
stuck_detector.stuck_analysis.loop_type
== 'repeating_action_observation_pattern'
)
assert stuck_detector.stuck_analysis.loop_repeat_times == 3
assert stuck_detector.stuck_analysis.loop_start_idx == 0 # null ignored
def test_is_stuck_not_stuck(self, stuck_detector: StuckDetector):
state = stuck_detector.state
@@ -585,6 +600,9 @@ class TestStuckDetector:
state.history.append(message_action_6)
assert stuck_detector.is_stuck(headless_mode=True)
assert stuck_detector.stuck_analysis.loop_type == 'monologue'
assert stuck_detector.stuck_analysis.loop_repeat_times == 3
assert stuck_detector.stuck_analysis.loop_start_idx == 2 # null ignored
# Add an observation event between the repeated message actions
cmd_output_observation = CmdOutputObservation(
@@ -628,6 +646,9 @@ class TestStuckDetector:
mock_warning.assert_called_once_with(
'Context window error loop detected - repeated condensation events'
)
assert stuck_detector.stuck_analysis.loop_type == 'context_window_error'
assert stuck_detector.stuck_analysis.loop_repeat_times == 2
assert stuck_detector.stuck_analysis.loop_start_idx == 0
def test_is_not_stuck_context_window_error_with_other_events(self, stuck_detector):
"""Test that we don't detect a loop when there are other events between condensation events."""
@@ -731,6 +752,9 @@ class TestStuckDetector:
mock_warning.assert_called_once_with(
'Context window error loop detected - repeated condensation events'
)
assert stuck_detector.stuck_analysis.loop_type == 'context_window_error'
assert stuck_detector.stuck_analysis.loop_repeat_times == 2
assert stuck_detector.stuck_analysis.loop_start_idx == 0
def test_is_not_stuck_context_window_error_in_non_headless(self, stuck_detector):
"""Test that in non-headless mode, we don't detect a loop if the condensation events
View File
@@ -0,0 +1,215 @@
"""Unit tests for ExperimentManager class, focusing on the v1 agent method."""
from types import SimpleNamespace
from unittest.mock import Mock, patch
from uuid import UUID, uuid4
import pytest
from openhands.app_server.app_conversation.live_status_app_conversation_service import (
LiveStatusAppConversationService,
)
from openhands.experiments.experiment_manager import ExperimentManager
from openhands.sdk import Agent
from openhands.sdk.llm import LLM
class TestExperimentManager:
"""Test cases for ExperimentManager class."""
def setup_method(self):
"""Set up test fixtures."""
self.user_id = 'test_user_123'
self.conversation_id = uuid4()
# Create a mock LLM
self.mock_llm = Mock(spec=LLM)
self.mock_llm.model = 'gpt-4'
self.mock_llm.usage_id = 'agent'
# Create a mock Agent
self.mock_agent = Mock(spec=Agent)
self.mock_agent.llm = self.mock_llm
self.mock_agent.system_prompt_filename = 'default_system_prompt.j2'
self.mock_agent.model_copy = Mock(return_value=self.mock_agent)
def test_run_agent_variant_tests__v1_returns_agent_unchanged(self):
"""Test that the base ExperimentManager returns the agent unchanged."""
result = ExperimentManager.run_agent_variant_tests__v1(
self.user_id, self.conversation_id, self.mock_agent
)
assert result is self.mock_agent
assert result == self.mock_agent
def test_run_agent_variant_tests__v1_with_none_user_id(self):
"""Test that the method works with None user_id."""
# Act
result = ExperimentManager.run_agent_variant_tests__v1(
None, self.conversation_id, self.mock_agent
)
# Assert
assert result is self.mock_agent
def test_run_agent_variant_tests__v1_with_different_conversation_ids(self):
"""Test that the method works with different conversation IDs."""
conversation_id_1 = uuid4()
conversation_id_2 = uuid4()
# Act
result_1 = ExperimentManager.run_agent_variant_tests__v1(
self.user_id, conversation_id_1, self.mock_agent
)
result_2 = ExperimentManager.run_agent_variant_tests__v1(
self.user_id, conversation_id_2, self.mock_agent
)
# Assert
assert result_1 is self.mock_agent
assert result_2 is self.mock_agent
class TestExperimentManagerIntegration:
"""Integration tests for ExperimentManager with start_app_conversation."""
def setup_method(self):
"""Set up test fixtures."""
self.user_id = 'test_user_123'
self.conversation_id = uuid4()
# Create a mock LLM
self.mock_llm = Mock(spec=LLM)
self.mock_llm.model = 'gpt-4'
self.mock_llm.usage_id = 'agent'
# Create a mock Agent
self.mock_agent = Mock(spec=Agent)
self.mock_agent.llm = self.mock_llm
self.mock_agent.system_prompt_filename = 'default_system_prompt.j2'
self.mock_agent.model_copy = Mock(return_value=self.mock_agent)
@patch('openhands.experiments.experiment_manager.ExperimentManagerImpl')
def test_start_app_conversation_calls_experiment_manager_v1(
self, mock_experiment_manager_impl
):
"""Test that start_app_conversation calls the experiment manager v1 method with correct parameters."""
# Arrange
mock_experiment_manager_impl.run_agent_variant_tests__v1.return_value = (
self.mock_agent
)
# Create a mock service instance
mock_service = Mock(spec=LiveStatusAppConversationService)
# Mock the _build_start_conversation_request_for_user method to simulate the call
with patch.object(mock_service, '_build_start_conversation_request_for_user'):
# Simulate the part of the code that calls the experiment manager
from uuid import uuid4
conversation_id = uuid4()
# This simulates the call that happens in the actual service
result_agent = mock_experiment_manager_impl.run_agent_variant_tests__v1(
self.user_id, conversation_id, self.mock_agent
)
# Assert
mock_experiment_manager_impl.run_agent_variant_tests__v1.assert_called_once_with(
self.user_id, conversation_id, self.mock_agent
)
assert result_agent == self.mock_agent
@pytest.mark.asyncio
async def test_experiment_manager_called_with_correct_parameters_in_context__noop_pass_through(
self,
):
"""
Use the real LiveStatusAppConversationService to build a StartConversationRequest,
and verify ExperimentManagerImpl.run_agent_variant_tests__v1:
- is called exactly once with the (user_id, generated conversation_id, agent)
- returns the *same* agent instance (no copy/mutation)
- does not tweak agent fields (LLM, system prompt, etc.)
"""
# --- Arrange: fixed UUID to assert call parameters deterministically
fixed_conversation_id = UUID('00000000-0000-0000-0000-000000000001')
# Create a stable Agent (and LLM) we can identity-check later
mock_llm = Mock(spec=LLM)
mock_llm.model = 'gpt-4'
mock_llm.usage_id = 'agent'
mock_agent = Mock(spec=Agent)
mock_agent.llm = mock_llm
mock_agent.system_prompt_filename = 'default_system_prompt.j2'
# Minimal, real-ish user context used by the service
class DummyUserContext:
async def get_user_info(self):
# confirmation_mode=False -> NeverConfirm()
return SimpleNamespace(
id='test_user_123',
llm_model='gpt-4',
llm_base_url=None,
llm_api_key=None,
confirmation_mode=False,
)
async def get_secrets(self):
return {}
async def get_latest_token(self, provider):
return None
async def get_user_id(self):
return 'test_user_123'
user_context = DummyUserContext()
# The service requires a lot of deps, but for this test we won't exercise them.
app_conversation_info_service = Mock()
app_conversation_start_task_service = Mock()
sandbox_service = Mock()
sandbox_spec_service = Mock()
jwt_service = Mock()
httpx_client = Mock()
service = LiveStatusAppConversationService(
init_git_in_empty_workspace=False,
user_context=user_context,
app_conversation_info_service=app_conversation_info_service,
app_conversation_start_task_service=app_conversation_start_task_service,
sandbox_service=sandbox_service,
sandbox_spec_service=sandbox_spec_service,
jwt_service=jwt_service,
sandbox_startup_timeout=30,
sandbox_startup_poll_frequency=1,
httpx_client=httpx_client,
web_url=None,
access_token_hard_timeout=None,
)
# Patch the pieces invoked by the service
with (
patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_default_agent',
return_value=mock_agent,
),
patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.uuid4',
return_value=fixed_conversation_id,
),
):
# --- Act: build the start request
start_req = await service._build_start_conversation_request_for_user(
initial_message=None,
git_provider=None, # Keep secrets path simple
working_dir='/tmp/project', # Arbitrary path
)
# The agent in the StartConversationRequest is the *same* object we provided
assert start_req.agent is mock_agent
# No tweaks to agent fields by the experiment manager (noop)
assert start_req.agent.llm is mock_llm
assert start_req.agent.system_prompt_filename == 'default_system_prompt.j2'

Some files were not shown because too many files have changed in this diff Show More