mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c126b042f4 | |||
| 76bb89b0cf | |||
| e450a3a603 | |||
| 17e32af6fe | |||
| 4b303ec9b4 | |||
| eb954164a5 | |||
| 0c1c2163b1 | |||
| dd2a62c992 | |||
| f3d9faef34 | |||
| 134c122026 | |||
| 523b40dbfc | |||
| 6a5b915088 | |||
| a5c5133961 | |||
| eea1e7f4e1 | |||
| e2d990f3a0 | |||
| f258eafa37 | |||
| 19634f364e | |||
| aa6446038c | |||
| dbddc1868e | |||
| cd967ef4bc | |||
| e34c13ea3c | |||
| 1f35a73cc4 | |||
| 267528fa82 | |||
| 49f360d021 | |||
| 9520da668c | |||
| 9d19292619 | |||
| fc9a87550d | |||
| 490d3dba10 | |||
| 5ed1dde2e9 |
@@ -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
|
||||
|
||||
Vendored
+16
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# =================================================
|
||||
|
||||
@@ -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
|
||||
Generated
+12
-12
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+7
-1
@@ -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: () =>
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}, []);
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "会話を停止しています...",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_service import (
|
||||
AppConversationService,
|
||||
)
|
||||
from openhands.app_server.config import depends_app_conversation_service
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.event_filter import EventFilter
|
||||
@@ -21,24 +27,116 @@ app = APIRouter(
|
||||
prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies()
|
||||
)
|
||||
|
||||
# Dependency for app conversation service
|
||||
app_conversation_service_dependency = depends_app_conversation_service()
|
||||
|
||||
@app.get('/config')
|
||||
async def get_remote_runtime_config(
|
||||
conversation: ServerConversation = Depends(get_conversation),
|
||||
) -> JSONResponse:
|
||||
"""Retrieve the runtime configuration.
|
||||
|
||||
Currently, this is the session ID and runtime ID (if available).
|
||||
async def _is_v1_conversation(
|
||||
conversation_id: str, app_conversation_service: AppConversationService
|
||||
) -> bool:
|
||||
"""Check if the given conversation_id corresponds to a V1 conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to check
|
||||
app_conversation_service: Service to query V1 conversations
|
||||
|
||||
Returns:
|
||||
True if this is a V1 conversation, False otherwise
|
||||
"""
|
||||
try:
|
||||
conversation_uuid = uuid.UUID(conversation_id)
|
||||
app_conversation = await app_conversation_service.get_app_conversation(
|
||||
conversation_uuid
|
||||
)
|
||||
return app_conversation is not None
|
||||
except (ValueError, TypeError):
|
||||
# Not a valid UUID, so it's not a V1 conversation
|
||||
return False
|
||||
except Exception:
|
||||
# Service error, assume it's not a V1 conversation
|
||||
return False
|
||||
|
||||
|
||||
async def _get_v1_conversation_config(
|
||||
conversation_id: str, app_conversation_service: AppConversationService
|
||||
) -> dict[str, str | None]:
|
||||
"""Get configuration for a V1 conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID
|
||||
app_conversation_service: Service to query V1 conversations
|
||||
|
||||
Returns:
|
||||
Dictionary with runtime_id (sandbox_id) and session_id (conversation_id)
|
||||
"""
|
||||
conversation_uuid = uuid.UUID(conversation_id)
|
||||
app_conversation = await app_conversation_service.get_app_conversation(
|
||||
conversation_uuid
|
||||
)
|
||||
|
||||
if app_conversation is None:
|
||||
raise ValueError(f'V1 conversation {conversation_id} not found')
|
||||
|
||||
return {
|
||||
'runtime_id': app_conversation.sandbox_id,
|
||||
'session_id': conversation_id,
|
||||
}
|
||||
|
||||
|
||||
def _get_v0_conversation_config(
|
||||
conversation: ServerConversation,
|
||||
) -> dict[str, str | None]:
|
||||
"""Get configuration for a V0 conversation.
|
||||
|
||||
Args:
|
||||
conversation: The server conversation object
|
||||
|
||||
Returns:
|
||||
Dictionary with runtime_id and session_id from the runtime
|
||||
"""
|
||||
runtime = conversation.runtime
|
||||
runtime_id = runtime.runtime_id if hasattr(runtime, 'runtime_id') else None
|
||||
session_id = runtime.sid if hasattr(runtime, 'sid') else None
|
||||
return JSONResponse(
|
||||
content={
|
||||
'runtime_id': runtime_id,
|
||||
'session_id': session_id,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'runtime_id': runtime_id,
|
||||
'session_id': session_id,
|
||||
}
|
||||
|
||||
|
||||
@app.get('/config')
|
||||
async def get_remote_runtime_config(
|
||||
conversation_id: str,
|
||||
app_conversation_service: AppConversationService = app_conversation_service_dependency,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> JSONResponse:
|
||||
"""Retrieve the runtime configuration.
|
||||
|
||||
For V0 conversations: returns runtime_id and session_id from the runtime.
|
||||
For V1 conversations: returns sandbox_id as runtime_id and conversation_id as session_id.
|
||||
"""
|
||||
# Check if this is a V1 conversation first
|
||||
if await _is_v1_conversation(conversation_id, app_conversation_service):
|
||||
# This is a V1 conversation
|
||||
config = await _get_v1_conversation_config(
|
||||
conversation_id, app_conversation_service
|
||||
)
|
||||
else:
|
||||
# V0 conversation - get the conversation and use the existing logic
|
||||
conversation = await conversation_manager.attach_to_conversation(
|
||||
conversation_id, user_id
|
||||
)
|
||||
if not conversation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Conversation {conversation_id} not found',
|
||||
)
|
||||
try:
|
||||
config = _get_v0_conversation_config(conversation)
|
||||
finally:
|
||||
await conversation_manager.detach_from_conversation(conversation)
|
||||
|
||||
return JSONResponse(content=config)
|
||||
|
||||
|
||||
@app.get('/vscode-url')
|
||||
@@ -279,12 +377,14 @@ async def get_microagents(
|
||||
content=r_agent.content,
|
||||
triggers=[],
|
||||
inputs=r_agent.metadata.inputs,
|
||||
tools=[
|
||||
server.name
|
||||
for server in r_agent.metadata.mcp_tools.stdio_servers
|
||||
]
|
||||
if r_agent.metadata.mcp_tools
|
||||
else [],
|
||||
tools=(
|
||||
[
|
||||
server.name
|
||||
for server in r_agent.metadata.mcp_tools.stdio_servers
|
||||
]
|
||||
if r_agent.metadata.mcp_tools
|
||||
else []
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -297,12 +397,14 @@ async def get_microagents(
|
||||
content=k_agent.content,
|
||||
triggers=k_agent.triggers,
|
||||
inputs=k_agent.metadata.inputs,
|
||||
tools=[
|
||||
server.name
|
||||
for server in k_agent.metadata.mcp_tools.stdio_servers
|
||||
]
|
||||
if k_agent.metadata.mcp_tools
|
||||
else [],
|
||||
tools=(
|
||||
[
|
||||
server.name
|
||||
for server in k_agent.metadata.mcp_tools.stdio_servers
|
||||
]
|
||||
if k_agent.metadata.mcp_tools
|
||||
else []
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ from fastapi.responses import JSONResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_info_service import (
|
||||
AppConversationInfoService,
|
||||
)
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversation,
|
||||
)
|
||||
@@ -19,6 +22,7 @@ from openhands.app_server.app_conversation.app_conversation_service import (
|
||||
AppConversationService,
|
||||
)
|
||||
from openhands.app_server.config import (
|
||||
depends_app_conversation_info_service,
|
||||
depends_app_conversation_service,
|
||||
)
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
@@ -90,6 +94,7 @@ from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
app = APIRouter(prefix='/api', dependencies=get_dependencies())
|
||||
app_conversation_service_dependency = depends_app_conversation_service()
|
||||
app_conversation_info_service_dependency = depends_app_conversation_info_service()
|
||||
|
||||
|
||||
def _filter_conversations_by_age(
|
||||
@@ -759,23 +764,201 @@ class UpdateConversationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
|
||||
async def _update_v1_conversation(
|
||||
conversation_uuid: uuid.UUID,
|
||||
new_title: str,
|
||||
user_id: str | None,
|
||||
app_conversation_info_service: AppConversationInfoService,
|
||||
app_conversation_service: AppConversationService,
|
||||
) -> JSONResponse | bool:
|
||||
"""Update a V1 conversation title.
|
||||
|
||||
Args:
|
||||
conversation_uuid: The conversation ID as a UUID
|
||||
new_title: The new title to set
|
||||
user_id: The authenticated user ID
|
||||
app_conversation_info_service: The app conversation info service
|
||||
app_conversation_service: The app conversation service for agent-server communication
|
||||
|
||||
Returns:
|
||||
JSONResponse on error, True on success
|
||||
"""
|
||||
conversation_id = str(conversation_uuid)
|
||||
logger.info(
|
||||
f'Updating V1 conversation {conversation_uuid}',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
|
||||
# Get the V1 conversation info
|
||||
app_conversation_info = (
|
||||
await app_conversation_info_service.get_app_conversation_info(conversation_uuid)
|
||||
)
|
||||
|
||||
if not app_conversation_info:
|
||||
# Not a V1 conversation
|
||||
return None
|
||||
|
||||
# Validate that the user owns this conversation
|
||||
if user_id and app_conversation_info.created_by_user_id != user_id:
|
||||
logger.warning(
|
||||
f'User {user_id} attempted to update V1 conversation {conversation_uuid} owned by {app_conversation_info.created_by_user_id}',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
'status': 'error',
|
||||
'message': 'Permission denied: You can only update your own conversations',
|
||||
'msg_id': 'AUTHORIZATION$PERMISSION_DENIED',
|
||||
},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Update the title and timestamp
|
||||
original_title = app_conversation_info.title
|
||||
app_conversation_info.title = new_title
|
||||
app_conversation_info.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Save the updated conversation info
|
||||
try:
|
||||
await app_conversation_info_service.save_app_conversation_info(
|
||||
app_conversation_info
|
||||
)
|
||||
except AssertionError:
|
||||
# This happens when user doesn't own the conversation
|
||||
logger.warning(
|
||||
f'User {user_id} attempted to update V1 conversation {conversation_uuid} - permission denied',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
'status': 'error',
|
||||
'message': 'Permission denied: You can only update your own conversations',
|
||||
'msg_id': 'AUTHORIZATION$PERMISSION_DENIED',
|
||||
},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Try to update the agent-server as well
|
||||
try:
|
||||
if hasattr(app_conversation_service, 'update_agent_server_conversation_title'):
|
||||
await app_conversation_service.update_agent_server_conversation_title(
|
||||
conversation_id=conversation_id,
|
||||
new_title=new_title,
|
||||
app_conversation_info=app_conversation_info,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the error but don't fail the database update
|
||||
logger.warning(
|
||||
f'Failed to update agent-server for conversation {conversation_uuid}: {e}',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Successfully updated V1 conversation {conversation_uuid} title from "{original_title}" to "{app_conversation_info.title}"',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _update_v0_conversation(
|
||||
conversation_id: str,
|
||||
new_title: str,
|
||||
user_id: str | None,
|
||||
conversation_store: ConversationStore,
|
||||
) -> JSONResponse | bool:
|
||||
"""Update a V0 conversation title.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID
|
||||
new_title: The new title to set
|
||||
user_id: The authenticated user ID
|
||||
conversation_store: The conversation store
|
||||
|
||||
Returns:
|
||||
JSONResponse on error, True on success
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the conversation is not found
|
||||
"""
|
||||
logger.info(
|
||||
f'Updating V0 conversation {conversation_id}',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
|
||||
# Get the existing conversation metadata
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
# Validate that the user owns this conversation
|
||||
if user_id and metadata.user_id != user_id:
|
||||
logger.warning(
|
||||
f'User {user_id} attempted to update conversation {conversation_id} owned by {metadata.user_id}',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
'status': 'error',
|
||||
'message': 'Permission denied: You can only update your own conversations',
|
||||
'msg_id': 'AUTHORIZATION$PERMISSION_DENIED',
|
||||
},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Update the conversation metadata
|
||||
original_title = metadata.title
|
||||
metadata.title = new_title
|
||||
metadata.last_updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Save the updated metadata
|
||||
await conversation_store.save_metadata(metadata)
|
||||
|
||||
# Emit a status update to connected clients about the title change
|
||||
try:
|
||||
status_update_dict = {
|
||||
'status_update': True,
|
||||
'type': 'info',
|
||||
'message': conversation_id,
|
||||
'conversation_title': metadata.title,
|
||||
}
|
||||
await conversation_manager.sio.emit(
|
||||
'oh_event',
|
||||
status_update_dict,
|
||||
to=f'room:{conversation_id}',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error emitting title update event: {e}')
|
||||
# Don't fail the update if we can't emit the event
|
||||
|
||||
logger.info(
|
||||
f'Successfully updated conversation {conversation_id} title from "{original_title}" to "{metadata.title}"',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@app.patch('/conversations/{conversation_id}')
|
||||
async def update_conversation(
|
||||
data: UpdateConversationRequest,
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
|
||||
app_conversation_service: AppConversationService = app_conversation_service_dependency,
|
||||
) -> bool:
|
||||
"""Update conversation metadata.
|
||||
|
||||
This endpoint allows updating conversation details like title.
|
||||
Only the conversation owner can update the conversation.
|
||||
Supports both V0 and V1 conversations.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to update
|
||||
data: The conversation update data (title, etc.)
|
||||
user_id: The authenticated user ID
|
||||
conversation_store: The conversation store dependency
|
||||
app_conversation_info_service: The app conversation info service for V1 conversations
|
||||
app_conversation_service: The app conversation service for agent-server communication
|
||||
|
||||
Returns:
|
||||
bool: True if the conversation was updated successfully
|
||||
@@ -788,57 +971,41 @@ async def update_conversation(
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
|
||||
new_title = data.title.strip()
|
||||
|
||||
# Try to handle as V1 conversation first
|
||||
try:
|
||||
# Get the existing conversation metadata
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
# Validate that the user owns this conversation
|
||||
if user_id and metadata.user_id != user_id:
|
||||
logger.warning(
|
||||
f'User {user_id} attempted to update conversation {conversation_id} owned by {metadata.user_id}',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
'status': 'error',
|
||||
'message': 'Permission denied: You can only update your own conversations',
|
||||
'msg_id': 'AUTHORIZATION$PERMISSION_DENIED',
|
||||
},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Update the conversation metadata
|
||||
original_title = metadata.title
|
||||
metadata.title = data.title.strip()
|
||||
metadata.last_updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Save the updated metadata
|
||||
await conversation_store.save_metadata(metadata)
|
||||
|
||||
# Emit a status update to connected clients about the title change
|
||||
try:
|
||||
status_update_dict = {
|
||||
'status_update': True,
|
||||
'type': 'info',
|
||||
'message': conversation_id,
|
||||
'conversation_title': metadata.title,
|
||||
}
|
||||
await conversation_manager.sio.emit(
|
||||
'oh_event',
|
||||
status_update_dict,
|
||||
to=f'room:{conversation_id}',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error emitting title update event: {e}')
|
||||
# Don't fail the update if we can't emit the event
|
||||
|
||||
logger.info(
|
||||
f'Successfully updated conversation {conversation_id} title from "{original_title}" to "{metadata.title}"',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
conversation_uuid = uuid.UUID(conversation_id)
|
||||
result = await _update_v1_conversation(
|
||||
conversation_uuid=conversation_uuid,
|
||||
new_title=new_title,
|
||||
user_id=user_id,
|
||||
app_conversation_info_service=app_conversation_info_service,
|
||||
app_conversation_service=app_conversation_service,
|
||||
)
|
||||
|
||||
return True
|
||||
# If result is not None, it's a V1 conversation (either success or error)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
except (ValueError, TypeError):
|
||||
# Not a valid UUID, fall through to V0 logic
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error checking V1 conversation {conversation_id}: {str(e)}',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
# Fall through to V0 logic
|
||||
|
||||
# Handle as V0 conversation
|
||||
try:
|
||||
return await _update_v0_conversation(
|
||||
conversation_id=conversation_id,
|
||||
new_title=new_title,
|
||||
user_id=user_id,
|
||||
conversation_store=conversation_store,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning(
|
||||
f'Conversation {conversation_id} not found for update',
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}'
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user