Compare commits

..

28 Commits

Author SHA1 Message Date
openhands 497fd4a02c Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-12 04:04:08 +00:00
openhands 7fac7d6dd0 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-12 03:03:01 +00:00
openhands 4155b8f801 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-12 02:39:00 +00:00
openhands e712b013f9 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-12 02:17:13 +00:00
OpenHands Bot 29b137e9b1 🤖 Auto-fix Python linting issues 2025-05-04 01:07:31 +00:00
Engel Nyst 1292f0c2ea Merge branch 'main' into delegates-cleanup 2025-05-04 03:06:23 +02:00
openhands da8c946078 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-04 00:44:28 +00:00
openhands 43cef1f969 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-04 00:14:47 +00:00
Graham Neubig 722711db3b Add OpenHands Cloud API documentation (#8127)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-05-04 00:10:56 +00:00
openhands ab661b485b Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-03 23:39:14 +00:00
openhands 9632914bf0 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-03 23:10:19 +00:00
Robert Brennan f45f398d81 Small tweaks for mobile styles (#8228) 2025-05-03 21:42:02 +00:00
openhands 3db780ef93 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-03 21:40:15 +00:00
Engel Nyst 43c16516e8 standardize delegate agents prompt and finish 2025-05-03 23:05:34 +02:00
Rohit Malhotra 0bab3b62f2 (Hotfix): Forbid extraneous params on new conversation route (#8234) 2025-05-03 14:26:38 -06:00
Rohit Malhotra ae990d3cb1 [Refactor]: Split settings and secrets stores (#8213)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-03 14:43:10 -04:00
Xingyao Wang 9babd756e5 Fix settings tab clickable area by extending it beyond just the text (#8240)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-03 17:07:41 +00:00
Engel Nyst 985e20d529 [chore] Run full agent pre-commit (#8235) 2025-05-03 11:24:03 -04:00
Boxuan Li 98cb2e24ee Make tool call json decode error recoverable (#8233) 2025-05-03 15:01:32 +00:00
Chase de175dcc87 bugfix for #8187 (infinite loop when delegating) (#8189) 2025-05-02 22:49:42 +02:00
Robert Brennan 976019ce11 Fix websocket error message handling (#8227)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-02 12:56:36 -04:00
dependabot[bot] 709b6ff39a chore(deps): bump the version-all group with 5 updates (#8226)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-02 18:14:29 +02:00
Rohit Malhotra 767d092f8f [Fix]: Use str in place of Repository for repository param when creating new conversation (#8159)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-05-02 11:17:04 -04:00
dependabot[bot] 7244e5df9f chore(deps): bump the version-all group across 1 directory with 12 updates (#8224)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-05-02 15:02:11 +00:00
மனோஜ்குமார் பழனிச்சாமி dfbb968ea0 Chore: Update pull_request_template.md (#8118) 2025-05-02 15:53:09 +02:00
Xingyao Wang e4c3bbbc08 Fix: Include RecallObservation in events sent to frontend from ENVIRONMENT source (#8196)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-02 10:18:37 +00:00
Bashwara Undupitiya 6e0fbfeeda refactor: Refactor pause/resume functionality and improve state handling in CLI (#8152) 2025-05-02 12:04:35 +02:00
Ryan H. Tran 03aa5d7456 Upgrade openhands-aci to 0.2.12 (#8220) 2025-05-02 16:54:58 +07:00
218 changed files with 9228 additions and 3520 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
- [ ] This change is worth documenting at https://docs.all-hands.dev/
- [ ] Include this change in the Release Notes. If checked, you **must** provide an **end-user friendly** description for your change below
**End-user friendly description of the problem this fixes or functionality that this introduces.**
**End-user friendly description of the problem this fixes or functionality this introduces.**
---
**Give a summary of what the PR does, explaining any non-trivial design decisions.**
**Summarize what the PR does, explaining any non-trivial design decisions.**
---
**Link of any specific issues this addresses.**
**Link of any specific issues this addresses:**
+8
View File
@@ -0,0 +1,8 @@
{
"label": "OpenHands Cloud",
"position": 9,
"link": {
"type": "generated-index",
"description": "Documentation for OpenHands Cloud features and services."
}
}
+177
View File
@@ -0,0 +1,177 @@
# OpenHands Cloud API
OpenHands Cloud provides a REST API that allows you to programmatically interact with the service. This is useful if you easily want to kick off your own jobs from your programs in a flexible way.
This guide explains how to obtain an API key and use the API to start conversations.
For more detailed information about the API, refer to the [OpenHands API Reference](https://docs.all-hands.dev/swagger-ui/).
## Obtaining an API Key
To use the OpenHands Cloud API, you'll need to generate an API key:
1. Log in to your [OpenHands Cloud](https://app.all-hands.dev) account
2. Navigate to the [Settings page](https://app.all-hands.dev/settings)
3. Locate the "API Keys" section
4. Click "Generate New Key"
5. Give your key a descriptive name (e.g., "Development", "Production")
6. Copy the generated API key and store it securely - it will only be shown once
![API Key Generation](/img/docs/api-key-generation.png)
## API Usage
### Starting a New Conversation
To start a new conversation with OpenHands performing a task, you'll need to make a POST request to the conversation endpoint.
#### Request Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `initial_user_msg` | string | Yes | The initial message to start the conversation |
| `repository` | string | No | Git repository name to provide context in the format `owner/repo`. You must have access to the repo. |
#### Examples
<details>
<summary>cURL</summary>
```bash
curl -X POST "https://app.all-hands.dev/api/conversations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}'
```
</details>
<details>
<summary>Python (with requests)</summary>
```python
import requests
api_key = "YOUR_API_KEY"
url = "https://app.all-hands.dev/api/conversations"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
data = {
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
"repository": "yourusername/your-repo"
}
response = requests.post(url, headers=headers, json=data)
conversation = response.json()
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['id']}")
print(f"Status: {conversation['status']}")
```
</details>
<details>
<summary>TypeScript/JavaScript (with fetch)</summary>
```typescript
const apiKey = "YOUR_API_KEY";
const url = "https://app.all-hands.dev/api/conversations";
const headers = {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
};
const data = {
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
repository: "yourusername/your-repo"
};
async function startConversation() {
try {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(data)
});
const conversation = await response.json();
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
console.log(`Status: ${conversation.status}`);
return conversation;
} catch (error) {
console.error("Error starting conversation:", error);
}
}
startConversation();
```
</details>
#### Response
The API will return a JSON object with details about the created conversation:
```json
{
"status": "ok",
"conversation_id": "abc1234",
}
```
You may also receive an `AuthenticationError` if:
1. You provided an invalid API key
2. You provided the wrong repo name
3. You don't have access to the repo
### Retrieving Conversation Status
You can check the status of a conversation by making a GET request to the conversation endpoint.
#### Endpoint
```
GET https://app.all-hands.dev/api/conversations/{conversation_id}
```
#### Example
<details>
<summary>cURL</summary>
```bash
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
-H "Authorization: Bearer YOUR_API_KEY"
```
</details>
#### Response
The response is formatted as follows:
```json
{
"conversation_id":"abc1234",
"title":"Update README.md",
"created_at":"2025-04-29T15:13:51.370706Z",
"last_updated_at":"2025-04-29T15:13:57.199210Z",
"status":"RUNNING",
"selected_repository":"yourusername/your-repo",
"trigger":"gui"
}
```
## Rate Limits
The API has a limit of 10 simultaneous conversations per account. If you need a higher limit for your use case, please contact us at [contact@all-hands.dev](mailto:contact@all-hands.dev).
If you exceed this limit, the API will return a 429 Too Many Requests response.
@@ -6,6 +6,8 @@ OpenHands Cloud is the cloud hosted version of OpenHands by All Hands AI.
OpenHands Cloud can be accessed at https://app.all-hands.dev/.
You can also interact with OpenHands Cloud programmatically using the [API](./cloud-api).
## Getting Started
After visiting OpenHands Cloud, you will be asked to connect with your GitHub or GitLab account:
+2 -2
View File
@@ -20,7 +20,7 @@ MCP configuration is defined in the `[mcp]` section of your `config.toml` file.
sse_servers = [
# Basic SSE server with just a URL
"http://example.com:8080/mcp",
# SSE server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
@@ -29,7 +29,7 @@ sse_servers = [
stdio_servers = [
# Basic stdio server
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
# Stdio server with environment variables
{
name="data-processor",
+1 -1
View File
@@ -55,4 +55,4 @@
"node": ">=18.0"
},
"packageManager": "npm@10.5.0"
}
}
+5 -1
View File
@@ -27,7 +27,11 @@ const sidebars: SidebarsConfig = {
label: 'Openhands Cloud',
id: 'usage/cloud/openhands-cloud',
},
{
type: 'doc',
label: 'Cloud API',
id: 'usage/cloud/cloud-api',
},
{
type: 'doc',
label: 'Cloud GitHub Resolver',
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+8 -7
View File
@@ -858,14 +858,15 @@
"schema": {
"type": "object",
"properties": {
"selected_repository": {
"type": "object",
"repository": {
"type": "string",
"nullable": true,
"properties": {
"full_name": {
"type": "string"
}
}
"description": "Full name of the repository (e.g., owner/repo)"
},
"git_provider": {
"type": "string",
"nullable": true,
"description": "The Git provider (e.g., github or gitlab). If omitted, all configured providers are checked for the repository."
},
"selected_branch": {
"type": "string",
@@ -36,13 +36,12 @@ from openhands.core.config import (
)
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import CmdRunAction, MessageAction, FileReadAction
from openhands.events.action import CmdRunAction, FileReadAction, MessageAction
from openhands.events.observation import CmdOutputObservation, ErrorObservation
from openhands.events.serialization.event import event_to_dict
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import call_async_from_sync
from openhands.utils.shutdown_listener import sleep_if_should_continue
import pdb
USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'true').lower() == 'true'
@@ -51,7 +50,7 @@ RUN_WITH_BROWSING = os.environ.get('RUN_WITH_BROWSING', 'false').lower() == 'tru
# TODO: migrate all swe-bench docker to ghcr.io/openhands
# TODO: 适应所有的语言
DOCKER_IMAGE_PREFIX = os.environ.get('EVAL_DOCKER_IMAGE_PREFIX', '')
LANGUAGE =os.environ.get('LANGUAGE', 'python')
LANGUAGE = os.environ.get('LANGUAGE', 'python')
logger.info(f'Using docker image prefix: {DOCKER_IMAGE_PREFIX}')
@@ -71,7 +70,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
# Instruction based on Anthropic's official trajectory
# https://github.com/eschluntz/swe-bench-experiments/tree/main/evaluation/verified/20241022_tools_claude-3-5-sonnet-updated/trajs
instructions = {
"python":(
'python': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -96,7 +95,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"java": (
'java': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -121,7 +120,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
" Make sure all these tests pass with your changes.\n"
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"go": (
'go': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -146,7 +145,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"c": (
'c': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -171,7 +170,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"cpp": (
'cpp': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -196,7 +195,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"javascript": (
'javascript': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -221,7 +220,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"typescript":(
'typescript': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -246,7 +245,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
),
"rust":(
'rust': (
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
@@ -270,11 +269,10 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
' - The functions you changed\n'
' Make sure all these tests pass with your changes.\n'
"Your thinking should be thorough and so it's fine if it's very long.\n"
)
),
}
instruction = instructions.get(LANGUAGE.lower())
if instruction and RUN_WITH_BROWSING:
instruction += (
'<IMPORTANT!>\n'
@@ -284,7 +282,6 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
return instruction
# TODO: 适应所有的语言
# def get_instance_docker_image(instance_id: str) -> str:
# image_name = 'sweb.eval.x86_64.' + instance_id
@@ -307,16 +304,15 @@ def get_instance_docker_image(instance: pd.Series):
container_name = container_name.replace('/', '_m_')
instance_id = instance.get('instance_id', '')
tag_suffix = instance_id.split('-')[-1] if instance_id else ''
container_tag = f"pr-{tag_suffix}"
container_tag = f'pr-{tag_suffix}'
# pdb.set_trace()
return f"mswebench/{container_name}:{container_tag}"
return f'mswebench/{container_name}:{container_tag}'
# return "kong/insomnia:pr-8284"
# return "'sweb.eval.x86_64.local_insomnia"
# return "local_insomnia_why"
# return "local/kong-insomnia:pr-8117"
def get_config(
instance: pd.Series,
metadata: EvalMetadata,
@@ -569,7 +565,6 @@ def complete_runtime(
f'Failed to git config --global core.pager "": {str(obs)}',
)
action = CmdRunAction(command='git add -A')
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
@@ -582,14 +577,14 @@ def complete_runtime(
##删除二进制文件
action = CmdRunAction(
command=f'''
command="""
for file in $(git status --porcelain | grep -E "^(M| M|\\?\\?|A| A)" | cut -c4-); do
if [ -f "$file" ] && (file "$file" | grep -q "executable" || git check-attr binary "$file" | grep -q "binary: set"); then
git rm -f "$file" 2>/dev/null || rm -f "$file"
echo "Removed: $file"
fi
done
'''
"""
)
action.set_hard_timeout(600)
logger.info(action, extra={'msg_type': 'ACTION'})
@@ -626,14 +621,12 @@ def complete_runtime(
else:
assert_and_raise(False, f'Unexpected observation type: {str(obs)}')
action = FileReadAction(
path='patch.diff'
)
action = FileReadAction(path='patch.diff')
action.set_hard_timeout(max(300 + 100 * n_retries, 600))
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
git_patch = obs.content
# pdb.set_trace()
# pdb.set_trace()
assert_and_raise(git_patch is not None, 'Failed to get git diff (None)')
@@ -714,12 +707,12 @@ def process_instance(
is_binary_block = False
for line in lines:
if line.startswith("diff --git "):
if line.startswith('diff --git '):
if block and not is_binary_block:
cleaned_lines.extend(block)
block = [line]
is_binary_block = False
elif "Binary files" in line:
elif 'Binary files' in line:
is_binary_block = True
block.append(line)
else:
@@ -727,7 +720,8 @@ def process_instance(
if block and not is_binary_block:
cleaned_lines.extend(block)
return "\n".join(cleaned_lines)
return '\n'.join(cleaned_lines)
git_patch = remove_binary_diffs(git_patch)
test_result = {
'git_patch': git_patch,
@@ -797,7 +791,7 @@ if __name__ == '__main__':
# so we don't need to manage file uploading to OpenHands's repo
# dataset = load_dataset(args.dataset, split=args.split)
# dataset = load_dataset(args.dataset)
dataset = load_dataset("json", data_files = args.dataset)
dataset = load_dataset('json', data_files=args.dataset)
dataset = dataset[args.split]
swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id')
logger.info(
@@ -3,7 +3,9 @@ import json
input_file = 'XXX.jsonl'
output_file = 'YYY.jsonl'
with open(input_file, 'r', encoding='utf-8') as fin, open(output_file, 'w', encoding='utf-8') as fout:
with open(input_file, 'r', encoding='utf-8') as fin, open(
output_file, 'w', encoding='utf-8'
) as fout:
for line in fin:
line = line.strip()
if not line:
@@ -13,18 +15,22 @@ with open(input_file, 'r', encoding='utf-8') as fin, open(output_file, 'w', enco
item = data
# 提取原始数据
org = item.get("org", "")
repo = item.get("repo", "")
number = str(item.get("number", ""))
org = item.get('org', '')
repo = item.get('repo', '')
number = str(item.get('number', ''))
new_item = {}
new_item["repo"] = f"{org}/{repo}"
new_item["instance_id"] = f"{org}__{repo}-{number}"
new_item["problem_statement"] = item["resolved_issues"][0].get("title", "") + "\n" + item["resolved_issues"][0].get("body", "")
new_item["FAIL_TO_PASS"] = []
new_item["PASS_TO_PASS"] = []
new_item["base_commit"] = item['base'].get("sha","")
new_item["version"] = "0.1" # depends
new_item['repo'] = f'{org}/{repo}'
new_item['instance_id'] = f'{org}__{repo}-{number}'
new_item['problem_statement'] = (
item['resolved_issues'][0].get('title', '')
+ '\n'
+ item['resolved_issues'][0].get('body', '')
)
new_item['FAIL_TO_PASS'] = []
new_item['PASS_TO_PASS'] = []
new_item['base_commit'] = item['base'].get('sha', '')
new_item['version'] = '0.1' # depends
output_data = new_item
fout.write(json.dumps(output_data, ensure_ascii=False) + "\n")
fout.write(json.dumps(output_data, ensure_ascii=False) + '\n')
@@ -15,7 +15,7 @@ def main():
'org': groups.group(1),
'repo': groups.group(2),
'number': groups.group(3),
'fix_patch': data['test_result']['git_patch']
'fix_patch': data['test_result']['git_patch'],
}
fout.write(json.dumps(patch) + '\n')
@@ -27,7 +27,7 @@ describe("AuthModal", () => {
it("should render the GitHub and GitLab buttons", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
const gitlabButton = screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" });
@@ -49,6 +49,7 @@ describe("HomeHeader", () => {
"gui",
undefined,
undefined,
undefined,
[],
undefined,
undefined,
@@ -165,12 +165,8 @@ describe("RepoConnector", () => {
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
"gui",
{
full_name: "rbren/polaris",
git_provider: "github",
id: 1,
is_public: true,
},
"rbren/polaris",
"github",
undefined,
[],
undefined,
@@ -89,7 +89,8 @@ describe("TaskCard", () => {
expect(createConversationSpy).toHaveBeenCalledWith(
"suggested_task",
MOCK_RESPOSITORIES[0],
MOCK_RESPOSITORIES[0].full_name,
MOCK_RESPOSITORIES[0].git_provider,
undefined,
[],
undefined,
@@ -4,7 +4,6 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import OpenHands from "#/api/open-hands";
import { PaymentForm } from "#/components/features/payment/payment-form";
import { AuthContext } from "#/context/auth-context";
describe("PaymentForm", () => {
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
@@ -14,18 +13,9 @@ describe("PaymentForm", () => {
const renderPaymentForm = () =>
render(<PaymentForm />, {
wrapper: ({ children }) => (
<AuthContext.Provider
value={{
providerTokensSet: ["github"],
setProviderTokensSet: vi.fn(),
providersAreSet: true,
setProvidersAreSet: vi.fn()
}}
>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthContext.Provider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
@@ -3,7 +3,6 @@ import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import OpenHands from "#/api/open-hands";
import { AuthContext } from "#/context/auth-context";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
@@ -16,18 +15,7 @@ const RouterStub = createRoutesStub([
]);
const renderSidebar = () =>
renderWithProviders(
<AuthContext.Provider
value={{
providerTokensSet: ["github"],
setProviderTokensSet: vi.fn(),
providersAreSet: true,
setProvidersAreSet: vi.fn()
}}
>
<RouterStub initialEntries={["/conversation/123"]} />
</AuthContext.Provider>
);
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
describe("Sidebar", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
@@ -36,18 +24,7 @@ describe("Sidebar", () => {
vi.clearAllMocks();
});
it.skip("should fetch settings data on mount", () => {
// Mock the useConfig hook to return OSS mode
vi.spyOn(OpenHands, "getConfig").mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-github-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false
}
});
it("should fetch settings data on mount", () => {
renderSidebar();
expect(getSettingsSpy).toHaveBeenCalled();
});
@@ -43,7 +43,7 @@ const createWrapper = () => {
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
@@ -61,7 +61,7 @@ describe("AcceptTOS", () => {
it("should render a TOS checkbox that is unchecked by default", () => {
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
@@ -72,7 +72,7 @@ describe("AcceptTOS", () => {
it("should enable the continue button when the TOS checkbox is checked", async () => {
const user = userEvent.setup();
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
@@ -96,7 +96,7 @@ describe("AcceptTOS", () => {
const user = userEvent.setup();
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
@@ -121,7 +121,7 @@ describe("AcceptTOS", () => {
const user = userEvent.setup();
render(<AcceptTOS />, { wrapper: createWrapper() });
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
@@ -133,4 +133,4 @@ describe("AcceptTOS", () => {
expect(window.location.href).toBe(externalUrl);
});
});
});
+19 -26
View File
@@ -9,6 +9,7 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { GetConfigResponse } from "#/api/open-hands.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { SecretsService } from "#/api/secrets-service";
const VALID_OSS_CONFIG: GetConfigResponse = {
APP_MODE: "oss",
@@ -230,7 +231,7 @@ describe("Content", () => {
describe("Form submission", () => {
it("should save the GitHub token", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
@@ -242,27 +243,19 @@ describe("Form submission", () => {
await userEvent.type(githubInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: { token: "test-token" },
gitlab: { token: "" },
},
}),
);
expect(saveProvidersSpy).toHaveBeenCalledWith({
github: { token: "test-token" },
gitlab: { token: "" },
});
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: { token: "" },
gitlab: { token: "test-token" },
},
}),
);
expect(saveProvidersSpy).toHaveBeenCalledWith({
github: { token: "test-token" },
gitlab: { token: "" },
});
});
it("should disable the button if there is no input", async () => {
@@ -346,7 +339,7 @@ describe("Form submission", () => {
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveSettingsSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
@@ -370,7 +363,7 @@ describe("Form submission", () => {
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
@@ -386,7 +379,7 @@ describe("Form submission", () => {
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(saveProvidersSpy).toHaveBeenCalled();
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
@@ -396,7 +389,7 @@ describe("Form submission", () => {
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(saveProvidersSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
@@ -404,7 +397,7 @@ describe("Form submission", () => {
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
@@ -422,18 +415,18 @@ describe("Status toasts", () => {
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(saveProvidersSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
saveProvidersSpy.mockRejectedValue(new Error("Failed to save settings"));
renderGitSettingsScreen();
@@ -444,7 +437,7 @@ describe("Status toasts", () => {
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(saveProvidersSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
@@ -8,7 +8,7 @@ import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "#/mocks/handlers";
import { AuthProvider, AuthContext } from "#/context/auth-context";
import { AuthProvider } from "#/context/auth-context";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
@@ -16,16 +16,7 @@ const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthContext.Provider
value={{
providerTokensSet: ["github"],
setProviderTokensSet: vi.fn(),
providersAreSet: true,
setProvidersAreSet: vi.fn()
}}
>
{children}
</AuthContext.Provider>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
+228 -230
View File
@@ -17,22 +17,22 @@
"@reduxjs/toolkit": "^2.7.0",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^7.2.0",
"@tanstack/react-query": "^5.74.9",
"@tanstack/react-query": "^5.75.1",
"@vitejs/plugin-react": "^4.4.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.9.2",
"framer-motion": "^12.9.4",
"i18next": "^25.0.2",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.27",
"jose": "^6.0.10",
"lucide-react": "^0.503.0",
"lucide-react": "^0.506.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.237.0",
"posthog-js": "^1.239.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -48,13 +48,13 @@
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.2.0",
"vite": "^6.3.3",
"vite": "^6.3.4",
"web-vitals": "^3.5.2",
"ws": "^8.18.1"
},
"devDependencies": {
"@babel/parser": "^7.27.0",
"@babel/traverse": "^7.27.0",
"@babel/parser": "^7.27.1",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.52.0",
@@ -67,7 +67,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.1",
"@types/react-dom": "^19.1.3",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -92,7 +92,7 @@
"msw": "^2.6.6",
"postcss": "^8.5.2",
"prettier": "^3.5.3",
"stripe": "^18.0.0",
"stripe": "^18.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite-plugin-svgr": "^4.2.0",
@@ -157,44 +157,44 @@
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.25.9",
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/compat-data": {
"version": "7.26.8",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz",
"integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.1.tgz",
"integrity": "sha512-Q+E+rd/yBzNQhXkG+zQnF58e4zoZfBedaxwzPmicKsiK3nt8iJYrSrDbjwFFDGC4f+rPafqRaPH6TsDoSvMf7A==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz",
"integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
"@babel/generator": "^7.26.10",
"@babel/helper-compilation-targets": "^7.26.5",
"@babel/helper-module-transforms": "^7.26.0",
"@babel/helpers": "^7.26.10",
"@babel/parser": "^7.26.10",
"@babel/template": "^7.26.9",
"@babel/traverse": "^7.26.10",
"@babel/types": "^7.26.10",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.1",
"@babel/helper-compilation-targets": "^7.27.1",
"@babel/helper-module-transforms": "^7.27.1",
"@babel/helpers": "^7.27.1",
"@babel/parser": "^7.27.1",
"@babel/template": "^7.27.1",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -219,13 +219,13 @@
}
},
"node_modules/@babel/generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
"integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
"integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.0",
"@babel/types": "^7.27.0",
"@babel/parser": "^7.27.1",
"@babel/types": "^7.27.1",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^3.0.2"
@@ -235,26 +235,26 @@
}
},
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz",
"integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz",
"integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.25.9"
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz",
"integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.1.tgz",
"integrity": "sha512-2YaDd/Rd9E598B5+WIc8wJPmWETiiJXFYVE60oX8FDohv7rAUU3CQj+A1MgeEmcsk2+dQuEjIe/GDvig0SqL4g==",
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.26.8",
"@babel/helper-validator-option": "^7.25.9",
"@babel/compat-data": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -273,18 +273,18 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
"integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz",
"integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.25.9",
"@babel/helper-member-expression-to-functions": "^7.25.9",
"@babel/helper-optimise-call-expression": "^7.25.9",
"@babel/helper-replace-supers": "^7.26.5",
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
"@babel/traverse": "^7.27.0",
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-member-expression-to-functions": "^7.27.1",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/traverse": "^7.27.1",
"semver": "^6.3.1"
},
"engines": {
@@ -305,41 +305,41 @@
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz",
"integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
"integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.25.9"
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz",
"integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.25.9"
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz",
"integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz",
"integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9",
"@babel/traverse": "^7.25.9"
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -349,37 +349,37 @@
}
},
"node_modules/@babel/helper-optimise-call-expression": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz",
"integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
"integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.25.9"
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.26.5",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz",
"integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-replace-supers": {
"version": "7.26.5",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz",
"integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz",
"integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-member-expression-to-functions": "^7.25.9",
"@babel/helper-optimise-call-expression": "^7.25.9",
"@babel/traverse": "^7.26.5"
"@babel/helper-member-expression-to-functions": "^7.27.1",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/traverse": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -389,66 +389,66 @@
}
},
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz",
"integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
"integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.25.9"
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz",
"integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz",
"integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.0",
"@babel/types": "^7.27.0"
"@babel/template": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz",
"integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.0"
"@babel/types": "^7.27.1"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -458,13 +458,13 @@
}
},
"node_modules/@babel/plugin-syntax-decorators": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.25.9.tgz",
"integrity": "sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz",
"integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -474,13 +474,13 @@
}
},
"node_modules/@babel/plugin-syntax-jsx": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz",
"integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
"integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -490,13 +490,13 @@
}
},
"node_modules/@babel/plugin-syntax-typescript": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz",
"integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -506,14 +506,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-commonjs": {
"version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz",
"integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz",
"integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.26.0",
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-module-transforms": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -523,12 +523,12 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz",
"integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -538,12 +538,12 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz",
"integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.25.9"
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -553,17 +553,17 @@
}
},
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.0.tgz",
"integrity": "sha512-fRGGjO2UEGPjvEcyAZXRXAS8AfdaQoq7HnxAbJoAoW10B9xOKesmmndJv+Sym2a+9FHWZ9KbyyLCe9s0Sn5jtg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz",
"integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.25.9",
"@babel/helper-create-class-features-plugin": "^7.27.0",
"@babel/helper-plugin-utils": "^7.26.5",
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
"@babel/plugin-syntax-typescript": "^7.25.9"
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-create-class-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -573,17 +573,17 @@
}
},
"node_modules/@babel/preset-typescript": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.0.tgz",
"integrity": "sha512-vxaPFfJtHhgeOVXRKuHpHPAOgymmy8V8I65T1q53R7GCZlefKeCaTyDs3zOPHTTbmquvNlQYC5klEvWsBAtrBQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.26.5",
"@babel/helper-validator-option": "^7.25.9",
"@babel/plugin-syntax-jsx": "^7.25.9",
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
"@babel/plugin-transform-typescript": "^7.27.0"
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
"@babel/plugin-transform-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -593,42 +593,39 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz",
"integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"@babel/parser": "^7.27.0",
"@babel/types": "^7.27.0"
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
"integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz",
"integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"@babel/generator": "^7.27.0",
"@babel/parser": "^7.27.0",
"@babel/template": "^7.27.0",
"@babel/types": "^7.27.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.1",
"@babel/parser": "^7.27.1",
"@babel/template": "^7.27.1",
"@babel/types": "^7.27.1",
"debug": "^4.3.1",
"globals": "^11.1.0"
},
@@ -637,13 +634,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
"integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1242,9 +1239,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.1.tgz",
"integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==",
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
"integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5784,9 +5781,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.74.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.9.tgz",
"integrity": "sha512-qmjXpWyigDw4SfqdSBy24FzRvpBPXlaSbl92N77lcrL+yvVQLQkf0T6bQNbTxl9IEB/SvVFhhVZoIlQvFnNuuw==",
"version": "5.75.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.0.tgz",
"integrity": "sha512-rk8KQuCdhoRkzjRVF3QxLgAfFUyS0k7+GCQjlGEpEGco+qazJ0eMH6aO1DjDjibH7/ik383nnztua3BG+lOnwg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5794,12 +5791,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.74.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.9.tgz",
"integrity": "sha512-F8xCXDQRDgsPzLzX9+d6ycNoITAIU2bycc1idZd06bt/GjN1quEJDjHvEDWZGoVn0A/ZmntVrYv6TE0kR7c7LA==",
"version": "5.75.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.75.1.tgz",
"integrity": "sha512-tN+gG+eXCHYm+VpmdXUP1rfE9LUrRzgYozTkBZtJV1/WFM3vwWNKQC8G6b2RKcs+2cPg+hdToZHZfjL3bF4yIQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.74.9"
"@tanstack/query-core": "5.75.0"
},
"funding": {
"type": "github",
@@ -6091,9 +6088,9 @@
}
},
"node_modules/@types/react-dom": {
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
"version": "19.1.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz",
"integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -7427,9 +7424,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001715",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz",
"integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==",
"version": "1.0.30001716",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001716.tgz",
"integrity": "sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==",
"funding": [
{
"type": "opencollective",
@@ -7930,9 +7927,9 @@
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.41.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz",
"integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==",
"version": "3.42.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz",
"integrity": "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
@@ -8175,9 +8172,9 @@
}
},
"node_modules/dedent": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz",
"integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz",
"integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -8384,9 +8381,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.144",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.144.tgz",
"integrity": "sha512-eJIaMRKeAzxfBSxtjYnoIAw/tdD6VIH6tHBZepZnAbE3Gyqqs5mGN87DvcldPUbVkIljTK8pY0CMcUljP64lfQ==",
"version": "1.5.149",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
"integrity": "sha512-UyiO82eb9dVOx8YO3ajDf9jz2kKyt98DEITRdeLPstOEuTlLzDA4Gyq5K9he71TQziU5jUVu2OAu5N48HmQiyQ==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -9842,13 +9839,13 @@
}
},
"node_modules/framer-motion": {
"version": "12.9.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.9.2.tgz",
"integrity": "sha512-R0O3Jdqbfwywpm45obP+8sTgafmdEcUoShQTAV+rB5pi+Y1Px/FYL5qLLRe5tPtBdN1J4jos7M+xN2VV2oEAbQ==",
"version": "12.9.4",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.9.4.tgz",
"integrity": "sha512-yaeGDmGQ3eCQEwZ95/pRQMaSh/Q4E2CK6JYOclG/PdjyQad0MULJ+JFVV8911Fl5a6tF6o0wgW8Dpl5Qx4Adjg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.9.1",
"motion-utils": "^12.8.3",
"motion-dom": "^12.9.4",
"motion-utils": "^12.9.4",
"tslib": "^2.4.0"
},
"peerDependencies": {
@@ -10588,9 +10585,9 @@
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.5.tgz",
"integrity": "sha512-OstebRKqKiQw8xEvQF5aRyUujsCatanj7Q9eo5iiH2gJpoXGZ7483ol3sVBwfqbobTQPNH1J+NAyJ1aCQoEC+w==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz",
"integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
@@ -11987,9 +11984,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.503.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.503.0.tgz",
"integrity": "sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w==",
"version": "0.506.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.506.0.tgz",
"integrity": "sha512-/2znFFzlXcZKu0ANFCnxUOBV5I2e08m19PGtb6X+BcByRj8ODlGAl3wpe4LNVrDMLJ263JoIkZn7MOGK/5sXtw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -13125,18 +13122,18 @@
}
},
"node_modules/motion-dom": {
"version": "12.9.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.9.1.tgz",
"integrity": "sha512-xqXEwRLDYDTzOgXobSoWtytRtGlf7zdkRfFbrrdP7eojaGQZ5Go4OOKtgnx7uF8sAkfr1ZjMvbCJSCIT2h6fkQ==",
"version": "12.9.4",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.9.4.tgz",
"integrity": "sha512-25TWkQPj5I18m+qVjXGtCsxboY11DaRC5HMjd29tHKExazW4Zf4XtAagBdLpyKsVuAxEQ6cx5/E4AB21PFpLnQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.8.3"
"motion-utils": "^12.9.4"
}
},
"node_modules/motion-utils": {
"version": "12.8.3",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.8.3.tgz",
"integrity": "sha512-GYVauZEbca8/zOhEiYOY9/uJeedYQld6co/GJFKOy//0c/4lDqk0zB549sBYqqV2iMuX+uHrY1E5zd8A2L+1Lw==",
"version": "12.9.4",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.9.4.tgz",
"integrity": "sha512-BW3I65zeM76CMsfh3kHid9ansEJk9Qvl+K5cu4DVHKGsI52n76OJ4z2CUJUV+Mn3uEP9k1JJA3tClG0ggSrRcg==",
"license": "MIT"
},
"node_modules/mri": {
@@ -14138,9 +14135,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.237.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.237.0.tgz",
"integrity": "sha512-DyZfwDRz405cKKskL22CXvc9EpkBmuM9lCOYsZO3L1/zXu7IGiP9nNlLaxlzy7K/8mHxQ3szoy/DBSw/zXL1pw==",
"version": "1.239.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.239.0.tgz",
"integrity": "sha512-d8WTXGHmVO1FQV7wvEIan/MlN+gzdR42GHVOSoP3jWH2eiyCHCK4tX48uLZfvaEabDfuJCExdlmelWuYPAjJFw==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"core-js": "^3.38.1",
@@ -14871,12 +14868,6 @@
"node": ">=6"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -16148,17 +16139,24 @@
}
},
"node_modules/stripe": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.0.0.tgz",
"integrity": "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA==",
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.1.0.tgz",
"integrity": "sha512-MLDiniPTHqcfIT3anyBPmOEcaiDhYa7/jRaNypQ3Rt2SJnayQZBvVbFghIziUCZdltGAndm/ZxVOSw6uuSCDig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
},
"peerDependencies": {
"@types/node": ">=12.x.x"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/style-to-js": {
@@ -17178,9 +17176,9 @@
}
},
"node_modules/vite": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz",
"integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==",
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz",
"integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
+10 -10
View File
@@ -16,22 +16,22 @@
"@reduxjs/toolkit": "^2.7.0",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^7.2.0",
"@tanstack/react-query": "^5.74.9",
"@tanstack/react-query": "^5.75.1",
"@vitejs/plugin-react": "^4.4.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.9.2",
"framer-motion": "^12.9.4",
"i18next": "^25.0.2",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.27",
"jose": "^6.0.10",
"lucide-react": "^0.503.0",
"lucide-react": "^0.506.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.237.0",
"posthog-js": "^1.239.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -47,7 +47,7 @@
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.2.0",
"vite": "^6.3.3",
"vite": "^6.3.4",
"web-vitals": "^3.5.2",
"ws": "^8.18.1"
},
@@ -77,8 +77,8 @@
]
},
"devDependencies": {
"@babel/parser": "^7.27.0",
"@babel/traverse": "^7.27.0",
"@babel/parser": "^7.27.1",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.52.0",
@@ -91,7 +91,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.1",
"@types/react-dom": "^19.1.3",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -116,7 +116,7 @@
"msw": "^2.6.6",
"postcss": "^8.5.2",
"prettier": "^3.5.3",
"stripe": "^18.0.0",
"stripe": "^18.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite-plugin-svgr": "^4.2.0",
+6 -4
View File
@@ -13,7 +13,7 @@ import {
ConversationTrigger,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { GitUser, GitRepository } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
@@ -152,7 +152,8 @@ class OpenHands {
static async createConversation(
conversation_trigger: ConversationTrigger = "gui",
selectedRepository?: GitRepository,
selectedRepository?: string,
git_provider?: Provider,
initialUserMsg?: string,
imageUrls?: string[],
replayJson?: string,
@@ -160,7 +161,8 @@ class OpenHands {
): Promise<Conversation> {
const body = {
conversation_trigger,
selected_repository: selectedRepository,
repository: selectedRepository,
git_provider,
selected_branch: undefined,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
@@ -274,7 +276,7 @@ class OpenHands {
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
const endpoint =
appMode === "saas" ? "/api/logout" : "/api/unset-settings-tokens";
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
await openHands.post(endpoint);
}
+16
View File
@@ -0,0 +1,16 @@
import { Provider, ProviderToken } from "#/types/settings";
import { openHands } from "./open-hands-axios";
import { POSTProviderTokens } from "./secrets-service.types";
export class SecretsService {
static async addGitProvider(providers: Record<Provider, ProviderToken>) {
const tokens: POSTProviderTokens = {
provider_tokens: providers,
};
const { data } = await openHands.post<boolean>(
"/api/add-git-providers",
tokens,
);
return data;
}
}
@@ -0,0 +1,5 @@
import { Provider, ProviderToken } from "#/types/settings";
export interface POSTProviderTokens {
provider_tokens: Record<Provider, ProviderToken>;
}
@@ -20,7 +20,7 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
useAutoTitle();
return (
<div className="flex items-center justify-between">
<div className="flex flex-col gap-2 md:items-center md:justify-between md:flex-row">
<div className="flex items-center gap-2">
<AgentControlBar />
<AgentStatusBar />
@@ -164,7 +164,7 @@ export function ConversationCard({
className={cn(
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
variant === "compact" &&
"h-auto w-fit rounded-xl border border-[#525252]",
"md:w-fit h-auto rounded-xl border border-[#525252]",
)}
>
<div className="flex items-center justify-between w-full">
+3 -8
View File
@@ -35,15 +35,10 @@ function AuthProvider({
providersAreSet,
setProvidersAreSet,
}),
[
providerTokensSet,
providersAreSet,
setProviderTokensSet,
setProvidersAreSet,
],
[providerTokensSet],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return <AuthContext value={value}>{children}</AuthContext>;
}
function useAuth() {
@@ -54,4 +49,4 @@ function useAuth() {
return context;
}
export { AuthProvider, useAuth, AuthContext };
export { AuthProvider, useAuth };
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { Provider, ProviderToken } from "#/types/settings";
export const useAddGitProviders = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
providers,
}: {
providers: Record<Provider, ProviderToken>;
}) => SecretsService.addGitProvider(providers),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
},
meta: {
disableToast: true,
},
});
};
@@ -24,13 +24,19 @@ export const useCreateConversation = () => {
conversation_trigger: ConversationTrigger;
q?: string;
selectedRepository?: GitRepository | null;
suggested_task?: SuggestedTask;
}) => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
variables.conversation_trigger,
variables.selectedRepository || undefined,
variables.selectedRepository
? variables.selectedRepository.full_name
: undefined,
variables.selectedRepository
? variables.selectedRepository.git_provider
: undefined,
variables.q,
files,
replayJson || undefined,
@@ -20,7 +20,6 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: settings.user_consents_to_analytics,
provider_tokens: settings.provider_tokens,
};
await OpenHands.saveSettings(apiSettings);
+1 -4
View File
@@ -2,12 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAuthState } from "#/hooks/use-auth-state";
export const useBalance = () => {
const { data: config } = useConfig();
const isOnTosPage = useIsOnTosPage();
const isLikelyAuthenticated = useAuthState();
return useQuery({
queryKey: ["user", "balance"],
@@ -15,7 +13,6 @@ export const useBalance = () => {
enabled:
!isOnTosPage &&
config?.APP_MODE === "saas" &&
config?.FEATURE_FLAGS.ENABLE_BILLING &&
isLikelyAuthenticated, // Only fetch balance if user is likely authenticated
config?.FEATURE_FLAGS.ENABLE_BILLING,
});
};
-2
View File
@@ -2,8 +2,6 @@ import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
// We need to fetch the config regardless of authentication state
// as it's needed to determine the app mode and other essential settings
export const useConfig = () => {
const isOnTosPage = useIsOnTosPage();
+1 -8
View File
@@ -4,25 +4,18 @@ import OpenHands from "#/api/open-hands";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAuthState } from "#/hooks/use-auth-state";
export const useIsAuthed = () => {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
const isOnTosPage = useIsOnTosPage();
const isLikelyAuthenticated = useAuthState();
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
// Only make the API call if the user is likely authenticated
// or if we're in OSS mode (where authentication is not required)
const shouldCheckAuth =
(!!appMode && appMode === "oss") || (!!appMode && isLikelyAuthenticated);
return useQuery({
queryKey: ["user", "authenticated", providersAreSet, appMode],
queryFn: () => OpenHands.authenticate(appMode!),
enabled: shouldCheckAuth && !isOnTosPage,
enabled: !!appMode && !isOnTosPage,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
retry: false,
+2 -12
View File
@@ -6,8 +6,6 @@ import { useAuth } from "#/context/auth-context";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { Settings } from "#/types/settings";
import { useAuthState } from "#/hooks/use-auth-state";
import { useConfig } from "./use-config";
const getSettingsQueryFn = async (): Promise<Settings> => {
const apiSettings = await OpenHands.getSettings();
@@ -25,7 +23,6 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
PROVIDER_TOKENS: apiSettings.provider_tokens,
IS_NEW_USER: false,
};
};
@@ -33,15 +30,8 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
export const useSettings = () => {
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
useAuth();
const isOnTosPage = useIsOnTosPage();
const isLikelyAuthenticated = useAuthState();
const { data: config } = useConfig();
// Only make the API call if the user is likely authenticated
// or if we're in OSS mode (where authentication is not required)
const appMode = config?.APP_MODE;
const shouldFetchSettings =
(!!appMode && appMode === "oss") || (!!appMode && isLikelyAuthenticated);
const isOnTosPage = useIsOnTosPage();
const query = useQuery({
queryKey: ["settings", providerTokensSet],
@@ -52,7 +42,7 @@ export const useSettings = () => {
retry: (_, error) => error.status !== 404,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnTosPage && shouldFetchSettings,
enabled: !isOnTosPage,
meta: {
disableToast: true,
},
-12
View File
@@ -1,12 +0,0 @@
import { useAuth } from "#/context/auth-context";
/**
* A hook that returns whether the user is likely authenticated based on local state.
* This is used to prevent unnecessary API calls when the user is not logged in.
*/
export const useAuthState = () => {
const { providersAreSet } = useAuth();
// If providers are set, the user is likely authenticated
return providersAreSet;
};
-3
View File
@@ -440,9 +440,6 @@ export enum I18nKey {
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
AUTH$AUTHENTICATION_FAILED = "AUTH$AUTHENTICATION_FAILED",
AUTH$AUTHENTICATION_SUCCESSFUL = "AUTH$AUTHENTICATION_SUCCESSFUL",
AUTH$PROCESSING_AUTHENTICATION = "AUTH$PROCESSING_AUTHENTICATION",
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB = "ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB",
-45
View File
@@ -6319,51 +6319,6 @@
"tr": "Kimlik sağlayıcınızla giriş yapın",
"de": "Melden Sie sich mit Ihrem Identitätsanbieter an"
},
"AUTH$AUTHENTICATION_FAILED": {
"en": "Authentication failed. Please try again.",
"ja": "認証に失敗しました。もう一度お試しください。",
"zh-CN": "认证失败。请重试。",
"zh-TW": "認證失敗。請重試。",
"ko-KR": "인증에 실패했습니다. 다시 시도해 주세요.",
"no": "Autentisering mislyktes. Vennligst prøv igjen.",
"it": "Autenticazione fallita. Per favore riprova.",
"pt": "Falha na autenticação. Por favor, tente novamente.",
"es": "Autenticación fallida. Por favor, inténtelo de nuevo.",
"ar": "فشل المصادقة. يرجى المحاولة مرة أخرى.",
"fr": "L'authentification a échoué. Veuillez réessayer.",
"tr": "Kimlik doğrulama başarısız oldu. Lütfen tekrar deneyin.",
"de": "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"AUTH$AUTHENTICATION_SUCCESSFUL": {
"en": "Authentication successful!",
"ja": "認証に成功しました!",
"zh-CN": "认证成功!",
"zh-TW": "認證成功!",
"ko-KR": "인증 성공!",
"no": "Autentisering vellykket!",
"it": "Autenticazione riuscita!",
"pt": "Autenticação bem-sucedida!",
"es": "¡Autenticación exitosa!",
"ar": "تمت المصادقة بنجاح!",
"fr": "Authentification réussie !",
"tr": "Kimlik doğrulama başarılı!",
"de": "Authentifizierung erfolgreich!"
},
"AUTH$PROCESSING_AUTHENTICATION": {
"en": "Processing authentication...",
"ja": "認証処理中...",
"zh-CN": "正在处理认证...",
"zh-TW": "正在處理認證...",
"ko-KR": "인증 처리 중...",
"no": "Behandler autentisering...",
"it": "Elaborazione dell'autenticazione in corso...",
"pt": "Processando autenticação...",
"es": "Procesando autenticación...",
"ar": "جاري معالجة المصادقة...",
"fr": "Traitement de l'authentification...",
"tr": "Kimlik doğrulama işleniyor...",
"de": "Authentifizierung wird verarbeitet..."
},
"WAITLIST$JOIN_WAITLIST": {
"en": "Join Waitlist",
"ja": "ウェイトリストに参加",
+29 -2
View File
@@ -6,7 +6,7 @@ import {
} from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitRepository, GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
@@ -26,7 +26,6 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
provider_tokens: DEFAULT_SETTINGS.PROVIDER_TOKENS,
};
const MOCK_USER_PREFERENCES: {
@@ -293,4 +292,32 @@ export const handlers = [
MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS };
return HttpResponse.json(null, { status: 200 });
}),
http.post("/api/add-git-providers", async ({ request }) => {
const body = await request.json();
if (typeof body === "object" && body?.provider_tokens) {
const rawTokens = body.provider_tokens as Record<
string,
{ token?: string }
>;
const providerTokensSet: Partial<Record<Provider, string | null>> =
Object.fromEntries(
Object.entries(rawTokens)
.filter(([, val]) => val && val.token)
.map(([provider]) => [provider as Provider, ""]),
);
const newSettings = {
...(MOCK_USER_PREFERENCES.settings ?? MOCK_DEFAULT_USER_SETTINGS),
provider_tokens_set: providerTokensSet,
};
MOCK_USER_PREFERENCES.settings = newSettings;
return HttpResponse.json(true, { status: 200 });
}
return HttpResponse.json(null, { status: 400 });
}),
];
-1
View File
@@ -9,7 +9,6 @@ export default [
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("accept-tos", "routes/accept-tos.tsx"),
route("oauth/keycloak/callback", "routes/oauth-callback.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/llm-settings.tsx"),
route("git", "routes/git-settings.tsx"),
+4 -4
View File
@@ -1,6 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { BrandButton } from "#/components/features/settings/brand-button";
@@ -16,11 +15,12 @@ import {
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
import { useAuth } from "#/context/auth-context";
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
function GitSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { mutate: saveGitProviders, isPending } = useAddGitProviders();
const { mutate: disconnectGitTokens } = useLogout();
const { providerTokensSet } = useAuth();
@@ -48,9 +48,9 @@ function GitSettingsScreen() {
const githubToken = formData.get("github-token-input")?.toString() || "";
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
saveSettings(
saveGitProviders(
{
provider_tokens: {
providers: {
github: { token: githubToken },
gitlab: { token: gitlabToken },
},
+2 -1
View File
@@ -22,10 +22,11 @@ function HomeScreen() {
<hr className="border-[#717888]" />
<main className="flex flex-col md:flex-row justify-between gap-4">
<main className="flex flex-col md:flex-row justify-between gap-8">
<RepoConnector
onRepoSelection={(title) => setSelectedRepoTitle(title)}
/>
<hr className="md:hidden border-[#717888]" />
{providersAreSet && <TaskSuggestions filterFor={selectedRepoTitle} />}
</main>
</div>
-63
View File
@@ -1,63 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
export default function OAuthCallback() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setProvidersAreSet } = useAuth();
const { t } = useTranslation();
const [isProcessing, setIsProcessing] = React.useState(true);
React.useEffect(() => {
const code = searchParams.get("code");
if (!code) {
displayErrorToast(t(I18nKey.AUTH$AUTHENTICATION_FAILED));
navigate("/");
return;
}
const processOAuthCallback = async () => {
try {
// Process the OAuth callback
await OpenHands.getGitHubAccessToken(code);
// Set authentication state
setProvidersAreSet(true);
// Show success message
displaySuccessToast(t(I18nKey.AUTH$AUTHENTICATION_SUCCESSFUL));
// Redirect to home page
navigate("/");
} catch (error) {
// Log error and show error toast
displayErrorToast(t(I18nKey.AUTH$AUTHENTICATION_FAILED));
navigate("/");
} finally {
setIsProcessing(false);
}
};
processOAuthCallback();
}, [navigate, searchParams, setProvidersAreSet, t]);
return (
<div className="flex flex-col items-center justify-center h-full">
{isProcessing && (
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary mx-auto mb-4" />
<p className="text-lg">{t(I18nKey.AUTH$PROCESSING_AUTHENTICATION)}</p>
</div>
)}
</div>
);
}
+1 -8
View File
@@ -131,23 +131,16 @@ export default function MainApp() {
}, [error?.status, pathname, isFetching, tosPageStatus]);
// When on TOS page, we don't make any API calls, so we need to handle this case
// If we haven't fetched auth status yet (because user is not logged in), consider them not authenticated
let userIsAuthed = false;
if (!tosPageStatus && !isFetchingAuth) {
userIsAuthed = !!isAuthed && !authError;
}
const userIsAuthed = tosPageStatus ? false : !!isAuthed && !authError;
// Only show the auth modal if:
// 1. User is not authenticated
// 2. We're not currently on the TOS page
// 3. We're in SaaS mode
// 4. We're not on the OAuth callback page
const isOAuthCallbackPage = pathname.includes("/oauth/keycloak/callback");
const renderAuthModal =
!isFetchingAuth &&
!userIsAuthed &&
!tosPageStatus &&
!isOAuthCallbackPage &&
config.data?.APP_MODE === "saas";
return (
+3 -3
View File
@@ -58,7 +58,7 @@ function SettingsScreen() {
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-9 border-b border-tertiary"
className="flex items-end gap-6 px-9 border-b border-tertiary"
>
{navItems.map(({ to, text }) => (
<NavLink
@@ -67,12 +67,12 @@ function SettingsScreen() {
to={to}
className={({ isActive }) =>
cn(
"border-b-2 border-transparent py-2.5",
"border-b-2 border-transparent py-2.5 px-4 min-w-[40px] flex items-center justify-center",
isActive && "border-primary",
)
}
>
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
<span className="text-[#F9FBFE] text-sm">{text}</span>
</NavLink>
))}
</nav>
+16
View File
@@ -152,6 +152,22 @@ export function handleAssistantMessage(message: Record<string, unknown>) {
handleObservationMessage(message as unknown as ObservationMessage);
} else if (message.status_update) {
handleStatusMessage(message as unknown as StatusMessage);
} else if (message.error) {
// Handle error messages from the server
const errorMessage =
typeof message.message === "string"
? message.message
: String(message.message || "Unknown error");
trackError({
message: errorMessage,
source: "websocket",
metadata: { raw_message: message },
});
store.dispatch(
addErrorMessage({
message: errorMessage,
}),
);
} else {
const errorMsg = "Unknown message type received";
trackError({
-4
View File
@@ -15,10 +15,6 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_DEFAULT_CONDENSER: true,
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,
PROVIDER_TOKENS: {
github: { token: "" },
gitlab: { token: "" },
},
IS_NEW_USER: true,
};
-4
View File
@@ -22,7 +22,6 @@ export type Settings = {
ENABLE_DEFAULT_CONDENSER: boolean;
ENABLE_SOUND_NOTIFICATIONS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
PROVIDER_TOKENS: Record<Provider, ProviderToken>;
IS_NEW_USER?: boolean;
};
@@ -39,17 +38,14 @@ export type ApiSettings = {
enable_default_condenser: boolean;
enable_sound_notifications: boolean;
user_consents_to_analytics: boolean | null;
provider_tokens: Record<Provider, ProviderToken>;
provider_tokens_set: Partial<Record<Provider, string | null>>;
};
export type PostSettings = Settings & {
provider_tokens: Record<Provider, ProviderToken>;
user_consents_to_analytics: boolean | null;
llm_api_key?: string | null;
};
export type PostApiSettings = ApiSettings & {
provider_tokens: Record<Provider, ProviderToken>;
user_consents_to_analytics: boolean | null;
};
+1 -14
View File
@@ -1,4 +1,4 @@
import { Provider, ProviderToken, Settings } from "#/types/settings";
import { Settings } from "#/types/settings";
const extractBasicFormData = (formData: FormData) => {
const provider = formData.get("llm-provider-input")?.toString();
@@ -61,18 +61,6 @@ export const extractSettings = (
ENABLE_DEFAULT_CONDENSER,
} = extractAdvancedFormData(formData);
// Extract provider tokens
const githubToken = formData.get("github-token")?.toString();
const gitlabToken = formData.get("gitlab-token")?.toString();
const providerTokens: Record<Provider, ProviderToken> = {
github: {
token: githubToken || "",
},
gitlab: {
token: gitlabToken || "",
},
};
return {
LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL,
LLM_API_KEY_SET: !!LLM_API_KEY,
@@ -82,7 +70,6 @@ export const extractSettings = (
CONFIRMATION_MODE,
SECURITY_ANALYZER,
ENABLE_DEFAULT_CONDENSER,
PROVIDER_TOKENS: providerTokens,
llm_api_key: LLM_API_KEY,
};
};
@@ -162,7 +162,7 @@ class BrowsingAgent(Agent):
last_action = event
elif isinstance(event, MessageAction) and event.source == EventSource.AGENT:
# agent has responded, task finished.
return AgentFinishAction(outputs={'content': event.content})
return AgentFinishAction(final_thought=event.content)
elif isinstance(event, Observation):
last_obs = event
@@ -201,10 +201,8 @@ class BrowsingAgent(Agent):
)
return MessageAction('Error encountered when browsing.')
goal, _ = state.get_current_user_intent()
if goal is None:
goal = state.inputs['task']
user_message_action = state.get_current_user_intent()
goal = user_message_action.content
system_msg = get_system_message(
goal,
@@ -76,7 +76,7 @@ def response_to_actions(
try:
arguments = json.loads(tool_call.function.arguments)
except json.decoder.JSONDecodeError as e:
raise RuntimeError(
raise FunctionCallValidationError(
f'Failed to parse tool call arguments: {tool_call.function.arguments}'
) from e
@@ -105,7 +105,8 @@ def response_to_actions(
elif tool_call.function.name == 'delegate_to_browsing_agent':
action = AgentDelegateAction(
agent='BrowsingAgent',
inputs=arguments,
prompt=arguments.get('prompt', ''),
inputs={},
)
# ================================================
@@ -113,8 +114,10 @@ def response_to_actions(
# ================================================
elif tool_call.function.name == FinishTool['function']['name']:
action = AgentFinishAction(
final_thought=arguments.get('message', ''),
outputs=arguments.get('outputs', {}),
thought=arguments.get('thought', ''),
task_completed=arguments.get('task_completed', None),
final_thought=arguments.get('final_thought', ''),
)
# ================================================
@@ -127,7 +127,7 @@ def response_to_actions(
try:
arguments = json.loads(tool_call.function.arguments)
except json.decoder.JSONDecodeError as e:
raise RuntimeError(
raise FunctionCallValidationError(
f'Failed to parse tool call arguments: {tool_call.function.arguments}'
) from e
@@ -216,7 +216,7 @@ Note:
last_action = event
elif isinstance(event, MessageAction) and event.source == EventSource.AGENT:
# agent has responded, task finished.
return AgentFinishAction(outputs={'content': event.content})
return AgentFinishAction(final_thought=event.content)
elif isinstance(event, Observation):
# Only process BrowserOutputObservation and skip other observation types
if not isinstance(event, BrowserOutputObservation):
@@ -271,10 +271,10 @@ Note:
)
return MessageAction('Error encountered when browsing.')
set_of_marks = last_obs.set_of_marks
goal, image_urls = state.get_current_user_intent()
user_message_action = state.get_current_user_intent()
goal = user_message_action.content
image_urls = user_message_action.image_urls
if goal is None:
goal = state.inputs['task']
goal_txt, goal_images = create_goal_prompt(goal, image_urls)
observation_txt, som_screenshot = create_observation_prompt(
cur_axtree_txt, tabs, focused_element, error_prefix, set_of_marks
+23 -29
View File
@@ -438,12 +438,13 @@ class AgentController:
elif isinstance(action, AgentDelegateAction):
await self.start_delegate(action)
assert self.delegate is not None
# Post a MessageAction with the task for the delegate
if 'task' in action.inputs:
# Post a MessageAction with the prompt for the delegate
if action.prompt:
self.event_stream.add_event(
MessageAction(content='TASK: ' + action.inputs['task']),
EventSource.USER,
MessageAction(content=action.prompt),
EventSource.USER, # Source is USER, as it represents the task prompt for the delegate
)
# Delegate starts in RUNNING state as it receives the prompt immediately
await self.delegate.set_agent_state_to(AgentState.RUNNING)
return
@@ -727,41 +728,34 @@ class AgentController:
# close the delegate controller before adding new events
asyncio.get_event_loop().run_until_complete(self.delegate.close())
if delegate_state in (AgentState.FINISHED, AgentState.REJECTED):
# retrieve delegate result
delegate_outputs = (
self.delegate.state.outputs if self.delegate.state else {}
)
# prepare delegate result observation
delegate_outputs = self.delegate.state.outputs if self.delegate.state else {}
formatted_output = ', '.join(
f'{key}: {value}' for key, value in delegate_outputs.items()
)
# prepare delegate result observation
# TODO: replace this with AI-generated summary (#2395)
formatted_output = ', '.join(
f'{key}: {value}' for key, value in delegate_outputs.items()
)
if delegate_state in (AgentState.FINISHED, AgentState.REJECTED):
content = (
f'{self.delegate.agent.name} finishes task with {formatted_output}'
)
# emit the delegate result observation
obs = AgentDelegateObservation(outputs=delegate_outputs, content=content)
self.event_stream.add_event(obs, EventSource.AGENT)
else:
# delegate state is ERROR
# emit AgentDelegateObservation with error content
delegate_outputs = (
self.delegate.state.outputs if self.delegate.state else {}
)
content = (
f'{self.delegate.agent.name} encountered an error during execution.'
)
content = f'{self.delegate.agent.name} encountered an error during execution. Known results: {delegate_outputs}'
# emit the delegate result observation
obs = AgentDelegateObservation(outputs=delegate_outputs, content=content)
self.event_stream.add_event(obs, EventSource.AGENT)
# emit the delegate result observation
obs = AgentDelegateObservation(content=content, outputs={})
# associate the delegate action with the initiating tool call
for event in reversed(self.state.history):
if isinstance(event, AgentDelegateAction):
delegate_action = event
obs.tool_call_metadata = delegate_action.tool_call_metadata
break
self.event_stream.add_event(obs, EventSource.AGENT)
# unset delegate so parent can resume normal handling
self.delegate = None
self.delegateAction = None
async def _step(self) -> None:
"""Executes a single step of the parent or delegate agent. Detects stuck agents and limits on the number of iterations and the task budget."""
+32 -12
View File
@@ -188,19 +188,39 @@ class State:
if not hasattr(self, 'history'):
self.history = []
def get_current_user_intent(self) -> tuple[str | None, list[str] | None]:
"""Returns the latest user message and image(if provided) that appears after a FinishAction, or the first (the task) if nothing was finished yet."""
last_user_message = None
last_user_message_image_urls: list[str] | None = []
for event in reversed(self.view):
if isinstance(event, MessageAction) and event.source == 'user':
last_user_message = event.content
last_user_message_image_urls = event.image_urls
elif isinstance(event, AgentFinishAction):
if last_user_message is not None:
return last_user_message, None
def get_current_user_intent(self) -> MessageAction:
"""Returns the latest user MessageAction that appears after a FinishAction, or the first (the task) if nothing was finished yet."""
likely_task: MessageAction | None = None
return last_user_message, last_user_message_image_urls
# Search in the view for the latest user message after the last finish action
for event in reversed(self.view):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
likely_task = event
elif isinstance(event, AgentFinishAction):
# If a FinishAction is found, the user message after it is the one we just found (if any)
break
# If a user message was found in the view after the last finish action, return it
if likely_task is not None:
return likely_task
# If no user message was found in the view after the last finish action,
# it means either there were no user messages in the view, or the last event in the view was a FinishAction
# In this case, we fall back to finding the very first user message in the full history.
logger.warning(
'No user message found in the view after the last FinishAction. Returning the first message in history.'
)
if self.history:
# Look for the very first user message in the full history
for event in self.history:
if (
isinstance(event, MessageAction)
and event.source == EventSource.USER
):
return event
# If no user message is found in the entire history, raise an error
raise ValueError('No user message found in history. This should not happen.')
def get_last_agent_message(self) -> MessageAction | None:
for event in reversed(self.view):
+29 -27
View File
@@ -101,7 +101,7 @@ async def run_session(
sid = str(uuid4())
is_loaded = asyncio.Event()
is_paused = asyncio.Event()
is_paused = asyncio.Event() # Event to track agent pause requests
# Show runtime initialization message
display_runtime_initialization_message(config.runtime)
@@ -157,20 +157,15 @@ async def run_session(
display_event(event, config)
update_usage_metrics(event, usage_metrics)
# Pause the agent if the pause event is set (if Ctrl-P is pressed)
if is_paused.is_set():
event_stream.add_event(
ChangeAgentStateAction(AgentState.PAUSED),
EventSource.USER,
)
is_paused.clear()
if isinstance(event, AgentStateChangedObservation):
if event.agent_state in [
AgentState.AWAITING_USER_INPUT,
AgentState.FINISHED,
AgentState.PAUSED,
]:
# If the agent is paused, do not prompt for input as it's already handled by PAUSED state change
if is_paused.is_set():
return
# Reload microagents after initialization of repo.md
if reload_microagents:
microagents: list[BaseMicroagent] = (
@@ -181,25 +176,32 @@ async def run_session(
await prompt_for_next_task(event.agent_state)
if event.agent_state == AgentState.AWAITING_USER_CONFIRMATION:
# Only display the confirmation prompt if the agent is not paused
if not is_paused.is_set():
user_confirmed = await read_confirmation_input()
if user_confirmed:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
else:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,
)
# If the agent is paused, do not prompt for confirmation
# The confirmation step will re-run after the agent has been resumed
if is_paused.is_set():
return
user_confirmed = await read_confirmation_input()
if user_confirmed:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
else:
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,
)
if event.agent_state == AgentState.PAUSED:
is_paused.clear() # Revert the event state before prompting for user input
await prompt_for_next_task(event.agent_state)
if event.agent_state == AgentState.RUNNING:
# Enable pause/resume functionality only if the confirmation mode is disabled
if not config.security.confirmation_mode:
display_agent_running_message()
loop.create_task(process_agent_pause(is_paused))
display_agent_running_message()
loop.create_task(
process_agent_pause(is_paused, event_stream)
) # Create a task to track agent pause requests from the user
def on_event(event: Event) -> None:
loop.create_task(on_event_async(event))
+11 -6
View File
@@ -25,10 +25,11 @@ from prompt_toolkit.widgets import Frame, TextArea
from openhands import __version__
from openhands.core.config import AppConfig
from openhands.core.schema import AgentState
from openhands.events import EventSource
from openhands.events import EventSource, EventStream
from openhands.events.action import (
Action,
ActionConfirmationStatus,
ChangeAgentStateAction,
CmdRunAction,
FileEditAction,
MessageAction,
@@ -60,7 +61,7 @@ COMMANDS = {
'/status': 'Display session details and usage metrics',
'/new': 'Create a new session',
'/settings': 'Display and modify current settings',
'/resume': 'Resume the agent',
'/resume': 'Resume the agent when paused',
}
@@ -396,7 +397,7 @@ def display_status(usage_metrics: UsageMetrics, session_id: str):
def display_agent_running_message():
print_formatted_text('')
print_formatted_text(
HTML('<gold>Agent running...</gold> <grey>(Ctrl-P to pause)</grey>')
HTML('<gold>Agent running...</gold> <grey>(Press Ctrl-P to pause)</grey>')
)
@@ -405,7 +406,7 @@ def display_agent_paused_message(agent_state: str):
return
print_formatted_text('')
print_formatted_text(
HTML('<gold>Agent paused</gold> <grey>(type /resume to resume)</grey>')
HTML('<gold>Agent paused...</gold> <grey>(Enter /resume to continue)</grey>')
)
@@ -430,7 +431,7 @@ class CommandCompleter(Completer):
command,
start_position=-len(text),
display_meta=description,
style='bg:ansidarkgray fg:ansiwhite',
style='bg:ansidarkgray fg:gold',
)
@@ -488,7 +489,7 @@ async def read_confirmation_input() -> bool:
return False
async def process_agent_pause(done: asyncio.Event) -> None:
async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) -> None:
input = create_input()
def keys_ready():
@@ -496,6 +497,10 @@ async def process_agent_pause(done: asyncio.Event) -> None:
if key_press.key == Keys.ControlP:
print_formatted_text('')
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
event_stream.add_event(
ChangeAgentStateAction(AgentState.PAUSED),
EventSource.USER,
)
done.set()
with input.raw_mode():
+17 -15
View File
@@ -15,7 +15,7 @@ from openhands.core.config import (
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.events.event import Event
from openhands.integrations.provider import ProviderToken, ProviderType, SecretStore
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.llm.llm import LLM
from openhands.memory.memory import Memory
from openhands.microagent.microagent import BaseMicroagent
@@ -23,6 +23,7 @@ from openhands.runtime import get_runtime_cls
from openhands.runtime.base import Runtime
from openhands.security import SecurityAnalyzer, options
from openhands.storage import get_file_store
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
@@ -85,40 +86,41 @@ def create_runtime(
def initialize_repository_for_runtime(
runtime: Runtime,
selected_repository: str | None = None,
github_token: SecretStr | None = None,
runtime: Runtime, selected_repository: str | None = None
) -> str | None:
"""Initialize the repository for the runtime.
Args:
runtime: The runtime to initialize the repository for.
selected_repository: (optional) The GitHub repository to use.
github_token: (optional) The GitHub token to use.
Returns:
The repository directory path if a repository was cloned, None otherwise.
"""
# clone selected repository if provided
if github_token is None and 'GITHUB_TOKEN' in os.environ:
provider_tokens = {}
if 'GITHUB_TOKEN' in os.environ:
github_token = SecretStr(os.environ['GITHUB_TOKEN'])
provider_tokens[ProviderType.GITHUB] = ProviderToken(
token=SecretStr(github_token)
)
if 'GITLAB_TOKEN' in os.environ:
gitlab_token = SecretStr(os.environ['GITLAB_TOKEN'])
provider_tokens[ProviderType.GITLAB] = ProviderToken(
token=SecretStr(gitlab_token)
)
secret_store = (
SecretStore(
provider_tokens={
ProviderType.GITHUB: ProviderToken(token=SecretStr(github_token))
}
)
if github_token
else None
UserSecrets(provider_tokens=provider_tokens) if provider_tokens else None
)
provider_tokens = secret_store.provider_tokens if secret_store else None
immutable_provider_tokens = secret_store.provider_tokens if secret_store else None
logger.debug(f'Selected repository {selected_repository}.')
repo_directory = call_async_from_sync(
runtime.clone_or_init_repo,
GENERAL_TIMEOUT,
provider_tokens,
immutable_provider_tokens,
selected_repository,
None,
)
+7
View File
@@ -86,6 +86,13 @@ class AgentRejectAction(Action):
class AgentDelegateAction(Action):
agent: str
inputs: dict
"""Deprecated.
Delegate agents run similarly to the main agent:
- start from a prompt (passed in the 'prompt' field)
- end with an AgentFinishAction.
"""
prompt: str
"""The prompt/task for the delegate agent"""
thought: str = ''
action: str = ActionType.DELEGATE
+7 -2
View File
@@ -10,13 +10,18 @@ class AgentDelegateObservation(Observation):
Attributes:
content (str): The content of the observation.
outputs (dict): The outputs of the delegated agent.
outputs (dict): The outputs of the delegated agent. (deprecated)
observation (str): The type of observation.
"""
outputs: dict
"""Deprecated.
Delegate agents run similarly to the main agent:
- start from a prompt (passed in the 'prompt' field)
- end with an AgentFinishAction.
"""
observation: str = ObservationType.DELEGATE
@property
def message(self) -> str:
return ''
return self.content
@@ -390,6 +390,20 @@ class GitHubService(BaseGitService, GitService):
except Exception:
return []
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
url = f'{self.BASE_URL}/repos/{repository}'
repo, _ = await self._make_request(url)
return Repository(
id=repo.get('id'),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
is_public=not repo.get('private', True),
)
github_service_cls = os.environ.get(
'OPENHANDS_GITHUB_SERVICE_CLS',
+47
View File
@@ -0,0 +1,47 @@
suggested_task_pr_graphql_query = """
query GetUserPRs($login: String!) {
user(login: $login) {
pullRequests(first: 50, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
repository {
nameWithOwner
}
mergeable
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
state
}
}
}
}
reviews(first: 50, states: [CHANGES_REQUESTED, COMMENTED]) {
nodes {
state
}
}
}
}
}
}
"""
suggested_task_issue_graphql_query = """
query GetUserIssues($login: String!) {
user(login: $login) {
issues(first: 50, states: [OPEN], filterBy: {assignee: $login}, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
repository {
nameWithOwner
}
}
}
}
}
"""
@@ -6,6 +6,7 @@ from pydantic import SecretStr
from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
ProviderType,
Repository,
@@ -131,7 +132,7 @@ class GitLabService(BaseGitService, GitService):
payload = {
'query': query,
'variables': variables,
'variables': variables if variables is not None else {},
}
response = await client.post(
@@ -195,6 +196,7 @@ class GitLabService(BaseGitService, GitService):
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=True,
)
for repo in response
]
@@ -382,6 +384,60 @@ class GitLabService(BaseGitService, GitService):
except Exception:
return []
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}'
repo, _ = await self._make_request(url)
return Repository(
id=repo.get('id'),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
)
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository"""
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
# Set maximum branches to fetch (10 pages with 100 per page)
MAX_BRANCHES = 1000
PER_PAGE = 100
all_branches: list[Branch] = []
page = 1
# Fetch up to 10 pages of branches
while page <= 10 and len(all_branches) < MAX_BRANCHES:
params = {'per_page': str(PER_PAGE), 'page': str(page)}
response, headers = await self._make_request(url, params)
if not response: # No more branches
break
for branch_data in response:
branch = Branch(
name=branch_data.get('name'),
commit_sha=branch_data.get('commit', {}).get('id', ''),
protected=branch_data.get('protected', False),
last_push_date=branch_data.get('commit', {}).get('committed_date'),
)
all_branches.append(branch)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
return all_branches
gitlab_service_cls = os.environ.get(
'OPENHANDS_GITLAB_SERVICE_CLS',
+85 -116
View File
@@ -7,12 +7,8 @@ from pydantic import (
BaseModel,
Field,
SecretStr,
SerializationInfo,
WithJsonSchema,
field_serializer,
model_validator,
)
from pydantic.json import pydantic_encoder
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.action import Action
@@ -22,6 +18,7 @@ from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.service_types import (
AuthenticationError,
Branch,
GitService,
ProviderType,
Repository,
@@ -34,6 +31,7 @@ from openhands.server.types import AppMode
class ProviderToken(BaseModel):
token: SecretStr | None = Field(default=None)
user_id: str | None = Field(default=None)
host: str | None = Field(default=None)
model_config = {
'frozen': True, # Makes the entire model immutable
@@ -43,15 +41,20 @@ class ProviderToken(BaseModel):
@classmethod
def from_value(cls, token_value: ProviderToken | dict[str, str]) -> ProviderToken:
"""Factory method to create a ProviderToken from various input types"""
if isinstance(token_value, ProviderToken):
if isinstance(token_value, cls):
return token_value
elif isinstance(token_value, dict):
token_str = token_value.get('token')
token_str = token_value.get('token', '')
# Override with emtpy string if it was set to None
# Cannot pass None to SecretStr
if token_str is None:
token_str = ''
user_id = token_value.get('user_id')
return cls(token=SecretStr(token_str), user_id=user_id)
host = token_value.get('host')
return cls(token=SecretStr(token_str), user_id=user_id, host=host)
else:
raise ValueError('Unsupport Provider token type')
raise ValueError('Unsupported Provider token type')
PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken]
@@ -66,113 +69,6 @@ CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Annotated[
]
class SecretStore(BaseModel):
provider_tokens: PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Field(
default_factory=lambda: MappingProxyType({})
)
custom_secrets: CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Field(
default_factory=lambda: MappingProxyType({}),
)
model_config = {
'frozen': True,
'validate_assignment': True,
'arbitrary_types_allowed': True,
}
@field_serializer('provider_tokens')
def provider_tokens_serializer(
self, provider_tokens: PROVIDER_TOKEN_TYPE, info: SerializationInfo
) -> dict[str, dict[str, str | Any]]:
tokens = {}
expose_secrets = info.context and info.context.get('expose_secrets', False)
for token_type, provider_token in provider_tokens.items():
if not provider_token or not provider_token.token:
continue
token_type_str = (
token_type.value
if isinstance(token_type, ProviderType)
else str(token_type)
)
tokens[token_type_str] = {
'token': provider_token.token.get_secret_value()
if expose_secrets
else pydantic_encoder(provider_token.token),
'user_id': provider_token.user_id,
}
return tokens
@field_serializer('custom_secrets')
def custom_secrets_serializer(
self, custom_secrets: CUSTOM_SECRETS_TYPE, info: SerializationInfo
):
secrets = {}
expose_secrets = info.context and info.context.get('expose_secrets', False)
if custom_secrets:
for secret_name, secret_key in custom_secrets.items():
secrets[secret_name] = (
secret_key.get_secret_value()
if expose_secrets
else pydantic_encoder(secret_key)
)
return secrets
@model_validator(mode='before')
@classmethod
def convert_dict_to_mappingproxy(
cls, data: dict[str, dict[str, Any] | MappingProxyType] | PROVIDER_TOKEN_TYPE
) -> dict[str, MappingProxyType | None]:
"""Custom deserializer to convert dictionary into MappingProxyType"""
if not isinstance(data, dict):
raise ValueError('SecretStore must be initialized with a dictionary')
new_data: dict[str, MappingProxyType | None] = {}
if 'provider_tokens' in data:
tokens = data['provider_tokens']
if isinstance(
tokens, dict
): # Ensure conversion happens only for dict inputs
converted_tokens = {}
for key, value in tokens.items():
try:
provider_type = (
ProviderType(key) if isinstance(key, str) else key
)
converted_tokens[provider_type] = ProviderToken.from_value(
value
)
except ValueError:
# Skip invalid provider types or tokens
continue
# Convert to MappingProxyType
new_data['provider_tokens'] = MappingProxyType(converted_tokens)
elif isinstance(tokens, MappingProxyType):
new_data['provider_tokens'] = tokens
if 'custom_secrets' in data:
secrets = data['custom_secrets']
if isinstance(secrets, dict):
converted_secrets = {}
for key, value in secrets.items():
if isinstance(value, str):
converted_secrets[key] = SecretStr(value)
elif isinstance(value, SecretStr):
converted_secrets[key] = value
new_data['custom_secrets'] = MappingProxyType(converted_secrets)
elif isinstance(secrets, MappingProxyType):
new_data['custom_secrets'] = secrets
return new_data
class ProviderHandler:
def __init__(
self,
@@ -276,7 +172,8 @@ class ProviderHandler:
query, per_page, sort, order
)
all_repos.extend(service_repos)
except Exception:
except Exception as e:
logger.warning(f'Error searching repos from {provider}: {e}')
continue
return all_repos
@@ -397,3 +294,75 @@ class ProviderHandler:
Map ProviderType value to the environment variable name in the runtime
"""
return f'{provider.value}_token'.lower()
async def verify_repo_provider(
self, repository: str, specified_provider: ProviderType | None = None
):
if specified_provider:
try:
service = self._get_service(specified_provider)
return await service.get_repository_details_from_repo_name(repository)
except Exception:
pass
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
return await service.get_repository_details_from_repo_name(repository)
except Exception:
pass
raise AuthenticationError(f'Unable to access repo {repository}')
async def get_branches(
self, repository: str, specified_provider: ProviderType | None = None
) -> list[Branch]:
"""
Get branches for a repository
Args:
repository: The repository name
specified_provider: Optional provider type to use
Returns:
A list of branches for the repository
"""
all_branches: list[Branch] = []
if specified_provider:
try:
service = self._get_service(specified_provider)
branches = await service.get_branches(repository)
return branches
except Exception as e:
logger.warning(
f'Error fetching branches from {specified_provider}: {e}'
)
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
branches = await service.get_branches(repository)
all_branches.extend(branches)
# If we found branches, no need to check other providers
if all_branches:
break
except Exception as e:
logger.warning(f'Error fetching branches from {provider}: {e}')
# Sort branches by last push date (newest first)
all_branches.sort(
key=lambda b: b.last_push_date if b.last_push_date else '', reverse=True
)
# Move main/master branch to the top if it exists
main_branches = []
other_branches = []
for branch in all_branches:
if branch.name.lower() in ['main', 'master']:
main_branches.append(branch)
else:
other_branches.append(branch)
return main_branches + other_branches
+16 -1
View File
@@ -91,6 +91,13 @@ class User(BaseModel):
email: str | None = None
class Branch(BaseModel):
name: str
commit_sha: str
protected: bool
last_push_date: str | None = None # ISO 8601 format date string
class Repository(BaseModel):
id: int
full_name: str
@@ -164,7 +171,7 @@ class BaseGitService(ABC):
def handle_http_error(self, e: HTTPError) -> UnknownException:
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
return UnknownException('Unknown error')
return UnknownException(f'HTTP error {type(e).__name__}')
class GitService(Protocol):
@@ -206,3 +213,11 @@ class GitService(Protocol):
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories"""
...
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
"""Gets all repository details from repository name"""
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository"""
@@ -0,0 +1,5 @@
Please summarize your work.
If you answered a question, please re-state the answer to the question
If you made changes, please create a concise overview on whether the request has been addressed successfully or if there are were issues with the attempt.
If successful, make sure your changes are pushed to the remote branch.
@@ -3,4 +3,4 @@ Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retriev
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
Then use the {{ apiName }} to look at the {{ ciSystem }} that are failing on the most recent commit. Try and reproduce the failure locally.
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the {{ ciProvider }} {{ ciSystem.lower() }} have run again.
If they are still failing, repeat the process.
If they are still failing, repeat the process.
@@ -1,4 +1,4 @@
You are working on {{ requestType }} #{{ issue_number }} in repository {{ repo }}. You need to fix the merge conflicts.
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the {{ requestTypeShort }} details.
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.
@@ -1,4 +1,4 @@
You are working on Issue #{{ issue_number }} in repository {{ repo }}. Your goal is to fix the issue.
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the issue details and any comments on the issue.
Then check out a new branch and investigate what changes will need to be made.
Finally, make the required changes and open up a {{ requestVerb }}. Be sure to reference the issue in the {{ requestTypeShort }} description.
Finally, make the required changes and open up a {{ requestVerb }}. Be sure to reference the issue in the {{ requestTypeShort }} description.
@@ -2,4 +2,4 @@ You are working on {{ requestType }} #{{ issue_number }} in repository {{ repo }
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the {{ requestTypeShort }} details.
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
Then use the {{ apiName }} to retrieve all the feedback on the {{ requestTypeShort }} so far.
If anything hasn't been addressed, address it and commit your changes back to the same branch.
If anything hasn't been addressed, address it and commit your changes back to the same branch.
+26 -17
View File
@@ -9,6 +9,7 @@ We follow format from: https://docs.litellm.ai/docs/completion/function_call
import copy
import json
import re
import sys
from typing import Iterable
from litellm import ChatCompletionToolParam
@@ -47,8 +48,15 @@ Reminder:
STOP_WORDS = ['</function']
def refine_prompt(prompt: str) -> str:
if sys.platform == 'win32':
return prompt.replace('bash', 'powershell')
return prompt
# NOTE: we need to make sure this example is always in-sync with the tool interface designed in openhands/agenthub/codeact_agent/function_calling.py
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = """
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = refine_prompt("""
Here's a running example of how to perform a task with the provided tools.
--------------------- START OF EXAMPLE ---------------------
@@ -75,7 +83,7 @@ from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
def index() -> str:
numbers = list(range(1, 11))
return str(numbers)
@@ -218,7 +226,7 @@ The server is running on port 5000 with PID 126. You can access the list of numb
Do NOT assume the environment is the same as in the example above.
--------------------- NEW TASK DESCRIPTION ---------------------
""".lstrip()
""").lstrip()
IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX = """
--------------------- END OF NEW TASK DESCRIPTION ---------------------
@@ -245,12 +253,12 @@ def convert_tool_call_to_string(tool_call: dict) -> str:
if tool_call['type'] != 'function':
raise FunctionCallConversionError("Tool call type must be 'function'.")
ret = f"<function={tool_call['function']['name']}>\n"
ret = f'<function={tool_call["function"]["name"]}>\n'
try:
args = json.loads(tool_call['function']['arguments'])
except json.JSONDecodeError as e:
raise FunctionCallConversionError(
f"Failed to parse arguments as JSON. Arguments: {tool_call['function']['arguments']}"
f'Failed to parse arguments as JSON. Arguments: {tool_call["function"]["arguments"]}'
) from e
for param_name, param_value in args.items():
is_multiline = isinstance(param_value, str) and '\n' in param_value
@@ -272,8 +280,8 @@ def convert_tools_to_description(tools: list[dict]) -> str:
fn = tool['function']
if i > 0:
ret += '\n'
ret += f"---- BEGIN FUNCTION #{i+1}: {fn['name']} ----\n"
ret += f"Description: {fn['description']}\n"
ret += f'---- BEGIN FUNCTION #{i + 1}: {fn["name"]} ----\n'
ret += f'Description: {fn["description"]}\n'
if 'parameters' in fn:
ret += 'Parameters:\n'
@@ -295,12 +303,12 @@ def convert_tools_to_description(tools: list[dict]) -> str:
desc += f'\nAllowed values: [{enum_values}]'
ret += (
f' ({j+1}) {param_name} ({param_type}, {param_status}): {desc}\n'
f' ({j + 1}) {param_name} ({param_type}, {param_status}): {desc}\n'
)
else:
ret += 'No parameters are required for this function.\n'
ret += f'---- END FUNCTION #{i+1} ----\n'
ret += f'---- END FUNCTION #{i + 1} ----\n'
return ret
@@ -351,7 +359,8 @@ def convert_fncall_messages_to_non_fncall_messages(
and any(
(
tool['type'] == 'function'
and tool['function']['name'] == 'execute_bash'
and tool['function']['name']
== refine_prompt('execute_bash')
and 'command'
in tool['function']['parameters']['properties']
)
@@ -658,7 +667,7 @@ def convert_non_fncall_messages_to_fncall_messages(
'content': [{'type': 'text', 'text': tool_result}]
if isinstance(content, list)
else tool_result,
'tool_call_id': f'toolu_{tool_call_counter-1:02d}', # Use last generated ID
'tool_call_id': f'toolu_{tool_call_counter - 1:02d}', # Use last generated ID
}
)
else:
@@ -781,14 +790,14 @@ def convert_from_multiple_tool_calls_to_single_tool_call_messages(
# add the tool result
converted_messages.append(message)
else:
assert (
len(pending_tool_calls) == 0
), f'Found pending tool calls but not found in pending list: {pending_tool_calls=}'
assert len(pending_tool_calls) == 0, (
f'Found pending tool calls but not found in pending list: {pending_tool_calls=}'
)
converted_messages.append(message)
else:
assert (
len(pending_tool_calls) == 0
), f'Found pending tool calls but not expect to handle it with role {role}: {pending_tool_calls=}, {message=}'
assert len(pending_tool_calls) == 0, (
f'Found pending tool calls but not expect to handle it with role {role}: {pending_tool_calls=}, {message=}'
)
converted_messages.append(message)
if not ignore_final_tool_result and len(pending_tool_calls) > 0:
+34 -9
View File
@@ -49,6 +49,8 @@ LLM_RETRY_EXCEPTIONS: tuple[type[Exception], ...] = (
# remove this when we gemini and deepseek are supported
CACHE_PROMPT_SUPPORTED_MODELS = [
'claude-3-7-sonnet-20250219',
'claude-sonnet-3-7-latest',
'claude-3.7-sonnet',
'claude-3-5-sonnet-20241022',
'claude-3-5-sonnet-20240620',
'claude-3-5-haiku-20241022',
@@ -59,6 +61,7 @@ CACHE_PROMPT_SUPPORTED_MODELS = [
# function calling supporting models
FUNCTION_CALLING_SUPPORTED_MODELS = [
'claude-3-7-sonnet-20250219',
'claude-sonnet-3-7-latest',
'claude-3-5-sonnet',
'claude-3-5-sonnet-20240620',
'claude-3-5-sonnet-20241022',
@@ -108,7 +111,7 @@ class LLM(RetryMixin, DebugMixin):
config: LLMConfig,
metrics: Metrics | None = None,
retry_listener: Callable[[int, int], None] | None = None,
):
) -> None:
"""Initializes the LLM. If LLMConfig is passed, its values will be the fallback.
Passing simple parameters always overrides config.
@@ -199,7 +202,7 @@ class LLM(RetryMixin, DebugMixin):
"""Wrapper for the litellm completion function. Logs the input and output of the completion function."""
from openhands.io import json
messages: list[dict[str, Any]] | dict[str, Any] = []
messages_kwarg: list[dict[str, Any]] | dict[str, Any] = []
mock_function_calling = not self.is_function_calling_active()
# some callers might send the model and messages directly
@@ -209,16 +212,18 @@ class LLM(RetryMixin, DebugMixin):
# design wise: we don't allow overriding the configured values
# implementation wise: the partial function set the model as a kwarg already
# as well as other kwargs
messages = args[1] if len(args) > 1 else args[0]
kwargs['messages'] = messages
messages_kwarg = args[1] if len(args) > 1 else args[0]
kwargs['messages'] = messages_kwarg
# remove the first args, they're sent in kwargs
args = args[2:]
elif 'messages' in kwargs:
messages = kwargs['messages']
messages_kwarg = kwargs['messages']
# ensure we work with a list of messages
messages = messages if isinstance(messages, list) else [messages]
messages: list[dict[str, Any]] = (
messages_kwarg if isinstance(messages_kwarg, list) else [messages_kwarg]
)
# handle conversion of to non-function calling messages if needed
original_fncall_messages = copy.deepcopy(messages)
@@ -290,6 +295,7 @@ class LLM(RetryMixin, DebugMixin):
)
non_fncall_response_message = resp.choices[0].message
# messages is already a list with proper typing from line 223
fn_call_messages_with_response = (
convert_non_fncall_messages_to_fncall_messages(
messages + [non_fncall_response_message], mock_fncall_tools
@@ -412,6 +418,7 @@ class LLM(RetryMixin, DebugMixin):
)
if current_model_info:
self.model_info = current_model_info['model_info']
logger.debug(f'Got model info from litellm proxy: {self.model_info}')
# Last two attempts to get model info from NAME
if not self.model_info:
@@ -467,7 +474,10 @@ class LLM(RetryMixin, DebugMixin):
self.model_info['max_tokens'], int
):
self.config.max_output_tokens = self.model_info['max_tokens']
if 'claude-3-7-sonnet' in self.config.model:
if any(
model in self.config.model
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
):
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
# Initialize function calling capability
@@ -598,6 +608,12 @@ class LLM(RetryMixin, DebugMixin):
if cache_write_tokens:
stats += 'Input tokens (cache write): ' + str(cache_write_tokens) + '\n'
# Get context window from model info
context_window = 0
if self.model_info and 'max_input_tokens' in self.model_info:
context_window = self.model_info['max_input_tokens']
logger.debug(f'Using context window: {context_window}')
# Record in metrics
# We'll treat cache_hit_tokens as "cache read" and cache_write_tokens as "cache write"
self.metrics.add_token_usage(
@@ -605,6 +621,7 @@ class LLM(RetryMixin, DebugMixin):
completion_tokens=completion_tokens,
cache_read_tokens=cache_hit_tokens,
cache_write_tokens=cache_write_tokens,
context_window=context_window,
response_id=response_id,
)
@@ -631,7 +648,15 @@ class LLM(RetryMixin, DebugMixin):
logger.info(
'Message objects now include serialized tool calls in token counting'
)
messages = self.format_messages_for_llm(messages) # type: ignore
# Assert the expected type for format_messages_for_llm
assert isinstance(messages, list) and all(
isinstance(m, Message) for m in messages
), 'Expected list of Message objects'
# We've already asserted that messages is a list of Message objects
# Use explicit typing to satisfy mypy
messages_typed: list[Message] = messages # type: ignore
messages = self.format_messages_for_llm(messages_typed)
# try to get the token count with the default litellm tokenizers
# or the custom tokenizer if set for this LLM configuration
@@ -662,7 +687,7 @@ class LLM(RetryMixin, DebugMixin):
boolean: True if executing a local model.
"""
if self.config.base_url is not None:
for substring in ['localhost', '127.0.0.1' '0.0.0.0']:
for substring in ['localhost', '127.0.0.1', '0.0.0.0']:
if substring in self.config.base_url:
return True
elif self.config.model is not None:
+16
View File
@@ -26,6 +26,8 @@ class TokenUsage(BaseModel):
completion_tokens: int = Field(default=0)
cache_read_tokens: int = Field(default=0)
cache_write_tokens: int = Field(default=0)
context_window: int = Field(default=0)
per_turn_token: int = Field(default=0)
response_id: str = Field(default='')
def __add__(self, other: 'TokenUsage') -> 'TokenUsage':
@@ -36,6 +38,8 @@ class TokenUsage(BaseModel):
completion_tokens=self.completion_tokens + other.completion_tokens,
cache_read_tokens=self.cache_read_tokens + other.cache_read_tokens,
cache_write_tokens=self.cache_write_tokens + other.cache_write_tokens,
context_window=max(self.context_window, other.context_window),
per_turn_token=other.per_turn_token,
response_id=self.response_id,
)
@@ -60,6 +64,7 @@ class Metrics:
completion_tokens=0,
cache_read_tokens=0,
cache_write_tokens=0,
context_window=0,
response_id='',
)
@@ -107,6 +112,7 @@ class Metrics:
completion_tokens=0,
cache_read_tokens=0,
cache_write_tokens=0,
context_window=0,
response_id='',
)
return self._accumulated_token_usage
@@ -130,15 +136,22 @@ class Metrics:
completion_tokens: int,
cache_read_tokens: int,
cache_write_tokens: int,
context_window: int,
response_id: str,
) -> None:
"""Add a single usage record."""
# Token each turn for calculating context usage.
per_turn_token = prompt_tokens + completion_tokens
usage = TokenUsage(
model=self.model_name,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
context_window=context_window,
per_turn_token=per_turn_token,
response_id=response_id,
)
self._token_usages.append(usage)
@@ -150,6 +163,8 @@ class Metrics:
completion_tokens=completion_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
context_window=context_window,
per_turn_token=per_turn_token,
response_id='',
)
@@ -190,6 +205,7 @@ class Metrics:
completion_tokens=0,
cache_read_tokens=0,
cache_write_tokens=0,
context_window=0,
response_id='',
)
+4 -4
View File
@@ -1,6 +1,6 @@
import asyncio
from contextlib import AsyncExitStack
from typing import Dict, List, Optional
from typing import Optional
from mcp import ClientSession
from mcp.client.sse import sse_client
@@ -18,8 +18,8 @@ class MCPClient(BaseModel):
session: Optional[ClientSession] = None
exit_stack: AsyncExitStack = AsyncExitStack()
description: str = 'MCP client tools for server interaction'
tools: List[MCPClientTool] = Field(default_factory=list)
tool_map: Dict[str, MCPClientTool] = Field(default_factory=dict)
tools: list[MCPClientTool] = Field(default_factory=list)
tool_map: dict[str, MCPClientTool] = Field(default_factory=dict)
class Config:
arbitrary_types_allowed = True
@@ -91,7 +91,7 @@ class MCPClient(BaseModel):
f'Connected to server with tools: {[tool.name for tool in response.tools]}'
)
async def call_tool(self, tool_name: str, args: Dict):
async def call_tool(self, tool_name: str, args: dict):
"""Call a tool on the MCP server."""
if tool_name not in self.tool_map:
raise ValueError(f'Tool {tool_name} not found.')
+1 -3
View File
@@ -1,5 +1,3 @@
from typing import Dict
from mcp.types import Tool
@@ -14,7 +12,7 @@ class MCPClientTool(Tool):
class Config:
arbitrary_types_allowed = True
def to_param(self) -> Dict:
def to_param(self) -> dict:
"""Convert tool to function call format."""
return {
'type': 'function',
+7 -7
View File
@@ -158,12 +158,12 @@ async def add_mcp_tools_to_agent(
ActionExecutionClient, # inline import to avoid circular import
)
assert isinstance(
runtime, ActionExecutionClient
), 'Runtime must be an instance of ActionExecutionClient'
assert (
runtime.runtime_initialized
), 'Runtime must be initialized before adding MCP tools'
assert isinstance(runtime, ActionExecutionClient), (
'Runtime must be an instance of ActionExecutionClient'
)
assert runtime.runtime_initialized, (
'Runtime must be initialized before adding MCP tools'
)
# Add the runtime as another MCP server
updated_mcp_config = runtime.get_updated_mcp_config()
@@ -171,7 +171,7 @@ async def add_mcp_tools_to_agent(
mcp_tools = await fetch_mcp_tools_from_config(updated_mcp_config)
logger.info(
f"Loaded {len(mcp_tools)} MCP tools: {[tool['function']['name'] for tool in mcp_tools]}"
f'Loaded {len(mcp_tools)} MCP tools: {[tool["function"]["name"] for tool in mcp_tools]}'
)
# Set the MCP tools on the agent
@@ -28,7 +28,7 @@ class BrowserOutputCondenser(Condenser):
):
results.append(
AgentCondensationObservation(
f'Current URL: {event.url}\nContent Omitted'
f'Visited URL {event.url}\nContent omitted'
)
)
else:
+1 -1
View File
@@ -412,7 +412,7 @@ class ConversationMemory:
logger.debug('Vision disabled for browsing, showing text')
elif isinstance(obs, AgentDelegateObservation):
text = truncate_content(
obs.outputs['content'] if 'content' in obs.outputs else '',
obs.outputs.get('content', obs.content),
max_message_chars,
)
message = Message(role='user', content=[TextContent(text=text)])
@@ -26,6 +26,7 @@ jobs:
base_container_image: ${{ vars.OPENHANDS_BASE_CONTAINER_IMAGE || '' }}
LLM_MODEL: ${{ vars.LLM_MODEL || 'anthropic/claude-3-5-sonnet-20241022' }}
target_branch: ${{ vars.TARGET_BRANCH || 'main' }}
runner: ${{ vars.TARGET_RUNNER }}
secrets:
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
+2 -2
View File
@@ -214,7 +214,7 @@ class GitlabIssueHandler(IssueHandlerInterface):
def reply_to_comment(self, pr_number: int, comment_id: str, reply: str) -> None:
response = httpx.get(
f'{self.base_url}/merge_requests/{pr_number}/discussions/{comment_id.split('/')[-1]}',
f'{self.base_url}/merge_requests/{pr_number}/discussions/{comment_id.split("/")[-1]}',
headers=self.headers,
)
response.raise_for_status()
@@ -225,7 +225,7 @@ class GitlabIssueHandler(IssueHandlerInterface):
'note_id': discussions.get('notes', [])[-1]['id'],
}
response = httpx.post(
f'{self.base_url}/merge_requests/{pr_number}/discussions/{comment_id.split('/')[-1]}/notes',
f'{self.base_url}/merge_requests/{pr_number}/discussions/{comment_id.split("/")[-1]}/notes',
headers=self.headers,
json=data,
)
@@ -0,0 +1,80 @@
from openhands.core.config import LLMConfig
from openhands.integrations.provider import ProviderType
from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler
from openhands.resolver.interfaces.issue_definitions import (
ServiceContextIssue,
ServiceContextPR,
)
class IssueHandlerFactory:
def __init__(
self,
owner: str,
repo: str,
token: str,
username: str,
platform: ProviderType,
base_domain: str,
issue_type: str,
llm_config: LLMConfig,
) -> None:
self.owner = owner
self.repo = repo
self.token = token
self.username = username
self.platform = platform
self.base_domain = base_domain
self.issue_type = issue_type
self.llm_config = llm_config
def create(self) -> ServiceContextIssue | ServiceContextPR:
if self.issue_type == 'issue':
if self.platform == ProviderType.GITHUB:
return ServiceContextIssue(
GithubIssueHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else: # platform == Platform.GITLAB
return ServiceContextIssue(
GitlabIssueHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
elif self.issue_type == 'pr':
if self.platform == ProviderType.GITHUB:
return ServiceContextPR(
GithubPRHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else: # platform == Platform.GITLAB
return ServiceContextPR(
GitlabPRHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else:
raise ValueError(f'Invalid issue type: {self.issue_type}')
+3 -5
View File
@@ -6,11 +6,9 @@ class HunkException(PatchingException):
def __init__(self, msg: str, hunk: int | None = None) -> None:
self.hunk = hunk
if hunk is not None:
super(HunkException, self).__init__(
'{msg}, in hunk #{n}'.format(msg=msg, n=hunk)
)
super().__init__('{msg}, in hunk #{n}'.format(msg=msg, n=hunk))
else:
super(HunkException, self).__init__(msg)
super().__init__(msg)
class ApplyException(PatchingException):
@@ -19,7 +17,7 @@ class ApplyException(PatchingException):
class SubprocessException(ApplyException):
def __init__(self, msg: str, code: int) -> None:
super(SubprocessException, self).__init__(msg)
super().__init__(msg)
self.code = code
+26 -62
View File
@@ -28,13 +28,12 @@ from openhands.events.observation import (
)
from openhands.events.stream import EventStreamSubscriber
from openhands.integrations.service_types import ProviderType
from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler
from openhands.resolver.interfaces.issue import Issue
from openhands.resolver.interfaces.issue_definitions import (
ServiceContextIssue,
ServiceContextPR,
)
from openhands.resolver.issue_handler_factory import IssueHandlerFactory
from openhands.resolver.resolver_output import ResolverOutput
from openhands.resolver.utils import (
codeact_user_response,
@@ -111,12 +110,22 @@ class IssueResolver:
model = args.llm_model or os.environ['LLM_MODEL']
base_url = args.llm_base_url or os.environ.get('LLM_BASE_URL', None)
api_version = os.environ.get('LLM_API_VERSION', None)
llm_num_retries = int(os.environ.get('LLM_NUM_RETRIES', '4'))
llm_retry_min_wait = int(os.environ.get('LLM_RETRY_MIN_WAIT', '5'))
llm_retry_max_wait = int(os.environ.get('LLM_RETRY_MAX_WAIT', '30'))
llm_retry_multiplier = int(os.environ.get('LLM_RETRY_MULTIPLIER', 2))
llm_timeout = int(os.environ.get('LLM_TIMEOUT', 0))
# Create LLMConfig instance
llm_config = LLMConfig(
model=model,
api_key=SecretStr(api_key) if api_key else None,
base_url=base_url,
num_retries=llm_num_retries,
retry_min_wait=llm_retry_min_wait,
retry_max_wait=llm_retry_max_wait,
retry_multiplier=llm_retry_multiplier,
timeout=llm_timeout,
)
# Only set api_version if it was explicitly provided, otherwise let LLMConfig handle it
@@ -152,8 +161,6 @@ class IssueResolver:
self.owner = owner
self.repo = repo
self.token = token
self.username = username
self.platform = platform
self.runtime_container_image = runtime_container_image
self.base_container_image = base_container_image
@@ -165,9 +172,20 @@ class IssueResolver:
self.repo_instruction = repo_instruction
self.issue_number = args.issue_number
self.comment_id = args.comment_id
self.base_domain = base_domain
self.platform = platform
factory = IssueHandlerFactory(
owner=self.owner,
repo=self.repo,
token=token,
username=username,
platform=self.platform,
base_domain=base_domain,
issue_type=self.issue_type,
llm_config=self.llm_config,
)
self.issue_handler = factory.create()
def initialize_runtime(
self,
runtime: Runtime,
@@ -435,58 +453,6 @@ class IssueResolver:
)
return output
def issue_handler_factory(self) -> ServiceContextIssue | ServiceContextPR:
# Determine default base_domain based on platform
if self.issue_type == 'issue':
if self.platform == ProviderType.GITHUB:
return ServiceContextIssue(
GithubIssueHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else: # platform == Platform.GITLAB
return ServiceContextIssue(
GitlabIssueHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
elif self.issue_type == 'pr':
if self.platform == ProviderType.GITHUB:
return ServiceContextPR(
GithubPRHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else: # platform == Platform.GITLAB
return ServiceContextPR(
GitlabPRHandler(
self.owner,
self.repo,
self.token,
self.username,
self.base_domain,
),
self.llm_config,
)
else:
raise ValueError(f'Invalid issue type: {self.issue_type}')
async def resolve_issue(
self,
reset_logger: bool = False,
@@ -497,10 +463,8 @@ class IssueResolver:
reset_logger: Whether to reset the logger for multiprocessing.
"""
issue_handler = self.issue_handler_factory()
# Load dataset
issues: list[Issue] = issue_handler.get_converted_issues(
issues: list[Issue] = self.issue_handler.get_converted_issues(
issue_numbers=[self.issue_number], comment_id=self.comment_id
)
@@ -546,7 +510,7 @@ class IssueResolver:
[
'git',
'clone',
issue_handler.get_clone_url(),
self.issue_handler.get_clone_url(),
f'{self.output_dir}/repo',
]
).decode('utf-8')
@@ -625,7 +589,7 @@ class IssueResolver:
output = await self.process_issue(
issue,
base_commit,
issue_handler,
self.issue_handler,
reset_logger,
)
output_fp.write(output.model_dump_json() + '\n')
+2 -4
View File
@@ -1,5 +1,3 @@
from typing import Type
from openhands.runtime.base import Runtime
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
from openhands.runtime.impl.docker.docker_runtime import (
@@ -13,7 +11,7 @@ from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
from openhands.utils.import_utils import get_impl
# mypy: disable-error-code="type-abstract"
_DEFAULT_RUNTIME_CLASSES: dict[str, Type[Runtime]] = {
_DEFAULT_RUNTIME_CLASSES: dict[str, type[Runtime]] = {
'eventstream': DockerRuntime,
'docker': DockerRuntime,
'e2b': E2BRuntime,
@@ -25,7 +23,7 @@ _DEFAULT_RUNTIME_CLASSES: dict[str, Type[Runtime]] = {
}
def get_runtime_cls(name: str) -> Type[Runtime]:
def get_runtime_cls(name: str) -> type[Runtime]:
"""
If name is one of the predefined runtime names (e.g. 'docker'), return its class.
Otherwise attempt to resolve name as subclass of Runtime and return it.
+105 -28
View File
@@ -13,6 +13,7 @@ import logging
import mimetypes
import os
import shutil
import sys
import tempfile
import time
import traceback
@@ -76,6 +77,10 @@ from openhands.utils.async_utils import call_sync_from_async, wait_all
mcp_router_logger.setLevel(logger.getEffectiveLevel())
if sys.platform == 'win32':
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
class ActionRequest(BaseModel):
action: dict
@@ -100,7 +105,7 @@ def _execute_file_editor(
view_range: list[int] | None = None,
old_str: str | None = None,
new_str: str | None = None,
insert_line: int | None = None,
insert_line: int | str | None = None,
enable_linting: bool = False,
) -> tuple[str, tuple[str | None, str | None]]:
"""Execute file editor command and handle exceptions.
@@ -113,13 +118,24 @@ def _execute_file_editor(
view_range: Optional view range tuple (start, end)
old_str: Optional string to replace
new_str: Optional replacement string
insert_line: Optional line number for insertion
insert_line: Optional line number for insertion (can be int or str)
enable_linting: Whether to enable linting
Returns:
tuple: A tuple containing the output string and a tuple of old and new file content
"""
result: ToolResult | None = None
# Convert insert_line from string to int if needed
if insert_line is not None and isinstance(insert_line, str):
try:
insert_line = int(insert_line)
except ValueError:
return (
f"ERROR:\nInvalid insert_line value: '{insert_line}'. Expected an integer.",
(None, None),
)
try:
result = editor(
command=command,
@@ -133,6 +149,9 @@ def _execute_file_editor(
)
except ToolError as e:
result = ToolResult(error=e.message)
except TypeError as e:
# Handle unexpected arguments or type errors
return f'ERROR:\n{str(e)}', (None, None)
if result.error:
return f'ERROR:\n{result.error}', (None, None)
@@ -167,13 +186,14 @@ class ActionExecutor:
if _updated_user_id is not None:
self.user_id = _updated_user_id
self.bash_session: BashSession | None = None
self.bash_session: BashSession | 'WindowsPowershellSession' | None = None # type: ignore[name-defined]
self.lock = asyncio.Lock()
self.plugins: dict[str, Plugin] = {}
self.file_editor = OHEditor(workspace_root=self._initial_cwd)
self.browser: BrowserEnv | None = None
self.browser_init_task: asyncio.Task | None = None
self.browsergym_eval_env = browsergym_eval_env
self.start_time = time.time()
self.last_execution_time = self.start_time
self._initialized = False
@@ -199,6 +219,10 @@ class ActionExecutor:
async def _init_browser_async(self):
"""Initialize the browser asynchronously."""
if sys.platform == 'win32':
logger.warning('Browser environment not supported on windows')
return
logger.debug('Initializing browser asynchronously')
try:
self.browser = BrowserEnv(self.browsergym_eval_env)
@@ -232,15 +256,25 @@ class ActionExecutor:
async def ainit(self):
# bash needs to be initialized first
logger.debug('Initializing bash session')
self.bash_session = BashSession(
work_dir=self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
),
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
self.bash_session.initialize()
if sys.platform == 'win32':
self.bash_session = WindowsPowershellSession( # type: ignore[name-defined]
work_dir=self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
),
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
else:
self.bash_session = BashSession(
work_dir=self._initial_cwd,
username=self.username,
no_change_timeout_seconds=int(
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
),
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
)
self.bash_session.initialize()
logger.debug('Bash session initialized')
# Start browser initialization in the background
@@ -282,19 +316,55 @@ class ActionExecutor:
logger.debug(f'Initializing plugin: {plugin.name}')
if isinstance(plugin, JupyterPlugin):
# Escape backslashes in Windows path
cwd = self.bash_session.cwd.replace('\\', '/')
await self.run_ipython(
IPythonRunCellAction(
code=f'import os; os.chdir("{self.bash_session.cwd}")'
)
IPythonRunCellAction(code=f'import os; os.chdir(r"{cwd}")')
)
async def _init_bash_commands(self):
INIT_COMMANDS = [
'git config --file ./.git_config user.name "openhands" && git config --file ./.git_config user.email "openhands@all-hands.dev" && alias git="git --no-pager" && export GIT_CONFIG=$(pwd)/.git_config'
if os.environ.get('LOCAL_RUNTIME_MODE') == '1'
else 'git config --global user.name "openhands" && git config --global user.email "openhands@all-hands.dev" && alias git="git --no-pager"'
]
logger.debug(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
INIT_COMMANDS = []
is_local_runtime = os.environ.get('LOCAL_RUNTIME_MODE') == '1'
is_windows = sys.platform == 'win32'
# Determine git config commands based on platform and runtime mode
if is_local_runtime:
if is_windows:
# Windows, local - split into separate commands
INIT_COMMANDS.append(
'git config --file ./.git_config user.name "openhands"'
)
INIT_COMMANDS.append(
'git config --file ./.git_config user.email "openhands@all-hands.dev"'
)
INIT_COMMANDS.append(
'$env:GIT_CONFIG = (Join-Path (Get-Location) ".git_config")'
)
else:
# Linux/macOS, local
base_git_config = (
'git config --file ./.git_config user.name "openhands" && '
'git config --file ./.git_config user.email "openhands@all-hands.dev" && '
'export GIT_CONFIG=$(pwd)/.git_config'
)
INIT_COMMANDS.append(base_git_config)
else:
# Non-local (implies Linux/macOS)
base_git_config = (
'git config --global user.name "openhands" && '
'git config --global user.email "openhands@all-hands.dev"'
)
INIT_COMMANDS.append(base_git_config)
# Determine no-pager command
if is_windows:
no_pager_cmd = 'function git { git.exe --no-pager $args }'
else:
no_pager_cmd = 'alias git="git --no-pager"'
INIT_COMMANDS.append(no_pager_cmd)
logger.info(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
for command in INIT_COMMANDS:
action = CmdRunAction(command=command)
action.set_hard_timeout(300)
@@ -345,9 +415,9 @@ class ActionExecutor:
logger.debug(
f'{self.bash_session.cwd} != {jupyter_cwd} -> reset Jupyter PWD'
)
reset_jupyter_cwd_code = (
f'import os; os.chdir("{self.bash_session.cwd}")'
)
# escape windows paths
cwd = self.bash_session.cwd.replace('\\', '/')
reset_jupyter_cwd_code = f'import os; os.chdir("{cwd}")'
_aux_action = IPythonRunCellAction(code=reset_jupyter_cwd_code)
_reset_obs: IPythonRunCellObservation = await _jupyter_plugin.run(
_aux_action
@@ -527,12 +597,20 @@ class ActionExecutor:
)
async def browse(self, action: BrowseURLAction) -> Observation:
if self.browser is None:
return ErrorObservation(
'Browser functionality is not supported on Windows.'
)
await self._ensure_browser_ready()
return await browse(action, self.browser)
return await browse(action, self.browser, self.initial_cwd)
async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
if self.browser is None:
return ErrorObservation(
'Browser functionality is not supported on Windows.'
)
await self._ensure_browser_ready()
return await browse(action, self.browser)
return await browse(action, self.browser, self.initial_cwd)
def close(self):
self.memory_monitor.stop_monitoring()
@@ -726,7 +804,6 @@ if __name__ == '__main__':
if not isinstance(action, Action):
raise HTTPException(status_code=400, detail='Invalid action type')
client.last_execution_time = time.time()
observation = await client.run_action(action)
return event_to_dict(observation)
except Exception as e:
@@ -897,7 +974,7 @@ if __name__ == '__main__':
To list files:
```sh
curl http://localhost:3000/api/list-files
curl -X POST -d '{"path": "/"}' http://localhost:3000/list_files
```
Args:
+292 -52
View File
@@ -47,7 +47,7 @@ from openhands.integrations.provider import (
ProviderHandler,
ProviderType,
)
from openhands.integrations.service_types import Repository
from openhands.integrations.service_types import AuthenticationError
from openhands.microagent import (
BaseMicroagent,
load_microagents_from_dir,
@@ -72,6 +72,7 @@ STATUS_MESSAGES = {
'STATUS$CONTAINER_STARTED': 'Container started.',
'STATUS$WAITING_FOR_CLIENT': 'Waiting for client...',
'STATUS$SETTING_UP_WORKSPACE': 'Setting up workspace...',
'STATUS$SETTING_UP_GIT_HOOKS': 'Setting up git hooks...',
}
@@ -311,10 +312,23 @@ class Runtime(FileEditRuntimeMixin):
async def clone_or_init_repo(
self,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None,
selected_repository: str | Repository | None,
selected_repository: str | None,
selected_branch: str | None,
repository_provider: ProviderType = ProviderType.GITHUB,
) -> str:
repository = None
if selected_repository: # Determine provider from repo name
try:
provider_handler = ProviderHandler(
git_provider_tokens or MappingProxyType({})
)
repository = await provider_handler.verify_repo_provider(
selected_repository
)
except AuthenticationError:
raise RuntimeError(
'Git provider authentication issue when cloning repo'
)
if not selected_repository:
# In SaaS mode (indicated by user_id being set), always run git init
# In OSS mode, only run git init if workspace_base is not set
@@ -332,36 +346,30 @@ class Runtime(FileEditRuntimeMixin):
)
return ''
# This satisfies mypy because param is optional, but `verify_repo_provider` guarentees this gets populated
if not repository:
return ''
provider = repository.git_provider
provider_domains = {
ProviderType.GITHUB: 'github.com',
ProviderType.GITLAB: 'gitlab.com',
}
chosen_provider = (
repository_provider
if isinstance(selected_repository, str)
else selected_repository.git_provider
)
domain = provider_domains[chosen_provider]
repository = (
selected_repository
if isinstance(selected_repository, str)
else selected_repository.full_name
)
domain = provider_domains[provider]
# Try to use token if available, otherwise use public URL
if git_provider_tokens and chosen_provider in git_provider_tokens:
git_token = git_provider_tokens[chosen_provider].token
if git_provider_tokens and provider in git_provider_tokens:
git_token = git_provider_tokens[provider].token
if git_token:
if chosen_provider == ProviderType.GITLAB:
remote_repo_url = f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repository}.git'
if provider == ProviderType.GITLAB:
remote_repo_url = f'https://oauth2:{git_token.get_secret_value()}@{domain}/{selected_repository}.git'
else:
remote_repo_url = f'https://{git_token.get_secret_value()}@{domain}/{repository}.git'
remote_repo_url = f'https://{git_token.get_secret_value()}@{domain}/{selected_repository}.git'
else:
remote_repo_url = f'https://{domain}/{repository}.git'
remote_repo_url = f'https://{domain}/{selected_repository}.git'
else:
remote_repo_url = f'https://{domain}/{repository}.git'
remote_repo_url = f'https://{domain}/{selected_repository}.git'
if not remote_repo_url:
raise ValueError('Missing either Git token or valid repository')
@@ -371,7 +379,7 @@ class Runtime(FileEditRuntimeMixin):
'info', 'STATUS$SETTING_UP_WORKSPACE', 'Setting up workspace...'
)
dir_name = repository.split('/')[-1]
dir_name = selected_repository.split('/')[-1]
# Generate a random branch name to avoid conflicts
random_str = ''.join(
@@ -417,21 +425,278 @@ class Runtime(FileEditRuntimeMixin):
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log('error', f'Setup script failed: {obs.content}')
def maybe_setup_git_hooks(self):
"""Set up git hooks if .openhands/pre-commit.sh exists in the workspace or repository."""
pre_commit_script = '.openhands/pre-commit.sh'
read_obs = self.read(FileReadAction(path=pre_commit_script))
if isinstance(read_obs, ErrorObservation):
return
if self.status_callback:
self.status_callback(
'info', 'STATUS$SETTING_UP_GIT_HOOKS', 'Setting up git hooks...'
)
# Ensure the git hooks directory exists
action = CmdRunAction('mkdir -p .git/hooks')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log('error', f'Failed to create git hooks directory: {obs.content}')
return
# Make the pre-commit script executable
action = CmdRunAction(f'chmod +x {pre_commit_script}')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log(
'error', f'Failed to make pre-commit script executable: {obs.content}'
)
return
# Check if there's an existing pre-commit hook
pre_commit_hook = '.git/hooks/pre-commit'
pre_commit_local = '.git/hooks/pre-commit.local'
# Read the existing pre-commit hook if it exists
read_obs = self.read(FileReadAction(path=pre_commit_hook))
if not isinstance(read_obs, ErrorObservation):
# If the existing hook wasn't created by OpenHands, preserve it
if 'This hook was installed by OpenHands' not in read_obs.content:
self.log('info', 'Preserving existing pre-commit hook')
# Move the existing hook to pre-commit.local
action = CmdRunAction(f'mv {pre_commit_hook} {pre_commit_local}')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log(
'error',
f'Failed to preserve existing pre-commit hook: {obs.content}',
)
return
# Make it executable
action = CmdRunAction(f'chmod +x {pre_commit_local}')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log(
'error',
f'Failed to make preserved hook executable: {obs.content}',
)
return
# Create the pre-commit hook that calls our script
pre_commit_hook_content = f"""#!/bin/bash
# This hook was installed by OpenHands
# It calls the pre-commit script in the .openhands directory
if [ -x "{pre_commit_script}" ]; then
source "{pre_commit_script}"
exit $?
else
echo "Warning: {pre_commit_script} not found or not executable"
exit 0
fi
"""
# Write the pre-commit hook
write_obs = self.write(
FileWriteAction(path=pre_commit_hook, content=pre_commit_hook_content)
)
if isinstance(write_obs, ErrorObservation):
self.log('error', f'Failed to write pre-commit hook: {write_obs.content}')
return
# Make the pre-commit hook executable
action = CmdRunAction(f'chmod +x {pre_commit_hook}')
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
self.log(
'error', f'Failed to make pre-commit hook executable: {obs.content}'
)
return
self.log('info', 'Git pre-commit hook installed successfully')
def _load_microagents_from_directory(
self, microagents_dir: Path, source_description: str
) -> list[BaseMicroagent]:
"""Load microagents from a directory.
Args:
microagents_dir: Path to the directory containing microagents
source_description: Description of the source for logging purposes
Returns:
A list of loaded microagents
"""
loaded_microagents: list[BaseMicroagent] = []
files = self.list_files(str(microagents_dir))
if not files:
return loaded_microagents
self.log(
'info',
f'Found {len(files)} files in {source_description} microagents directory',
)
zip_path = self.copy_from(str(microagents_dir))
microagent_folder = tempfile.mkdtemp()
try:
with ZipFile(zip_path, 'r') as zip_file:
zip_file.extractall(microagent_folder)
zip_path.unlink()
repo_agents, knowledge_agents = load_microagents_from_dir(microagent_folder)
self.log(
'info',
f'Loaded {len(repo_agents)} repo agents and {len(knowledge_agents)} knowledge agents from {source_description}',
)
loaded_microagents.extend(repo_agents.values())
loaded_microagents.extend(knowledge_agents.values())
finally:
shutil.rmtree(microagent_folder)
return loaded_microagents
def _get_authenticated_git_url(self, repo_path: str) -> str:
"""Get an authenticated git URL for a repository.
Args:
repo_path: Repository path (e.g., "github.com/acme-co/api")
Returns:
Authenticated git URL if credentials are available, otherwise regular HTTPS URL
"""
remote_url = f'https://{repo_path}.git'
# Determine provider from repo path
provider = None
if 'github.com' in repo_path:
provider = ProviderType.GITHUB
elif 'gitlab.com' in repo_path:
provider = ProviderType.GITLAB
# Add authentication if available
if (
provider
and self.git_provider_tokens
and provider in self.git_provider_tokens
):
git_token = self.git_provider_tokens[provider].token
if git_token:
if provider == ProviderType.GITLAB:
remote_url = f'https://oauth2:{git_token.get_secret_value()}@{repo_path.replace("gitlab.com/", "")}.git'
else:
remote_url = f'https://{git_token.get_secret_value()}@{repo_path.replace("github.com/", "")}.git'
return remote_url
def get_microagents_from_org_or_user(
self, selected_repository: str
) -> list[BaseMicroagent]:
"""Load microagents from the organization or user level .openhands repository.
For example, if the repository is github.com/acme-co/api, this will check if
github.com/acme-co/.openhands exists. If it does, it will clone it and load
the microagents from the ./microagents/ folder.
Args:
selected_repository: The repository path (e.g., "github.com/acme-co/api")
Returns:
A list of loaded microagents from the org/user level repository
"""
loaded_microagents: list[BaseMicroagent] = []
workspace_root = Path(self.config.workspace_mount_path_in_sandbox)
repo_parts = selected_repository.split('/')
if len(repo_parts) < 2:
return loaded_microagents
# Extract the domain and org/user name
domain = repo_parts[0] if len(repo_parts) > 2 else 'github.com'
org_name = repo_parts[-2]
# Construct the org-level .openhands repo path
org_openhands_repo = f'{domain}/{org_name}/.openhands'
if domain not in org_openhands_repo:
org_openhands_repo = f'github.com/{org_openhands_repo}'
self.log(
'info',
f'Checking for org-level microagents at {org_openhands_repo}',
)
# Try to clone the org-level .openhands repo
try:
# Create a temporary directory for the org-level repo
org_repo_dir = workspace_root / f'org_openhands_{org_name}'
# Get authenticated URL and do a shallow clone (--depth 1) for efficiency
remote_url = self._get_authenticated_git_url(org_openhands_repo)
clone_cmd = f"git clone --depth 1 {remote_url} {org_repo_dir} 2>/dev/null || echo 'Org repo not found'"
action = CmdRunAction(command=clone_cmd)
obs = self.run_action(action)
if (
isinstance(obs, CmdOutputObservation)
and obs.exit_code == 0
and 'Org repo not found' not in obs.content
):
self.log(
'info',
f'Successfully cloned org-level microagents from {org_openhands_repo}',
)
# Load microagents from the org-level repo
org_microagents_dir = org_repo_dir / 'microagents'
loaded_microagents = self._load_microagents_from_directory(
org_microagents_dir, 'org-level'
)
# Clean up the org repo directory
shutil.rmtree(org_repo_dir)
else:
self.log(
'info',
f'No org-level microagents found at {org_openhands_repo}',
)
except Exception as e:
self.log('error', f'Error loading org-level microagents: {str(e)}')
return loaded_microagents
def get_microagents_from_selected_repo(
self, selected_repository: str | None
) -> list[BaseMicroagent]:
"""Load microagents from the selected repository.
If selected_repository is None, load microagents from the current workspace.
This is the main entry point for loading microagents.
This method also checks for user/org level microagents stored in a .openhands repository.
For example, if the repository is github.com/acme-co/api, it will also check for
github.com/acme-co/.openhands and load microagents from there if it exists.
"""
loaded_microagents: list[BaseMicroagent] = []
workspace_root = Path(self.config.workspace_mount_path_in_sandbox)
microagents_dir = workspace_root / '.openhands' / 'microagents'
repo_root = None
# Check for user/org level microagents if a repository is selected
if selected_repository:
# Load microagents from the org/user level repository
org_microagents = self.get_microagents_from_org_or_user(selected_repository)
loaded_microagents.extend(org_microagents)
# Continue with repository-specific microagents
repo_root = workspace_root / selected_repository.split('/')[-1]
microagents_dir = repo_root / '.openhands' / 'microagents'
self.log(
'info',
f'Selected repo: {selected_repository}, loading microagents from {microagents_dir} (inside runtime)',
@@ -463,35 +728,10 @@ class Runtime(FileEditRuntimeMixin):
)
# Load microagents from directory
files = self.list_files(str(microagents_dir))
if files:
self.log('info', f'Found {len(files)} files in microagents directory.')
zip_path = self.copy_from(str(microagents_dir))
microagent_folder = tempfile.mkdtemp()
# Properly handle the zip file
with ZipFile(zip_path, 'r') as zip_file:
zip_file.extractall(microagent_folder)
# Add debug print of directory structure
self.log('debug', 'Microagent folder structure:')
for root, _, files in os.walk(microagent_folder):
relative_path = os.path.relpath(root, microagent_folder)
self.log('debug', f'Directory: {relative_path}/')
for file in files:
self.log('debug', f' File: {os.path.join(relative_path, file)}')
# Clean up the temporary zip file
zip_path.unlink()
# Load all microagents using the existing function
repo_agents, knowledge_agents = load_microagents_from_dir(microagent_folder)
self.log(
'info',
f'Loaded {len(repo_agents)} repo agents and {len(knowledge_agents)} knowledge agents',
)
loaded_microagents.extend(repo_agents.values())
loaded_microagents.extend(knowledge_agents.values())
shutil.rmtree(microagent_folder)
repo_microagents = self._load_microagents_from_directory(
microagents_dir, 'repository'
)
loaded_microagents.extend(repo_microagents)
return loaded_microagents
+34
View File
@@ -0,0 +1,34 @@
import base64
import io
import numpy as np
from PIL import Image
def image_to_png_base64_url(
image: np.ndarray | Image.Image, add_data_prefix: bool = False
) -> str:
"""Convert a numpy array to a base64 encoded png image url."""
if isinstance(image, np.ndarray):
image = Image.fromarray(image)
if image.mode in ('RGBA', 'LA'):
image = image.convert('RGB')
buffered = io.BytesIO()
image.save(buffered, format='PNG')
image_base64 = base64.b64encode(buffered.getvalue()).decode()
return (
f'data:image/png;base64,{image_base64}'
if add_data_prefix
else f'{image_base64}'
)
def png_base64_url_to_image(png_base64_url: str) -> Image.Image:
"""Convert a base64 encoded png image url to a PIL Image."""
splited = png_base64_url.split(',')
if len(splited) == 2:
base64_data = splited[1]
else:
base64_data = png_base64_url
return Image.open(io.BytesIO(base64.b64decode(base64_data)))
+10 -50
View File
@@ -1,6 +1,4 @@
import atexit
import base64
import io
import json
import multiprocessing
import time
@@ -9,13 +7,12 @@ import uuid
import browsergym.core # noqa F401 (we register the openended task as a gym environment)
import gymnasium as gym
import html2text
import numpy as np
import tenacity
from browsergym.utils.obs import flatten_dom_to_str, overlay_som
from PIL import Image
from openhands.core.exceptions import BrowserInitException
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.browser.base64 import image_to_png_base64_url
from openhands.utils.shutdown_listener import should_continue, should_exit
from openhands.utils.tenacity_stop import stop_if_should_exit
@@ -40,7 +37,7 @@ class BrowserEnv:
self.init_browser()
atexit.register(self.close)
def get_html_text_converter(self):
def get_html_text_converter(self) -> html2text.HTML2Text:
html_text_converter = html2text.HTML2Text()
# ignore links and images
html_text_converter.ignore_links = False
@@ -56,7 +53,7 @@ class BrowserEnv:
stop=tenacity.stop_after_attempt(5) | stop_if_should_exit(),
retry=tenacity.retry_if_exception_type(BrowserInitException),
)
def init_browser(self):
def init_browser(self) -> None:
logger.debug('Starting browser env...')
try:
self.process = multiprocessing.Process(target=self.browser_process)
@@ -69,7 +66,7 @@ class BrowserEnv:
self.close()
raise BrowserInitException('Failed to start browser environment.')
def browser_process(self):
def browser_process(self) -> None:
if self.eval_mode:
assert self.browsergym_eval_env is not None
logger.info('Initializing browser env for web browsing evaluation.')
@@ -165,13 +162,13 @@ class BrowserEnv:
html_str = flatten_dom_to_str(obs['dom_object'])
obs['text_content'] = self.html_text_converter.handle(html_str)
# make observation serializable
obs['set_of_marks'] = self.image_to_png_base64_url(
obs['set_of_marks'] = image_to_png_base64_url(
overlay_som(
obs['screenshot'], obs.get('extra_element_properties', {})
),
add_data_prefix=True,
)
obs['screenshot'] = self.image_to_png_base64_url(
obs['screenshot'] = image_to_png_base64_url(
obs['screenshot'], add_data_prefix=True
)
obs['active_page_index'] = obs['active_page_index'].item()
@@ -196,17 +193,18 @@ class BrowserEnv:
if self.agent_side.poll(timeout=0.01):
response_id, obs = self.agent_side.recv()
if response_id == unique_request_id:
return obs
return dict(obs)
def check_alive(self, timeout: float = 60):
def check_alive(self, timeout: float = 60) -> bool:
self.agent_side.send(('IS_ALIVE', None))
if self.agent_side.poll(timeout=timeout):
response_id, _ = self.agent_side.recv()
if response_id == 'ALIVE':
return True
logger.debug(f'Browser env is not alive. Response ID: {response_id}')
return False
def close(self):
def close(self) -> None:
if not self.process.is_alive():
return
try:
@@ -225,41 +223,3 @@ class BrowserEnv:
self.browser_side.close()
except Exception as e:
logger.error(f'Encountered an error when closing browser env: {e}')
@staticmethod
def image_to_png_base64_url(
image: np.ndarray | Image.Image, add_data_prefix: bool = False
):
"""Convert a numpy array to a base64 encoded png image url."""
if isinstance(image, np.ndarray):
image = Image.fromarray(image)
if image.mode in ('RGBA', 'LA'):
image = image.convert('RGB')
buffered = io.BytesIO()
image.save(buffered, format='PNG')
image_base64 = base64.b64encode(buffered.getvalue()).decode()
return (
f'data:image/png;base64,{image_base64}'
if add_data_prefix
else f'{image_base64}'
)
@staticmethod
def image_to_jpg_base64_url(
image: np.ndarray | Image.Image, add_data_prefix: bool = False
):
"""Convert a numpy array to a base64 encoded jpeg image url."""
if isinstance(image, np.ndarray):
image = Image.fromarray(image)
if image.mode in ('RGBA', 'LA'):
image = image.convert('RGB')
buffered = io.BytesIO()
image.save(buffered, format='JPEG')
image_base64 = base64.b64encode(buffered.getvalue()).decode()
return (
f'data:image/jpeg;base64,{image_base64}'
if add_data_prefix
else f'{image_base64}'
)
+50 -1
View File
@@ -1,15 +1,23 @@
import base64
import datetime
import os
from pathlib import Path
from PIL import Image
from openhands.core.exceptions import BrowserUnavailableException
from openhands.core.schema import ActionType
from openhands.events.action import BrowseInteractiveAction, BrowseURLAction
from openhands.events.observation import BrowserOutputObservation
from openhands.runtime.browser.base64 import png_base64_url_to_image
from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.utils.async_utils import call_sync_from_async
async def browse(
action: BrowseURLAction | BrowseInteractiveAction, browser: BrowserEnv | None
action: BrowseURLAction | BrowseInteractiveAction,
browser: BrowserEnv | None,
workspace_dir: str | None = None,
) -> BrowserOutputObservation:
if browser is None:
raise BrowserUnavailableException()
@@ -31,10 +39,50 @@ async def browse(
try:
# obs provided by BrowserGym: see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/env.py#L396
obs = await call_sync_from_async(browser.step, action_str)
# Save screenshot if workspace_dir is provided
screenshot_path = None
if workspace_dir is not None and obs.get('screenshot'):
# Create screenshots directory if it doesn't exist
screenshots_dir = Path(workspace_dir) / '.browser_screenshots'
screenshots_dir.mkdir(exist_ok=True)
# Generate a filename based on timestamp
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')
screenshot_filename = f'screenshot_{timestamp}.png'
screenshot_path = str(screenshots_dir / screenshot_filename)
# Direct image saving from base64 data without using PIL's Image.open
# This approach bypasses potential encoding issues that might occur when
# converting between different image representations, ensuring the raw PNG
# data from the browser is saved directly to disk.
# Extract the base64 data
base64_data = obs.get('screenshot', '')
if ',' in base64_data:
base64_data = base64_data.split(',')[1]
try:
# Decode base64 directly to binary
image_data = base64.b64decode(base64_data)
# Write binary data directly to file
with open(screenshot_path, 'wb') as f:
f.write(image_data)
# Verify the image was saved correctly by opening it
# This is just a verification step and can be removed in production
Image.open(screenshot_path).verify()
except Exception:
# If direct saving fails, fall back to the original method
image = png_base64_url_to_image(obs.get('screenshot'))
image.save(screenshot_path, format='PNG', optimize=True)
return BrowserOutputObservation(
content=obs['text_content'], # text content of the page
url=obs.get('url', ''), # URL of the page
screenshot=obs.get('screenshot', None), # base64-encoded screenshot, png
screenshot_path=screenshot_path, # path to saved screenshot file
set_of_marks=obs.get(
'set_of_marks', None
), # base64-encoded Set-of-Marks annotated screenshot, png,
@@ -60,6 +108,7 @@ async def browse(
return BrowserOutputObservation(
content=str(e),
screenshot='',
screenshot_path=None,
error=True,
last_browser_action_error=str(e),
url=asked_url if action.action == ActionType.BROWSE else '',
+1 -1
View File
@@ -36,7 +36,7 @@ class DockerRuntimeBuilder(RuntimeBuilder):
self.rolling_logger = RollingLogger(max_lines=10)
@staticmethod
def check_buildx(is_podman: bool = False):
def check_buildx(is_podman: bool = False) -> bool:
"""Check if Docker Buildx is available"""
try:
result = subprocess.run(
+6 -6
View File
@@ -99,8 +99,8 @@ class RemoteRuntimeBuilder(RuntimeBuilder):
logger.info(f'Build status: {status}')
if status == 'SUCCESS':
logger.debug(f"Successfully built {status_data['image']}")
return status_data['image']
logger.debug(f'Successfully built {status_data["image"]}')
return str(status_data['image'])
elif status in [
'FAILURE',
'INTERNAL_ERROR',
@@ -139,11 +139,11 @@ class RemoteRuntimeBuilder(RuntimeBuilder):
if result['exists']:
logger.debug(
f"Image {image_name} exists. "
f"Uploaded at: {result['image']['upload_time']}, "
f"Size: {result['image']['image_size_bytes'] / 1024 / 1024:.2f} MB"
f'Image {image_name} exists. '
f'Uploaded at: {result["image"]["upload_time"]}, '
f'Size: {result["image"]["image_size_bytes"] / 1024 / 1024:.2f} MB'
)
else:
logger.debug(f'Image {image_name} does not exist.')
return result['exists']
return bool(result['exists'])
+3 -4
View File
@@ -5,7 +5,6 @@ This server has no authentication and only listens to localhost traffic.
import os
import threading
from typing import Tuple
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
@@ -22,12 +21,12 @@ def create_app() -> FastAPI:
)
@app.get('/')
async def root():
async def root() -> dict[str, str]:
"""Root endpoint to check if the server is running."""
return {'status': 'File viewer server is running'}
@app.get('/view')
async def view_file(path: str, request: Request):
async def view_file(path: str, request: Request) -> HTMLResponse:
"""View a file using an embedded viewer.
Args:
@@ -75,7 +74,7 @@ def create_app() -> FastAPI:
return app
def start_file_viewer_server(port: int) -> Tuple[str, threading.Thread]:
def start_file_viewer_server(port: int) -> tuple[str, threading.Thread]:
"""Start the file viewer server on the specified port or find an available one.
Args:
@@ -1,4 +1,3 @@
import asyncio
import os
import tempfile
import threading
@@ -46,6 +45,7 @@ from openhands.runtime.utils.request import send_request
from openhands.utils.http_session import HttpSession
from openhands.utils.tenacity_stop import stop_if_should_exit
def _is_retryable_error(exception):
return isinstance(
exception, (httpx.RemoteProtocolError, httpcore.RemoteProtocolError)
@@ -158,7 +158,6 @@ class ActionExecutionClient(Runtime):
def copy_from(self, path: str) -> Path:
"""Zip all files in the sandbox and return as a stream of bytes."""
try:
params = {'path': path}
with self.session.stream(
@@ -183,25 +182,44 @@ class ActionExecutionClient(Runtime):
if not os.path.exists(host_src):
raise FileNotFoundError(f'Source file {host_src} does not exist')
temp_zip_path: str | None = None # Define temp_zip_path outside the try block
try:
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
file_to_upload = None
upload_data = {}
if recursive:
# Create and write the zip file inside the try block
with tempfile.NamedTemporaryFile(
suffix='.zip', delete=False
) as temp_zip:
temp_zip_path = temp_zip.name
with ZipFile(temp_zip_path, 'w') as zipf:
for root, _, files in os.walk(host_src):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(
file_path, os.path.dirname(host_src)
)
zipf.write(file_path, arcname)
try:
with ZipFile(temp_zip_path, 'w') as zipf:
for root, _, files in os.walk(host_src):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(
file_path, os.path.dirname(host_src)
)
zipf.write(file_path, arcname)
upload_data = {'file': open(temp_zip_path, 'rb')}
self.log(
'debug',
f'Opening temporary zip file for upload: {temp_zip_path}',
)
file_to_upload = open(temp_zip_path, 'rb')
upload_data = {'file': file_to_upload}
except Exception as e:
# Ensure temp file is cleaned up if zipping fails
if temp_zip_path and os.path.exists(temp_zip_path):
os.unlink(temp_zip_path)
raise e # Re-raise the exception after cleanup attempt
else:
upload_data = {'file': open(host_src, 'rb')}
file_to_upload = open(host_src, 'rb')
upload_data = {'file': file_to_upload}
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
@@ -217,11 +235,18 @@ class ActionExecutionClient(Runtime):
f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}. Response: {response.text}',
)
finally:
if recursive:
os.unlink(temp_zip_path)
self.log(
'debug', f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}'
)
if file_to_upload:
file_to_upload.close()
# Cleanup the temporary zip file if it was created
if temp_zip_path and os.path.exists(temp_zip_path):
try:
os.unlink(temp_zip_path)
except Exception as e:
self.log(
'error',
f'Failed to delete temporary zip file {temp_zip_path}: {e}',
)
def get_vscode_token(self) -> str:
if self.vscode_enabled and self.runtime_initialized:
@@ -334,50 +359,59 @@ class ActionExecutionClient(Runtime):
server.model_dump(mode='json')
for server in updated_mcp_config.stdio_servers
]
self.log('debug', f'Updating MCP server to: {stdio_tools}')
response = self._send_action_server_request(
'POST',
f'{self.action_execution_server_url}/update_mcp_server',
json=stdio_tools,
timeout=10,
)
if response.status_code != 200:
raise RuntimeError(f'Failed to update MCP server: {response.text}')
# No API key by default. Child runtime can override this when appropriate
updated_mcp_config.sse_servers.append(
MCPSSEServerConfig(
url=self.action_execution_server_url.rstrip('/') + '/sse', api_key=None
if len(stdio_tools) > 0:
self.log('debug', f'Updating MCP server to: {stdio_tools}')
response = self._send_action_server_request(
'POST',
f'{self.action_execution_server_url}/update_mcp_server',
json=stdio_tools,
timeout=10,
)
if response.status_code != 200:
self.log('warning', f'Failed to update MCP server: {response.text}')
# No API key by default. Child runtime can override this when appropriate
updated_mcp_config.sse_servers.append(
MCPSSEServerConfig(
url=self.action_execution_server_url.rstrip('/') + '/sse',
api_key=None,
)
)
self.log(
'info',
f'Updated MCP config: {updated_mcp_config.sse_servers}',
)
else:
self.log(
'debug',
'MCP servers inside runtime is not updated since no stdio servers are provided',
)
)
self.log(
'debug',
f'Updated MCP config by adding runtime as another server: {updated_mcp_config}',
)
return updated_mcp_config
async def call_tool_mcp(self, action: MCPAction) -> Observation:
# Import here to avoid circular imports
from openhands.mcp.utils import create_mcp_clients, call_tool_mcp as call_tool_mcp_handler
from openhands.mcp.utils import call_tool_mcp as call_tool_mcp_handler
from openhands.mcp.utils import create_mcp_clients
# Get the updated MCP config
updated_mcp_config = self.get_updated_mcp_config()
self.log(
'debug',
f'Creating MCP clients with servers: {updated_mcp_config.sse_servers}',
)
# Create clients for this specific operation
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers)
# Call the tool and return the result
# No need for try/finally since disconnect() is now just resetting state
result = await call_tool_mcp_handler(mcp_clients, action)
# Reset client state (no active connections to worry about)
for client in mcp_clients:
await client.disconnect()
return result
def close(self) -> None:
+48
View File
@@ -12,18 +12,32 @@
### Step 2: Set Your API Key as an Environment Variable
Run the following command in your terminal, replacing `<your-api-key>` with the actual key you copied:
Mac/Linux:
```bash
export DAYTONA_API_KEY="<your-api-key>"
```
Windows PowerShell:
```powershell
$env:DAYTONA_API_KEY="<your-api-key>"
```
This step ensures that OpenHands can authenticate with the Daytona platform when it runs.
### Step 3: Run OpenHands Locally Using Docker
To start the latest version of OpenHands on your machine, execute the following command in your terminal:
Mac/Linux:
```bash
bash -i <(curl -sL https://get.daytona.io/openhands)
```
Windows:
```powershell
powershell -Command "irm https://get.daytona.io/openhands-windows | iex"
```
#### What This Command Does:
- Downloads the latest OpenHands release script.
- Runs the script in an interactive Bash session.
@@ -36,10 +50,16 @@ Once executed, OpenHands should be running locally and ready for use.
### Step 1: Set the `OPENHANDS_VERSION` Environment Variable
Run the following command in your terminal, replacing `<openhands-release>` with the latest release's version seen in the [main README.md file](https://github.com/All-Hands-AI/OpenHands?tab=readme-ov-file#-quick-start):
#### Mac/Linux:
```bash
export OPENHANDS_VERSION="<openhands-release>" # e.g. 0.27
```
#### Windows PowerShell:
```powershell
$env:OPENHANDS_VERSION="<openhands-release>" # e.g. 0.27
```
### Step 2: Retrieve Your Daytona API Key
1. Visit the [Daytona Dashboard](https://app.daytona.io/dashboard/keys).
2. Click **"Create Key"**.
@@ -48,13 +68,21 @@ export OPENHANDS_VERSION="<openhands-release>" # e.g. 0.27
### Step 3: Set Your API Key as an Environment Variable:
Run the following command in your terminal, replacing `<your-api-key>` with the actual key you copied:
#### Mac/Linux:
```bash
export DAYTONA_API_KEY="<your-api-key>"
```
#### Windows PowerShell:
```powershell
$env:DAYTONA_API_KEY="<your-api-key>"
```
### Step 4: Run the following `docker` command:
This command pulls and runs the OpenHands container using Docker. Once executed, OpenHands should be running locally and ready for use.
#### Mac/Linux:
```bash
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:${OPENHANDS_VERSION}-nikolaik \
@@ -67,16 +95,36 @@ docker run -it --rm --pull=always \
docker.all-hands.dev/all-hands-ai/openhands:${OPENHANDS_VERSION}
```
#### Windows:
```powershell
docker run -it --rm --pull=always `
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:${env:OPENHANDS_VERSION}-nikolaik `
-e LOG_ALL_EVENTS=true `
-e RUNTIME=daytona `
-e DAYTONA_API_KEY=${env:DAYTONA_API_KEY} `
-v ~/.openhands-state:/.openhands-state `
-p 3000:3000 `
--name openhands-app `
docker.all-hands.dev/all-hands-ai/openhands:${env:OPENHANDS_VERSION}
```
> **Tip:** If you don't want your sandboxes to default to the EU region, you can set the `DAYTONA_TARGET` environment variable to `us`
### Running OpenHands Locally Without Docker
Alternatively, if you want to run the OpenHands app on your local machine using `make run` without Docker, make sure to set the following environment variables first:
#### Mac/Linux:
```bash
export RUNTIME="daytona"
export DAYTONA_API_KEY="<your-api-key>"
```
#### Windows PowerShell:
```powershell
$env:RUNTIME="daytona"
$env:DAYTONA_API_KEY="<your-api-key>"
```
## Documentation
Read more by visiting our [documentation](https://www.daytona.io/docs/) page.
@@ -115,12 +115,12 @@ class DaytonaRuntime(ActionExecutionClient):
def _construct_api_url(self, port: int) -> str:
assert self.workspace is not None, 'Workspace is not initialized'
assert (
self.workspace.instance.info is not None
), 'Workspace info is not available'
assert (
self.workspace.instance.info.provider_metadata is not None
), 'Provider metadata is not available'
assert self.workspace.instance.info is not None, (
'Workspace info is not available'
)
assert self.workspace.instance.info.provider_metadata is not None, (
'Provider metadata is not available'
)
node_domain = json.loads(self.workspace.instance.info.provider_metadata)[
'nodeDomain'
+63 -16
View File
@@ -47,6 +47,7 @@ def _is_retryable_wait_until_alive_error(exception):
exception,
(
ConnectionError,
httpx.ConnectTimeout,
httpx.NetworkError,
httpx.RemoteProtocolError,
httpx.HTTPStatusError,
@@ -207,12 +208,64 @@ class DockerRuntime(ActionExecutionClient):
)
raise ex
def _process_volumes(self) -> dict[str, dict[str, str]]:
"""Process volume mounts based on configuration.
Returns:
A dictionary mapping host paths to container bind mounts with their modes.
"""
# Initialize volumes dictionary
volumes: dict[str, dict[str, str]] = {}
# Process volumes (comma-delimited)
if self.config.sandbox.volumes is not None:
# Handle multiple mounts with comma delimiter
mounts = self.config.sandbox.volumes.split(',')
for mount in mounts:
parts = mount.split(':')
if len(parts) >= 2:
host_path = os.path.abspath(parts[0])
container_path = parts[1]
# Default mode is 'rw' if not specified
mount_mode = parts[2] if len(parts) > 2 else 'rw'
volumes[host_path] = {
'bind': container_path,
'mode': mount_mode,
}
logger.debug(
f'Mount dir (sandbox.volumes): {host_path} to {container_path} with mode: {mount_mode}'
)
# Legacy mounting with workspace_* parameters
elif (
self.config.workspace_mount_path is not None
and self.config.workspace_mount_path_in_sandbox is not None
):
mount_mode = 'rw' # Default mode
# e.g. result would be: {"/home/user/openhands/workspace": {'bind': "/workspace", 'mode': 'rw'}}
volumes[self.config.workspace_mount_path] = {
'bind': self.config.workspace_mount_path_in_sandbox,
'mode': mount_mode,
}
logger.debug(
f'Mount dir (legacy): {self.config.workspace_mount_path} with mode: {mount_mode}'
)
return volumes
def _init_container(self):
self.log('debug', 'Preparing to start container...')
self.send_status_message('STATUS$PREPARING_CONTAINER')
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._container_port = self._host_port
self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE)
# Use the configured vscode_port if provided, otherwise find an available port
self._vscode_port = (
self.config.sandbox.vscode_port
or self._find_available_port(VSCODE_PORT_RANGE)
)
self._app_ports = [
self._find_available_port(APP_PORT_RANGE_1),
self._find_available_port(APP_PORT_RANGE_2),
@@ -268,23 +321,16 @@ class DockerRuntime(ActionExecutionClient):
environment.update(self.config.sandbox.runtime_startup_env_vars)
self.log('debug', f'Workspace Base: {self.config.workspace_base}')
if (
self.config.workspace_mount_path is not None
and self.config.workspace_mount_path_in_sandbox is not None
):
# e.g. result would be: {"/home/user/openhands/workspace": {'bind': "/workspace", 'mode': 'rw'}}
volumes = {
self.config.workspace_mount_path: {
'bind': self.config.workspace_mount_path_in_sandbox,
'mode': 'rw',
}
}
logger.debug(f'Mount dir: {self.config.workspace_mount_path}')
else:
# Process volumes for mounting
volumes = self._process_volumes()
# If no volumes were configured, set to None
if not volumes:
logger.debug(
'Mount dir is not set, will not mount the workspace directory to the container'
)
volumes = None
volumes = {} # Empty dict instead of None to satisfy mypy
self.log(
'debug',
f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}',
@@ -443,8 +489,9 @@ class DockerRuntime(ActionExecutionClient):
def web_hosts(self):
hosts: dict[str, int] = {}
host_addr = os.environ.get('DOCKER_HOST_ADDR', 'localhost')
for port in self._app_ports:
hosts[f'http://localhost:{port}'] = port
hosts[f'http://{host_addr}:{port}'] = port
return hosts
+6 -6
View File
@@ -40,9 +40,9 @@ class E2BBox:
def _archive(self, host_src: str, recursive: bool = False):
if recursive:
assert os.path.isdir(
host_src
), 'Source must be a directory when recursive is True'
assert os.path.isdir(host_src), (
'Source must be a directory when recursive is True'
)
files = glob(host_src + '/**/*', recursive=True)
srcname = os.path.basename(host_src)
tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar')
@@ -52,9 +52,9 @@ class E2BBox:
file, arcname=os.path.relpath(file, os.path.dirname(host_src))
)
else:
assert os.path.isfile(
host_src
), 'Source must be a file when recursive is False'
assert os.path.isfile(host_src), (
'Source must be a file when recursive is False'
)
srcname = os.path.basename(host_src)
tar_filename = os.path.join(os.path.dirname(host_src), srcname + '.tar')
with tarfile.open(tar_filename, mode='w') as tar:
+90 -33
View File
@@ -41,6 +41,18 @@ from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.tenacity_stop import stop_if_should_exit
def get_user_info():
"""Get user ID and username in a cross-platform way."""
username = os.getenv('USER')
if sys.platform == 'win32':
# On Windows, we don't use user IDs the same way
# Return a default value that won't cause issues
return 1000, username
else:
# On Unix systems, use os.getuid()
return os.getuid(), username
def check_dependencies(code_repo_path: str, poetry_venvs_path: str):
ERROR_MESSAGE = 'Please follow the instructions in https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md to install OpenHands.'
if not os.path.exists(code_repo_path):
@@ -63,28 +75,33 @@ def check_dependencies(code_repo_path: str, poetry_venvs_path: str):
if 'jupyter' not in output.lower():
raise ValueError('Jupyter is not properly installed. ' + ERROR_MESSAGE)
# Check libtmux is installed
logger.debug('Checking dependencies: libtmux')
import libtmux
# Check libtmux is installed (skip on Windows)
server = libtmux.Server()
try:
session = server.new_session(session_name='test-session')
except Exception:
raise ValueError('tmux is not properly installed or available on the path.')
pane = session.attached_pane
pane.send_keys('echo "test"')
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)
session.kill_session()
if 'test' not in pane_output:
raise ValueError('libtmux is not properly installed. ' + ERROR_MESSAGE)
if sys.platform != 'win32':
logger.debug('Checking dependencies: libtmux')
import libtmux
# Check browser works
logger.debug('Checking dependencies: browser')
from openhands.runtime.browser.browser_env import BrowserEnv
server = libtmux.Server()
try:
session = server.new_session(session_name='test-session')
except Exception:
raise ValueError('tmux is not properly installed or available on the path.')
pane = session.attached_pane
pane.send_keys('echo "test"')
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)
session.kill_session()
if 'test' not in pane_output:
raise ValueError('libtmux is not properly installed. ' + ERROR_MESSAGE)
browser = BrowserEnv()
browser.close()
# Skip browser environment check on Windows
if sys.platform != 'win32':
logger.debug('Checking dependencies: browser')
from openhands.runtime.browser.browser_env import BrowserEnv
browser = BrowserEnv()
browser.close()
else:
logger.warning('Running on Windows - browser environment check skipped.')
class LocalRuntime(ActionExecutionClient):
@@ -110,9 +127,15 @@ class LocalRuntime(ActionExecutionClient):
attach_to_existing: bool = False,
headless_mode: bool = True,
):
self.is_windows = sys.platform == 'win32'
if self.is_windows:
logger.warning(
'Running on Windows - some features that require tmux will be limited. '
'For full functionality, please consider using WSL or Docker runtime.'
)
self.config = config
self._user_id = os.getuid()
self._username = os.getenv('USER')
self._user_id, self._username = get_user_info()
if self.config.workspace_base is not None:
logger.warning(
@@ -161,6 +184,7 @@ class LocalRuntime(ActionExecutionClient):
self.status_callback = status_callback
self.server_process: subprocess.Popen[str] | None = None
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
self._log_thread_exit_event = threading.Event() # Add exit event
# Update env vars
if self.config.sandbox.runtime_startup_env_vars:
@@ -199,7 +223,7 @@ class LocalRuntime(ActionExecutionClient):
server_port=self._host_port,
plugins=self.plugins,
app_config=self.config,
python_prefix=[],
python_prefix=['poetry', 'run'],
override_user_id=self._user_id,
override_username=self._username,
)
@@ -208,7 +232,7 @@ class LocalRuntime(ActionExecutionClient):
env = os.environ.copy()
# Get the code repo path
code_repo_path = os.path.dirname(os.path.dirname(openhands.__file__))
env['PYTHONPATH'] = f'{code_repo_path}{os.pathsep}{env.get("PYTHONPATH", "")}'
env['PYTHONPATH'] = os.pathsep.join([code_repo_path, env.get('PYTHONPATH', '')])
env['OPENHANDS_REPO_PATH'] = code_repo_path
env['LOCAL_RUNTIME_MODE'] = '1'
@@ -230,19 +254,50 @@ class LocalRuntime(ActionExecutionClient):
universal_newlines=True,
bufsize=1,
env=env,
cwd=code_repo_path, # Explicitly set the working directory
)
# Start a thread to read and log server output
def log_output():
while (
self.server_process
and self.server_process.poll()
and self.server_process.stdout
):
line = self.server_process.stdout.readline()
if not line:
break
self.log('debug', f'Server: {line.strip()}')
if not self.server_process or not self.server_process.stdout:
self.log('error', 'Server process or stdout not available for logging.')
return
try:
# Read lines while the process is running and stdout is available
while self.server_process.poll() is None:
if self._log_thread_exit_event.is_set(): # Check exit event
self.log('info', 'Log thread received exit signal.')
break # Exit loop if signaled
line = self.server_process.stdout.readline()
if not line:
# Process might have exited between poll() and readline()
break
self.log('info', f'Server: {line.strip()}')
# Capture any remaining output after the process exits OR if signaled
if (
not self._log_thread_exit_event.is_set()
): # Check again before reading remaining
self.log('info', 'Server process exited, reading remaining output.')
for line in self.server_process.stdout:
if (
self._log_thread_exit_event.is_set()
): # Check inside loop too
self.log(
'info',
'Log thread received exit signal while reading remaining output.',
)
break
self.log('info', f'Server (remaining): {line.strip()}')
except Exception as e:
# Log the error, but don't prevent the thread from potentially exiting
self.log('error', f'Error reading server output: {e}')
finally:
self.log(
'info', 'Log output thread finished.'
) # Add log for thread exit
self._log_thread = threading.Thread(target=log_output, daemon=True)
self._log_thread.start()
@@ -312,6 +367,8 @@ class LocalRuntime(ActionExecutionClient):
def close(self):
"""Stop the server process."""
self._log_thread_exit_event.set() # Signal the log thread to exit
if self.server_process:
self.server_process.terminate()
try:
@@ -319,7 +376,7 @@ class LocalRuntime(ActionExecutionClient):
except subprocess.TimeoutExpired:
self.server_process.kill()
self.server_process = None
self._log_thread.join()
self._log_thread.join(timeout=5) # Add timeout to join
if self._temp_workspace:
shutil.rmtree(self._temp_workspace)
+43 -29
View File
@@ -1,11 +1,12 @@
import json
import logging
import os
from typing import Callable
from typing import Any, Callable
from urllib.parse import urlparse
import httpx
import tenacity
from tenacity import RetryCallState
from openhands.core.config import AppConfig
from openhands.core.exceptions import (
@@ -37,6 +38,9 @@ class RemoteRuntime(ActionExecutionClient):
runtime_id: str | None = None
runtime_url: str | None = None
_runtime_initialized: bool = False
runtime_builder: RemoteRuntimeBuilder
container_image: str
available_hosts: dict[str, int]
def __init__(
self,
@@ -45,12 +49,12 @@ class RemoteRuntime(ActionExecutionClient):
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
status_callback: Callable[..., None] | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
):
) -> None:
super().__init__(
config,
event_stream,
@@ -94,10 +98,12 @@ class RemoteRuntime(ActionExecutionClient):
getattr(logger, level)(message, stacklevel=2)
@property
def action_execution_server_url(self):
def action_execution_server_url(self) -> str:
if self.runtime_url is None:
raise NotImplementedError('Runtime URL is not initialized')
return self.runtime_url
async def connect(self):
async def connect(self) -> None:
try:
await call_sync_from_async(self._start_or_attach_to_runtime)
except Exception:
@@ -107,7 +113,7 @@ class RemoteRuntime(ActionExecutionClient):
await call_sync_from_async(self.setup_initial_env)
self._runtime_initialized = True
def _start_or_attach_to_runtime(self):
def _start_or_attach_to_runtime(self) -> None:
existing_runtime = self._check_existing_runtime()
if existing_runtime:
self.log('debug', f'Using existing runtime with ID: {self.runtime_id}')
@@ -130,12 +136,12 @@ class RemoteRuntime(ActionExecutionClient):
)
self.container_image = self.config.sandbox.runtime_container_image
self._start_runtime()
assert (
self.runtime_id is not None
), 'Runtime ID is not set. This should never happen.'
assert (
self.runtime_url is not None
), 'Runtime URL is not set. This should never happen.'
assert self.runtime_id is not None, (
'Runtime ID is not set. This should never happen.'
)
assert self.runtime_url is not None, (
'Runtime URL is not set. This should never happen.'
)
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
if not self.attach_to_existing:
self.log('info', 'Waiting for runtime to be alive...')
@@ -179,7 +185,7 @@ class RemoteRuntime(ActionExecutionClient):
self.log('error', f'Invalid response from runtime API: {data}')
return False
def _build_runtime(self):
def _build_runtime(self) -> None:
self.log('debug', f'Building RemoteRuntime config:\n{self.config}')
response = self._send_runtime_api_request(
'GET',
@@ -223,18 +229,18 @@ class RemoteRuntime(ActionExecutionClient):
f'Container image {self.container_image} does not exist'
)
def _start_runtime(self):
def _start_runtime(self) -> None:
# Prepare the request body for the /start endpoint
command = get_action_execution_server_startup_command(
server_port=self.port,
plugins=self.plugins,
app_config=self.config,
)
environment = {}
environment: dict[str, str] = {}
if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true':
environment['DEBUG'] = 'true'
environment.update(self.config.sandbox.runtime_startup_env_vars)
start_request = {
start_request: dict[str, Any] = {
'image': self.container_image,
'command': command,
'working_dir': '/openhands/code/',
@@ -262,8 +268,10 @@ class RemoteRuntime(ActionExecutionClient):
self.log('error', f'Unable to start runtime: {str(e)}')
raise AgentRuntimeUnavailableError() from e
def _resume_runtime(self):
"""
def _resume_runtime(self) -> None:
"""Resume a stopped runtime.
Steps:
1. Show status update that runtime is being started.
2. Send the runtime API a /resume request
3. Poll for the runtime to be ready
@@ -279,7 +287,7 @@ class RemoteRuntime(ActionExecutionClient):
self.setup_initial_env()
self.log('debug', 'Runtime resumed.')
def _parse_runtime_response(self, response: httpx.Response):
def _parse_runtime_response(self, response: httpx.Response) -> None:
start_response = response.json()
self.runtime_id = start_response['runtime_id']
self.runtime_url = start_response['url']
@@ -310,7 +318,7 @@ class RemoteRuntime(ActionExecutionClient):
def web_hosts(self) -> dict[str, int]:
return self.available_hosts
def _wait_until_alive(self):
def _wait_until_alive(self) -> None:
retry_decorator = tenacity.retry(
stop=tenacity.stop_after_delay(
self.config.sandbox.remote_runtime_init_timeout
@@ -321,9 +329,9 @@ class RemoteRuntime(ActionExecutionClient):
retry=tenacity.retry_if_exception_type(AgentRuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
return retry_decorator(self._wait_until_alive_impl)()
retry_decorator(self._wait_until_alive_impl)()
def _wait_until_alive_impl(self):
def _wait_until_alive_impl(self) -> None:
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
runtime_info_response = self._send_runtime_api_request(
'GET',
@@ -384,7 +392,7 @@ class RemoteRuntime(ActionExecutionClient):
)
raise AgentRuntimeNotReadyError()
def close(self):
def close(self) -> None:
if self.attach_to_existing:
super().close()
return
@@ -417,7 +425,9 @@ class RemoteRuntime(ActionExecutionClient):
finally:
super().close()
def _send_runtime_api_request(self, method, url, **kwargs):
def _send_runtime_api_request(
self, method: str, url: str, **kwargs: Any
) -> httpx.Response:
try:
kwargs['timeout'] = self.config.sandbox.remote_runtime_api_timeout
return send_request(self.session, method, url, **kwargs)
@@ -428,7 +438,9 @@ class RemoteRuntime(ActionExecutionClient):
)
raise
def _send_action_server_request(self, method, url, **kwargs):
def _send_action_server_request(
self, method: str, url: str, **kwargs: Any
) -> httpx.Response:
if not self.config.sandbox.remote_runtime_enable_retries:
return self._send_action_server_request_impl(method, url, **kwargs)
@@ -444,7 +456,9 @@ class RemoteRuntime(ActionExecutionClient):
method, url, **kwargs
)
def _send_action_server_request_impl(self, method, url, **kwargs):
def _send_action_server_request_impl(
self, method: str, url: str, **kwargs: Any
) -> httpx.Response:
try:
return super()._send_action_server_request(method, url, **kwargs)
except httpx.TimeoutException:
@@ -455,7 +469,7 @@ class RemoteRuntime(ActionExecutionClient):
raise
except httpx.HTTPError as e:
if e.response.status_code in (404, 502, 504):
if hasattr(e, 'response') and e.response.status_code in (404, 502, 504):
if e.response.status_code == 404:
raise AgentRuntimeDisconnectedError(
f'Runtime is not responding. This may be temporary, please try again. Original error: {e}'
@@ -464,7 +478,7 @@ class RemoteRuntime(ActionExecutionClient):
raise AgentRuntimeDisconnectedError(
f'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again. Original error: {e}'
) from e
elif e.response.status_code == 503:
elif hasattr(e, 'response') and e.response.status_code == 503:
if self.config.sandbox.keep_runtime_alive:
self.log('warning', 'Runtime appears to be paused. Resuming...')
self._resume_runtime()
@@ -476,5 +490,5 @@ class RemoteRuntime(ActionExecutionClient):
else:
raise e
def _stop_if_closed(self, retry_state: tenacity.RetryCallState) -> bool:
def _stop_if_closed(self, retry_state: RetryCallState) -> bool:
return self._runtime_closed

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