mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 497fd4a02c | |||
| 7fac7d6dd0 | |||
| 4155b8f801 | |||
| e712b013f9 | |||
| 29b137e9b1 | |||
| 1292f0c2ea | |||
| da8c946078 | |||
| 43cef1f969 | |||
| 722711db3b | |||
| ab661b485b | |||
| 9632914bf0 | |||
| f45f398d81 | |||
| 3db780ef93 | |||
| 43c16516e8 | |||
| 0bab3b62f2 | |||
| ae990d3cb1 | |||
| 9babd756e5 | |||
| 985e20d529 | |||
| 98cb2e24ee | |||
| de175dcc87 | |||
| 976019ce11 | |||
| 709b6ff39a | |||
| 767d092f8f | |||
| 7244e5df9f | |||
| dfbb968ea0 | |||
| e4c3bbbc08 | |||
| 6e0fbfeeda | |||
| 03aa5d7456 |
@@ -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:**
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "OpenHands Cloud",
|
||||
"position": 9,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Documentation for OpenHands Cloud features and services."
|
||||
}
|
||||
}
|
||||
@@ -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 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:
|
||||
|
||||
@@ -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
@@ -55,4 +55,4 @@
|
||||
"node": ">=18.0"
|
||||
},
|
||||
"packageManager": "npm@10.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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',
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Vendored
+8
-7
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
|
||||
Generated
+228
-230
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ウェイトリストに参加",
|
||||
|
||||
@@ -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 });
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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='',
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}')
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)))
|
||||
@@ -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}'
|
||||
)
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user