Compare commits

...

37 Commits

Author SHA1 Message Date
Graham Neubig
2e5f482400 Update button positioning 2024-12-31 06:27:59 +09:00
openhands
ac6101833b Fix expand/collapse button positioning and visibility issues 2024-12-30 09:17:36 +00:00
openhands
a07df54fe1 Adjust expand/collapse button size and positioning, update tests 2024-12-30 09:12:44 +00:00
openhands
faa6b91530 Restore margin between chat and workspace windows 2024-12-30 09:05:01 +00:00
openhands
74f72a4dc9 Fix chat window layout to fill entire available space 2024-12-30 08:50:21 +00:00
openhands
3df3a07a98 Fix ToggleWorkspaceIconButton test and update component positioning 2024-12-30 08:38:12 +00:00
openhands
a53dd1773b Fix ToggleWorkspaceIconButton issues: correct arrow direction and visibility 2024-12-30 08:30:06 +00:00
openhands
b9a8b756ae Revert changes to chat-suggestions.tsx and fix linting issues in route.tsx 2024-12-30 08:19:23 +00:00
openhands
ce2a7c4b1f Simplify PR: Revert unnecessary changes and focus on collapsible workspace feature 2024-12-30 08:12:28 +00:00
openhands
b71623510c Fix pr #5896: Fix issue #5894: Make it possible to collapse the right-hand side of the openhands screen 2024-12-30 04:59:14 +00:00
openhands
3ad6afbe08 Update frontend test workflow to clear cache and use npx tsc 2024-12-30 03:57:07 +00:00
openhands
f763ba827c Fix TypeScript error in toggle-workspace-icon-button.tsx 2024-12-30 03:51:55 +00:00
openhands
aac04fe6ca Fix pr #5896: Fix issue #5894: Make it possible to collapse the right-hand side of the openhands screen 2024-12-30 01:09:56 +00:00
openhands
8bd4ae2a77 Fix pr #5896: Fix issue #5894: Make it possible to collapse the right-hand side of the openhands screen 2024-12-29 05:35:02 +00:00
openhands
ebce3c2fe4 Fix issue #5894: Make it possible to collapse the right-hand side of the openhands screen 2024-12-29 04:56:12 +00:00
OpenHands
037457dec9 Fix issue #5890: Add an automatic check of version consistency in documentation (#5891)
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>
2024-12-29 04:28:47 +00:00
Graham Neubig
7f665c2fb6 Improve test coverage of codeact_agent folder (#5757)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>
2024-12-28 20:12:34 -05:00
Boxuan Li
ebb2d86ce3 Headless or endless? Rewrite auto continue response in headless mode (#5879) 2024-12-28 10:25:50 -08:00
Boxuan Li
6a4442e590 [Evaluation] Add summarise_results script for TheAgentCompany benchmark (#5811) 2024-12-27 20:33:41 -08:00
mamoodi
157ff4a4b9 Fix: Prevent submission of empty prompts with spaces (#5874)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-27 15:31:28 -05:00
mamoodi
cc928e6d3f Fix: Add vertical scrolling to file content viewer (#5872)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-27 15:03:15 -05:00
Robert Brennan
6a75800e1b fix github auth for settings (#5871) 2024-12-27 14:15:55 -05:00
tofarr
c9cecbc461 Responsive splash screen (#5864) 2024-12-27 11:12:48 -07:00
Robert Brennan
97b1867ea1 Fix for settings update (#5858) 2024-12-27 16:28:11 +00:00
dependabot[bot]
9bdc1df2df chore(deps): bump boto3 from 1.35.87 to 1.35.88 in the version-all group (#5861)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 17:15:29 +01:00
sp.wack
9d984aaa30 chore(frontend): Upgrade to React 19 (#5835) 2024-12-27 19:10:41 +04:00
Boxuan Li
5ed80b5c32 [doc] Fix link in TheAgentCompany benchmark's README.md (#5848) 2024-12-27 22:21:02 +08:00
mamoodi
df82202178 Fix formatting in docs (#5842) 2024-12-26 20:06:27 -05:00
tofarr
500598666e Feat: Allow checking multiple conversations running at the same time (#5843) 2024-12-26 23:46:54 +00:00
Robert Brennan
69a9080480 fix install instructions (#5844) 2024-12-27 00:16:23 +01:00
Robert Brennan
b72f50cc4a Remove file editing functionality from UI (#5823)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-26 18:02:38 -05:00
mamoodi
f1a8be3817 Update Installation to align with README (#5841) 2024-12-26 17:44:54 -05:00
Robert Brennan
b34209c9a0 Fix state dir in docker mode (#5840) 2024-12-26 22:42:04 +00:00
Xingyao Wang
a021045dce fix(#5818): Force to use string serializer for deepseek function calling (#5824)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2024-12-26 20:45:39 +00:00
Robert Brennan
ad45f8dab0 Add loading spinner to task form during conversation creation (#5828)
Co-authored-by: openhands <openhands@all-hands.dev>
2024-12-26 15:22:03 -05:00
Rohit Malhotra
3bf5956493 [Regression]: Fix modal orders (#5779)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2024-12-26 19:12:27 +00:00
sp.wack
d86b536d2f chore(frontend): Update dependencies safely (#5829) 2024-12-26 18:47:23 +00:00
81 changed files with 1546 additions and 1007 deletions

66
.github/scripts/check_version_consistency.py vendored Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
import os
import re
import sys
from typing import Set, Tuple
def find_version_references(directory: str) -> Tuple[Set[str], Set[str]]:
openhands_versions = set()
runtime_versions = set()
version_pattern_openhands = re.compile(r'openhands:(\d{1})\.(\d{2})')
version_pattern_runtime = re.compile(r'runtime:(\d{1})\.(\d{2})')
for root, _, files in os.walk(directory):
# Skip .git directory
if '.git' in root:
continue
for file in files:
if file.endswith(
('.md', '.yml', '.yaml', '.txt', '.html', '.py', '.js', '.ts')
):
file_path = os.path.join(root, file)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find all openhands version references
matches = version_pattern_openhands.findall(content)
openhands_versions.update(matches)
# Find all runtime version references
matches = version_pattern_runtime.findall(content)
runtime_versions.update(matches)
except Exception as e:
print(f'Error reading {file_path}: {e}', file=sys.stderr)
return openhands_versions, runtime_versions
def main():
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
openhands_versions, runtime_versions = find_version_references(repo_root)
exit_code = 0
if len(openhands_versions) > 1:
print('Error: Multiple openhands versions found:', file=sys.stderr)
print('Found versions:', sorted(openhands_versions), file=sys.stderr)
exit_code = 1
elif len(openhands_versions) == 0:
print('Warning: No openhands version references found', file=sys.stderr)
if len(runtime_versions) > 1:
print('Error: Multiple runtime versions found:', file=sys.stderr)
print('Found versions:', sorted(runtime_versions), file=sys.stderr)
exit_code = 1
elif len(runtime_versions) == 0:
print('Warning: No runtime version references found', file=sys.stderr)
sys.exit(exit_code)
if __name__ == '__main__':
main()

View File

@@ -53,3 +53,16 @@ jobs:
run: pip install pre-commit==3.7.0
- name: Run pre-commit hooks
run: pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
# Check version consistency across documentation
check-version-consistency:
name: Check version consistency
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up python
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Run version consistency check
run: .github/scripts/check_version_consistency.py

View File

@@ -49,7 +49,7 @@ docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/home/openhands/.openhands-state \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \

View File

@@ -43,7 +43,8 @@ ENV WORKSPACE_BASE=/opt/workspace_base
ENV OPENHANDS_BUILD_VERSION=$OPENHANDS_BUILD_VERSION
ENV SANDBOX_USER_ID=0
ENV FILE_STORE=local
ENV FILE_STORE_PATH=~/.openhands-state
ENV FILE_STORE_PATH=/.openhands-state
RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE
RUN apt-get update -y \

View File

@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.cli
```

View File

@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.16
docker.all-hands.dev/all-hands-ai/openhands:0.17
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).

View File

@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.cli
```

View File

@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.16 \
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.16
docker.all-hands.dev/all-hands-ai/openhands:0.17
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。

View File

@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -17,6 +17,7 @@ docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \

View File

@@ -176,24 +176,20 @@ Guidelines:
Examples:
1. Creating a Dockerfile:
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]
```
2. Docker Compose usage:
```yaml
version: '3'
services:
web:
build: .
ports:
- "3000:3000"
```
Remember to:
- Validate Dockerfile syntax

View File

@@ -1,14 +1,13 @@
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { HomepageHeader } from "../components/HomepageHeader/HomepageHeader";
import { Welcome } from "../components/Welcome/Welcome";
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import { HomepageHeader } from '../components/HomepageHeader/HomepageHeader';
import { translate } from '@docusaurus/Translate';
export function Header({ title, summary }): JSX.Element {
return (
<div>
<h1>{title}</h1>
<h2 style={{ fontSize: "3rem" }}>{summary}</h2>
<h2 style={{ fontSize: '3rem' }}>{summary}</h2>
</div>
);
}
@@ -23,7 +22,7 @@ export default function Home(): JSX.Element {
message: 'Code Less, Make More',
})}
>
<HomepageHeader />
<HomepageHeader />
</Layout>
);
}

View File

@@ -5,7 +5,7 @@ This folder contains the evaluation harness that we built on top of the original
The evaluation consists of three steps:
1. Environment setup: [install python environment](../../README.md#development-environment), [configure LLM config](../../README.md#configure-openhands-and-your-llm), [launch services](https://github.com/TheAgentCompany/TheAgentCompany/blob/main/docs/SETUP.md).
2. [Run Evaluation](#run-inference-on-the-agent-company-instances): Run all tasks and get the evaluation results.
2. [Run Evaluation](#run-inference-on-the-agent-company-tasks): Run all tasks and get the evaluation results.
## Setup Environment and LLM Configuration

View File

@@ -0,0 +1,316 @@
###########################################################################################################
# Adapted from https://github.com/TheAgentCompany/TheAgentCompany/blob/main/evaluation/summarise_results.py
###########################################################################################################
import glob
import json
import os
import re
import sys
from typing import Dict, Tuple
def calculate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
"""
Calculate the cost of the model call.
"""
if 'claude-3-5-sonnet' in model.lower():
# https://www.anthropic.com/pricing#anthropic-api, accessed 12/11/2024
return 0.000003 * prompt_tokens + 0.000015 * completion_tokens
elif 'gpt-4o' in model.lower():
# https://openai.com/api/pricing/, accessed 12/11/2024
return 0.0000025 * prompt_tokens + 0.00001 * completion_tokens
elif 'gemini-1.5-pro' in model.lower():
# https://ai.google.dev/pricing#1_5pro, accessed 12/11/2024
# assuming prompts up to 128k tokens
cost = 0.00000125 * prompt_tokens + 0.000005 * completion_tokens
if prompt_tokens > 128000:
cost *= 2
return cost
elif 'gemini-2.0-flash-exp' in model.lower():
# price unknown for gemini-2.0-flash-exp, assuming same price as gemini-1.5-flash
cost = 0.000000075 * prompt_tokens + 0.0000003 * completion_tokens
if prompt_tokens > 128000:
cost *= 2
return cost
elif 'qwen2-72b' in model.lower():
# assuming hosted on Together
# https://www.together.ai/pricing, accessed 12/11/2024
return 0.0000009 * (prompt_tokens + completion_tokens)
elif 'qwen2p5-72b' in model.lower():
# assuming hosted on Together
# https://www.together.ai/pricing, accessed 12/14/2024
return 0.0000012 * (prompt_tokens + completion_tokens)
elif 'llama-v3p1-405b-instruct' in model.lower():
# assuming hosted on Fireworks AI
# https://fireworks.ai/pricing, accessed 12/11/2024
return 0.000003 * (prompt_tokens + completion_tokens)
elif 'llama-v3p1-70b-instruct' in model.lower():
# assuming hosted on Fireworks AI
return 0.0000009 * (prompt_tokens + completion_tokens)
elif 'llama-v3p3-70b-instruct' in model.lower():
# assuming hosted on Fireworks AI
return 0.0000009 * (prompt_tokens + completion_tokens)
elif 'amazon.nova-pro-v1:0' in model.lower():
# assuming hosted on Amazon Bedrock
# https://aws.amazon.com/bedrock/pricing/, accessed 12/11/2024
return 0.0000008 * prompt_tokens + 0.0000032 * completion_tokens
else:
raise ValueError(f'Unknown model: {model}')
def analyze_eval_json_file(filepath: str) -> Tuple[int, int]:
"""
Analyze a single eval JSON file and extract the total and result from final_score.
Args:
filepath: Path to the JSON file
Returns:
Tuple containing (total, result) from final_score
"""
try:
with open(filepath, 'r') as f:
data = json.load(f)
final_score = data.get('final_score', {})
return (final_score.get('total', 0), final_score.get('result', 0))
except json.JSONDecodeError as e:
print(f'Error decoding JSON in {filepath}: {e}')
return (0, 0)
except Exception as e:
print(f'Error processing {filepath}: {e}')
return (0, 0)
def analyze_traj_json_file(filepath: str) -> Tuple[int, float]:
"""
Analyze a single trajectory JSON file and extract the steps and tokens
for each step. Then estimate the cost based on the tokens and the model type.
Note: this is assuming there's no prompt caching at all.
"""
steps: int = 0
cost: float = 0.0
with open(filepath, 'r') as f:
data = json.load(f)
response_id = None
for action in data:
if 'tool_call_metadata' in action:
if action['tool_call_metadata']['model_response']['id'] != response_id:
response_id = action['tool_call_metadata']['model_response']['id']
else:
# openhands displays the same model response meta data multiple times, when
# a single LLM call leads to multiple actions and observations.
continue
steps += 1
usage = action['tool_call_metadata']['model_response']['usage']
model: str = action['tool_call_metadata']['model_response']['model']
prompt_tokens = usage['prompt_tokens']
completion_tokens = usage['completion_tokens']
cost += calculate_cost(model, prompt_tokens, completion_tokens)
return (steps, cost)
def analyze_folder(
folder_path: str,
) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, Tuple[int, float]]]:
"""
Analyze all eval_*.json & traj_*.json files in the specified folder.
Args:
folder_path: Path to the folder containing JSON files
Returns:
dictionaries:
- eval_results: Dictionary with filename as key and (total, result) tuple as value
- traj_results: Dictionary with filename as key and (steps, cost) tuple as value
"""
eval_results = {}
traj_results = {}
eval_pattern = os.path.join(folder_path, 'eval_*.json')
traj_pattern = os.path.join(folder_path, 'traj_*.json')
for filepath in glob.glob(eval_pattern):
filename = os.path.basename(filepath)
total, result = analyze_eval_json_file(filepath)
key = re.search(r'eval_(.+)\.json', filename).group(1)
eval_results[key] = (total, result)
for filepath in glob.glob(traj_pattern):
filename = os.path.basename(filepath)
steps, cost = analyze_traj_json_file(filepath)
key = re.search(r'traj_(.+)\.json', filename).group(1)
traj_results[key] = (steps, cost)
return eval_results, traj_results
def get_task_nature_category(task_name: str) -> str:
"""
Get the nature category of the task.
"""
task_nature = task_name.split('-')[0]
if task_nature.lower() in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance']:
return task_nature
else:
return 'other'
def calculate_score(total: int, result: int) -> float:
"""
Calculate the score as a number between 0 and 1.
Formula: score = (result / total) * 0.5 + (result // total) * 0.5
Explanation:
- (result / total) * 0.5: This is the completion ratio, scaled down to a 0-0.5 range.
- (result // total) * 0.5: This is a binary score indicating whether the task was completed or not.
Args:
total: Total possible points
result: Actual points achieved
Returns:
Score as a number between 0 and 1
"""
return (result / total * 0.5) + (result // total * 0.5)
def is_perfect_completion(total: int, result: int) -> bool:
"""
Check if the task achieved perfect completion.
Args:
total: Total possible points
result: Actual points achieved
Returns:
True if result equals total, False otherwise
"""
return total > 0 and total == result
def main():
if len(sys.argv) != 2:
print('Usage: poetry run python summarise_results.py <folder_path>')
sys.exit(1)
folder_path = sys.argv[1]
if not os.path.isdir(folder_path):
print(f"Error: '{folder_path}' is not a valid directory")
sys.exit(1)
eval_results, traj_results = analyze_folder(folder_path)
if not eval_results:
print(f'No eval_*.json files found in {folder_path}')
return
# Create list of results with completion ratios for sorting
detailed_results = [
(
task_name,
total,
result,
calculate_score(total, result),
is_perfect_completion(total, result),
get_task_nature_category(task_name),
)
for task_name, (total, result) in eval_results.items()
]
# Sort by score in descending order
detailed_results.sort(key=lambda x: (-x[3], x[0]))
# Calculate perfect completion stats
perfect_completions = sum(
1 for _, _, _, _, is_perfect, _ in detailed_results if is_perfect
)
# Print header
print('\n# Evaluation Results Report')
print('\n## Results per File')
print('\n*Sorted by score (⭐ indicates perfect completion)*\n')
# Print table header
print(
'| Filename | Total | Result | Score | Steps | Cost (assuming no prompt caching)|'
)
print('|----------|--------|---------|-------|-------|------|')
# Print individual file results
for task_name, total, result, score, is_perfect, task_nature in detailed_results:
perfect_marker = '' if is_perfect else ''
print(
f'| {task_name} | {total:,} | {result:,} | {score:.2f}{perfect_marker} | {traj_results[task_name][0]} | {traj_results[task_name][1]:.2f} |'
)
# Print summary section
print('\n## Summary\n')
print(f'**Tasks Evaluated:** {len(eval_results)}\n')
print(
f'**Perfect Completions:** {perfect_completions}/{len(eval_results)} ({(perfect_completions/len(eval_results)*100):.2f}%)\n'
)
overall_score = (
sum(score for _, _, _, score, _, _ in detailed_results)
/ len(detailed_results)
* 100
)
avg_steps = sum(steps for steps, _ in traj_results.values()) / len(traj_results)
avg_cost = sum(cost for _, cost in traj_results.values()) / len(traj_results)
print(f'**Overall Score:** {overall_score:.2f}%\n')
print(f'**Average Steps:** {avg_steps:.2f}\n')
print(f'**Average Cost (USD):** {avg_cost:.2f}\n')
# Additional statistics
if detailed_results:
highest_score = max(score for _, _, _, score, _, _ in detailed_results)
lowest_score = min(score for _, _, _, score, _, _ in detailed_results)
median_score = detailed_results[len(detailed_results) // 2][3]
avg_score = sum(score for _, _, _, score, _, _ in detailed_results) / len(
detailed_results
)
print('\n## Statistics\n')
print('| Metric | Value |')
print('|---------|--------|')
print(f'| Highest Task Score | {highest_score*100:.2f}% |')
print(f'| Lowest Task Score | {lowest_score*100:.2f}% |')
print(f'| Median Task Score | {median_score*100:.2f}% |')
print(f'| Average Task Score | {avg_score*100:.2f}% |')
# compute avg score per nature category
print('\n## Statistics per Nature Category\n')
print('| Metric | Value |')
print('|---------|--------|')
for task_nature in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance', 'other']:
num_of_tasks = sum(
1
for _, _, _, _, _, nature_category in detailed_results
if nature_category == task_nature
)
task_nature_score = (
sum(
score
for _, _, _, score, _, nature_category in detailed_results
if nature_category == task_nature
)
/ num_of_tasks
)
perfect_completions = sum(
1
for _, _, _, _, is_perfect, nature_category in detailed_results
if nature_category == task_nature and is_perfect
)
print(
f'| Perfect Completions for {task_nature} | {perfect_completions}/{num_of_tasks} ({perfect_completions/num_of_tasks*100:.2f}%) |'
)
print(f'| Average Score for {task_nature} | {task_nature_score*100:.2f}% |')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,35 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
describe("Workspace Toggle", () => {
it("should render toggle button with correct icon and label", () => {
const onClickMock = vi.fn();
// Test initial state (workspace visible)
const { rerender } = render(
<ToggleWorkspaceIconButton onClick={onClickMock} isHidden={false} />
);
const button = screen.getByTestId("toggle");
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute("aria-label", "Close workspace");
// Test hidden state
rerender(
<ToggleWorkspaceIconButton onClick={onClickMock} isHidden={true} />
);
expect(button).toHaveAttribute("aria-label", "Open workspace");
});
it("should call onClick handler when clicked", () => {
const onClickMock = vi.fn();
render(
<ToggleWorkspaceIconButton onClick={onClickMock} isHidden={false} />
);
const button = screen.getByTestId("toggle");
fireEvent.click(button);
expect(onClickMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -40,7 +40,7 @@ describe("frontend/routes/_oh", () => {
await screen.findByTestId("root-layout");
});
it("should render the AI config modal if settings are not up-to-date", async () => {
it.skip("should render the AI config modal if settings are not up-to-date", async () => {
settingsAreUpToDateMock.mockReturnValue(false);
renderWithProviders(<RouteStub />);

View File

@@ -8,56 +8,56 @@
"name": "openhands-frontend",
"version": "0.17.0",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
"@react-router/node": "^7.0.1",
"@react-router/serve": "^7.0.1",
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.10",
"@react-router/node": "^7.1.1",
"@react-router/serve": "^7.1.1",
"@react-types/shared": "^3.25.0",
"@reduxjs/toolkit": "^2.3.0",
"@tanstack/react-query": "^5.60.5",
"@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.62.10",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.7",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"i18next": "^23.15.2",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.2",
"isbot": "^5.1.17",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"isbot": "^5.1.19",
"jose": "^5.9.4",
"monaco-editor": "^0.52.0",
"posthog-js": "^1.184.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.203.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^15.0.2",
"react-icons": "^5.3.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.1.2",
"react-router": "^7.0.1",
"react-redux": "^9.2.0",
"react-router": "^7.1.1",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.4",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.4",
"tailwind-merge": "^2.6.0",
"vite": "^5.4.9",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@playwright/test": "^1.48.2",
"@react-router/dev": "^7.0.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@tanstack/eslint-plugin-query": "^5.62.9",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.0.1",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.5.12",
@@ -73,18 +73,18 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
"lint-staged": "^15.2.10",
"lint-staged": "^15.2.11",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.0.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^1.6.0"
},
"engines": {
@@ -1595,17 +1595,17 @@
}
},
"node_modules/@monaco-editor/react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
"version": "4.7.0-rc.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0-rc.0.tgz",
"integrity": "sha512-YfjXkDK0bcwS0zo8PXptvQdCQfOPPtzGsAzmIv7PnoUGFdIohsR+NVDyjbajMddF+3cWUm/3q9NzP/DUke9a+w==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.4.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mswjs/interceptors": {
@@ -2241,6 +2241,23 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@nextui-org/listbox/node_modules/@tanstack/react-virtual": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.10.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@nextui-org/menu": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@nextui-org/menu/-/menu-2.2.8.tgz",
@@ -2579,6 +2596,23 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@nextui-org/select/node_modules/@tanstack/react-virtual": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.10.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@nextui-org/shared-icons": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nextui-org/shared-icons/-/shared-icons-2.1.1.tgz",
@@ -5336,23 +5370,6 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz",
"integrity": "sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.10.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.10.9",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz",
@@ -5603,30 +5620,23 @@
"undici-types": "~6.20.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"version": "19.0.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.2.tgz",
"integrity": "sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
"integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
"version": "19.0.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz",
"integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.0.0"
}
},
"node_modules/@types/react-highlight": {
@@ -10141,9 +10151,9 @@
}
},
"node_modules/i18next": {
"version": "23.16.8",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",
"integrity": "sha512-ArJJTS1lV6lgKH7yEf4EpgNZ7+THl7bsGxxougPYiXRTJ/Fe1j08/TBpV9QsXCIYVfdE/HWG/xLezJ5DOlfBOA==",
"funding": [
{
"type": "individual",
@@ -10161,6 +10171,14 @@
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
@@ -10173,9 +10191,9 @@
}
},
"node_modules/i18next-http-backend": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.7.1.tgz",
"integrity": "sha512-vPksHIckysGgykCD8JwCr2YsJEml9Cyw+Yu2wtb4fQ7xIn9RH/hkUDh5UkwnIzb0kSL4SJ30Ab/sCInhQxbCgg==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz",
"integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
@@ -11536,6 +11554,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -14059,28 +14078,24 @@
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.25.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.0.0"
}
},
"node_modules/react-highlight": {
@@ -14918,13 +14933,10 @@
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/scroll-into-view-if-needed": {
"version": "3.0.10",

View File

@@ -7,41 +7,41 @@
"node": ">=20.0.0"
},
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
"@react-router/node": "^7.0.1",
"@react-router/serve": "^7.0.1",
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.10",
"@react-router/node": "^7.1.1",
"@react-router/serve": "^7.1.1",
"@react-types/shared": "^3.25.0",
"@reduxjs/toolkit": "^2.3.0",
"@tanstack/react-query": "^5.60.5",
"@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.62.10",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.7",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"i18next": "^23.15.2",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.2",
"isbot": "^5.1.17",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"isbot": "^5.1.19",
"jose": "^5.9.4",
"monaco-editor": "^0.52.0",
"posthog-js": "^1.184.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.203.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^15.0.2",
"react-icons": "^5.3.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.1.2",
"react-router": "^7.0.1",
"react-redux": "^9.2.0",
"react-router": "^7.1.1",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.4",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.4",
"tailwind-merge": "^2.6.0",
"vite": "^5.4.9",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
@@ -75,16 +75,16 @@
]
},
"devDependencies": {
"@playwright/test": "^1.48.2",
"@react-router/dev": "^7.0.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@tanstack/eslint-plugin-query": "^5.62.9",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.0.1",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.5.12",
@@ -100,18 +100,18 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
"lint-staged": "^15.2.10",
"lint-staged": "^15.2.11",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.0.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^1.6.0"
},
"packageManager": "npm@10.5.0",

View File

@@ -8,8 +8,8 @@
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.6.6'
const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074'
const PACKAGE_VERSION = '2.7.0'
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
@@ -199,7 +199,19 @@ async function getResponse(event, client, requestId) {
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
headers.delete('accept', 'msw/passthrough')
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}

View File

@@ -250,14 +250,12 @@ class OpenHands {
static async newConversation(params: {
githubToken?: string;
args?: Record<string, unknown>;
selectedRepository?: string;
}): Promise<{ conversation_id: string }> {
const { data } = await openHands.post<{
conversation_id: string;
}>("/api/conversations", {
github_token: params.githubToken,
args: params.args,
selected_repository: params.selectedRepository,
});
// TODO: remove this once we have a multi-conversation UI

View File

@@ -1,6 +1,6 @@
import React from "react";
function ArrowIcon(): JSX.Element {
function ArrowIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function CogTooth(): JSX.Element {
function CogTooth() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function ConfirmIcon(): JSX.Element {
function ConfirmIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function PauseIcon(): JSX.Element {
function PauseIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function PlayIcon(): JSX.Element {
function PlayIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function RejectIcon(): JSX.Element {
function RejectIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
import React from "react";
function StopIcon(): JSX.Element {
function StopIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -8,7 +8,7 @@ import {
FaPython,
} from "react-icons/fa";
export const EXTENSION_ICON_MAP: Record<string, JSX.Element> = {
export const EXTENSION_ICON_MAP: Record<string, React.ReactNode> = {
js: <DiJavascript />,
ts: <DiJavascript />,
py: <FaPython />,

View File

@@ -2,13 +2,19 @@ import React from "react";
import { cn } from "#/utils/utils";
interface ContextMenuProps {
ref: React.RefObject<HTMLUListElement | null>;
testId?: string;
children: React.ReactNode;
className?: React.HTMLAttributes<HTMLUListElement>["className"];
}
export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
({ testId, children, className }, ref) => (
export function ContextMenu({
testId,
children,
className,
ref,
}: ContextMenuProps) {
return (
<ul
data-testid={testId}
ref={ref}
@@ -16,7 +22,5 @@ export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
>
{children}
</ul>
),
);
ContextMenu.displayName = "ContextMenu";
);
}

View File

@@ -1,115 +0,0 @@
import { Editor, EditorProps } from "@monaco-editor/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { VscCode } from "react-icons/vsc";
import { I18nKey } from "#/i18n/declaration";
import { useFiles } from "#/context/files";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
interface CodeEditorComponentProps {
onMount: EditorProps["onMount"];
isReadOnly: boolean;
}
function CodeEditorComponent({
onMount,
isReadOnly,
}: CodeEditorComponentProps) {
const { t } = useTranslation();
const {
files,
selectedPath,
modifiedFiles,
modifyFileContent,
saveFileContent: saveNewFileContent,
} = useFiles();
const { mutate: saveFile } = useSaveFile();
const handleEditorChange = (value: string | undefined) => {
if (selectedPath && value) modifyFileContent(selectedPath, value);
};
const isBase64Image = (content: string) => content.startsWith("data:image/");
const isPDF = (content: string) => content.startsWith("data:application/pdf");
const isVideo = (content: string) => content.startsWith("data:video/");
React.useEffect(() => {
const handleSave = async (event: KeyboardEvent) => {
if (selectedPath && event.metaKey && event.key === "s") {
const content = saveNewFileContent(selectedPath);
if (content) {
saveFile({ path: selectedPath, content });
}
}
};
document.addEventListener("keydown", handleSave);
return () => {
document.removeEventListener("keydown", handleSave);
};
}, [saveNewFileContent]);
if (!selectedPath) {
return (
<div
data-testid="code-editor-empty-message"
className="flex flex-col h-full items-center justify-center text-neutral-400"
>
<VscCode size={100} />
{t(I18nKey.CODE_EDITOR$EMPTY_MESSAGE)}
</div>
);
}
const fileContent: string | undefined =
modifiedFiles[selectedPath] || files[selectedPath];
if (fileContent) {
if (isBase64Image(fileContent)) {
return (
<section className="flex flex-col relative items-center overflow-auto h-[90%]">
<img
src={fileContent}
alt={selectedPath}
className="object-contain"
/>
</section>
);
}
if (isPDF(fileContent)) {
return (
<iframe
src={fileContent}
title={selectedPath}
width="100%"
height="100%"
/>
);
}
if (isVideo(fileContent)) {
return (
<video controls src={fileContent} width="100%" height="100%">
<track kind="captions" label="English captions" />
</video>
);
}
}
return (
<Editor
data-testid="code-editor"
path={selectedPath ?? undefined}
defaultValue=""
value={selectedPath ? fileContent : undefined}
onMount={onMount}
onChange={handleEditorChange}
options={{ readOnly: isReadOnly }}
/>
);
}
export default React.memo(CodeEditorComponent);

View File

@@ -1,33 +0,0 @@
import { EditorActionButton } from "#/components/shared/buttons/editor-action-button";
interface EditorActionsProps {
onSave: () => void;
onDiscard: () => void;
isDisabled: boolean;
}
export function EditorActions({
onSave,
onDiscard,
isDisabled,
}: EditorActionsProps) {
return (
<div className="flex gap-2">
<EditorActionButton
onClick={onSave}
disabled={isDisabled}
className="bg-neutral-800 disabled:hover:bg-neutral-800"
>
Save
</EditorActionButton>
<EditorActionButton
onClick={onDiscard}
disabled={isDisabled}
className="border border-neutral-800 disabled:hover:bg-transparent"
>
Discard
</EditorActionButton>
</div>
);
}

View File

@@ -1,27 +0,0 @@
import { useTranslation } from "react-i18next";
import { IoFileTray } from "react-icons/io5";
import { I18nKey } from "#/i18n/declaration";
interface DropzoneProps {
onDragLeave: () => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
}
export function Dropzone({ onDragLeave, onDrop }: DropzoneProps) {
const { t } = useTranslation();
return (
<div
data-testid="dropzone"
onDragLeave={onDragLeave}
onDrop={onDrop}
onDragOver={(event) => event.preventDefault()}
className="z-10 absolute flex flex-col justify-center items-center bg-black top-0 bottom-0 left-0 right-0 opacity-65"
>
<IoFileTray size={32} />
<p className="font-bold text-xl">
{t(I18nKey.EXPLORER$LABEL_DROP_FILES)}
</p>
</div>
);
}

View File

@@ -1,11 +1,9 @@
import { RefreshIconButton } from "#/components/shared/buttons/refresh-icon-button";
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
import { UploadIconButton } from "#/components/shared/buttons/upload-icon-button";
import { cn } from "#/utils/utils";
interface ExplorerActionsProps {
onRefresh: () => void;
onUpload: () => void;
toggleHidden: () => void;
isHidden: boolean;
}
@@ -13,7 +11,6 @@ interface ExplorerActionsProps {
export function ExplorerActions({
toggleHidden,
onRefresh,
onUpload,
isHidden,
}: ExplorerActionsProps) {
return (
@@ -23,12 +20,7 @@ export function ExplorerActions({
isHidden ? "right-3" : "right-2",
)}
>
{!isHidden && (
<>
<RefreshIconButton onClick={onRefresh} />
<UploadIconButton onClick={onUpload} />
</>
)}
{!isHidden && <RefreshIconButton onClick={onRefresh} />}
<ToggleWorkspaceIconButton isHidden={isHidden} onClick={toggleHidden} />
</div>

View File

@@ -7,14 +7,12 @@ interface FileExplorerHeaderProps {
isOpen: boolean;
onToggle: () => void;
onRefreshWorkspace: () => void;
onUploadFile: () => void;
}
export function FileExplorerHeader({
isOpen,
onToggle,
onRefreshWorkspace,
onUploadFile,
}: FileExplorerHeaderProps) {
const { t } = useTranslation();
@@ -35,7 +33,6 @@ export function FileExplorerHeader({
isHidden={!isOpen}
toggleHidden={onToggle}
onRefresh={onRefreshWorkspace}
onUpload={onUploadFile}
/>
</div>
);

View File

@@ -1,5 +1,5 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
@@ -7,14 +7,10 @@ import toast from "#/utils/toast";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { useListFiles } from "#/hooks/query/use-list-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { cn } from "#/utils/utils";
import { Dropzone } from "./dropzone";
import { FileExplorerHeader } from "./file-explorer-header";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { OpenVSCodeButton } from "#/components/shared/buttons/open-vscode-button";
import { addAssistantMessage } from "#/state/chat-slice";
interface FileExplorerProps {
isOpen: boolean;
@@ -23,26 +19,16 @@ interface FileExplorerProps {
export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { data: paths, refetch, error } = useListFiles();
const { mutate: uploadFiles } = useUploadFiles();
const { data: vscodeUrl } = useVSCodeUrl({
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
});
const handleOpenVSCode = () => {
if (vscodeUrl?.vscode_url) {
dispatch(
addAssistantMessage(
"You opened VS Code. Please inform the agent of any changes you made to the workspace or environment. To avoid conflicts, it's best to pause the agent before making any changes.",
),
);
window.open(vscodeUrl.vscode_url, "_blank");
} else if (vscodeUrl?.error) {
toast.error(
@@ -54,86 +40,18 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
}
};
const selectFileInput = () => {
fileInputRef.current?.click(); // Trigger the file browser
};
const handleUploadSuccess = (data: FileUploadSuccessResponse) => {
const uploadedCount = data.uploaded_files.length;
const skippedCount = data.skipped_files.length;
if (uploadedCount > 0) {
toast.success(
`upload-success-${new Date().getTime()}`,
t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
count: uploadedCount,
}),
);
}
if (skippedCount > 0) {
const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
count: skippedCount,
});
toast.info(message);
}
if (uploadedCount === 0 && skippedCount === 0) {
toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
}
};
const handleUploadError = (uploadError: Error) => {
toast.error(
`upload-error-${new Date().getTime()}`,
uploadError.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
);
};
const refreshWorkspace = () => {
if (!RUNTIME_INACTIVE_STATES.includes(curAgentState)) {
refetch();
}
};
const uploadFileData = (files: FileList) => {
uploadFiles(
{ files: Array.from(files) },
{ onSuccess: handleUploadSuccess, onError: handleUploadError },
);
refreshWorkspace();
};
const handleDropFiles = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const { files: droppedFiles } = event.dataTransfer;
if (droppedFiles.length > 0) {
uploadFileData(droppedFiles);
}
setIsDragging(false);
};
React.useEffect(() => {
refreshWorkspace();
}, [curAgentState]);
return (
<div
data-testid="file-explorer"
className="relative h-full"
onDragEnter={() => {
setIsDragging(true);
}}
onDragEnd={() => {
setIsDragging(false);
}}
>
{isDragging && (
<Dropzone
onDragLeave={() => setIsDragging(false)}
onDrop={handleDropFiles}
/>
)}
<div data-testid="file-explorer" className="relative h-full">
<div
className={cn(
"bg-neutral-800 h-full border-r-1 border-r-neutral-600 flex flex-col",
@@ -145,7 +63,6 @@ export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
isOpen={isOpen}
onToggle={onToggle}
onRefreshWorkspace={refreshWorkspace}
onUploadFile={selectFileInput}
/>
{!error && (
<div className="overflow-auto flex-grow min-h-0">

View File

@@ -4,7 +4,7 @@ interface FolderIconProps {
isOpen: boolean;
}
export function FolderIcon({ isOpen }: FolderIconProps): JSX.Element {
export function FolderIcon({ isOpen }: FolderIconProps) {
return isOpen ? (
<FaFolderOpen color="D9D3D0" className="icon" />
) : (

View File

@@ -14,13 +14,7 @@ interface TreeNodeProps {
}
function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
const {
setFileContent,
modifiedFiles,
setSelectedPath,
files,
selectedPath,
} = useFiles();
const { setFileContent, setSelectedPath, files, selectedPath } = useFiles();
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -35,8 +29,7 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
React.useEffect(() => {
if (fileContent) {
const code = modifiedFiles[path] || files[path];
if (!code || fileContent !== files[path]) {
if (fileContent !== files[path]) {
setFileContent(path, fileContent);
}
}
@@ -79,10 +72,6 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
type={isDirectory ? "folder" : "file"}
isOpen={isOpen}
/>
{modifiedFiles[path] && (
<div className="w-2 h-2 rounded-full bg-neutral-500" />
)}
</button>
{isOpen && paths && (

View File

@@ -0,0 +1,46 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { ToggleWorkspaceIconButton } from "../toggle-workspace-icon-button";
describe("ToggleWorkspaceIconButton", () => {
it("renders with correct dimensions and styling", () => {
const mockOnClick = vi.fn();
render(
<ToggleWorkspaceIconButton onClick={mockOnClick} isHidden={false} />,
);
const button = screen.getByTestId("toggle");
expect(button).toBeInTheDocument();
expect(button).toHaveClass("h-[100px] w-[20px]");
expect(button).toHaveClass("bg-neutral-800");
expect(button).toHaveClass("hover:bg-neutral-700");
expect(button).toHaveClass("rounded-md");
});
it("displays the correct icon based on isHidden prop", () => {
const mockOnClick = vi.fn();
const { rerender } = render(
<ToggleWorkspaceIconButton onClick={mockOnClick} isHidden={false} />,
);
expect(screen.getByLabelText("Close workspace")).toBeInTheDocument();
expect(screen.getByTestId("toggle")).toContainElement(
screen.getByTestId("arrow-forward-icon"),
);
rerender(<ToggleWorkspaceIconButton onClick={mockOnClick} isHidden />);
expect(screen.getByLabelText("Open workspace")).toBeInTheDocument();
expect(screen.getByTestId("toggle")).toContainElement(
screen.getByTestId("arrow-back-icon"),
);
});
it("remains visible when workspace is collapsed", () => {
const mockOnClick = vi.fn();
render(<ToggleWorkspaceIconButton onClick={mockOnClick} isHidden />);
const button = screen.getByTestId("toggle");
expect(button).toBeVisible();
});
});

View File

@@ -1,11 +1,12 @@
import { Button } from "@nextui-org/react";
import React, { MouseEventHandler, ReactElement } from "react";
import React, { ReactElement } from "react";
export interface IconButtonProps {
icon: ReactElement;
onClick: MouseEventHandler<HTMLButtonElement>;
onClick: () => void;
ariaLabel: string;
testId?: string;
className?: string;
}
export function IconButton({
@@ -13,13 +14,14 @@ export function IconButton({
onClick,
ariaLabel,
testId = "",
className = "",
}: IconButtonProps): React.ReactElement {
return (
<Button
type="button"
variant="flat"
onClick={onClick}
className="cursor-pointer text-[12px] bg-transparent aspect-square px-0 min-w-[20px] h-[20px]"
onPress={onClick}
className={`cursor-pointer text-[12px] bg-transparent aspect-square px-0 min-w-[12px] h-[20px] ${className}`}
aria-label={ariaLabel}
data-testid={testId}
>

View File

@@ -14,20 +14,24 @@ export function ToggleWorkspaceIconButton({
<IconButton
icon={
isHidden ? (
<IoIosArrowForward
size={20}
<IoIosArrowBack
size={10}
className="text-neutral-400 hover:text-neutral-100 transition"
data-testid="arrow-back-icon"
/>
) : (
<IoIosArrowBack
size={20}
<IoIosArrowForward
size={10}
className="text-neutral-400 hover:text-neutral-100 transition"
data-testid="arrow-forward-icon"
/>
)
}
testId="toggle"
ariaLabel={isHidden ? "Open workspace" : "Close workspace"}
title={isHidden ? "Open workspace" : "Close workspace"}
onClick={onClick}
className="h-[80px] w-[8px] bg-neutral-800 hover:bg-neutral-700 rounded-md"
/>
);
}

View File

@@ -1,22 +0,0 @@
import { IoIosCloudUpload } from "react-icons/io";
import { IconButton } from "./icon-button";
interface UploadIconButtonProps {
onClick: () => void;
}
export function UploadIconButton({ onClick }: UploadIconButtonProps) {
return (
<IconButton
icon={
<IoIosCloudUpload
size={16}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
}
testId="upload"
ariaLabel="Upload File"
onClick={onClick}
/>
);
}

View File

@@ -25,7 +25,6 @@ export function SecurityAnalyzerInput({
</label>
<Autocomplete
isDisabled={isDisabled}
isRequired
id="security-analyzer"
name="security-analyzer"
aria-label="Security Analyzer"

View File

@@ -4,7 +4,7 @@ interface InvariantLogoIconProps {
className?: string;
}
function InvariantLogoIcon({ className }: InvariantLogoIconProps): JSX.Element {
function InvariantLogoIcon({ className }: InvariantLogoIconProps) {
return (
<svg
width="39"

View File

@@ -24,7 +24,7 @@ import { useGetTraces } from "#/hooks/query/use-get-traces";
type SectionType = "logs" | "policy" | "settings";
function SecurityInvariant(): JSX.Element {
function SecurityInvariant() {
const { t } = useTranslation();
const { logs } = useSelector((state: RootState) => state.securityAnalyzer);
@@ -122,7 +122,7 @@ function SecurityInvariant(): JSX.Element {
[],
);
const sections: { [key in SectionType]: JSX.Element } = {
const sections: Record<SectionType, React.ReactNode> = {
logs: (
<>
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">

View File

@@ -7,11 +7,7 @@ import { getDefaultSettings, Settings } from "#/services/settings";
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
import { DangerModal } from "../confirmation-modals/danger-modal";
import { I18nKey } from "#/i18n/declaration";
import {
extractSettings,
saveSettingsView,
updateSettingsVersion,
} from "#/utils/settings-utils";
import { extractSettings, saveSettingsView } from "#/utils/settings-utils";
import { useEndSession } from "#/hooks/use-end-session";
import { useSettings } from "#/context/settings-context";
import { ModalButton } from "../../buttons/modal-button";
@@ -24,7 +20,6 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
import { ModalBackdrop } from "../modal-backdrop";
import { ModelSelector } from "./model-selector";
import { useAuth } from "#/context/auth-context";
interface SettingsFormProps {
disabled?: boolean;
@@ -45,7 +40,6 @@ export function SettingsForm({
}: SettingsFormProps) {
const { saveSettings } = useSettings();
const endSession = useEndSession();
const { logout } = useAuth();
const location = useLocation();
const { t } = useTranslation();
@@ -92,14 +86,13 @@ export function SettingsForm({
}
};
const handleFormSubmission = (formData: FormData) => {
const handleFormSubmission = async (formData: FormData) => {
const keys = Array.from(formData.keys());
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
const newSettings = extractSettings(formData);
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
updateSettingsVersion(logout);
saveSettings(newSettings);
await saveSettings(newSettings);
resetOngoingSession();
posthog.capture("settings_saved", {
@@ -108,8 +101,8 @@ export function SettingsForm({
});
};
const handleConfirmResetSettings = () => {
saveSettings(getDefaultSettings());
const handleConfirmResetSettings = async () => {
await saveSettings(getDefaultSettings());
resetOngoingSession();
posthog.capture("settings_reset");

View File

@@ -11,7 +11,6 @@ import {
} from "#/state/initial-query-slice";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
import { SUGGESTIONS } from "#/utils/suggestions";
@@ -22,13 +21,17 @@ import { cn } from "#/utils/utils";
import { AttachImageLabel } from "../features/images/attach-image-label";
import { ImageCarousel } from "../features/images/image-carousel";
import { UploadImageInput } from "../features/images/upload-image-input";
import { LoadingSpinner } from "./loading-spinner";
export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
interface TaskFormProps {
ref: React.RefObject<HTMLFormElement | null>;
}
export function TaskForm({ ref }: TaskFormProps) {
const dispatch = useDispatch();
const navigation = useNavigation();
const navigate = useNavigate();
const { gitHubToken } = useAuth();
const { settings } = useSettings();
const { selectedRepository, files } = useSelector(
(state: RootState) => state.initialQuery,
@@ -45,7 +48,6 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
return OpenHands.newConversation({
githubToken: gitHubToken || undefined,
selectedRepository: selectedRepository || undefined,
args: settings || undefined,
});
},
onSuccess: ({ conversation_id: conversationId }, { q }) => {
@@ -88,7 +90,9 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
const formData = new FormData(event.currentTarget);
const q = formData.get("q")?.toString();
newConversationMutation.mutate({ q });
if (q?.trim()) {
newConversationMutation.mutate({ q });
}
};
return (
@@ -110,32 +114,35 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
"hover:border-neutral-500 focus-within:border-neutral-500",
)}
>
<ChatInput
name="q"
onSubmit={() => {
if (typeof ref !== "function") ref?.current?.requestSubmit();
}}
onChange={(message) => setText(message)}
onFocus={() => setInputIsFocused(true)}
onBlur={() => setInputIsFocused(false)}
onImagePaste={async (imageFiles) => {
const promises = imageFiles.map(convertImageToBase64);
const base64Images = await Promise.all(promises);
base64Images.forEach((base64) => {
dispatch(addFile(base64));
});
}}
placeholder={placeholder}
value={text}
maxRows={15}
showButton={!!text}
className="text-[17px] leading-5 py-[17px]"
buttonClassName="pb-[17px]"
disabled={
navigation.state === "submitting" ||
newConversationMutation.isPending
}
/>
{newConversationMutation.isPending ? (
<div className="flex justify-center py-[17px]">
<LoadingSpinner size="small" />
</div>
) : (
<ChatInput
name="q"
onSubmit={() => {
if (typeof ref !== "function") ref?.current?.requestSubmit();
}}
onChange={(message) => setText(message)}
onFocus={() => setInputIsFocused(true)}
onBlur={() => setInputIsFocused(false)}
onImagePaste={async (imageFiles) => {
const promises = imageFiles.map(convertImageToBase64);
const base64Images = await Promise.all(promises);
base64Images.forEach((base64) => {
dispatch(addFile(base64));
});
}}
placeholder={placeholder}
value={text}
maxRows={15}
showButton={!!text}
className="text-[17px] leading-5 py-[17px]"
buttonClassName="pb-[17px]"
disabled={navigation.state === "submitting"}
/>
)}
</div>
</form>
<UploadImageInput
@@ -157,6 +164,4 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
)}
</div>
);
});
TaskForm.displayName = "TaskForm";
}

View File

@@ -102,7 +102,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
[gitHubTokenState],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return <AuthContext value={value}>{children}</AuthContext>;
}
function useAuth() {

View File

@@ -24,11 +24,7 @@ export function ConversationProvider({
const value = useMemo(() => ({ conversationId }), [conversationId]);
return (
<ConversationContext.Provider value={value}>
{children}
</ConversationContext.Provider>
);
return <ConversationContext value={value}>{children}</ConversationContext>;
}
export function useConversation() {

View File

@@ -24,10 +24,6 @@ interface FilesContextType {
setFileContent: (path: string, content: string) => void;
selectedPath: string | null;
setSelectedPath: (path: string | null) => void;
modifiedFiles: Record<string, string>;
modifyFileContent: (path: string, content: string) => void;
saveFileContent: (path: string) => string | undefined;
discardChanges: (path: string) => void;
}
const FilesContext = React.createContext<FilesContextType | undefined>(
@@ -41,49 +37,12 @@ interface FilesProviderProps {
function FilesProvider({ children }: FilesProviderProps) {
const [paths, setPaths] = React.useState<string[]>([]);
const [files, setFiles] = React.useState<Record<string, string>>({});
const [modifiedFiles, setModifiedFiles] = React.useState<
Record<string, string>
>({});
const [selectedPath, setSelectedPath] = React.useState<string | null>(null);
const setFileContent = React.useCallback((path: string, content: string) => {
setFiles((prev) => ({ ...prev, [path]: content }));
}, []);
const modifyFileContent = React.useCallback(
(path: string, content: string) => {
if (files[path] !== content) {
setModifiedFiles((prev) => ({ ...prev, [path]: content }));
} else {
const newModifiedFiles = { ...modifiedFiles };
delete newModifiedFiles[path];
setModifiedFiles(newModifiedFiles);
}
},
[files, modifiedFiles],
);
const discardChanges = React.useCallback((path: string) => {
setModifiedFiles((prev) => {
const newModifiedFiles = { ...prev };
delete newModifiedFiles[path];
return newModifiedFiles;
});
}, []);
const saveFileContent = React.useCallback(
(path: string): string | undefined => {
const content = modifiedFiles[path];
if (content) {
setFiles((prev) => ({ ...prev, [path]: content }));
discardChanges(path);
}
return content;
},
[files, modifiedFiles, selectedPath, discardChanges],
);
const value = React.useMemo(
() => ({
paths,
@@ -92,28 +51,11 @@ function FilesProvider({ children }: FilesProviderProps) {
setFileContent,
selectedPath,
setSelectedPath,
modifiedFiles,
modifyFileContent,
saveFileContent,
discardChanges,
}),
[
paths,
setPaths,
files,
setFileContent,
selectedPath,
setSelectedPath,
modifiedFiles,
modifyFileContent,
saveFileContent,
discardChanges,
],
[paths, setPaths, files, setFileContent, selectedPath, setSelectedPath],
);
return (
<FilesContext.Provider value={value}>{children}</FilesContext.Provider>
);
return <FilesContext value={value}>{children}</FilesContext>;
}
function useFiles() {

View File

@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getSettings,
Settings,
saveSettings as updateAndSaveSettingsToLocalStorage,
saveSettings,
settingsAreUpToDate as checkIfSettingsAreUpToDate,
DEFAULT_SETTINGS,
} from "#/services/settings";
@@ -33,8 +33,8 @@ function SettingsProvider({ children }: React.PropsWithChildren) {
);
const queryClient = useQueryClient();
const saveSettings = (newSettings: Partial<Settings>) => {
updateAndSaveSettingsToLocalStorage(newSettings);
const handleSaveSettings = async (newSettings: Partial<Settings>) => {
await saveSettings(newSettings);
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY });
setSettingsAreUpToDate(checkIfSettingsAreUpToDate());
};
@@ -49,16 +49,12 @@ function SettingsProvider({ children }: React.PropsWithChildren) {
() => ({
settings,
settingsAreUpToDate,
saveSettings,
saveSettings: handleSaveSettings,
}),
[settings, settingsAreUpToDate],
);
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
return <SettingsContext value={value}>{children}</SettingsContext>;
}
function useSettings() {

View File

@@ -151,11 +151,7 @@ export function WsClientProvider({
[status, messageRateHandler.isUnderThreshold, events],
);
return (
<WsClientContext.Provider value={value}>
{children}
</WsClientContext.Provider>
);
return <WsClientContext value={value}>{children}</WsClientContext>;
}
export function useWsClient() {

View File

@@ -1,20 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type SaveFileArgs = {
path: string;
content: string;
};
export const useSaveFile = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ path, content }: SaveFileArgs) =>
OpenHands.saveFile(conversationId, path, content),
onError: (error) => {
toast.error(error.message);
},
});
};

View File

@@ -1,15 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
type UploadFilesArgs = {
files: File[];
};
export const useUploadFiles = () => {
const { conversationId } = useConversation();
return useMutation({
mutationFn: ({ files }: UploadFilesArgs) =>
OpenHands.uploadFiles(conversationId, files),
});
};

View File

@@ -20,7 +20,7 @@ export function useDownloadProgress(
const [progress, setProgress] =
useState<DownloadProgressState>(INITIAL_PROGRESS);
const progressRef = useRef<DownloadProgressState>(INITIAL_PROGRESS);
const abortController = useRef<AbortController>();
const abortController = useRef<AbortController>(null);
const { conversationId } = useConversation();
// Create AbortController on mount
@@ -31,7 +31,7 @@ export function useDownloadProgress(
progressRef.current = INITIAL_PROGRESS;
return () => {
controller.abort();
abortController.current = undefined;
abortController.current = null;
};
}, []); // Empty deps array - only run on mount/unmount

View File

@@ -1,6 +1,6 @@
import { RefObject, useEffect, useState } from "react";
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement>) {
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
// for auto-scroll
const [autoScroll, setAutoScroll] = useState(true);

View File

@@ -36,15 +36,15 @@ function Home() {
return (
<div
data-testid="root-index"
className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto"
className="bg-root-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
>
<HeroHeading />
<div className="flex flex-col gap-8 w-[600px] items-center">
<div className="flex flex-col gap-8 w-full md:w-[600px] items-center">
<div className="flex flex-col gap-2 w-full">
<TaskForm ref={formRef} />
</div>
<div className="flex gap-4 w-full">
<div className="flex gap-4 w-full flex-col md:flex-row">
<GitHubRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
repositories={

View File

@@ -1,16 +1,9 @@
import React from "react";
import { useSelector } from "react-redux";
import { useRouteError } from "react-router";
import { editor } from "monaco-editor";
import { EditorProps } from "@monaco-editor/react";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import CodeEditorComponent from "../../components/features/editor/code-editor-component";
import { useFiles } from "#/context/files";
import { useSaveFile } from "#/hooks/mutation/use-save-file";
import { ASSET_FILE_TYPES } from "./constants";
import { EditorActions } from "#/components/features/editor/editor-actions";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
import { useFiles } from "#/context/files";
export function ErrorBoundary() {
const error = useRouteError();
@@ -23,90 +16,91 @@ export function ErrorBoundary() {
);
}
function CodeEditor() {
const {
selectedPath,
modifiedFiles,
saveFileContent: saveNewFileContent,
discardChanges,
} = useFiles();
function getLanguageFromPath(path: string): string {
const extension = path.split(".").pop()?.toLowerCase();
switch (extension) {
case "js":
case "jsx":
return "javascript";
case "ts":
case "tsx":
return "typescript";
case "py":
return "python";
case "html":
return "html";
case "css":
return "css";
case "json":
return "json";
case "md":
return "markdown";
case "yml":
case "yaml":
return "yaml";
case "sh":
case "bash":
return "bash";
case "dockerfile":
return "dockerfile";
case "rs":
return "rust";
case "go":
return "go";
case "java":
return "java";
case "cpp":
case "cc":
case "cxx":
return "cpp";
case "c":
return "c";
case "rb":
return "ruby";
case "php":
return "php";
case "sql":
return "sql";
default:
return "text";
}
}
function FileViewer() {
const [fileExplorerIsOpen, setFileExplorerIsOpen] = React.useState(true);
const editorRef = React.useRef<editor.IStandaloneCodeEditor | null>(null);
const { mutate: saveFile } = useSaveFile();
const { selectedPath, files } = useFiles();
const toggleFileExplorer = () => {
setFileExplorerIsOpen((prev) => !prev);
editorRef.current?.layout({ width: 0, height: 0 });
};
const handleEditorDidMount: EditorProps["onMount"] = (e, monaco) => {
editorRef.current = e;
monaco.editor.defineTheme("oh-dark", {
base: "vs-dark",
inherit: true,
rules: [],
colors: {
"editor.background": "#171717",
},
});
monaco.editor.setTheme("oh-dark");
};
const agentState = useSelector(
(state: RootState) => state.agent.curAgentState,
);
// Code editing is only allowed when the agent is paused, finished, or awaiting user input (server rules)
const isEditingAllowed = React.useMemo(
() =>
agentState === AgentState.PAUSED ||
agentState === AgentState.FINISHED ||
agentState === AgentState.AWAITING_USER_INPUT,
[agentState],
);
const handleSave = async () => {
if (selectedPath) {
const content = modifiedFiles[selectedPath];
if (content) {
saveFile({ path: selectedPath, content });
saveNewFileContent(selectedPath);
}
}
};
const handleDiscard = () => {
if (selectedPath) discardChanges(selectedPath);
};
const isAssetFileType = selectedPath
? ASSET_FILE_TYPES.some((ext) => selectedPath.endsWith(ext))
: false;
return (
<div className="flex h-full bg-neutral-900 relative">
<FileExplorer isOpen={fileExplorerIsOpen} onToggle={toggleFileExplorer} />
<div className="w-full">
{selectedPath && !isAssetFileType && (
<div className="w-full h-full flex flex-col">
{selectedPath && (
<div className="flex w-full items-center justify-between self-end p-2">
<span className="text-sm text-neutral-500">{selectedPath}</span>
<EditorActions
onSave={handleSave}
onDiscard={handleDiscard}
isDisabled={!isEditingAllowed || !modifiedFiles[selectedPath]}
/>
</div>
)}
<CodeEditorComponent
onMount={handleEditorDidMount}
isReadOnly={!isEditingAllowed}
/>
{selectedPath && files[selectedPath] && (
<div className="p-4 flex-1 overflow-auto">
<SyntaxHighlighter
language={getLanguageFromPath(selectedPath)}
style={vscDarkPlus}
customStyle={{
margin: 0,
background: "#171717",
fontSize: "0.875rem",
}}
>
{files[selectedPath]}
</SyntaxHighlighter>
</div>
)}
</div>
</div>
);
}
export default CodeEditor;
export default FileViewer;

View File

@@ -1,13 +1,9 @@
import React from "react";
import toast from "react-hot-toast";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { useAuth } from "#/context/auth-context";
import { useWsClient } from "#/context/ws-client-provider";
import { getGitHubTokenCommand } from "#/services/terminal-service";
import { setImportedProjectZip } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
import { useGitHubUser } from "../../../hooks/query/use-github-user";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
@@ -17,48 +13,19 @@ export const useHandleRuntimeActive = () => {
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const dispatch = useDispatch();
const { data: user } = useGitHubUser();
const { mutate: uploadFiles } = useUploadFiles();
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
const { importedProjectZip } = useSelector(
(state: RootState) => state.initialQuery,
);
const userId = React.useMemo(() => {
if (user && !isGitHubErrorReponse(user)) return user.id;
return null;
}, [user]);
const handleUploadFiles = (zip: string) => {
const blob = base64ToBlob(zip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
toast.error("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
};
React.useEffect(() => {
if (runtimeActive && userId && gitHubToken) {
// Export if the user valid, this could happen mid-session so it is handled here
send(getGitHubTokenCommand(gitHubToken));
}
}, [userId, gitHubToken, runtimeActive]);
React.useEffect(() => {
if (runtimeActive && importedProjectZip) {
handleUploadFiles(importedProjectZip);
}
}, [runtimeActive, importedProjectZip]);
};

View File

@@ -27,6 +27,7 @@ import { Container } from "#/components/layout/container";
import Security from "#/components/shared/modals/security/security";
import { CountBadge } from "#/components/layout/count-badge";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
function AppContent() {
const { gitHubToken } = useAuth();
@@ -62,6 +63,12 @@ function AppContent() {
dispatch(clearJupyter());
});
const [isWorkspaceHidden, setIsWorkspaceHidden] = React.useState(false);
const toggleWorkspace = React.useCallback(() => {
setIsWorkspaceHidden((prev) => !prev);
}, []);
const {
isOpen: securityModalIsOpen,
onOpen: onSecurityModalOpen,
@@ -71,15 +78,31 @@ function AppContent() {
return (
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
<EventHandler>
<div className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto gap-3">
<Container className="w-full md:w-[390px] max-h-full relative">
<ChatInterface />
</Container>
<div className="flex flex-col h-full">
<div className="flex flex-grow overflow-hidden">
<div
className={`relative flex-grow mr-5 ${isWorkspaceHidden ? "w-full" : "md:w-[390px]"}`}
>
<Container className="h-full">
<ChatInterface />
</Container>
<div
className={`absolute top-1/2 -translate-y-1/2 ${isWorkspaceHidden ? "-right-4" : "-right-4"} z-10`}
>
<ToggleWorkspaceIconButton
onClick={toggleWorkspace}
isHidden={isWorkspaceHidden}
/>
</div>
</div>
<div className="hidden md:flex flex-col grow gap-3">
<div
className={`hidden md:flex flex-col flex-grow transition-all duration-300 ${
isWorkspaceHidden ? "w-0 opacity-0 overflow-hidden" : ""
}`}
>
<Container
className="h-2/3"
className="flex-grow"
labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },

View File

@@ -5,11 +5,11 @@ import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useAuth } from "#/context/auth-context";
import { useSettings } from "#/context/settings-context";
import { updateSettingsVersion } from "#/utils/settings-utils";
import { useConfig } from "#/hooks/query/use-config";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
export function ErrorBoundary() {
const error = useRouteError();
@@ -45,15 +45,13 @@ export function ErrorBoundary() {
export default function MainApp() {
const { gitHubToken } = useAuth();
const { settings, settingsAreUpToDate } = useSettings();
const { settings } = useSettings();
const { logout } = useAuth();
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
!localStorage.getItem("analytics-consent"),
);
const [aiConfigModalIsOpen, setAiConfigModalIsOpen] =
React.useState(!settingsAreUpToDate);
const config = useConfig();
const { data: isAuthed, isFetching: isFetchingAuth } = useIsAuthed();
@@ -69,6 +67,10 @@ export default function MainApp() {
}
}, [settings.LANGUAGE]);
React.useEffect(() => {
updateSettingsVersion(logout);
}, []);
const isInWaitlist =
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
@@ -92,13 +94,6 @@ export default function MainApp() {
onClose={() => setConsentFormIsOpen(false)}
/>
)}
{aiConfigModalIsOpen && (
<SettingsModal
onClose={() => setAiConfigModalIsOpen(false)}
data-testid="ai-config-modal"
/>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { openHands } from "#/api/open-hands-axios";
export const LATEST_SETTINGS_VERSION = 4;
export const LATEST_SETTINGS_VERSION = 5;
export type Settings = {
LLM_MODEL: string;
@@ -45,54 +45,8 @@ export const getCurrentSettingsVersion = () => {
export const settingsAreUpToDate = () =>
getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
export const maybeMigrateSettings = (logout: () => void) => {
// Sometimes we ship major changes, like a new default agent.
// In this case, we may want to override a previous choice made by the user.
const currentVersion = getCurrentSettingsVersion();
if (currentVersion < 1) {
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
}
if (currentVersion < 2) {
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
if (customModel) {
localStorage.setItem("LLM_MODEL", customModel);
}
localStorage.removeItem("CUSTOM_LLM_MODEL");
localStorage.removeItem("USING_CUSTOM_MODEL");
}
if (currentVersion < 3) {
localStorage.removeItem("token");
}
if (currentVersion < 4) {
logout();
}
};
/**
* Get the default settings
*/
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
/**
* Get the settings from the server or use the default settings if not found
*/
export const getSettings = async (): Promise<Settings> => {
const { data: apiSettings } =
await openHands.get<ApiSettings>("/api/settings");
if (apiSettings != null) {
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: "",
};
}
// TODO: localStorage settings are deprecated. Remove this after 1/31/2025
export const getLocalStorageSettings = (): Settings => {
const llmModel = localStorage.getItem("LLM_MODEL");
const baseUrl = localStorage.getItem("LLM_BASE_URL");
const agent = localStorage.getItem("AGENT");
@@ -133,7 +87,61 @@ export const saveSettings = async (
const { data } = await openHands.post("/api/settings", apiSettings);
return data;
} catch (error) {
console.error("Error saving settings:", error);
return false;
}
};
export const maybeMigrateSettings = async (logout: () => void) => {
// Sometimes we ship major changes, like a new default agent.
// In this case, we may want to override a previous choice made by the user.
const currentVersion = getCurrentSettingsVersion();
if (currentVersion < 1) {
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
}
if (currentVersion < 2) {
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
if (customModel) {
localStorage.setItem("LLM_MODEL", customModel);
}
localStorage.removeItem("CUSTOM_LLM_MODEL");
localStorage.removeItem("USING_CUSTOM_MODEL");
}
if (currentVersion < 3) {
localStorage.removeItem("token");
}
if (currentVersion < 4) {
logout();
}
if (currentVersion < 5) {
const localSettings = getLocalStorageSettings();
await saveSettings(localSettings);
}
};
/**
* Get the default settings
*/
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
/**
* Get the settings from the server or use the default settings if not found
*/
export const getSettings = async (): Promise<Settings> => {
const { data: apiSettings } =
await openHands.get<ApiSettings>("/api/settings");
if (apiSettings != null) {
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: "",
};
}
return getLocalStorageSettings();
};

View File

@@ -1,17 +0,0 @@
import toast from "react-hot-toast";
import { ErrorToast } from "#/components/shared/error-toast";
export const displayErrorToast = (error: string) =>
toast((t) => <ErrorToast id={t.id} error={error} />, {
style: {
background: "#C63143",
color: "#fff",
fontSize: "12px",
fontWeight: "500",
lineHeight: "20px",
borderRadius: "4px",
width: "336px",
},
duration: Infinity,
position: "bottom-right",
});

View File

@@ -82,9 +82,9 @@ const saveSettingsView = (view: "basic" | "advanced") => {
* Updates the settings version in local storage if the current settings are not up to date.
* If the settings are outdated, it attempts to migrate them before updating the version.
*/
const updateSettingsVersion = (logout: () => void) => {
const updateSettingsVersion = async (logout: () => void) => {
if (!settingsAreUpToDate()) {
maybeMigrateSettings(logout);
await maybeMigrateSettings(logout);
localStorage.setItem(
"SETTINGS_VERSION",
LATEST_SETTINGS_VERSION.toString(),

View File

@@ -3,14 +3,12 @@
import React, { PropsWithChildren } from "react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
// eslint-disable-next-line import/no-extraneous-dependencies
import { RenderOptions, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { AppStore, RootState, rootReducer } from "./src/store";
import { vi } from "vitest";
import { AppStore, RootState, rootReducer } from "./src/store";
import { AuthProvider } from "#/context/auth-context";
import { SettingsProvider } from "#/context/settings-context";
import { ConversationProvider } from "#/context/conversation-context";
@@ -26,22 +24,20 @@ vi.mock("react-router", async () => {
});
// Initialize i18n for tests
i18n
.use(initReactI18next)
.init({
lng: "en",
fallbackLng: "en",
ns: ["translation"],
defaultNS: "translation",
resources: {
en: {
translation: {},
},
i18n.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
ns: ["translation"],
defaultNS: "translation",
resources: {
en: {
translation: {},
},
interpolation: {
escapeValue: false,
},
});
},
interpolation: {
escapeValue: false,
},
});
const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({
@@ -67,16 +63,14 @@ export function renderWithProviders(
...renderOptions
}: ExtendedRenderOptions = {},
) {
function Wrapper({ children }: PropsWithChildren<object>): JSX.Element {
function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={store}>
<QueryClientProvider client={new QueryClient()}>
<SettingsProvider>
<AuthProvider>
<ConversationProvider>
<I18nextProvider i18n={i18n}>
{children}
</I18nextProvider>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</ConversationProvider>
</AuthProvider>
</SettingsProvider>

View File

@@ -83,6 +83,7 @@ class CodeActAgent(Agent):
- llm (LLM): The llm to be used by this agent
"""
super().__init__(llm, config)
self.pending_actions: deque[Action] = deque()
self.reset()
self.mock_function_calling = False
@@ -110,8 +111,6 @@ class CodeActAgent(Agent):
disabled_microagents=self.config.disabled_microagents,
)
self.pending_actions: deque[Action] = deque()
def get_action_message(
self,
action: Action,
@@ -340,6 +339,7 @@ class CodeActAgent(Agent):
def reset(self) -> None:
"""Resets the CodeAct Agent."""
super().reset()
self.pending_actions.clear()
def step(self, state: State) -> Action:
"""Performs one step using the CodeAct Agent.

View File

@@ -249,9 +249,14 @@ def auto_continue_response(
try_parse: Callable[[Action | None], str] | None = None,
) -> str:
"""Default function to generate user responses.
Returns 'continue' to tell the agent to proceed without asking for more input.
Tell the agent to proceed without asking for more input, or finish the interaction.
"""
return 'continue'
message = (
'Please continue on whatever approach you think is suitable.\n'
'If you think you have solved the task, please finish the interaction.\n'
'IMPORTANT: YOU SHOULD NEVER ASK FOR HUMAN RESPONSE.\n'
)
return message
if __name__ == '__main__':

View File

@@ -62,6 +62,8 @@ class Message(BaseModel):
# - tool execution result (to LLM)
tool_call_id: str | None = None
name: str | None = None # name of the tool
# force string serializer
force_string_serializer: bool = False
@property
def contains_image(self) -> bool:
@@ -73,7 +75,9 @@ class Message(BaseModel):
# - into a single string: for providers that don't support list of content items (e.g. no vision, no tool calls)
# - into a list of content items: the new APIs of providers with vision/prompt caching/tool calls
# NOTE: remove this when litellm or providers support the new API
if self.cache_enabled or self.vision_enabled or self.function_calling_enabled:
if not self.force_string_serializer and (
self.cache_enabled or self.vision_enabled or self.function_calling_enabled
):
return self._list_serializer()
# some providers, like HF and Groq/llama, don't support a list here, but a single string
return self._string_serializer()

View File

@@ -122,6 +122,12 @@ class LLM(RetryMixin, DebugMixin):
if self.is_function_calling_active():
logger.debug('LLM: model supports function calling')
# Compatibility flag: use string serializer for DeepSeek models
# See this issue: https://github.com/All-Hands-AI/OpenHands/issues/5818
self._use_string_serializer = False
if 'deepseek' in self.config.model:
self._use_string_serializer = True
# if using a custom tokenizer, make sure it's loaded and accessible in the format expected by litellm
if self.config.custom_tokenizer is not None:
self.tokenizer = create_pretrained_tokenizer(self.config.custom_tokenizer)
@@ -618,6 +624,8 @@ class LLM(RetryMixin, DebugMixin):
message.cache_enabled = self.is_caching_prompt_active()
message.vision_enabled = self.vision_is_active()
message.function_calling_enabled = self.is_function_calling_active()
if 'deepseek' in self.config.model:
message.force_string_serializer = True
# let pydantic handle the serialization
return [message.model_dump() for message in messages]

View File

@@ -13,6 +13,7 @@ from openhands.events.observation import (
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.server.routes.settings import SettingsStoreImpl
from openhands.server.session.manager import ConversationDoesNotExistError
from openhands.server.shared import config, openhands_config, session_manager, sio
from openhands.server.types import AppMode
@@ -32,10 +33,12 @@ async def connect(connection_id: str, environ, auth):
logger.error('No conversation_id in query params')
raise ConnectionRefusedError('No conversation_id in query params')
github_token = ''
if openhands_config.app_mode != AppMode.OSS:
user_id = ''
if auth and 'github_token' in auth:
with Github(auth['github_token']) as g:
github_token = auth['github_token']
with Github(github_token) as g:
gh_user = await call_sync_from_async(g.get_user)
user_id = gh_user.id
@@ -51,9 +54,15 @@ async def connect(connection_id: str, environ, auth):
f'User {user_id} is not allowed to join conversation {conversation_id}'
)
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
if not settings:
raise ConnectionRefusedError('Settings not found')
try:
event_stream = await session_manager.join_conversation(
conversation_id, connection_id
conversation_id, connection_id, settings
)
except ConversationDoesNotExistError:
logger.error(f'Conversation {conversation_id} does not exist')

View File

@@ -1,6 +1,4 @@
from typing import Annotated
from fastapi import APIRouter, Header, status
from fastapi import APIRouter, Request, status
from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
@@ -16,10 +14,13 @@ SettingsStoreImpl = get_impl(SettingsStore, openhands_config.settings_store_clas
@app.get('/settings')
async def load_settings(
github_auth: Annotated[str | None, Header()] = None,
request: Request,
) -> Settings | None:
github_token = ''
if hasattr(request.state, 'github_token'):
github_token = request.state.github_token
try:
settings_store = await SettingsStoreImpl.get_instance(config, github_auth)
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
if settings:
# For security reasons we don't ever send the api key to the client
@@ -35,18 +36,24 @@ async def load_settings(
@app.post('/settings')
async def store_settings(
request: Request,
settings: Settings,
github_auth: Annotated[str | None, Header()] = None,
) -> bool:
) -> JSONResponse:
github_token = ''
if hasattr(request.state, 'github_token'):
github_token = request.state.github_token
try:
settings_store = await SettingsStoreImpl.get_instance(config, github_auth)
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
existing_settings = await settings_store.load()
if existing_settings:
settings = Settings(**{**existing_settings.__dict__, **settings.__dict__})
if settings.llm_api_key is None:
settings.llm_api_key = existing_settings.llm_api_key
await settings_store.store(settings)
return True
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(

View File

@@ -2,6 +2,7 @@ import asyncio
import json
import time
from dataclasses import dataclass, field
from uuid import uuid4
import socketio
@@ -10,8 +11,8 @@ from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.events.stream import EventStream, session_exists
from openhands.server.session.conversation import Conversation
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.session.session import ROOM_KEY, Session
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import should_continue
@@ -27,6 +28,14 @@ class ConversationDoesNotExistError(Exception):
pass
@dataclass
class _SessionIsRunningCheck:
request_id: str
request_sids: list[str]
running_sids: set[str] = field(default_factory=set)
flag: asyncio.Event = field(default_factory=asyncio.Event)
@dataclass
class SessionManager:
sio: socketio.AsyncServer
@@ -36,7 +45,9 @@ class SessionManager:
local_connection_id_to_session_id: dict[str, str] = field(default_factory=dict)
_last_alive_timestamps: dict[str, float] = field(default_factory=dict)
_redis_listen_task: asyncio.Task | None = None
_session_is_running_flags: dict[str, asyncio.Event] = field(default_factory=dict)
_session_is_running_checks: dict[str, _SessionIsRunningCheck] = field(
default_factory=dict
)
_active_conversations: dict[str, tuple[Conversation, int]] = field(
default_factory=dict
)
@@ -97,27 +108,41 @@ class SessionManager:
async def _process_message(self, message: dict):
data = json.loads(message['data'])
logger.debug(f'got_published_message:{message}')
sid = data['sid']
message_type = data['message_type']
if message_type == 'event':
sid = data['sid']
session = self._local_agent_loops_by_sid.get(sid)
if session:
await session.dispatch(data['data'])
elif message_type == 'is_session_running':
# Another node in the cluster is asking if the current node is running the session given.
session = self._local_agent_loops_by_sid.get(sid)
if session:
request_id = data['request_id']
sids = [
sid for sid in data['sids'] if sid in self._local_agent_loops_by_sid
]
if sids:
await self._get_redis_client().publish(
'oh_event',
json.dumps({'sid': sid, 'message_type': 'session_is_running'}),
json.dumps(
{
'request_id': request_id,
'sids': sids,
'message_type': 'session_is_running',
}
),
)
elif message_type == 'session_is_running':
self._last_alive_timestamps[sid] = time.time()
flag = self._session_is_running_flags.get(sid)
if flag:
flag.set()
request_id = data['request_id']
for sid in data['sids']:
self._last_alive_timestamps[sid] = time.time()
check = self._session_is_running_checks.get(request_id)
if check:
check.running_sids.update(data['sids'])
if len(check.request_sids) == len(check.running_sids):
check.flag.set()
elif message_type == 'has_remote_connections_query':
# Another node in the cluster is asking if the current node is connected to a session
sid = data['sid']
required = sid in self.local_connection_id_to_session_id.values()
if required:
await self._get_redis_client().publish(
@@ -127,12 +152,14 @@ class SessionManager:
),
)
elif message_type == 'has_remote_connections_response':
sid = data['sid']
flag = self._has_remote_connections_flags.get(sid)
if flag:
flag.set()
elif message_type == 'session_closing':
# Session closing event - We only get this in the event of graceful shutdown,
# which can't be guaranteed - nodes can simply vanish unexpectedly!
sid = data['sid']
logger.debug(f'session_closing:{sid}')
for (
connection_id,
@@ -178,13 +205,13 @@ class SessionManager:
self._active_conversations[sid] = (c, 1)
return c
async def join_conversation(self, sid: str, connection_id: str) -> EventStream:
async def join_conversation(self, sid: str, connection_id: str, settings: Settings):
logger.info(f'join_conversation:{sid}:{connection_id}')
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
self.local_connection_id_to_session_id[connection_id] = sid
event_stream = await self._get_event_stream(sid)
if not event_stream:
return await self.maybe_start_agent_loop(sid)
return await self.maybe_start_agent_loop(sid, settings)
return event_stream
async def detach_from_conversation(self, conversation: Conversation):
@@ -234,33 +261,47 @@ class SessionManager:
logger.warning('error_cleaning_detached_conversations', exc_info=True)
await asyncio.sleep(_CLEANUP_EXCEPTION_WAIT_TIME)
async def _is_agent_loop_running(self, sid: str) -> bool:
if await self._is_agent_loop_running_locally(sid):
async def get_agent_loop_running(self, sids: set[str]) -> set[str]:
running_sids = set(sid for sid in sids if sid in self._local_agent_loops_by_sid)
check_cluster_sids = [sid for sid in sids if sid not in running_sids]
running_cluster_sids = await self.get_agent_loop_running_in_cluster(
check_cluster_sids
)
running_sids.union(running_cluster_sids)
return running_sids
async def is_agent_loop_running(self, sid: str) -> bool:
if await self.is_agent_loop_running_locally(sid):
return True
if await self._is_agent_loop_running_in_cluster(sid):
if await self.is_agent_loop_running_in_cluster(sid):
return True
return False
async def _is_agent_loop_running_locally(self, sid: str) -> bool:
if self._local_agent_loops_by_sid.get(sid, None):
return True
return False
async def is_agent_loop_running_locally(self, sid: str) -> bool:
return sid in self._local_agent_loops_by_sid
async def _is_agent_loop_running_in_cluster(self, sid: str) -> bool:
async def is_agent_loop_running_in_cluster(self, sid: str) -> bool:
running_sids = await self.get_agent_loop_running_in_cluster([sid])
return bool(running_sids)
async def get_agent_loop_running_in_cluster(self, sids: list[str]) -> set[str]:
"""As the rest of the cluster if a session is running. Wait a for a short timeout for a reply"""
redis_client = self._get_redis_client()
if not redis_client:
return False
return set()
flag = asyncio.Event()
self._session_is_running_flags[sid] = flag
request_id = str(uuid4())
check = _SessionIsRunningCheck(request_id=request_id, request_sids=sids)
self._session_is_running_checks[request_id] = check
try:
logger.debug(f'publish:is_session_running:{sid}')
logger.debug(f'publish:is_session_running:{sids}')
await redis_client.publish(
'oh_event',
json.dumps(
{
'sid': sid,
'request_id': request_id,
'sids': sids,
'message_type': 'is_session_running',
}
),
@@ -268,13 +309,12 @@ class SessionManager:
async with asyncio.timeout(_REDIS_POLL_TIMEOUT):
await flag.wait()
result = flag.is_set()
return result
return check.running_sids
except TimeoutError:
# Nobody replied in time
return False
return check.running_sids
finally:
self._session_is_running_flags.pop(sid, None)
self._session_is_running_checks.pop(request_id, None)
async def _has_remote_connections(self, sid: str) -> bool:
"""As the rest of the cluster if they still want this session running. Wait a for a short timeout for a reply"""
@@ -302,18 +342,16 @@ class SessionManager:
finally:
self._has_remote_connections_flags.pop(sid, None)
async def maybe_start_agent_loop(
self, sid: str, conversation_init_data: ConversationInitData | None = None
) -> EventStream:
async def maybe_start_agent_loop(self, sid: str, settings: Settings) -> EventStream:
logger.info(f'maybe_start_agent_loop:{sid}')
session: Session | None = None
if not await self._is_agent_loop_running(sid):
if not await self.is_agent_loop_running(sid):
logger.info(f'start_agent_loop:{sid}')
session = Session(
sid=sid, file_store=self.file_store, config=self.config, sio=self.sio
)
self._local_agent_loops_by_sid[sid] = session
await session.initialize_agent(conversation_init_data)
await session.initialize_agent(settings)
event_stream = await self._get_event_stream(sid)
if not event_stream:
@@ -328,7 +366,7 @@ class SessionManager:
logger.info(f'found_local_agent_loop:{sid}')
return session.agent_session.event_stream
if await self._is_agent_loop_running_in_cluster(sid):
if await self.is_agent_loop_running_in_cluster(sid):
logger.info(f'found_remote_agent_loop:{sid}')
return EventStream(sid, self.file_store)
@@ -352,7 +390,7 @@ class SessionManager:
next_alive_check = last_alive_at + _CHECK_ALIVE_INTERVAL
if (
next_alive_check > time.time()
or await self._is_agent_loop_running_in_cluster(sid)
or await self.is_agent_loop_running_in_cluster(sid)
):
# Send the event to the other pod
await redis_client.publish(

View File

@@ -1,5 +1,4 @@
import asyncio
import json
import time
from copy import deepcopy
@@ -23,9 +22,8 @@ from openhands.events.stream import EventStreamSubscriber
from openhands.llm.llm import LLM
from openhands.server.session.agent_session import AgentSession
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
from openhands.storage.locations import get_conversation_init_data_filename
from openhands.utils.async_utils import call_sync_from_async
ROOM_KEY = 'room:{sid}'
@@ -65,63 +63,33 @@ class Session:
self.is_alive = False
self.agent_session.close()
async def _restore_init_data(self, sid: str) -> ConversationInitData:
# FIXME: we should not store/restore this data once we have server-side
# LLM configs. Should be done by 1/1/2025
json_str = await call_sync_from_async(
self.file_store.read, get_conversation_init_data_filename(sid)
)
data = json.loads(json_str)
return ConversationInitData(**data)
async def _save_init_data(self, sid: str, init_data: ConversationInitData):
# FIXME: we should not store/restore this data once we have server-side
# LLM configs. Should be done by 1/1/2025
json_str = json.dumps(init_data.__dict__)
await call_sync_from_async(
self.file_store.write, get_conversation_init_data_filename(sid), json_str
)
async def initialize_agent(
self, conversation_init_data: ConversationInitData | None = None
self,
settings: Settings,
):
self.agent_session.event_stream.add_event(
AgentStateChangedObservation('', AgentState.LOADING),
EventSource.ENVIRONMENT,
)
if conversation_init_data is None:
try:
conversation_init_data = await self._restore_init_data(self.sid)
except FileNotFoundError:
logger.error(f'User settings not found for session {self.sid}')
raise RuntimeError('User settings not found')
agent_cls = conversation_init_data.agent or self.config.default_agent
agent_cls = settings.agent or self.config.default_agent
self.config.security.confirmation_mode = (
self.config.security.confirmation_mode
if conversation_init_data.confirmation_mode is None
else conversation_init_data.confirmation_mode
if settings.confirmation_mode is None
else settings.confirmation_mode
)
self.config.security.security_analyzer = (
conversation_init_data.security_analyzer
or self.config.security.security_analyzer
)
max_iterations = (
conversation_init_data.max_iterations or self.config.max_iterations
settings.security_analyzer or self.config.security.security_analyzer
)
max_iterations = settings.max_iterations or self.config.max_iterations
# override default LLM config
default_llm_config = self.config.get_llm_config()
default_llm_config.model = (
conversation_init_data.llm_model or default_llm_config.model
)
default_llm_config.api_key = (
conversation_init_data.llm_api_key or default_llm_config.api_key
)
default_llm_config.model = settings.llm_model or default_llm_config.model
default_llm_config.api_key = settings.llm_api_key or default_llm_config.api_key
default_llm_config.base_url = (
conversation_init_data.llm_base_url or default_llm_config.base_url
settings.llm_base_url or default_llm_config.base_url
)
await self._save_init_data(self.sid, conversation_init_data)
# TODO: override other LLM config & agent config groups (#2075)
@@ -129,6 +97,12 @@ class Session:
agent_config = self.config.get_agent_config(agent_cls)
agent = Agent.get_cls(agent_cls)(llm, agent_config)
github_token = None
selected_repository = None
if isinstance(settings, ConversationInitData):
github_token = settings.github_token
selected_repository = settings.selected_repository
try:
await self.agent_session.start(
runtime_name=self.config.runtime,
@@ -138,8 +112,8 @@ class Session:
max_budget_per_task=self.config.max_budget_per_task,
agent_to_llm_config=self.config.get_agent_to_llm_config_map(),
agent_configs=self.config.get_agent_configs(),
github_token=conversation_init_data.github_token,
selected_repository=conversation_init_data.selected_repository,
github_token=github_token,
selected_repository=selected_repository,
)
except Exception as e:
logger.exception(f'Error creating controller: {e}')

View File

@@ -9,8 +9,8 @@ IN_MEMORY_FILES: dict = {}
class InMemoryFileStore(FileStore):
files: dict[str, str]
def __init__(self):
self.files = IN_MEMORY_FILES
def __init__(self, files: dict[str, str] = IN_MEMORY_FILES):
self.files = files
def write(self, path: str, contents: str) -> None:
self.files[path] = contents

14
poetry.lock generated
View File

@@ -553,17 +553,17 @@ files = [
[[package]]
name = "boto3"
version = "1.35.87"
version = "1.35.88"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.35.87-py3-none-any.whl", hash = "sha256:588ab05e2771c50fca5c242be14e7a25200ffd3dd95c45950ce40993473864c7"},
{file = "boto3-1.35.87.tar.gz", hash = "sha256:341c58602889078a4a25dc4331b832b5b600a33acd73471d2532c6f01b16fbb4"},
{file = "boto3-1.35.88-py3-none-any.whl", hash = "sha256:7bc9b27ad87607256470c70a86c8b8c319ddd6ecae89cc191687cbf8ccb7b6a6"},
{file = "boto3-1.35.88.tar.gz", hash = "sha256:43c6a7a70bb226770a82a601870136e3bb3bf2808f4576ab5b9d7d140dbf1323"},
]
[package.dependencies]
botocore = ">=1.35.87,<1.36.0"
botocore = ">=1.35.88,<1.36.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@@ -572,13 +572,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.35.87"
version = "1.35.88"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.35.87-py3-none-any.whl", hash = "sha256:81cf84f12030d9ab3829484b04765d5641697ec53c2ac2b3987a99eefe501692"},
{file = "botocore-1.35.87.tar.gz", hash = "sha256:3062d073ce4170a994099270f469864169dc1a1b8b3d4a21c14ce0ae995e0f89"},
{file = "botocore-1.35.88-py3-none-any.whl", hash = "sha256:e60cc3fbe8d7a10f70e7e852d76be2b29f23ead418a5899d366ea32b1eacb5a5"},
{file = "botocore-1.35.88.tar.gz", hash = "sha256:58dcd9a464c354b8c6c25261d8de830d175d9739eae568bf0c52e57116fb03c6"},
]
[package.dependencies]

View File

@@ -100,6 +100,7 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -129,6 +130,7 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"

View File

@@ -20,6 +20,7 @@ from openhands.llm import LLM
from openhands.llm.metrics import Metrics
from openhands.runtime.base import Runtime
from openhands.storage import get_file_store
from openhands.storage.memory import InMemoryFileStore
@pytest.fixture
@@ -168,7 +169,7 @@ async def test_run_controller_with_fatal_error(mock_agent, mock_event_stream):
@pytest.mark.asyncio
async def test_run_controller_stop_with_stuck():
config = AppConfig()
file_store = get_file_store(config.file_store, config.file_store_path)
file_store = InMemoryFileStore({})
event_stream = EventStream(sid='test', file_store=file_store)
agent = MagicMock(spec=Agent)

View File

@@ -3,14 +3,37 @@ from unittest.mock import Mock
import pytest
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
from openhands.agenthub.codeact_agent.function_calling import (
_BROWSER_DESCRIPTION,
_BROWSER_TOOL_DESCRIPTION,
BrowserTool,
CmdRunTool,
IPythonTool,
LLMBasedFileEditTool,
StrReplaceEditorTool,
WebReadTool,
get_tools,
response_to_actions,
)
from openhands.core.config import AgentConfig, LLMConfig
from openhands.core.message import TextContent
from openhands.core.exceptions import FunctionCallNotExistsError
from openhands.core.message import ImageContent, TextContent
from openhands.events.action import (
AgentFinishAction,
CmdRunAction,
MessageAction,
)
from openhands.events.event import EventSource, FileEditSource, FileReadSource
from openhands.events.observation.browse import BrowserOutputObservation
from openhands.events.observation.commands import (
CmdOutputObservation,
IPythonRunCellObservation,
)
from openhands.events.observation.delegate import AgentDelegateObservation
from openhands.events.observation.error import ErrorObservation
from openhands.events.observation.files import FileEditObservation, FileReadObservation
from openhands.events.observation.reject import UserRejectObservation
from openhands.events.tool import ToolCallMetadata
from openhands.llm.llm import LLM
@@ -102,3 +125,387 @@ def test_unknown_observation_message(agent: CodeActAgent):
with pytest.raises(ValueError, match='Unknown observation type'):
agent.get_observation_message(obs, tool_call_id_to_message={})
def test_file_edit_observation_message(agent: CodeActAgent):
agent.config.function_calling = False
obs = FileEditObservation(
path='/test/file.txt',
prev_exist=True,
old_content='old content',
new_content='new content',
content='diff content',
impl_source=FileEditSource.LLM_BASED_EDIT,
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert '[Existing file /test/file.txt is edited with' in result.content[0].text
def test_file_read_observation_message(agent: CodeActAgent):
agent.config.function_calling = False
obs = FileReadObservation(
path='/test/file.txt',
content='File content',
impl_source=FileReadSource.DEFAULT,
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == 'File content'
def test_browser_output_observation_message(agent: CodeActAgent):
agent.config.function_calling = False
obs = BrowserOutputObservation(
url='http://example.com',
trigger_by_action='browse',
screenshot='',
content='Page loaded',
error=False,
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert '[Current URL: http://example.com]' in result.content[0].text
def test_user_reject_observation_message(agent: CodeActAgent):
agent.config.function_calling = False
obs = UserRejectObservation('Action rejected')
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Action rejected' in result.content[0].text
assert '[Last action has been rejected by the user]' in result.content[0].text
def test_function_calling_observation_message(agent: CodeActAgent):
agent.config.function_calling = True
mock_response = {
'id': 'mock_id',
'total_calls_in_response': 1,
'choices': [{'message': {'content': 'Task completed'}}],
}
obs = CmdOutputObservation(
command='echo hello',
content='Command output',
command_id=1,
exit_code=0,
)
obs.tool_call_metadata = ToolCallMetadata(
tool_call_id='123',
function_name='execute_bash',
model_response=mock_response,
total_calls_in_response=1,
)
results = agent.get_observation_message(obs, tool_call_id_to_message={})
assert len(results) == 0 # No direct message when using function calling
def test_message_action_with_image(agent: CodeActAgent):
action = MessageAction(
content='Message with image',
image_urls=['http://example.com/image.jpg'],
)
action._source = EventSource.AGENT
results = agent.get_action_message(action, {})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'assistant'
assert len(result.content) == 2
assert isinstance(result.content[0], TextContent)
assert isinstance(result.content[1], ImageContent)
assert result.content[0].text == 'Message with image'
assert result.content[1].image_urls == ['http://example.com/image.jpg']
def test_user_cmd_action_message(agent: CodeActAgent):
action = CmdRunAction(command='ls -l')
action._source = EventSource.USER
results = agent.get_action_message(action, {})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'user'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'User executed the command' in result.content[0].text
assert 'ls -l' in result.content[0].text
def test_agent_finish_action_with_tool_metadata(agent: CodeActAgent):
mock_response = {
'id': 'mock_id',
'total_calls_in_response': 1,
'choices': [{'message': {'content': 'Task completed'}}],
}
action = AgentFinishAction(thought='Initial thought')
action._source = EventSource.AGENT
action.tool_call_metadata = ToolCallMetadata(
tool_call_id='123',
function_name='finish',
model_response=mock_response,
total_calls_in_response=1,
)
results = agent.get_action_message(action, {})
assert len(results) == 1
result = results[0]
assert result is not None
assert result.role == 'assistant'
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert 'Initial thought\nTask completed' in result.content[0].text
def test_reset(agent: CodeActAgent):
# Add some state
action = MessageAction(content='test')
action._source = EventSource.AGENT
agent.pending_actions.append(action)
# Reset
agent.reset()
# Verify state is cleared
assert len(agent.pending_actions) == 0
def test_step_with_pending_actions(agent: CodeActAgent):
# Add a pending action
pending_action = MessageAction(content='test')
pending_action._source = EventSource.AGENT
agent.pending_actions.append(pending_action)
# Step should return the pending action
result = agent.step(Mock())
assert result == pending_action
assert len(agent.pending_actions) == 0
def test_get_tools_default():
tools = get_tools(
codeact_enable_jupyter=True,
codeact_enable_llm_editor=True,
codeact_enable_browsing=True,
)
assert len(tools) > 0
# Check required tools are present
tool_names = [tool['function']['name'] for tool in tools]
assert 'execute_bash' in tool_names
assert 'execute_ipython_cell' in tool_names
assert 'edit_file' in tool_names
assert 'web_read' in tool_names
def test_get_tools_with_options():
# Test with all options enabled
tools = get_tools(
codeact_enable_browsing=True,
codeact_enable_jupyter=True,
codeact_enable_llm_editor=True,
)
tool_names = [tool['function']['name'] for tool in tools]
assert 'browser' in tool_names
assert 'execute_ipython_cell' in tool_names
assert 'edit_file' in tool_names
# Test with all options disabled
tools = get_tools(
codeact_enable_browsing=False,
codeact_enable_jupyter=False,
codeact_enable_llm_editor=False,
)
tool_names = [tool['function']['name'] for tool in tools]
assert 'browser' not in tool_names
assert 'execute_ipython_cell' not in tool_names
assert 'edit_file' not in tool_names
def test_cmd_run_tool():
assert CmdRunTool['type'] == 'function'
assert CmdRunTool['function']['name'] == 'execute_bash'
assert 'command' in CmdRunTool['function']['parameters']['properties']
assert CmdRunTool['function']['parameters']['required'] == ['command']
def test_ipython_tool():
assert IPythonTool['type'] == 'function'
assert IPythonTool['function']['name'] == 'execute_ipython_cell'
assert 'code' in IPythonTool['function']['parameters']['properties']
assert IPythonTool['function']['parameters']['required'] == ['code']
def test_llm_based_file_edit_tool():
assert LLMBasedFileEditTool['type'] == 'function'
assert LLMBasedFileEditTool['function']['name'] == 'edit_file'
properties = LLMBasedFileEditTool['function']['parameters']['properties']
assert 'path' in properties
assert 'content' in properties
assert 'start' in properties
assert 'end' in properties
assert LLMBasedFileEditTool['function']['parameters']['required'] == [
'path',
'content',
]
def test_str_replace_editor_tool():
assert StrReplaceEditorTool['type'] == 'function'
assert StrReplaceEditorTool['function']['name'] == 'str_replace_editor'
properties = StrReplaceEditorTool['function']['parameters']['properties']
assert 'command' in properties
assert 'path' in properties
assert 'file_text' in properties
assert 'old_str' in properties
assert 'new_str' in properties
assert 'insert_line' in properties
assert 'view_range' in properties
assert StrReplaceEditorTool['function']['parameters']['required'] == [
'command',
'path',
]
def test_web_read_tool():
assert WebReadTool['type'] == 'function'
assert WebReadTool['function']['name'] == 'web_read'
assert 'url' in WebReadTool['function']['parameters']['properties']
assert WebReadTool['function']['parameters']['required'] == ['url']
def test_browser_tool():
assert BrowserTool['type'] == 'function'
assert BrowserTool['function']['name'] == 'browser'
assert 'code' in BrowserTool['function']['parameters']['properties']
assert BrowserTool['function']['parameters']['required'] == ['code']
# Check that the description includes all the functions
description = _BROWSER_TOOL_DESCRIPTION
assert 'goto(' in description
assert 'go_back()' in description
assert 'go_forward()' in description
assert 'noop(' in description
assert 'scroll(' in description
assert 'fill(' in description
assert 'select_option(' in description
assert 'click(' in description
assert 'dblclick(' in description
assert 'hover(' in description
assert 'press(' in description
assert 'focus(' in description
assert 'clear(' in description
assert 'drag_and_drop(' in description
assert 'upload_file(' in description
# Test BrowserTool definition
assert BrowserTool['type'] == 'function'
assert BrowserTool['function']['name'] == 'browser'
assert BrowserTool['function']['description'] == _BROWSER_DESCRIPTION
assert BrowserTool['function']['parameters']['type'] == 'object'
assert 'code' in BrowserTool['function']['parameters']['properties']
assert BrowserTool['function']['parameters']['required'] == ['code']
assert (
BrowserTool['function']['parameters']['properties']['code']['type'] == 'string'
)
assert 'description' in BrowserTool['function']['parameters']['properties']['code']
def test_mock_function_calling():
# Test mock function calling when LLM doesn't support it
llm = Mock()
llm.is_function_calling_active = lambda: False
config = AgentConfig()
config.use_microagents = False
agent = CodeActAgent(llm=llm, config=config)
assert agent.mock_function_calling is True
def test_response_to_actions_invalid_tool():
# Test response with invalid tool call
mock_response = Mock()
mock_response.choices = [Mock()]
mock_response.choices[0].message = Mock()
mock_response.choices[0].message.content = 'Invalid tool'
mock_response.choices[0].message.tool_calls = [Mock()]
mock_response.choices[0].message.tool_calls[0].id = 'tool_call_10'
mock_response.choices[0].message.tool_calls[0].function = Mock()
mock_response.choices[0].message.tool_calls[0].function.name = 'invalid_tool'
mock_response.choices[0].message.tool_calls[0].function.arguments = '{}'
with pytest.raises(FunctionCallNotExistsError):
response_to_actions(mock_response)
def test_step_with_no_pending_actions():
# Mock the LLM response
mock_response = Mock()
mock_response.id = 'mock_id'
mock_response.total_calls_in_response = 1
mock_response.choices = [Mock()]
mock_response.choices[0].message = Mock()
mock_response.choices[0].message.content = 'Task completed'
mock_response.choices[0].message.tool_calls = []
llm = Mock()
llm.completion = Mock(return_value=mock_response)
llm.is_function_calling_active = Mock(return_value=True) # Enable function calling
llm.is_caching_prompt_active = Mock(return_value=False)
# Create agent with mocked LLM
config = AgentConfig()
config.use_microagents = False
agent = CodeActAgent(llm=llm, config=config)
# Test step with no pending actions
state = Mock()
state.history = []
state.latest_user_message = None
state.latest_user_message_id = None
state.latest_user_message_timestamp = None
state.latest_user_message_cause = None
state.latest_user_message_timeout = None
state.latest_user_message_llm_metrics = None
state.latest_user_message_tool_call_metadata = None
action = agent.step(state)
assert isinstance(action, MessageAction)
assert action.content == 'Task completed'

View File

@@ -2,6 +2,7 @@ import asyncio
import json
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
@@ -35,44 +36,56 @@ def get_mock_sio(get_message: GetMessageMock | None = None):
@pytest.mark.asyncio
async def test_session_not_running_in_cluster():
sio = get_mock_sio()
id = uuid4()
with (
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
patch('openhands.server.session.manager.uuid4', MagicMock(return_value=id)),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
result = await session_manager._is_agent_loop_running_in_cluster(
result = await session_manager.is_agent_loop_running_in_cluster(
'non-existant-session'
)
assert result is False
assert sio.manager.redis.publish.await_count == 1
sio.manager.redis.publish.assert_called_once_with(
'oh_event',
'{"sid": "non-existant-session", "message_type": "is_session_running"}',
'{"request_id": "'
+ str(id)
+ '", "sids": ["non-existant-session"], "message_type": "is_session_running"}',
)
@pytest.mark.asyncio
async def test_session_is_running_in_cluster():
id = uuid4()
sio = get_mock_sio(
GetMessageMock(
{'sid': 'existing-session', 'message_type': 'session_is_running'}
{
'request_id': str(id),
'sids': ['existing-session'],
'message_type': 'session_is_running',
}
)
)
with (
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.1),
patch('openhands.server.session.manager.uuid4', MagicMock(return_value=id)),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
result = await session_manager._is_agent_loop_running_in_cluster(
result = await session_manager.is_agent_loop_running_in_cluster(
'existing-session'
)
assert result is True
assert sio.manager.redis.publish.await_count == 1
sio.manager.redis.publish.assert_called_once_with(
'oh_event',
'{"sid": "existing-session", "message_type": "is_session_running"}',
'{"request_id": "'
+ str(id)
+ '", "sids": ["existing-session"], "message_type": "is_session_running"}',
)
@@ -93,7 +106,7 @@ async def test_init_new_local_session():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
@@ -103,7 +116,9 @@ async def test_init_new_local_session():
await session_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData()
)
await session_manager.join_conversation('new-session-id', 'new-session-id')
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData()
)
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 1
@@ -125,7 +140,7 @@ async def test_join_local_session():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
@@ -135,8 +150,12 @@ async def test_join_local_session():
await session_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData()
)
await session_manager.join_conversation('new-session-id', 'new-session-id')
await session_manager.join_conversation('new-session-id', 'new-session-id')
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData()
)
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData()
)
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 2
@@ -158,14 +177,16 @@ async def test_join_cluster_session():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.join_conversation('new-session-id', 'new-session-id')
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData()
)
assert session_instance.initialize_agent.call_count == 0
assert sio.enter_room.await_count == 1
@@ -187,7 +208,7 @@ async def test_add_to_local_event_stream():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
@@ -197,7 +218,9 @@ async def test_add_to_local_event_stream():
await session_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData()
)
await session_manager.join_conversation('new-session-id', 'connection-id')
await session_manager.join_conversation(
'new-session-id', 'connection-id', ConversationInitData()
)
await session_manager.send_to_event_stream(
'connection-id', {'event_type': 'some_event'}
)
@@ -221,14 +244,16 @@ async def test_add_to_cluster_event_stream():
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._is_agent_loop_running_in_cluster',
'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
is_agent_loop_running_in_cluster_mock,
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.join_conversation('new-session-id', 'connection-id')
await session_manager.join_conversation(
'new-session-id', 'connection-id', ConversationInitData()
)
await session_manager.send_to_event_stream(
'connection-id', {'event_type': 'some_event'}
)