Compare commits

...

29 Commits

Author SHA1 Message Date
sp.wack
28d7127257 hotfix(frontend): Return DEFAULT_SETTINGS if GET /settings is 404 (#6517) 2025-01-29 17:24:17 +00:00
dependabot[bot]
1509f4ce56 chore(deps): bump the version-all group with 6 updates (#6516)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-29 16:36:47 +01:00
sp.wack
a7bb6720ba feat: Better error message handling (#6502) 2025-01-29 15:25:31 +00:00
sp.wack
b987f33a67 chore: Remove settings local storage logic (#6504) 2025-01-29 15:42:20 +04:00
Rohit Malhotra
eb760f32c7 Refactor: Don't serialize matching events when searching event stream (#6509) 2025-01-28 18:17:44 -05:00
sp.wack
35346068d1 chore: Remove root level package.json (#6498) 2025-01-29 00:31:48 +04:00
Chriest Yu
8ae5655157 fix(frontend): make chat message content wrappable (#6421) 2025-01-28 19:03:11 +00:00
dependabot[bot]
de786f930d chore(deps): bump the version-all group across 1 directory with 21 updates (#6493)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-01-28 18:10:09 +00:00
Xingyao Wang
7bf354be53 chore: typo fix for for add_openhands_repo_instruction.md (#6501) 2025-01-28 17:50:11 +00:00
Rohit Malhotra
f18729f5f8 Remove unused refresh func (#6499) 2025-01-28 17:09:29 +00:00
Robert Brennan
f3b8bad09f Fix file descriptor leak in S3FileStore (#6486)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-28 11:47:37 -05:00
Robert Brennan
41e5d12f63 update slack link (#6497) 2025-01-28 11:37:33 -05:00
dependabot[bot]
fa009f0a57 chore(deps): bump the version-all group with 10 updates (#6496)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-28 16:11:50 +00:00
Xingyao Wang
391200510c fix: revert #5506 for SWE-Bench performance regression (#6491)
Co-authored-by: Robert Brennan <accounts@rbren.io>
2025-01-28 22:52:57 +08:00
sp.wack
36c2abadc2 chore: Move GitHub logic out of the frontend (#6307)
Co-authored-by: Robert Brennan <accounts@rbren.io>
2025-01-28 13:14:32 +00:00
dependabot[bot]
d6655f3470 chore(deps): bump the version-all group in /docs with 3 updates (#6288)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-28 16:57:22 +04:00
Engel Nyst
f2427d7ffa Add the resolver to the bug_template (#6490) 2025-01-28 02:45:24 +00:00
Rohit Malhotra
94a64a47f2 Feat: Filter matching events in reverse order (#6485) 2025-01-27 22:53:16 +00:00
Rohit Malhotra
0ba96ce69e Feat: Ability to filter events by multiple types (#6484) 2025-01-27 22:09:16 +00:00
Engel Nyst
89c7bf59a7 Fix first user message (#6471) 2025-01-27 22:09:03 +01:00
Rohit Malhotra
604534905f Refactor: Use type[Event] instead of str to filter events (#6480)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-27 13:58:09 -05:00
Xingyao Wang
4bde644fab Improve function call validation with better error handling (#6453)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-28 02:42:45 +08:00
tofarr
ffdab28abc Fix Docker runtimes not stopping (#6470)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-27 11:09:09 -07:00
Calvin Smith
12dd23ba1c Enable memory condensation from the frontend (#6333)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-01-27 11:02:35 -07:00
Robert Brennan
9611093458 allow http session reuse (#6478) 2025-01-27 12:29:49 -05:00
tofarr
8a65df6bce refactor: Update get_github_installation_ids to use httpx (#6451)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-27 09:59:50 -07:00
tofarr
c997495200 Fix S3FileStore / GoogleCloudFileStore directory list & deletion (#6449)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-27 08:40:08 -07:00
Calvin Smith
23348af431 Add test for context window truncation in agent controller (#6477)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-01-27 08:35:43 -07:00
dependabot[bot]
5b53dbd85c chore(deps-dev): bump llama-index from 0.12.13 to 0.12.14 in the llama group (#6476)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 15:30:53 +00:00
101 changed files with 4971 additions and 2533 deletions

View File

@@ -30,6 +30,7 @@ body:
description: How are you running OpenHands?
options:
- Docker command in README
- GitHub resolver
- Development workflow
- app.all-hands.dev
- Other

View File

@@ -12,7 +12,7 @@
<a href="https://codecov.io/github/All-Hands-AI/OpenHands?branch=main"><img alt="CodeCov" src="https://img.shields.io/codecov/c/github/All-Hands-AI/OpenHands?style=for-the-badge&color=blue"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
<br/>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
<br/>
@@ -96,7 +96,7 @@ troubleshooting resources, and advanced configuration options.
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
through Slack, so this is the best place to start, but we also are happy to have you contact us on Discord or Github:
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) - Here we talk about research, architecture, and future development.
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg) - Here we talk about research, architecture, and future development.
- [Join our Discord server](https://discord.gg/ESHStjSjD4) - This is a community-run server for general discussion, questions, and feedback.
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.

View File

@@ -95,7 +95,3 @@ sandbox_user_id="1001"
### Erreurs de port d'utilisation
Si vous voyez un message d'erreur indiquant que le port est utilisé ou indisponible, essayez de supprimer toutes les containers docker en cours d'exécution (exécutez `docker ps` et `docker rm` des containers concernés) puis ré-exécutez ```make run```
## Discuter
Pour d'autres problèmes ou questions rejoignez le [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) ou le [Discord](https://discord.gg/ESHStjSjD4) et demandez!

View File

@@ -42,7 +42,7 @@ Explorez le code source d'OpenHands sur [GitHub](https://github.com/All-Hands-AI
/>
</a>
<br></br>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw">
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg">
<img
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
alt="Join our Slack community"

View File

@@ -96,7 +96,3 @@ sandbox_user_id="1001"
### 端口使用错误
如果您遇到端口被占用或不可用的错误提示,可以尝试先用`docker ps`命令列出所有运行中的 Docker 容器,然后使用`docker rm`命令删除相关容器,最后再重新执行```make run```命令。
## 讨论
对于其他问题或疑问,请加入 [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) 或 [Discord](https://discord.gg/ESHStjSjD4) 提问!

View File

@@ -42,7 +42,7 @@ OpenHands 是一个**自主 AI 软件工程师**,能够执行复杂的工程
/>
</a>
<br></br>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw">
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg">
<img
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
alt="Join our Slack community"

2077
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,8 +22,8 @@
"@mdx-js/react": "^3.1.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.4.0",
"react-use": "^17.6.0"
},
@@ -31,7 +31,7 @@
"@docusaurus/module-type-aliases": "^3.5.1",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.5.1",
"typescript": "~5.7.2"
"typescript": "~5.7.3"
},
"browserslist": {
"production": [

View File

@@ -8,7 +8,7 @@ function CustomFooter() {
<footer className="custom-footer">
<div className="footer-content">
<div className="footer-icons">
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw" target="_blank" rel="noopener noreferrer">
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg" target="_blank" rel="noopener noreferrer">
<FaSlack />
</a>
<a href="https://discord.gg/ESHStjSjD4" target="_blank" rel="noopener noreferrer">

View File

@@ -23,7 +23,7 @@ export function HomepageHeader() {
<a href="https://codecov.io/github/All-Hands-AI/OpenHands?branch=main"><img alt="CodeCov" src="https://img.shields.io/codecov/c/github/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" /></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License" /></a>
<br/>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community" /></a>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community" /></a>
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community" /></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits" /></a>
<br/>

View File

@@ -67,11 +67,11 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata):
'<uploaded_files>\n'
f'/workspace/{workspace_dir_name}\n'
'</uploaded_files>\n'
f"I've uploaded a python code repository in the directory {workspace_dir_name}. Consider the following PR description:\n\n"
f'<pr_description>\n'
f"I've uploaded a python code repository in the directory {workspace_dir_name}. Consider the following issue description:\n\n"
f'<issue_description>\n'
f'{instance.problem_statement}\n'
'</pr_description>\n\n'
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <pr_description> are met?\n'
'</issue_description>\n\n'
'Can you help me implement the necessary changes to the repository so that the requirements specified in the <issue_description> are met?\n'
"I've already taken care of all changes to any of the test files described in the <pr_description>. This means you DON'T have to modify the testing logic or any of the tests in any way!\n"
'Your task is to make the minimal changes to non-tests files in the /workspace directory to ensure the <pr_description> is satisfied.\n'
'Follow these steps to resolve the issue:\n'

View File

@@ -15,11 +15,25 @@ parser.add_argument(
action='store_true',
help='Show visualization paths for failed instances',
)
parser.add_argument(
'--only-x-instances',
action='store_true',
help='Only show instances that are ran by X',
)
args = parser.parse_args()
df1 = pd.read_json(args.input_file_1, orient='records', lines=True)
df2 = pd.read_json(args.input_file_2, orient='records', lines=True)
if args.only_x_instances:
instance_ids_1 = set(df1['instance_id'].tolist())
print(
f'Before removing instances not in X={args.input_file_1}: Y={df2.shape[0]} instances'
)
df2 = df2[df2['instance_id'].isin(instance_ids_1)]
print(
f'After removing instances not in X={args.input_file_1}: Y={df2.shape[0]} instances'
)
# Get the intersection of the instance_ids
df = pd.merge(df1, df2, on='instance_id', how='inner')
@@ -86,7 +100,7 @@ repo_diffs = []
for repo in all_repos:
x_count = len(x_only_by_repo.get(repo, []))
y_count = len(y_only_by_repo.get(repo, []))
diff = abs(x_count - y_count)
diff = y_count - x_count
repo_diffs.append((repo, diff))
# Sort by diff (descending) and then by repo name
@@ -106,7 +120,13 @@ for repo, diff in repo_diffs:
repo_color = 'red' if is_significant else 'yellow'
print(f"\n{colored(repo, repo_color, attrs=['bold'])}:")
print(colored(f'Difference: {diff} instances!', repo_color, attrs=['bold']))
print(
colored(
f'Difference: {diff} instances! (Larger diff = Y better)',
repo_color,
attrs=['bold'],
)
)
print(colored(f'X resolved but Y failed: ({len(x_instances)} instances)', 'green'))
if x_instances:
print(' ' + str(x_instances))

View File

@@ -4,6 +4,7 @@
import argparse
import json
import os
from glob import glob
import pandas as pd
from tqdm import tqdm
@@ -20,6 +21,7 @@ output_md_folder = args.oh_output_file.replace('.jsonl', '.viz')
print(f'Converting {args.oh_output_file} to markdown files in {output_md_folder}')
oh_format = pd.read_json(args.oh_output_file, orient='records', lines=True)
output_dir = os.path.dirname(args.oh_output_file)
swebench_eval_file = args.oh_output_file.replace('.jsonl', '.swebench_eval.jsonl')
if os.path.exists(swebench_eval_file):
@@ -57,22 +59,172 @@ def convert_history_to_str(history):
return ret
# Load trajectories for resolved instances
def load_completions(instance_id: str):
global output_dir
glob_path = os.path.join(output_dir, 'llm_completions', instance_id, '*.json')
files = sorted(glob(glob_path)) # this is ascending order
# pick the last file (last turn)
try:
file_path = files[-1]
except IndexError:
# print(f'No files found for instance {instance_id}: files={files}')
return None
with open(file_path, 'r') as f:
result = json.load(f)
# create messages
messages = result['messages']
messages.append(result['response']['choices'][0]['message'])
tools = result['kwargs']['tools']
return {
'messages': messages,
'tools': tools,
}
def _convert_content(content) -> str:
ret = ''
if isinstance(content, list):
for item in content:
assert item['type'] == 'text', 'Only text is supported for now'
ret += f'{item["text"]}\n'
else:
assert isinstance(content, str), 'Only str is supported for now'
ret = content
return ret
def convert_tool_call_to_string(tool_call: dict) -> str:
"""Convert tool call to content in string format."""
if 'function' not in tool_call:
raise ValueError("Tool call must contain 'function' key.")
if 'id' not in tool_call:
raise ValueError("Tool call must contain 'id' key.")
if 'type' not in tool_call:
raise ValueError("Tool call must contain 'type' key.")
if tool_call['type'] != 'function':
raise ValueError("Tool call type must be 'function'.")
ret = f"<function={tool_call['function']['name']}>\n"
try:
args = json.loads(tool_call['function']['arguments'])
except json.JSONDecodeError as e:
raise ValueError(
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
ret += f'<parameter={param_name}>'
if is_multiline:
ret += '\n'
ret += f'{param_value}'
if is_multiline:
ret += '\n'
ret += '</parameter>\n'
ret += '</function>'
return ret
def format_traj(traj, first_n_turns=None, last_n_turns=None) -> str:
output = ''
system_message = None
# Handle system message if present
if traj[0]['role'] == 'system':
system_message = traj[0]
traj = traj[1:]
content = _convert_content(system_message['content'])
output += "*** System Message that describes the assistant's behavior ***\n"
output += f'{content}\n'
# Merge consecutive user messages first
merged_traj = []
current_messages = []
n_turns = len(traj)
for i, message in enumerate(traj):
# Skip this message if...
if (
# Case 1: first_n_turns specified and we're past it
(first_n_turns is not None and i >= first_n_turns and last_n_turns is None)
or
# Case 2: last_n_turns specified and we're before it
(
last_n_turns is not None
and i < n_turns - last_n_turns
and first_n_turns is None
)
or
# Case 3: both specified and we're in the middle section
(
first_n_turns is not None
and last_n_turns is not None
and i >= first_n_turns
and i < n_turns - last_n_turns
)
):
continue
if message['role'] == 'user':
current_messages.append(message)
else:
if current_messages:
# Merge all accumulated user messages into one
merged_content = '\n'.join(
_convert_content(msg['content']) for msg in current_messages
)
merged_traj.append({'role': 'user', 'content': merged_content})
current_messages = []
merged_traj.append(message)
# Don't forget to handle any remaining user messages
if current_messages:
merged_content = '\n'.join(
_convert_content(msg['content']) for msg in current_messages
)
merged_traj.append({'role': 'user', 'content': merged_content})
# Now process the merged trajectory
for i, message in enumerate(merged_traj):
role, content = message['role'], message['content']
content = _convert_content(content) if isinstance(content, list) else content
turn_id = i // 2 + 1
output += '-' * 100 + '\n'
output += f'*** Turn {turn_id} - {role.upper() if role != "tool" else "TOOL EXECUTION RESULT"} ***\n'
if role == 'user':
output += f'{content}\n'
elif role == 'tool':
output += f'{content}\n'
elif role == 'assistant':
output += f'{content}\n'
if (
'tool_calls' in message
and message['tool_calls'] is not None
and len(message['tool_calls']) > 0
):
for toolcall_id, tool_call in enumerate(message['tool_calls']):
output += f'### Tool Call {toolcall_id}\n'
output += f'{convert_tool_call_to_string(tool_call)}\n'
else:
raise ValueError(f'Unexpected role: {role}')
output += '-' * 100 + '\n'
return output
def write_row_to_md_file(row, instance_id_to_test_result):
if 'git_patch' in row:
model_patch = row['git_patch']
elif 'test_result' in row and 'git_patch' in row['test_result']:
model_patch = row['test_result']['git_patch']
else:
raise ValueError(f'Row {row} does not have a git_patch')
print(f'Row {row} does not have a git_patch')
return
test_output = None
if row['instance_id'] in instance_id_to_test_result:
report = instance_id_to_test_result[row['instance_id']].get('report', {})
resolved = report.get('resolved', False)
test_output = instance_id_to_test_result[row['instance_id']].get(
'test_output', None
)
elif 'report' in row and row['report'] is not None:
# Use result from output.jsonl FIRST if available.
if 'report' in row and row['report'] is not None:
if not isinstance(row['report'], dict):
resolved = None
print(
@@ -80,6 +232,12 @@ def write_row_to_md_file(row, instance_id_to_test_result):
)
else:
resolved = row['report'].get('resolved', False)
elif row['instance_id'] in instance_id_to_test_result:
report = instance_id_to_test_result[row['instance_id']].get('report', {})
resolved = report.get('resolved', False)
test_output = instance_id_to_test_result[row['instance_id']].get(
'test_output', None
)
else:
resolved = None
@@ -88,6 +246,8 @@ def write_row_to_md_file(row, instance_id_to_test_result):
os.makedirs(output_md_folder, exist_ok=True)
filepath = os.path.join(output_md_folder, filename)
completions = load_completions(instance_id)
with open(filepath, 'w') as f:
f.write(f'# {instance_id} (resolved: {resolved})\n')
@@ -97,7 +257,12 @@ def write_row_to_md_file(row, instance_id_to_test_result):
f.write(json.dumps(row['metadata'], indent=2))
f.write('\n```\n')
# Trajectory
# Completion
if completions is not None:
f.write('## Completion\n')
traj = completions['messages']
f.write(format_traj(traj))
f.write('## History\n')
f.write(convert_history_to_str(row['history']))

View File

@@ -207,12 +207,13 @@ with open(args.input_file, 'r') as infile:
for line in tqdm(infile, desc='Checking for changes'):
data = json.loads(line)
instance_id = data['instance_id']
if instance_id in instance_id_to_status:
current_report = data.get('report', {})
new_report = instance_id_to_status[instance_id]
if current_report != new_report:
needs_update = True
break
current_report = data.get('report', {})
new_report = instance_id_to_status[
instance_id
] # if no report, it's not resolved
if current_report != new_report:
needs_update = True
break
if not needs_update:
print('No updates detected. Skipping file update.')
@@ -234,6 +235,5 @@ with open(args.input_file + '.bak', 'r') as infile, open(
for line in tqdm(infile, desc='Updating output file'):
data = json.loads(line)
instance_id = data['instance_id']
if instance_id in instance_id_to_status:
data['report'] = instance_id_to_status[instance_id]
data['report'] = instance_id_to_status[instance_id]
outfile.write(json.dumps(data) + '\n')

View File

@@ -76,7 +76,7 @@ echo "Running SWE-bench evaluation"
echo "=============================================================="
RUN_ID=$(date +"%Y%m%d_%H%M%S")
N_PROCESS=16
N_PROCESS=4
if [ -z "$INSTANCE_ID" ]; then
echo "Running SWE-bench evaluation on the whole input file..."
@@ -87,7 +87,7 @@ if [ -z "$INSTANCE_ID" ]; then
--dataset_name "$DATASET_NAME" \
--split "$SPLIT" \
--predictions_path $SWEBENCH_FORMAT_JSONL \
--timeout 1800 \
--timeout 3600 \
--cache_level instance \
--max_workers $N_PROCESS \
--run_id $RUN_ID
@@ -133,7 +133,7 @@ else
--dataset_name "$DATASET_NAME" \
--split "$SPLIT" \
--predictions_path $SWEBENCH_FORMAT_JSONL \
--timeout 1800 \
--timeout 3600 \
--instance_ids $INSTANCE_ID \
--cache_level instance \
--max_workers $N_PROCESS \

View File

@@ -4,51 +4,22 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import OpenHands from "#/api/open-hands";
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
const renderSidebar = () => {
const RouterStub = createRoutesStub([
{
path: "/conversation/:conversationId",
Component: Sidebar,
},
]);
const RouterStub = createRoutesStub([
{
path: "/conversation/:conversationId",
Component: () => <Sidebar />,
},
]);
const renderSidebar = () =>
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
};
describe("Sidebar", () => {
it.skipIf(!MULTI_CONVERSATION_UI)(
"should have the conversation panel open by default",
() => {
renderSidebar();
expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
},
);
it.skipIf(!MULTI_CONVERSATION_UI)(
"should toggle the conversation panel",
async () => {
const user = userEvent.setup();
renderSidebar();
const projectPanelButton = screen.getByTestId(
"toggle-conversation-panel",
);
await user.click(projectPanelButton);
expect(
screen.queryByTestId("conversation-panel"),
).not.toBeInTheDocument();
},
);
describe("Settings", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
@@ -76,35 +47,12 @@ describe("Sidebar", () => {
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
// the actual values are falsey (null or "") but we're checking for undefined
llm_api_key: undefined,
llm_base_url: undefined,
security_analyzer: undefined,
});
});
it("should send all settings data when saving account settings", async () => {
const user = userEvent.setup();
renderSidebar();
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const menu = screen.getByTestId("account-settings-context-menu");
const accountSettingsButton = within(menu).getByTestId(
"account-settings-button",
);
await user.click(accountSettingsButton);
const accountSettingsModal = screen.getByTestId("account-settings-form");
const saveButton =
within(accountSettingsModal).getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
llm_api_key: undefined, // null or undefined
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
});
});
@@ -139,9 +87,15 @@ describe("Sidebar", () => {
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
github_token: "new-token",
language: "no",
llm_api_key: undefined, // null or undefined
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
});
});
@@ -169,11 +123,40 @@ describe("Sidebar", () => {
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
llm_api_key: undefined,
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_base_url: "",
security_analyzer: undefined,
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
});
});
});
describe("Settings Modal", () => {
it("should open the settings modal if the user clicks the settings button", async () => {
const user = userEvent.setup();
renderSidebar();
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
const settingsButton = screen.getByTestId("settings-button");
await user.click(settingsButton);
const settingsModal = screen.getByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
it("should open the settings modal if GET /settings fails", async () => {
vi.spyOn(OpenHands, "getSettings").mockRejectedValue(
new Error("Failed to fetch settings"),
);
renderSidebar();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
});
});

View File

@@ -14,7 +14,7 @@ describe("WaitlistModal", () => {
});
it("should render a tos checkbox that is unchecked by default", () => {
render(<WaitlistModal ghToken={null} githubAuthUrl={null} />);
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).not.toBeChecked();
@@ -22,7 +22,7 @@ describe("WaitlistModal", () => {
it("should only enable the GitHub button if the tos checkbox is checked", async () => {
const user = userEvent.setup();
render(<WaitlistModal ghToken={null} githubAuthUrl={null} />);
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl={null} />);
const checkbox = screen.getByRole("checkbox");
const button = screen.getByRole("button", { name: "Connect to GitHub" });
@@ -40,7 +40,7 @@ describe("WaitlistModal", () => {
);
const user = userEvent.setup();
render(<WaitlistModal ghToken={null} githubAuthUrl="mock-url" />);
render(<WaitlistModal ghTokenIsSet={false} githubAuthUrl="mock-url" />);
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);

View File

@@ -0,0 +1,116 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import OpenHands from "#/api/open-hands";
describe("AccountSettingsModal", () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
beforeEach(() => {
vi.resetAllMocks();
});
it("should send all settings data when saving account settings", async () => {
const user = userEvent.setup();
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const languageInput = screen.getByLabelText(/language/i);
await user.click(languageInput);
const norskOption = screen.getByText(/norsk/i);
await user.click(norskOption);
const tokenInput = screen.getByTestId("github-token-input");
await user.type(tokenInput, "new-token");
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "no",
github_token: "new-token",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
});
});
it("should render a checkmark and not the input if the github token is set", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
await waitFor(() => {
const checkmark = screen.queryByTestId("github-token-set-checkmark");
const input = screen.queryByTestId("github-token-input");
expect(checkmark).toBeInTheDocument();
expect(input).not.toBeInTheDocument();
});
});
it("should send an unset github token property when pressing disconnect", async () => {
const user = userEvent.setup();
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const disconnectButton = await screen.findByTestId("disconnect-github");
await user.click(disconnectButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
unset_github_token: true,
});
});
it("should not unset the github token when changing the language", async () => {
const user = userEvent.setup();
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const languageInput = screen.getByLabelText(/language/i);
await user.click(languageInput);
const norskOption = screen.getByText(/norsk/i);
await user.click(norskOption);
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "no",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
});
});
});

View File

@@ -1,28 +0,0 @@
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { getCachedConfig } from "../../src/utils/storage";
describe("getCachedConfig", () => {
beforeEach(() => {
// Clear all instances and calls to constructor and all methods
Storage.prototype.getItem = vi.fn();
});
it("should return an empty object when local storage is null or undefined", () => {
(Storage.prototype.getItem as Mock).mockReturnValue(null);
expect(getCachedConfig()).toEqual({});
(Storage.prototype.getItem as Mock).mockReturnValue(undefined);
expect(getCachedConfig()).toEqual({});
});
it("should return an empty object when local storage has invalid JSON", () => {
(Storage.prototype.getItem as Mock).mockReturnValue("invalid JSON");
expect(getCachedConfig()).toEqual({});
});
it("should return parsed object when local storage has valid JSON", () => {
const validJSON = '{"key":"value"}';
(Storage.prototype.getItem as Mock).mockReturnValue(validJSON);
expect(getCachedConfig()).toEqual({ key: "value" });
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -9,25 +9,25 @@
"dependencies": {
"@monaco-editor/react": "^4.7.0-rc.0",
"@nextui-org/react": "^2.6.11",
"@react-router/node": "^7.1.2",
"@react-router/serve": "^7.1.2",
"@react-router/node": "^7.1.3",
"@react-router/serve": "^7.1.3",
"@react-types/shared": "^3.27.0",
"@reduxjs/toolkit": "^2.5.0",
"@tanstack/react-query": "^5.64.1",
"@reduxjs/toolkit": "^2.5.1",
"@tanstack/react-query": "^5.65.1",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.0.1",
"i18next": "^24.2.1",
"framer-motion": "^12.0.6",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.21",
"jose": "^5.9.4",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.207.0",
"posthog-js": "^1.211.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
@@ -36,14 +36,14 @@
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-redux": "^9.2.0",
"react-router": "^7.1.2",
"react-router": "^7.1.3",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.7",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.6.0",
"vite": "^5.4.11",
"vite": "^6.0.11",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
},
@@ -77,23 +77,23 @@
},
"devDependencies": {
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.2",
"@playwright/test": "^1.50.0",
"@react-router/dev": "^7.1.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.64.2",
"@tanstack/eslint-plugin-query": "^5.65.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.0",
"@types/node": "^22.10.7",
"@types/react": "^19.0.7",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.12.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.5.12",
"@types/ws": "^8.5.14",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.0.2",
"@vitest/coverage-v8": "^3.0.4",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
@@ -107,7 +107,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^26.0.0",
"lint-staged": "^15.4.1",
"lint-staged": "^15.4.3",
"msw": "^2.6.6",
"postcss": "^8.5.1",
"prettier": "^3.4.2",

View File

@@ -1,107 +0,0 @@
import axios, { AxiosError } from "axios";
const github = axios.create({
baseURL: "https://api.github.com",
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
const setAuthTokenHeader = (token: string) => {
github.defaults.headers.common.Authorization = `Bearer ${token}`;
};
const removeAuthTokenHeader = () => {
if (github.defaults.headers.common.Authorization) {
delete github.defaults.headers.common.Authorization;
}
};
/**
* Checks if response has attributes to perform refresh
*/
const canRefresh = (error: unknown): boolean =>
!!(
error instanceof AxiosError &&
error.config &&
error.response &&
error.response.status
);
/**
* Checks if the data is a GitHub error response
* @param data The data to check
* @returns Boolean indicating if the data is a GitHub error response
*/
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
data: T | GitHubErrorReponse | null,
): data is GitHubErrorReponse =>
!!data && "message" in data && data.message !== undefined;
// Axios interceptor to handle token refresh
const setupAxiosInterceptors = (
appMode: string,
refreshToken: () => Promise<boolean>,
logout: () => void,
) => {
github.interceptors.response.use(
// Pass successful responses through
(response) => {
const parsedData = response.data;
if (isGitHubErrorReponse(parsedData)) {
const error = new AxiosError(
"Failed",
"",
response.config,
response.request,
response,
);
throw error;
}
return response;
},
// Retry request exactly once if token is expired
async (error) => {
if (!canRefresh(error)) {
return Promise.reject(new Error("Failed to refresh token"));
}
const originalRequest = error.config;
// Check if the error is due to an expired token
if (
error.response.status === 401 &&
!originalRequest._retry // Prevent infinite retry loops
) {
originalRequest._retry = true;
if (appMode === "saas") {
try {
const refreshed = await refreshToken();
if (refreshed) {
return await github(originalRequest);
}
logout();
return await Promise.reject(new Error("Failed to refresh token"));
} catch (refreshError) {
// If token refresh fails, evict the user
logout();
return Promise.reject(refreshError);
}
}
}
// If the error is not due to an expired token, propagate the error
return Promise.reject(error);
},
);
};
export {
github,
setAuthTokenHeader,
removeAuthTokenHeader,
setupAxiosInterceptors,
};

View File

@@ -13,7 +13,7 @@ import {
GetTrajectoryResponse,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings } from "#/services/settings";
import { ApiSettings } from "#/types/settings";
class OpenHands {
/**
@@ -154,25 +154,6 @@ class OpenHands {
return response.status === 200;
}
/**
* Refresh Github Token
* @returns Refreshed Github access token
*/
static async refreshToken(
appMode: GetConfigResponse["APP_MODE"],
userId: string,
): Promise<string> {
if (appMode === "oss") return "";
const response = await openHands.post<GitHubAccessTokenResponse>(
"/api/refresh-token",
{
userId,
},
);
return response.data.access_token;
}
/**
* Get the blob of the workspace zip
* @returns Blob of the workspace zip
@@ -242,13 +223,11 @@ class OpenHands {
}
static async createConversation(
githubToken?: string,
selectedRepository?: string,
initialUserMsg?: string,
imageUrls?: string[],
): Promise<Conversation> {
const body = {
github_token: githubToken,
selected_repository: selectedRepository,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
@@ -368,6 +347,10 @@ class OpenHands {
);
return data;
}
static async logout(): Promise<void> {
await openHands.post("/api/logout");
}
}
export default OpenHands;

View File

@@ -2,9 +2,9 @@ import posthog from "posthog-js";
import React from "react";
import { useSelector } from "react-redux";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import { useAuth } from "#/context/auth-context";
import { DownloadModal } from "#/components/shared/download-modal";
import type { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
@@ -13,7 +13,7 @@ interface ActionSuggestionsProps {
export function ActionSuggestions({
onSuggestionsClick,
}: ActionSuggestionsProps) {
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery,
);
@@ -32,7 +32,7 @@ export function ActionSuggestions({
onClose={handleDownloadClose}
isOpen={isDownloading}
/>
{gitHubToken && selectedRepository ? (
{githubTokenIsSet && selectedRepository ? (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>

View File

@@ -58,7 +58,7 @@ export function ChatMessage({
mode={isCopy ? "copied" : "copy"}
/>
<Markdown
className="text-sm overflow-auto"
className="text-sm overflow-auto break-words"
components={{
code,
ul,

View File

@@ -5,14 +5,12 @@ import { SuggestionBox } from "#/components/features/suggestions/suggestion-box"
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { GitHubRepositorySelector } from "./github-repo-selector";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useDebounce } from "#/hooks/use-debounce";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
@@ -51,7 +49,7 @@ export function GitHubRepositoriesSuggestionBox({
}
};
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
const isLoggedIn = !!user;
return (
<>
@@ -76,11 +74,9 @@ export function GitHubRepositoriesSuggestionBox({
}
/>
{connectToGitHubModalOpen && (
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
<ConnectToGitHubModal
onClose={() => setConnectToGitHubModalOpen(false)}
/>
</ModalBackdrop>
<AccountSettingsModal
onClose={() => setConnectToGitHubModalOpen(false)}
/>
)}
</>
);

View File

@@ -1,9 +1,8 @@
import React from "react";
import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import { useAuth } from "#/context/auth-context";
import posthog from "posthog-js";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
import { DocsButton } from "#/components/shared/buttons/docs-button";
@@ -21,20 +20,17 @@ import { setCurrentAgentState } from "#/state/agent-slice";
import { AgentState } from "#/types/agent-state";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
import { useLogout } from "#/hooks/mutation/use-logout";
import { useConfig } from "#/hooks/query/use-config";
export function Sidebar() {
const dispatch = useDispatch();
const endSession = useEndSession();
const user = useGitHubUser();
const { data: isAuthed } = useIsAuthed();
const { logout } = useAuth();
const {
data: settings,
isError: settingsIsError,
isSuccess: settingsSuccessfulyFetched,
} = useSettings();
const { isUpToDate: settingsAreUpToDate } = useCurrentSettings();
const { data: config } = useConfig();
const { data: settings, isError: settingsError } = useSettings();
const { mutateAsync: logout } = useLogout();
const { saveUserSettings } = useCurrentSettings();
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
@@ -56,15 +52,14 @@ export function Sidebar() {
};
const handleAccountSettingsModalClose = () => {
// If the user closes the modal without connecting to GitHub,
// we need to log them out to clear the invalid token from the
// local storage
if (user.isError) logout();
setAccountSettingsModalOpen(false);
};
const showSettingsModal =
isAuthed && (!settingsAreUpToDate || settingsModalIsOpen);
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else await saveUserSettings({ unset_github_token: true });
posthog.reset();
};
return (
<>
@@ -92,7 +87,7 @@ export function Sidebar() {
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined
}
onLogout={logout}
onLogout={handleLogout}
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
/>
)}
@@ -110,13 +105,12 @@ export function Sidebar() {
{accountSettingsModalOpen && (
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
)}
{settingsIsError ||
(showSettingsModal && settingsSuccessfulyFetched && (
<SettingsModal
settings={settings}
onClose={() => setSettingsModalIsOpen(false)}
/>
))}
{(settingsError || settingsModalIsOpen) && (
<SettingsModal
settings={settings}
onClose={() => setSettingsModalIsOpen(false)}
/>
)}
</>
);
}

View File

@@ -10,11 +10,14 @@ import { TOSCheckbox } from "./tos-checkbox";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
interface WaitlistModalProps {
ghToken: string | null;
ghTokenIsSet: boolean;
githubAuthUrl: string | null;
}
export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
export function WaitlistModal({
ghTokenIsSet,
githubAuthUrl,
}: WaitlistModalProps) {
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
const handleGitHubAuth = () => {
@@ -28,11 +31,11 @@ export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
<ModalBackdrop>
<ModalBody>
<AllHandsLogo width={68} height={46} />
<WaitlistMessage content={ghToken ? "waitlist" : "sign-in"} />
<WaitlistMessage content={ghTokenIsSet ? "waitlist" : "sign-in"} />
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
{!ghToken && (
{!ghTokenIsSet && (
<ModalButton
disabled={!isTosAccepted}
text="Connect to GitHub"
@@ -41,7 +44,7 @@ export function WaitlistModal({ ghToken, githubAuthUrl }: WaitlistModalProps) {
onClick={handleGitHubAuth}
/>
)}
{ghToken && <JoinWaitlistAnchor />}
{ghTokenIsSet && <JoinWaitlistAnchor />}
</ModalBody>
</ModalBackdrop>
);

View File

@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import {
BaseModalDescription,
BaseModalTitle,
@@ -7,13 +8,13 @@ import {
import { ModalBody } from "../modal-body";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { useAuth } from "#/context/auth-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { ModalButton } from "../../buttons/modal-button";
import { CustomInput } from "../../custom-input";
import { FormFieldset } from "../../form-fieldset";
import { useConfig } from "#/hooks/query/use-config";
import { useCurrentSettings } from "#/context/settings-context";
import { GitHubTokenInput } from "./github-token-input";
import { PostSettings } from "#/types/settings";
interface AccountSettingsFormProps {
onClose: () => void;
@@ -28,11 +29,12 @@ export function AccountSettingsForm({
gitHubError,
analyticsConsent,
}: AccountSettingsFormProps) {
const { gitHubToken, setGitHubToken, logout } = useAuth();
const { data: config } = useConfig();
const { saveUserSettings } = useCurrentSettings();
const { saveUserSettings, settings } = useCurrentSettings();
const { t } = useTranslation();
const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
@@ -41,7 +43,9 @@ export function AccountSettingsForm({
const language = formData.get("language")?.toString();
const analytics = formData.get("analytics")?.toString() === "on";
if (ghToken) setGitHubToken(ghToken);
const newSettings: Partial<PostSettings> = {};
if (ghToken) newSettings.github_token = ghToken;
// The form returns the language label, so we need to find the corresponding
// language key to save it in the settings
@@ -50,9 +54,11 @@ export function AccountSettingsForm({
({ label }) => label === language,
)?.value;
if (languageKey) await saveUserSettings({ LANGUAGE: languageKey });
if (languageKey) newSettings.LANGUAGE = languageKey;
}
await saveUserSettings(newSettings);
handleCaptureConsent(analytics);
const ANALYTICS = analytics.toString();
localStorage.setItem("analytics-consent", ANALYTICS);
@@ -60,6 +66,12 @@ export function AccountSettingsForm({
onClose();
};
const onDisconnect = async () => {
await saveUserSettings({ unset_github_token: true });
posthog.reset();
onClose();
};
return (
<ModalBody testID="account-settings-form">
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
@@ -89,23 +101,20 @@ export function AccountSettingsForm({
{config?.APP_MODE !== "saas" && (
<>
<CustomInput
name="ghToken"
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
defaultValue={gitHubToken ?? ""}
/>
<BaseModalDescription>
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
{t(I18nKey.COMMON$HERE)}
</a>
</BaseModalDescription>
<GitHubTokenInput githubTokenIsSet={githubTokenIsSet} />
{!githubTokenIsSet && (
<BaseModalDescription>
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
{t(I18nKey.COMMON$HERE)}
</a>
</BaseModalDescription>
)}
</>
)}
{gitHubError && (
@@ -113,14 +122,12 @@ export function AccountSettingsForm({
{t(I18nKey.GITHUB$TOKEN_INVALID)}
</p>
)}
{gitHubToken && !gitHubError && (
{githubTokenIsSet && !gitHubError && (
<ModalButton
testId="disconnect-github"
variant="text-like"
text={t(I18nKey.BUTTON$DISCONNECT)}
onClick={() => {
logout();
onClose();
}}
onClick={onDisconnect}
className="text-danger self-start"
/>
)}

View File

@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { FaCheckCircle } from "react-icons/fa";
import { I18nKey } from "#/i18n/declaration";
interface GitHubTokenInputProps {
githubTokenIsSet: boolean;
}
export function GitHubTokenInput({ githubTokenIsSet }: GitHubTokenInputProps) {
const { t } = useTranslation();
return (
<label htmlFor="ghToken" className="flex flex-col gap-2">
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3] flex items-center gap-1">
{githubTokenIsSet && (
<FaCheckCircle
data-testid="github-token-set-checkmark"
size={12}
className="text-[#00D1B2]"
/>
)}
{t(I18nKey.GITHUB$TOKEN_LABEL)}
<span className="text-[#A3A3A3]">
{" "}
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
</span>
</span>
{!githubTokenIsSet && (
<input
data-testid="github-token-input"
id="ghToken"
name="ghToken"
type="password"
className="bg-[#27272A] text-xs py-[10px] px-3 rounded"
/>
)}
</label>
);
}

View File

@@ -1,74 +0,0 @@
import { useTranslation } from "react-i18next";
import { ModalBody } from "./modal-body";
import {
BaseModalDescription,
BaseModalTitle,
} from "./confirmation-modals/base-modal";
import { I18nKey } from "#/i18n/declaration";
import { useAuth } from "#/context/auth-context";
import { ModalButton } from "../buttons/modal-button";
import { CustomInput } from "../custom-input";
interface ConnectToGitHubModalProps {
onClose: () => void;
}
export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) {
const { gitHubToken, setGitHubToken } = useAuth();
const { t } = useTranslation();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const ghToken = formData.get("ghToken")?.toString();
if (ghToken) setGitHubToken(ghToken);
onClose();
};
return (
<ModalBody>
<div className="flex flex-col gap-2 self-start">
<BaseModalTitle title="Connect to GitHub" />
<BaseModalDescription
description={
<span>
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
</a>
</span>
}
/>
</div>
<form onSubmit={handleSubmit} className="w-full flex flex-col gap-6">
<CustomInput
label="GitHub Token"
name="ghToken"
required
type="password"
defaultValue={gitHubToken ?? ""}
/>
<div className="flex flex-col gap-2 w-full">
<ModalButton
testId="connect-to-github"
type="submit"
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)}
className="bg-[#791B80] w-full"
/>
<ModalButton
onClick={onClose}
text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CLOSE)}
className="bg-[#737373] w-full"
/>
</div>
</form>
</ModalBody>
);
}

View File

@@ -4,10 +4,10 @@ import React from "react";
import posthog from "posthog-js";
import { I18nKey } from "#/i18n/declaration";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { getDefaultSettings, Settings } from "#/services/settings";
import { getDefaultSettings } from "#/services/settings";
import { extractModelAndProvider } from "#/utils/extract-model-and-provider";
import { DangerModal } from "../confirmation-modals/danger-modal";
import { extractSettings, saveSettingsView } from "#/utils/settings-utils";
import { extractSettings } from "#/utils/settings-utils";
import { useEndSession } from "#/hooks/use-end-session";
import { ModalButton } from "../../buttons/modal-button";
import { AdvancedOptionSwitch } from "../../inputs/advanced-option-switch";
@@ -23,6 +23,8 @@ import { ModelSelector } from "./model-selector";
import { RuntimeSizeSelector } from "./runtime-size-selector";
import { useConfig } from "#/hooks/query/use-config";
import { useCurrentSettings } from "#/context/settings-context";
import { MEMORY_CONDENSER } from "#/utils/feature-flags";
import { Settings } from "#/types/settings";
interface SettingsFormProps {
disabled?: boolean;
@@ -64,12 +66,14 @@ export function SettingsForm({
const isUsingConfirmationMode = !!settings.CONFIRMATION_MODE;
const isUsingBaseUrl = !!settings.LLM_BASE_URL;
const isUsingCustomModel = !!settings.LLM_MODEL && !isKnownModel;
const isUsingDefaultCondenser = !!settings.ENABLE_DEFAULT_CONDENSER;
return (
isUsingSecurityAnalyzer ||
isUsingConfirmationMode ||
isUsingBaseUrl ||
isUsingCustomModel
isUsingCustomModel ||
isUsingDefaultCondenser
);
}
@@ -90,11 +94,11 @@ export function SettingsForm({
};
const handleFormSubmission = async (formData: FormData) => {
const keys = Array.from(formData.keys());
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
const newSettings = extractSettings(formData);
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
// Inject the condenser config from the current feature flag value
newSettings.ENABLE_DEFAULT_CONDENSER = MEMORY_CONDENSER;
await saveUserSettings(newSettings);
onClose();
resetOngoingSession();

View File

@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { Settings } from "#/services/settings";
import { I18nKey } from "#/i18n/declaration";
import { LoadingSpinner } from "../../loading-spinner";
import { ModalBackdrop } from "../modal-backdrop";
import { SettingsForm } from "./settings-form";
import { Settings } from "#/types/settings";
interface SettingsModalProps {
settings: Settings;

View File

@@ -1,110 +1,21 @@
import posthog from "posthog-js";
import React from "react";
import OpenHands from "#/api/open-hands";
import {
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
} from "#/api/open-hands-axios";
import {
setAuthTokenHeader as setGitHubAuthTokenHeader,
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
setupAxiosInterceptors as setupGithubAxiosInterceptors,
} from "#/api/github-axios-instance";
interface AuthContextType {
gitHubToken: string | null;
setUserId: (userId: string) => void;
setGitHubToken: (token: string | null) => void;
clearGitHubToken: () => void;
refreshToken: () => Promise<boolean>;
logout: () => void;
githubTokenIsSet: boolean;
setGitHubTokenIsSet: (value: boolean) => void;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({ children }: React.PropsWithChildren) {
const [gitHubTokenState, setGitHubTokenState] = React.useState<string | null>(
() => localStorage.getItem("ghToken"),
);
const [userIdState, setUserIdState] = React.useState<string>(
() => localStorage.getItem("userId") || "",
);
const clearGitHubToken = () => {
setGitHubTokenState(null);
setUserIdState("");
localStorage.removeItem("ghToken");
localStorage.removeItem("userId");
removeOpenHandsGitHubTokenHeader();
removeGitHubAuthTokenHeader();
};
const setGitHubToken = (token: string | null) => {
setGitHubTokenState(token);
if (token) {
localStorage.setItem("ghToken", token);
setOpenHandsGitHubTokenHeader(token);
setGitHubAuthTokenHeader(token);
} else {
clearGitHubToken();
}
};
const setUserId = (userId: string) => {
setUserIdState(userIdState);
localStorage.setItem("userId", userId);
};
const logout = () => {
clearGitHubToken();
posthog.reset();
};
const refreshToken = async (): Promise<boolean> => {
const config = await OpenHands.getConfig();
if (config.APP_MODE !== "saas" || !gitHubTokenState) {
return false;
}
const newToken = await OpenHands.refreshToken(config.APP_MODE, userIdState);
if (newToken) {
setGitHubToken(newToken);
return true;
}
clearGitHubToken();
return false;
};
React.useEffect(() => {
const storedGitHubToken = localStorage.getItem("ghToken");
const userId = localStorage.getItem("userId") || "";
setGitHubToken(storedGitHubToken);
setUserId(userId);
const setupIntercepter = async () => {
const config = await OpenHands.getConfig();
setupGithubAxiosInterceptors(config.APP_MODE, refreshToken, logout);
};
setupIntercepter();
}, []);
const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(false);
const value = React.useMemo(
() => ({
gitHubToken: gitHubTokenState,
setGitHubToken,
setUserId,
clearGitHubToken,
refreshToken,
logout,
githubTokenIsSet,
setGitHubTokenIsSet,
}),
[gitHubTokenState],
[githubTokenIsSet, setGitHubTokenIsSet],
);
return <AuthContext value={value}>{children}</AuthContext>;

View File

@@ -1,16 +1,10 @@
import React from "react";
import {
LATEST_SETTINGS_VERSION,
Settings,
settingsAreUpToDate,
} from "#/services/settings";
import { useSettings } from "#/hooks/query/use-settings";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { PostSettings, Settings } from "#/types/settings";
interface SettingsContextType {
isUpToDate: boolean;
setIsUpToDate: (value: boolean) => void;
saveUserSettings: (newSettings: Partial<Settings>) => Promise<void>;
saveUserSettings: (newSettings: Partial<PostSettings>) => Promise<void>;
settings: Settings | undefined;
}
@@ -26,10 +20,8 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
const { data: userSettings } = useSettings();
const { mutateAsync: saveSettings } = useSaveSettings();
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
const saveUserSettings = async (newSettings: Partial<Settings>) => {
const updatedSettings: Partial<Settings> = {
const saveUserSettings = async (newSettings: Partial<PostSettings>) => {
const updatedSettings: Partial<PostSettings> = {
...userSettings,
...newSettings,
};
@@ -38,27 +30,15 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
delete updatedSettings.LLM_API_KEY;
}
await saveSettings(updatedSettings, {
onSuccess: () => {
if (!isUpToDate) {
localStorage.setItem(
"SETTINGS_VERSION",
LATEST_SETTINGS_VERSION.toString(),
);
setIsUpToDate(true);
}
},
});
await saveSettings(updatedSettings);
};
const value = React.useMemo(
() => ({
isUpToDate,
setIsUpToDate,
saveUserSettings,
settings: userSettings,
}),
[isUpToDate, setIsUpToDate, saveUserSettings, userSettings],
[saveUserSettings, userSettings],
);
return <SettingsContext value={value}>{children}</SettingsContext>;

View File

@@ -5,12 +5,10 @@ import { useDispatch, useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
export const useCreateConversation = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const { gitHubToken } = useAuth();
const queryClient = useQueryClient();
const { selectedRepository, files, importedProjectZip } = useSelector(
@@ -31,7 +29,6 @@ export const useCreateConversation = () => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
gitHubToken || undefined,
selectedRepository || undefined,
variables.q,
files,

View File

@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
export const useLogout = () => {
const { setGitHubTokenIsSet } = useAuth();
const queryClient = useQueryClient();
return useMutation({
mutationFn: OpenHands.logout,
onSuccess: async () => {
setGitHubTokenIsSet(false);
await queryClient.invalidateQueries();
},
});
};

View File

@@ -1,9 +1,10 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiSettings, DEFAULT_SETTINGS, Settings } from "#/services/settings";
import { DEFAULT_SETTINGS } from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { PostSettings, PostApiSettings } from "#/types/settings";
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
const apiSettings: Partial<ApiSettings> = {
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
const apiSettings: Partial<PostApiSettings> = {
llm_model: settings.LLM_MODEL,
llm_base_url: settings.LLM_BASE_URL,
agent: settings.AGENT || DEFAULT_SETTINGS.AGENT,
@@ -11,6 +12,10 @@ const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
confirmation_mode: settings.CONFIRMATION_MODE,
security_analyzer: settings.SECURITY_ANALYZER,
llm_api_key: settings.LLM_API_KEY?.trim() || undefined,
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
github_token: settings.github_token,
unset_github_token: settings.unset_github_token,
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
};
await OpenHands.saveSettings(apiSettings);

View File

@@ -1,17 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
export const useAppInstallations = () => {
const { data: config } = useConfig();
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
return useQuery({
queryKey: ["installations", gitHubToken, config?.GITHUB_CLIENT_ID],
queryKey: ["installations", githubTokenIsSet, config?.GITHUB_CLIENT_ID],
queryFn: OpenHands.getGitHubUserInstallationIds,
enabled:
!!gitHubToken &&
githubTokenIsSet &&
!!config?.GITHUB_CLIENT_ID &&
config?.APP_MODE === "saas",
});

View File

@@ -1,17 +1,17 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveGitHubAppRepositories } from "#/api/github";
import { useAuth } from "#/context/auth-context";
import { useAppInstallations } from "./use-app-installations";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useAppRepositories = () => {
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const { data: installations } = useAppInstallations();
const repos = useInfiniteQuery({
queryKey: ["repositories", gitHubToken, installations],
queryKey: ["repositories", githubTokenIsSet, installations],
queryFn: async ({
pageParam,
}: {
@@ -46,7 +46,7 @@ export const useAppRepositories = () => {
return null;
},
enabled:
!!gitHubToken &&
githubTokenIsSet &&
Array.isArray(installations) &&
installations.length > 0 &&
config?.APP_MODE === "saas",

View File

@@ -1,24 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useLogout } from "../mutation/use-logout";
import { useCurrentSettings } from "#/context/settings-context";
export const useGitHubUser = () => {
const { gitHubToken, setUserId, logout } = useAuth();
const { githubTokenIsSet } = useAuth();
const { setGitHubTokenIsSet } = useAuth();
const { mutateAsync: logout } = useLogout();
const { saveUserSettings } = useCurrentSettings();
const { data: config } = useConfig();
const user = useQuery({
queryKey: ["user", gitHubToken],
queryKey: ["user", githubTokenIsSet],
queryFn: OpenHands.getGitHubUser,
enabled: !!gitHubToken && !!config?.APP_MODE,
enabled: githubTokenIsSet && !!config?.APP_MODE,
retry: false,
});
React.useEffect(() => {
if (user.data) {
setUserId(user.data.id.toString());
posthog.identify(user.data.login, {
company: user.data.company,
name: user.data.name,
@@ -29,9 +33,18 @@ export const useGitHubUser = () => {
}
}, [user.data]);
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else {
await saveUserSettings({ unset_github_token: true });
setGitHubTokenIsSet(false);
}
posthog.reset();
};
React.useEffect(() => {
if (user.isError) {
logout();
handleLogout();
}
}, [user.isError]);

View File

@@ -5,13 +5,13 @@ import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useIsAuthed = () => {
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
return useQuery({
queryKey: ["user", "authenticated", gitHubToken, appMode],
queryKey: ["user", "authenticated", githubTokenIsSet, appMode],
queryFn: () => OpenHands.authenticate(appMode!),
enabled: !!appMode,
staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -1,44 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import { AxiosError } from "axios";
import { DEFAULT_SETTINGS, getLocalStorageSettings } from "#/services/settings";
import { DEFAULT_SETTINGS } from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
const getSettingsQueryFn = async () => {
try {
const apiSettings = await OpenHands.getSettings();
const apiSettings = await OpenHands.getSettings();
if (apiSettings !== null) {
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: apiSettings.llm_api_key,
REMOTE_RUNTIME_RESOURCE_FACTOR:
apiSettings.remote_runtime_resource_factor,
};
}
return getLocalStorageSettings();
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
return DEFAULT_SETTINGS;
}
}
throw error;
}
return {
LLM_MODEL: apiSettings.llm_model,
LLM_BASE_URL: apiSettings.llm_base_url,
AGENT: apiSettings.agent,
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
LLM_API_KEY: apiSettings.llm_api_key,
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set,
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
};
};
export const useSettings = () => {
const { setGitHubTokenIsSet } = useAuth();
const query = useQuery({
queryKey: ["settings"],
queryFn: getSettingsQueryFn,
initialData: DEFAULT_SETTINGS,
staleTime: 0,
retry: false,
});
React.useEffect(() => {
@@ -47,5 +39,9 @@ export const useSettings = () => {
}
}, [query.data?.LLM_API_KEY]);
React.useEffect(() => {
setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET);
}, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]);
return query;
};

View File

@@ -1,20 +1,20 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import { retrieveGitHubUserRepositories } from "#/api/github";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const useUserRepositories = () => {
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: config } = useConfig();
const repos = useInfiniteQuery({
queryKey: ["repositories", gitHubToken],
queryKey: ["repositories", githubTokenIsSet],
queryFn: async ({ pageParam }) =>
retrieveGitHubUserRepositories(pageParam, 100),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled: !!gitHubToken && config?.APP_MODE === "oss",
enabled: githubTokenIsSet && config?.APP_MODE === "oss",
});
// TODO: Once we create our custom dropdown component, we should fetch data onEndReached

View File

@@ -1,20 +1,23 @@
import React from "react";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types";
import { useAuth } from "#/context/auth-context";
interface UseGitHubAuthUrlConfig {
gitHubToken: string | null;
appMode: GetConfigResponse["APP_MODE"] | null;
gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null;
}
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) =>
React.useMemo(() => {
if (config.appMode === "saas" && !config.gitHubToken)
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => {
const { githubTokenIsSet } = useAuth();
return React.useMemo(() => {
if (config.appMode === "saas" && !githubTokenIsSet)
return generateGitHubAuthUrl(
config.gitHubClientId || "",
new URL(window.location.href),
);
return null;
}, [config.gitHubToken, config.appMode, config.gitHubClientId]);
}, [githubTokenIsSet, config.appMode, config.gitHubClientId]);
};

View File

@@ -1,52 +0,0 @@
// Sometimes we ship major changes, like a new default agent.
import React from "react";
import { useCurrentSettings } from "#/context/settings-context";
import {
getCurrentSettingsVersion,
DEFAULT_SETTINGS,
getLocalStorageSettings,
} from "#/services/settings";
import { useSaveSettings } from "./mutation/use-save-settings";
// In this case, we may want to override a previous choice made by the user.
export const useMaybeMigrateSettings = () => {
const { mutateAsync: saveSettings } = useSaveSettings();
const { isUpToDate } = useCurrentSettings();
const maybeMigrateSettings = async () => {
const currentVersion = getCurrentSettingsVersion();
if (currentVersion < 1) {
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
}
if (currentVersion < 2) {
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
if (customModel) {
localStorage.setItem("LLM_MODEL", customModel);
}
localStorage.removeItem("CUSTOM_LLM_MODEL");
localStorage.removeItem("USING_CUSTOM_MODEL");
}
if (currentVersion < 3) {
localStorage.removeItem("token");
}
if (currentVersion < 4) {
// We used to log out here, but it's breaking things
}
// Only save settings if user already previously saved settings
// That way we avoid setting defaults for new users too early
if (currentVersion !== 0 && currentVersion < 5) {
const localSettings = getLocalStorageSettings();
await saveSettings(localSettings);
}
};
React.useEffect(() => {
if (!isUpToDate) {
maybeMigrateSettings();
}
}, []);
};

View File

@@ -4506,5 +4506,8 @@
"fr": "Que voulez-vous construire ?",
"tr": "Ne inşa etmek istiyorsun?",
"de": "Was möchten Sie erstellen?"
},
"SETTINGS_FORM$ENABLE_DEFAULT_CONDENSER_SWITCH_LABEL": {
"en": "Enable Memory Condenser"
}
}

View File

@@ -5,17 +5,26 @@ import {
ResultSet,
} from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { ApiSettings, PostApiSettings } from "#/types/settings";
export const MOCK_USER_PREFERENCES = {
settings: {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
agent: DEFAULT_SETTINGS.AGENT,
language: DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
},
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
agent: DEFAULT_SETTINGS.AGENT,
language: DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
github_token_is_set: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET,
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
};
const MOCK_USER_PREFERENCES: {
settings: ApiSettings | PostApiSettings;
} = {
settings: MOCK_DEFAULT_USER_SETTINGS,
};
const conversations: Conversation[] = [
@@ -168,17 +177,32 @@ export const handlers = [
return HttpResponse.json(config);
}),
http.get("/api/settings", async () =>
HttpResponse.json(MOCK_USER_PREFERENCES.settings),
),
http.get("/api/settings", async () => {
const settings: ApiSettings = {
...MOCK_USER_PREFERENCES.settings,
};
// @ts-expect-error - mock types
if (settings.github_token) settings.github_token_is_set = true;
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
const body = await request.json();
if (body) {
let newSettings: Partial<PostApiSettings> = {};
if (typeof body === "object") {
newSettings = { ...body };
if (newSettings.unset_github_token) {
newSettings.github_token = undefined;
newSettings.github_token_is_set = false;
delete newSettings.unset_github_token;
}
}
MOCK_USER_PREFERENCES.settings = {
...MOCK_USER_PREFERENCES.settings,
// @ts-expect-error - We know this is a settings object
...body,
...newSettings,
};
return HttpResponse.json(null, { status: 200 });

View File

@@ -1,13 +1,13 @@
import { QueryClientConfig, QueryCache } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { renderToastIfError } from "./utils/render-toast-if-error";
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts", "settings"];
export const queryClientConfig: QueryClientConfig = {
queryCache: new QueryCache({
onError: (error, query) => {
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
toast.error(error.message);
renderToastIfError(error);
}
},
}),
@@ -18,7 +18,7 @@ export const queryClientConfig: QueryClientConfig = {
},
mutations: {
onError: (error) => {
toast.error(error.message);
renderToastIfError(error);
},
},
},

View File

@@ -8,7 +8,6 @@ import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useConfig } from "#/hooks/query/use-config";
import { useAuth } from "#/context/auth-context";
import { ImportProjectSuggestionBox } from "../../components/features/suggestions/import-project-suggestion-box";
import { GitHubRepositoriesSuggestionBox } from "#/components/features/github/github-repositories-suggestion-box";
import { HeroHeading } from "#/components/shared/hero-heading";
@@ -16,7 +15,6 @@ import { TaskForm } from "#/components/shared/task-form";
function Home() {
const { t } = useTranslation();
const { gitHubToken } = useAuth();
const dispatch = useDispatch();
const formRef = React.useRef<HTMLFormElement>(null);
@@ -24,7 +22,6 @@ function Home() {
const { data: user } = useGitHubUser();
const gitHubAuthUrl = useGitHubAuthUrl({
gitHubToken,
appMode: config?.APP_MODE || null,
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
});

View File

@@ -22,7 +22,6 @@ import { FilesProvider } from "#/context/files";
import { ChatInterface } from "../../components/features/chat/chat-interface";
import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "./event-handler";
import { useAuth } from "#/context/auth-context";
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
import { Container } from "#/components/layout/container";
import {
@@ -42,7 +41,6 @@ import { RootState } from "#/store";
function AppContent() {
useConversationConfig();
const { t } = useTranslation();
const { gitHubToken } = useAuth();
const { data: settings } = useSettings();
const { conversationId } = useConversation();
const { data: conversation, isFetched } = useUserConversation(
@@ -57,8 +55,9 @@ function AppContent() {
const [width, setWidth] = React.useState(window.innerWidth);
const secrets = React.useMemo(
() => [gitHubToken].filter((secret) => secret !== null),
[gitHubToken],
// secrets to filter go here
() => [].filter((secret) => secret !== null),
[],
);
const Terminal = React.useMemo(

View File

@@ -3,13 +3,12 @@ import { useRouteError, isRouteErrorResponse, Outlet } from "react-router";
import i18n from "#/i18n";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "#/hooks/query/use-config";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import { useSettings } from "#/hooks/query/use-settings";
import { useMaybeMigrateSettings } from "#/hooks/use-maybe-migrate-settings";
import { useAuth } from "#/context/auth-context";
export function ErrorBoundary() {
const error = useRouteError();
@@ -44,9 +43,7 @@ export function ErrorBoundary() {
}
export default function MainApp() {
useMaybeMigrateSettings();
const { gitHubToken } = useAuth();
const { githubTokenIsSet } = useAuth();
const { data: settings } = useSettings();
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
@@ -54,10 +51,13 @@ export default function MainApp() {
);
const config = useConfig();
const { data: isAuthed, isFetching: isFetchingAuth } = useIsAuthed();
const {
data: isAuthed,
isFetching: isFetchingAuth,
isError: authError,
} = useIsAuthed();
const gitHubAuthUrl = useGitHubAuthUrl({
gitHubToken,
appMode: config.data?.APP_MODE || null,
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
});
@@ -68,8 +68,9 @@ export default function MainApp() {
}
}, [settings?.LANGUAGE]);
const isInWaitlist =
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
const userIsAuthed = !!isAuthed && !authError;
const renderWaitlistModal =
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
return (
<div
@@ -85,8 +86,11 @@ export default function MainApp() {
<Outlet />
</div>
{isInWaitlist && (
<WaitlistModal ghToken={gitHubToken} githubAuthUrl={gitHubAuthUrl} />
{renderWaitlistModal && (
<WaitlistModal
ghTokenIsSet={githubTokenIsSet}
githubAuthUrl={gitHubAuthUrl}
/>
)}
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (

View File

@@ -2,16 +2,13 @@ import { useNavigate, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
function OAuthGitHubCallback() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setGitHubToken } = useAuth();
const code = searchParams.get("code");
const { data, isSuccess, error } = useQuery({
const { isSuccess, error } = useQuery({
queryKey: ["access_token", code],
queryFn: () => OpenHands.getGitHubAccessToken(code!),
enabled: !!code,
@@ -19,7 +16,6 @@ function OAuthGitHubCallback() {
React.useEffect(() => {
if (isSuccess) {
setGitHubToken(data.access_token);
navigate("/");
}
}, [isSuccess]);

View File

@@ -1,27 +1,7 @@
import { Settings } from "#/types/settings";
export const LATEST_SETTINGS_VERSION = 5;
export type Settings = {
LLM_MODEL: string;
LLM_BASE_URL: string;
AGENT: string;
LANGUAGE: string;
LLM_API_KEY: string | null;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
};
export type ApiSettings = {
llm_model: string;
llm_base_url: string;
agent: string;
language: string;
llm_api_key: string | null;
confirmation_mode: boolean;
security_analyzer: string;
remote_runtime_resource_factor: number;
};
export const DEFAULT_SETTINGS: Settings = {
LLM_MODEL: "anthropic/claude-3-5-sonnet-20241022",
LLM_BASE_URL: "",
@@ -31,55 +11,11 @@ export const DEFAULT_SETTINGS: Settings = {
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
};
export const getCurrentSettingsVersion = () => {
const settingsVersion = localStorage.getItem("SETTINGS_VERSION");
if (!settingsVersion) return 0;
try {
return parseInt(settingsVersion, 10);
} catch (e) {
return 0;
}
};
export const settingsAreUpToDate = () =>
getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
// TODO: localStorage settings are deprecated. Remove this after 1/31/2025
/**
* Get the settings from local storage
* @returns the settings from local storage
* @deprecated
*/
export const getLocalStorageSettings = (): Settings => {
const llmModel = localStorage.getItem("LLM_MODEL");
const baseUrl = localStorage.getItem("LLM_BASE_URL");
const agent = localStorage.getItem("AGENT");
const language = localStorage.getItem("LANGUAGE");
const llmApiKey = localStorage.getItem("LLM_API_KEY");
const confirmationMode = localStorage.getItem("CONFIRMATION_MODE") === "true";
const securityAnalyzer = localStorage.getItem("SECURITY_ANALYZER");
return {
LLM_MODEL: llmModel || DEFAULT_SETTINGS.LLM_MODEL,
LLM_BASE_URL: baseUrl || DEFAULT_SETTINGS.LLM_BASE_URL,
AGENT: agent || DEFAULT_SETTINGS.AGENT,
LANGUAGE: language || DEFAULT_SETTINGS.LANGUAGE,
LLM_API_KEY: llmApiKey || DEFAULT_SETTINGS.LLM_API_KEY,
CONFIRMATION_MODE: confirmationMode || DEFAULT_SETTINGS.CONFIRMATION_MODE,
SECURITY_ANALYZER: securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
REMOTE_RUNTIME_RESOURCE_FACTOR:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
};
GITHUB_TOKEN_IS_SET: false,
ENABLE_DEFAULT_CONDENSER: false,
};
/**
* Get the default settings
*/
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
/**
* Get the current settings, either from local storage or defaults
*/
export const getSettings = (): Settings => getLocalStorageSettings();

8
frontend/src/types/react-query.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import "@tanstack/react-query";
import type { AxiosError } from "axios";
declare module "@tanstack/react-query" {
interface Register {
defaultError: AxiosError;
}
}

View File

@@ -0,0 +1,35 @@
export type Settings = {
LLM_MODEL: string;
LLM_BASE_URL: string;
AGENT: string;
LANGUAGE: string;
LLM_API_KEY: string | null;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
GITHUB_TOKEN_IS_SET: boolean;
ENABLE_DEFAULT_CONDENSER: boolean;
};
export type ApiSettings = {
llm_model: string;
llm_base_url: string;
agent: string;
language: string;
llm_api_key: string | null;
confirmation_mode: boolean;
security_analyzer: string;
remote_runtime_resource_factor: number;
github_token_is_set: boolean;
enable_default_condenser: boolean;
};
export type PostSettings = Settings & {
github_token: string;
unset_github_token: boolean;
};
export type PostApiSettings = ApiSettings & {
github_token: string;
unset_github_token: boolean;
};

View File

@@ -13,3 +13,4 @@ function loadFeatureFlag(
}
export const MULTI_CONVERSATION_UI = loadFeatureFlag("MULTI_CONVERSATION_UI");
export const MEMORY_CONDENSER = loadFeatureFlag("MEMORY_CONDENSER");

View File

@@ -0,0 +1,19 @@
import { AxiosError } from "axios";
import toast from "react-hot-toast";
import { isAxiosErrorWithResponse } from "./type-guards";
/**
* Renders a toast with the error message from an Axios error
* @param error The error to render a toast for
*/
export const renderToastIfError = (error: AxiosError) => {
let errorMessage: string | null = null;
if (isAxiosErrorWithResponse(error) && error.response?.data.error) {
errorMessage = error.response?.data.error;
} else {
errorMessage = error.message;
}
toast.error(errorMessage || "An error occurred");
};

View File

@@ -1,4 +1,4 @@
import { Settings } from "#/services/settings";
import { Settings } from "#/types/settings";
const extractBasicFormData = (formData: FormData) => {
const provider = formData.get("llm-provider")?.toString();
@@ -44,7 +44,7 @@ const extractAdvancedFormData = (formData: FormData) => {
};
};
const extractSettings = (formData: FormData): Partial<Settings> => {
export const extractSettings = (formData: FormData): Partial<Settings> => {
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
extractBasicFormData(formData);
@@ -65,12 +65,3 @@ const extractSettings = (formData: FormData): Partial<Settings> => {
SECURITY_ANALYZER,
};
};
const saveSettingsView = (view: "basic" | "advanced") => {
localStorage.setItem(
"use-advanced-options",
view === "advanced" ? "true" : "false",
);
};
export { extractSettings, saveSettingsView };

View File

@@ -1,11 +0,0 @@
const getCachedConfig = (): { [key: string]: string } => {
const config = localStorage.getItem("ALL_SETTINGS");
if (config === null || config === undefined) return {};
try {
return JSON.parse(config);
} catch (e) {
return {};
}
};
export { getCachedConfig };

View File

@@ -0,0 +1,9 @@
import { AxiosError } from "axios";
export const isAxiosErrorWithResponse = (
error: AxiosError,
): error is AxiosError<{ error: string }> =>
typeof error.response?.data === "object" &&
error.response?.data !== null &&
"error" in error.response.data &&
typeof error.response?.data?.error === "string";

View File

@@ -34,7 +34,6 @@ test.beforeEach(async ({ page }) => {
await page.evaluate(() => {
localStorage.setItem("FEATURE_MULTI_CONVERSATION_UI", "true");
localStorage.setItem("analytics-consent", "true");
localStorage.setItem("SETTINGS_VERSION", "5");
});
});

View File

@@ -9,7 +9,6 @@ test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
localStorage.setItem("analytics-consent", "true");
localStorage.setItem("SETTINGS_VERSION", "5");
});
});

View File

@@ -4,7 +4,6 @@ test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
localStorage.setItem("analytics-consent", "true");
localStorage.setItem("SETTINGS_VERSION", "4");
});
});

View File

@@ -22,7 +22,7 @@ type: repo
agent: CodeActAgent
---
This repository contains the code for runtime-API, an automated AI software engineer. It has a Python backend
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).
## General Setup:

View File

@@ -433,26 +433,6 @@ class CodeActAgent(Agent):
],
)
]
example_message = self.prompt_manager.get_example_user_message()
if example_message:
messages.append(
Message(
role='user',
content=[TextContent(text=example_message)],
cache_prompt=self.llm.is_caching_prompt_active(),
)
)
# Repository and runtime info
additional_info = self.prompt_manager.get_additional_info()
if self.config.enable_prompt_extensions and additional_info:
# only add these if prompt extension is enabled
messages.append(
Message(
role='user',
content=[TextContent(text=additional_info)],
)
)
pending_tool_call_action_messages: dict[str, Message] = {}
tool_call_id_to_message: dict[str, Message] = {}
@@ -460,6 +440,7 @@ class CodeActAgent(Agent):
# Condense the events from the state.
events = self.condenser.condensed_history(state)
is_first_message_handled = False
for event in events:
# create a regular message from an event
if isinstance(event, Action):
@@ -501,11 +482,22 @@ class CodeActAgent(Agent):
for response_id in _response_ids_to_remove:
pending_tool_call_action_messages.pop(response_id)
for message in messages_to_add:
if message:
if message.role == 'user':
self.prompt_manager.enhance_message(message)
messages.append(message)
for msg in messages_to_add:
if msg:
if msg.role == 'user' and not is_first_message_handled:
is_first_message_handled = True
# compose the first user message with examples
self.prompt_manager.add_examples_to_initial_message(msg)
# and/or repo/runtime info
if self.config.enable_prompt_extensions:
self.prompt_manager.add_info_to_initial_message(msg)
# enhance the user message with additional context based on keywords matched
if msg.role == 'user':
self.prompt_manager.enhance_message(msg)
messages.append(msg)
if self.llm.is_caching_prompt_active():
# NOTE: this is only needed for anthropic
@@ -513,7 +505,7 @@ class CodeActAgent(Agent):
# https://github.com/anthropics/anthropic-quickstarts/blob/8f734fd08c425c6ec91ddd613af04ff87d70c5a0/computer-use-demo/computer_use_demo/loop.py#L241-L262
breakpoints_remaining = 3 # remaining 1 for system/tool
for message in reversed(messages):
if message.role == 'user' or message.role == 'tool':
if message.role in ('user', 'tool'):
if breakpoints_remaining > 0:
message.content[
-1

View File

@@ -12,7 +12,10 @@ from litellm import (
ModelResponse,
)
from openhands.core.exceptions import FunctionCallNotExistsError
from openhands.core.exceptions import (
FunctionCallNotExistsError,
FunctionCallValidationError,
)
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
Action,
@@ -494,15 +497,19 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
f'Failed to parse tool call arguments: {tool_call.function.arguments}'
) from e
if tool_call.function.name == 'execute_bash':
# this is an LLM error: add empty command to avoid breaking the tool call
if 'command' not in arguments:
arguments['command'] = ''
raise FunctionCallValidationError(
f'Missing required argument "command" in tool call {tool_call.function.name}'
)
# convert is_input to boolean
if 'is_input' in arguments:
arguments['is_input'] = arguments['is_input'] == 'true'
action = CmdRunAction(**arguments)
is_input = arguments.get('is_input', 'false') == 'true'
action = CmdRunAction(command=arguments['command'], is_input=is_input)
elif tool_call.function.name == 'execute_ipython_cell':
action = IPythonRunCellAction(**arguments)
if 'code' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "code" in tool call {tool_call.function.name}'
)
action = IPythonRunCellAction(code=arguments['code'])
elif tool_call.function.name == 'delegate_to_browsing_agent':
action = AgentDelegateAction(
agent='BrowsingAgent',
@@ -511,8 +518,30 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
elif tool_call.function.name == 'finish':
action = AgentFinishAction()
elif tool_call.function.name == 'edit_file':
action = FileEditAction(**arguments)
if 'path' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "path" in tool call {tool_call.function.name}'
)
if 'content' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "content" in tool call {tool_call.function.name}'
)
action = FileEditAction(
path=arguments['path'],
content=arguments['content'],
start=arguments.get('start', 1),
end=arguments.get('end', -1),
)
elif tool_call.function.name == 'str_replace_editor':
if 'command' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "command" in tool call {tool_call.function.name}'
)
if 'path' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "path" in tool call {tool_call.function.name}'
)
# We implement this in agent_skills, which can be used via Jupyter
# convert tool_call.function.arguments to kwargs that can be passed to file_editor
code = f'print(file_editor(**{arguments}))'
@@ -534,8 +563,16 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
impl_source=FileEditSource.OH_ACI,
)
elif tool_call.function.name == 'browser':
if 'code' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "code" in tool call {tool_call.function.name}'
)
action = BrowseInteractiveAction(browser_actions=arguments['code'])
elif tool_call.function.name == 'web_read':
if 'url' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "url" in tool call {tool_call.function.name}'
)
action = BrowseURLAction(url=arguments['url'])
else:
raise FunctionCallNotExistsError(

View File

@@ -1,7 +1,6 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* You should start exploring the file system with your view command, unless you need to explore more deeply.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* You MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>

View File

@@ -37,7 +37,7 @@ class SandboxConfig(BaseModel):
This should be a JSON string that will be parsed into a dictionary.
"""
remote_runtime_api_url: str = Field(default='http://localhost:8000')
remote_runtime_api_url: str | None = Field(default='http://localhost:8000')
local_runtime_url: str = Field(default='http://localhost')
keep_runtime_alive: bool = Field(default=False)
rm_all_containers: bool = Field(default=False)

View File

@@ -57,6 +57,7 @@ class AsyncEventStreamWrapper:
class EventStream:
sid: str
file_store: FileStore
secrets: dict[str, str]
# For each subscriber ID, there is a map of callback functions - useful
# when there are multiple listeners
_subscribers: dict[str, dict[str, Callable]]
@@ -82,6 +83,7 @@ class EventStream:
self._subscribers = {}
self._lock = threading.Lock()
self._cur_id = 0
self.secrets = {}
# load the stream
self.__post_init__()
@@ -267,10 +269,24 @@ class EventStream:
event._timestamp = datetime.now().isoformat()
event._source = source # type: ignore [attr-defined]
data = event_to_dict(event)
data = self._replace_secrets(data)
event = event_from_dict(data)
if event.id is not None:
self.file_store.write(self._get_filename_for_id(event.id), json.dumps(data))
self._queue.put(event)
def set_secrets(self, secrets: dict[str, str]):
self.secrets = secrets.copy()
def _replace_secrets(self, data: dict) -> dict:
for key in data:
if isinstance(data[key], dict):
data[key] = self._replace_secrets(data[key])
elif isinstance(data[key], str):
for secret in self.secrets.values():
data[key] = data[key].replace(secret, '<secret_hidden>')
return data
def _run_queue_loop(self):
self._queue_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._queue_loop)
@@ -319,7 +335,7 @@ class EventStream:
self,
event,
query: str | None = None,
event_type: str | None = None,
event_types: tuple[type[Event], ...] | None = None,
source: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
@@ -328,16 +344,16 @@ class EventStream:
Args:
event: The event to check
query (str, optional): Text to search for in event content
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
source (str, optional): Filter by event source
start_date (str, optional): Filter events after this date (ISO format)
end_date (str, optional): Filter events before this date (ISO format)
query: Text to search for in event content
event_type: Filter by event type classes (e.g., (FileReadAction, ) ).
source: Filter by event source
start_date: Filter events after this date (ISO format)
end_date: Filter events before this date (ISO format)
Returns:
bool: True if the event should be filtered out, False if it matches all criteria
"""
if event_type and not event.__class__.__name__ == event_type:
if event_types and not isinstance(event, event_types):
return True
if source and not event.source.value == source:
@@ -361,23 +377,25 @@ class EventStream:
def get_matching_events(
self,
query: str | None = None,
event_type: str | None = None,
event_types: tuple[type[Event], ...] | None = None,
source: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
start_id: int = 0,
limit: int = 100,
) -> list:
reverse: bool = False,
) -> list[type[Event]]:
"""Get matching events from the event stream based on filters.
Args:
query (str, optional): Text to search for in event content
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
source (str, optional): Filter by event source
start_date (str, optional): Filter events after this date (ISO format)
end_date (str, optional): Filter events before this date (ISO format)
start_id (int): Starting ID in the event stream. Defaults to 0
limit (int): Maximum number of events to return. Must be between 1 and 100. Defaults to 100
query: Text to search for in event content
event_types: Filter by event type classes (e.g., (FileReadAction, ) ).
source: Filter by event source
start_date: Filter events after this date (ISO format)
end_date: Filter events before this date (ISO format)
start_id: Starting ID in the event stream. Defaults to 0
limit: Maximum number of events to return. Must be between 1 and 100. Defaults to 100
reverse: Whether to retrieve events in reverse order. Defaults to False.
Returns:
list: List of matching events (as dicts)
@@ -390,13 +408,13 @@ class EventStream:
matching_events: list = []
for event in self.get_events(start_id=start_id):
for event in self.get_events(start_id=start_id, reverse=reverse):
if self._should_filter_event(
event, query, event_type, source, start_date, end_date
event, query, event_types, source, start_date, end_date
):
continue
matching_events.append(event_to_dict(event))
matching_events.append(event)
# Stop if we have enough events
if len(matching_events) >= limit:

View File

@@ -200,7 +200,6 @@ ASSISTANT:
Running the updated file:
<function=execute_bash>
<parameter=command>
<parameter=command>
python3 app.py > server.log 2>&1 &
</parameter>
</function>

View File

@@ -185,4 +185,4 @@ You can customize how the AI agent approaches issue resolution by adding a `.ope
## Troubleshooting
If you have any issues, please open an issue on this github repo, we're happy to help!
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the [OpenHands Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2wkh4pklz-w~h_DVDtEe9H5kyQlcNxVw) and ask there.
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the OpenHands Slack workspace (see [the README](/README.md) for an invite link).

View File

@@ -1,6 +1,6 @@
import atexit
from functools import lru_cache
from typing import Callable
from uuid import UUID
import docker
import requests
@@ -26,6 +26,7 @@ from openhands.runtime.utils.command import get_action_execution_server_startup_
from openhands.runtime.utils.log_streamer import LogStreamer
from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import add_shutdown_listener
from openhands.utils.tenacity_stop import stop_if_should_exit
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
@@ -36,13 +37,6 @@ APP_PORT_RANGE_1 = (50000, 54999)
APP_PORT_RANGE_2 = (55000, 59999)
def stop_all_runtime_containers():
stop_all_containers(CONTAINER_NAME_PREFIX)
_atexit_registered = False
class DockerRuntime(ActionExecutionClient):
"""This runtime will subscribe the event stream.
When receive an event, it will send the event to runtime-client which run inside the docker environment.
@@ -55,6 +49,8 @@ class DockerRuntime(ActionExecutionClient):
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
"""
_shutdown_listener_id: UUID | None = None
def __init__(
self,
config: AppConfig,
@@ -66,10 +62,10 @@ class DockerRuntime(ActionExecutionClient):
attach_to_existing: bool = False,
headless_mode: bool = True,
):
global _atexit_registered
if not _atexit_registered:
_atexit_registered = True
atexit.register(stop_all_runtime_containers)
if not DockerRuntime._shutdown_listener_id:
DockerRuntime._shutdown_listener_id = add_shutdown_listener(
lambda: stop_all_containers(CONTAINER_NAME_PREFIX)
)
self.config = config
self._runtime_initialized: bool = False

View File

@@ -68,6 +68,10 @@ class RemoteRuntime(ActionExecutionClient):
'debug',
'Setting workspace_base is not supported in the remote runtime.',
)
if self.config.sandbox.remote_runtime_api_url is None:
raise ValueError(
'remote_runtime_api_url is required in the remote runtime.'
)
self.runtime_builder = RemoteRuntimeBuilder(
self.config.sandbox.remote_runtime_api_url,

View File

@@ -1,5 +1,9 @@
from fastapi import Request
def get_github_token(request: Request) -> str | None:
return getattr(request.state, 'github_token', None)
def get_user_id(request: Request) -> str | None:
return getattr(request.state, 'github_user_id', None)

View File

@@ -6,11 +6,13 @@ from openhands.core.logger import openhands_logger as logger
from openhands.server.middleware import (
AttachConversationMiddleware,
CacheControlMiddleware,
GitHubTokenMiddleware,
InMemoryRateLimiter,
LocalhostCORSMiddleware,
RateLimitMiddleware,
)
from openhands.server.types import AppMode, OpenhandsConfigInterface
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
@@ -48,6 +50,8 @@ class OpenhandsConfig(OpenhandsConfigInterface):
return config
def attach_middleware(self, api: FastAPI) -> None:
SettingsStoreImpl = get_impl(SettingsStore, self.settings_store_class) # type: ignore
api.add_middleware(
LocalhostCORSMiddleware,
allow_credentials=True,
@@ -61,6 +65,7 @@ class OpenhandsConfig(OpenhandsConfigInterface):
rate_limiter=InMemoryRateLimiter(requests=10, seconds=1),
)
api.middleware('http')(AttachConversationMiddleware(api))
api.middleware('http')(GitHubTokenMiddleware(api, SettingsStoreImpl)) # type: ignore
def load_openhands_config():

View File

@@ -12,7 +12,9 @@ from starlette.requests import Request as StarletteRequest
from starlette.types import ASGIApp
from openhands.server import shared
from openhands.server.auth import get_user_id
from openhands.server.types import SessionMiddlewareInterface
from openhands.storage.settings.settings_store import SettingsStore
class LocalhostCORSMiddleware(CORSMiddleware):
@@ -180,3 +182,22 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
await self._detach_session(request)
return response
class GitHubTokenMiddleware(SessionMiddlewareInterface):
def __init__(self, app, settings_store: SettingsStore):
self.app = app
self.settings_store_impl = settings_store
async def __call__(self, request: Request, call_next: Callable):
settings_store = await self.settings_store_impl.get_instance(
shared.config, get_user_id(request)
)
settings = await settings_store.load()
if settings and settings.github_token:
request.state.github_token = settings.github_token
else:
request.state.github_token = None
return await call_next(request)

View File

@@ -2,11 +2,26 @@ from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.events.event import Event
from openhands.events.serialization.event import event_from_dict, event_to_dict
from openhands.runtime.base import Runtime
app = APIRouter(prefix='/api/conversations/{conversation_id}')
def str_to_event_type(event: str | None) -> Event | None:
if not event:
return None
for event_type in ['observation', 'action']:
try:
return event_from_dict({event_type: event})
except Exception:
continue
return None
@app.get('/config')
async def get_remote_runtime_config(request: Request):
"""Retrieve the runtime configuration.
@@ -104,14 +119,14 @@ async def search_events(
):
"""Search through the event stream with filtering and pagination.
Args:
request (Request): The incoming request object
query (str, optional): Text to search for in event content
start_id (int): Starting ID in the event stream. Defaults to 0
limit (int): Maximum number of events to return. Must be between 1 and 100. Defaults to 20
event_type (str, optional): Filter by event type (e.g., "FileReadAction")
source (str, optional): Filter by event source
start_date (str, optional): Filter events after this date (ISO format)
end_date (str, optional): Filter events before this date (ISO format)
request: The incoming request object
query: Text to search for in event content
start_id: Starting ID in the event stream. Defaults to 0
limit: Maximum number of events to return. Must be between 1 and 100. Defaults to 20
event_type: Filter by event type (e.g., "FileReadAction")
source: Filter by event source
start_date: Filter events after this date (ISO format)
end_date: Filter events before this date (ISO format)
Returns:
dict: Dictionary containing:
- events: List of matching events
@@ -126,9 +141,11 @@ async def search_events(
)
# Get matching events from the stream
event_stream = request.state.conversation.event_stream
cast_event_type = str_to_event_type(event_type)
matching_events = event_stream.get_matching_events(
query=query,
event_type=event_type,
event_types=(cast_event_type),
source=source,
start_date=start_date,
end_date=end_date,
@@ -139,6 +156,8 @@ async def search_events(
has_more = len(matching_events) > limit
if has_more:
matching_events = matching_events[:limit] # Remove the extra event
matching_events = [event_to_dict(event) for event in matching_events]
return {
'events': matching_events,
'has_more': has_more,

View File

@@ -1,8 +1,9 @@
import httpx
import requests
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from openhands.server.auth import get_github_token
from openhands.server.shared import openhands_config
from openhands.utils.async_utils import call_sync_from_async
@@ -10,12 +11,13 @@ app = APIRouter(prefix='/api/github')
def require_github_token(request: Request):
github_token = request.headers.get('X-GitHub-Token')
github_token = get_github_token(request)
if not github_token:
raise HTTPException(
status_code=400,
detail='Missing X-GitHub-Token header',
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Missing GitHub token',
)
return github_token
@@ -90,23 +92,19 @@ async def get_github_installation_ids(
):
headers = generate_github_headers(github_token)
try:
response = await call_sync_from_async(
requests.get, 'https://api.github.com/user/installations', headers=headers
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
async with httpx.AsyncClient() as client:
response = await client.get('https://api.github.com/user/installations', headers=headers)
response.raise_for_status()
data = response.json()
ids = [installation['id'] for installation in data['installations']]
return JSONResponse(content=ids)
except httpx.HTTPError as e:
raise HTTPException(
status_code=response.status_code if response else 500,
status_code=e.response.status_code if hasattr(e, 'response') else 500,
detail=f'Error fetching installations: {str(e)}',
)
data = response.json()
ids = [installation['id'] for installation in data['installations']]
json_response = JSONResponse(content=ids)
response.close()
return json_response
@app.get('/search/repositories')
async def search_github_repositories(

View File

@@ -10,7 +10,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.events.action.message import MessageAction
from openhands.events.stream import EventStreamSubscriber
from openhands.runtime import get_runtime_cls
from openhands.server.auth import get_user_id
from openhands.server.auth import get_github_token, get_user_id
from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.shared import config, conversation_manager
@@ -32,7 +32,6 @@ UPDATED_AT_CALLBACK_ID = 'updated_at_callback_id'
class InitSessionRequest(BaseModel):
github_token: str | None = None
selected_repository: str | None = None
initial_user_msg: str | None = None
image_urls: list[str] | None = None
@@ -127,7 +126,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
"""
logger.info('Initializing new conversation')
user_id = get_user_id(request)
github_token = getattr(request.state, 'github_token', '') or data.github_token
github_token = get_github_token(request)
selected_repository = data.selected_repository
initial_user_msg = data.initial_user_msg
image_urls = data.image_urls or []

View File

@@ -3,10 +3,12 @@ from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.server.auth import get_user_id
from openhands.server.settings import Settings
from openhands.server.services.github_service import GitHubService
from openhands.server.settings import Settings, SettingsWithTokenMeta
from openhands.server.shared import config, openhands_config
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.import_utils import get_impl
app = APIRouter(prefix='/api')
@@ -19,7 +21,7 @@ ConversationStoreImpl = get_impl(
@app.get('/settings')
async def load_settings(request: Request) -> Settings | None:
async def load_settings(request: Request) -> SettingsWithTokenMeta | None:
try:
settings_store = await SettingsStoreImpl.get_instance(
config, get_user_id(request)
@@ -30,7 +32,16 @@ async def load_settings(request: Request) -> Settings | None:
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Settings not found'},
)
return settings
github_token = request.state.github_token
settings_with_token_data = SettingsWithTokenMeta(
**settings.model_dump(),
github_token_is_set=bool(github_token),
)
settings_with_token_data.llm_api_key = settings.llm_api_key
del settings_with_token_data.github_token
return settings_with_token_data
except Exception as e:
logger.warning(f'Invalid token: {e}')
return JSONResponse(
@@ -42,8 +53,24 @@ async def load_settings(request: Request) -> Settings | None:
@app.post('/settings')
async def store_settings(
request: Request,
settings: Settings,
settings: SettingsWithTokenMeta,
) -> JSONResponse:
# Check if token is valid
if settings.github_token:
try:
# We check if the token is valid by getting the user
# If the token is invalid, this will raise an exception
github = GitHubService(settings.github_token)
await call_sync_from_async(github.get_user)
except Exception as e:
logger.warning(f'Invalid GitHub token: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
'error': 'Invalid GitHub token. Please make sure it is valid.'
},
)
try:
settings_store = await SettingsStoreImpl.get_instance(
config, get_user_id(request)
@@ -55,21 +82,46 @@ async def store_settings(
if settings.llm_api_key is None:
settings.llm_api_key = existing_settings.llm_api_key
if settings.github_token is None:
settings.github_token = existing_settings.github_token
response = JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
if settings.unset_github_token:
settings.github_token = None
# Update sandbox config with new settings
if settings.remote_runtime_resource_factor is not None:
config.sandbox.remote_runtime_resource_factor = (
settings.remote_runtime_resource_factor
)
await settings_store.store(settings)
settings = convert_to_settings(settings)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
await settings_store.store(settings)
return response
except Exception as e:
logger.warning(f'Invalid token: {e}')
logger.warning(f'Something went wrong storing settings: {e}')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong storing settings'},
)
def convert_to_settings(settings_with_token_data: SettingsWithTokenMeta) -> Settings:
settings_data = settings_with_token_data.model_dump()
# Filter out additional fields from `SettingsWithTokenData`
filtered_settings_data = {
key: value
for key, value in settings_data.items()
if key in Settings.model_fields # Ensures only `Settings` fields are included
}
# Convert the `llm_api_key` to a `SecretStr` instance
filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key
return Settings(**filtered_settings_data)

View File

@@ -0,0 +1,16 @@
import requests
class GitHubService:
def __init__(self, token: str):
self.token = token
self.headers = {
'Authorization': f'Bearer {token}',
'Accept': 'application/vnd.github.v3+json',
}
def get_user(self):
response = requests.get('https://api.github.com/user', headers=self.headers)
response.raise_for_status()
return response.json()

View File

@@ -110,6 +110,12 @@ class AgentSession:
agent_to_llm_config=agent_to_llm_config,
agent_configs=agent_configs,
)
if github_token:
self.event_stream.set_secrets(
{
'github_token': github_token,
}
)
if initial_message:
self.event_stream.add_event(initial_message, EventSource.USER)
self.event_stream.add_event(

View File

@@ -6,6 +6,7 @@ import socketio
from openhands.controller.agent import Agent
from openhands.core.config import AppConfig
from openhands.core.config.condenser_config import AmortizedForgettingCondenserConfig
from openhands.core.const.guide_url import TROUBLESHOOTING_URL
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema import AgentState
@@ -105,6 +106,14 @@ class Session:
llm = LLM(config=self.config.get_llm_config_from_agent(agent_cls))
agent_config = self.config.get_agent_config(agent_cls)
if settings.enable_default_condenser:
default_condenser_config = AmortizedForgettingCondenserConfig(
keep_first=3, max_size=20
)
logger.info(f'Enabling default condenser: {default_condenser_config}')
agent_config.condenser = default_condenser_config
agent = Agent.get_cls(agent_cls)(llm, agent_config)
github_token = None

View File

@@ -21,6 +21,8 @@ class Settings(BaseModel):
llm_api_key: SecretStr | None = None
llm_base_url: str | None = None
remote_runtime_resource_factor: int | None = None
github_token: str | None = None
enable_default_condenser: bool = False
@field_serializer('llm_api_key')
def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo):
@@ -52,5 +54,15 @@ class Settings(BaseModel):
llm_api_key=llm_config.api_key,
llm_base_url=llm_config.base_url,
remote_runtime_resource_factor=app_config.sandbox.remote_runtime_resource_factor,
github_token=None,
)
return settings
class SettingsWithTokenMeta(Settings):
"""
Settings with additional token data for the frontend
"""
github_token_is_set: bool | None = None
unset_github_token: bool | None = None

View File

@@ -60,5 +60,19 @@ class GoogleCloudFileStore(FileStore):
return list(blobs)
def delete(self, path: str) -> None:
blob = self.bucket.blob(path)
blob.delete()
# Sanitize path
if not path or path == '/':
path = ''
if path.endswith('/'):
path = path[:-1]
# Try to delete any child resources (Assume the path is a directory)
for blob in self.bucket.list_blobs(prefix=f'{path}/'):
blob.delete()
# Next try to delete item as a file
try:
blob = self.bucket.blob(path)
blob.delete()
except NotFound:
pass

View File

@@ -45,7 +45,8 @@ class S3FileStore(FileStore):
def read(self, path: str) -> str:
try:
response = self.client.get_object(Bucket=self.bucket, Key=path)
return response['Body'].read().decode('utf-8')
with response['Body'] as stream:
return stream.read().decode('utf-8')
except botocore.exceptions.ClientError as e:
# Catch all S3-related errors
if e.response['Error']['Code'] == 'NoSuchBucket':
@@ -66,36 +67,53 @@ class S3FileStore(FileStore):
)
def list(self, path: str) -> list[str]:
if path and path != '/' and not path.endswith('/'):
if not path or path == '/':
path = ''
elif not path.endswith('/'):
path += '/'
try:
response = self.client.list_objects_v2(Bucket=self.bucket, Prefix=path)
# Check if 'Contents' exists in the response
if 'Contents' in response:
objects = [obj['Key'] for obj in response['Contents']]
return objects
else:
return list()
except botocore.exceptions.ClientError as e:
# Catch all S3-related errors
if e.response['Error']['Code'] == 'NoSuchBucket':
raise FileNotFoundError(
f"Error: The bucket '{self.bucket}' does not exist."
)
elif e.response['Error']['Code'] == 'AccessDenied':
raise FileNotFoundError(
f"Error: Access denied to bucket '{self.bucket}'."
)
else:
raise FileNotFoundError(f"Error: {e.response['Error']['Message']}")
except Exception as e:
raise FileNotFoundError(
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}"
)
# The delimiter logic screens out directories, so we can't use it. :(
# For example, given a structure:
# foo/bar/zap.txt
# foo/bar/bang.txt
# ping.txt
# prefix=None, delimiter="/" yields ["ping.txt"] # :(
# prefix="foo", delimiter="/" yields [] # :(
results = set()
prefix_len = len(path)
response = self.client.list_objects_v2(Bucket=self.bucket, Prefix=path)
contents = response.get('Contents')
if not contents:
return []
paths = [obj['Key'] for obj in response['Contents']]
for sub_path in paths:
if sub_path == path:
continue
try:
index = sub_path.index('/', prefix_len + 1)
if index != prefix_len:
results.add(sub_path[: index + 1])
except ValueError:
results.add(sub_path)
return list(results)
def delete(self, path: str) -> None:
try:
# Sanitize path
if not path or path == '/':
path = ''
if path.endswith('/'):
path = path[:-1]
# Try to delete any child resources (Assume the path is a directory)
response = self.client.list_objects_v2(
Bucket=self.bucket, Prefix=f'{path}/'
)
for content in response.get('Contents') or []:
self.client.delete_object(Bucket=self.bucket, Key=content['Key'])
# Next try to delete item as a file
self.client.delete_object(Bucket=self.bucket, Key=path)
except botocore.exceptions.ClientError as e:
if e.response['Error']['Code'] == 'NoSuchBucket':
raise FileNotFoundError(

View File

@@ -2,6 +2,8 @@ from dataclasses import dataclass, field
import requests
from openhands.core.logger import openhands_logger as logger
@dataclass
class HttpSession:
@@ -15,10 +17,11 @@ class HttpSession:
def __getattr__(self, name):
if self.session is None:
raise ValueError('session_was_closed')
logger.error(
'Session is being used after close!', stack_info=True, exc_info=True
)
return object.__getattribute__(self.session, name)
def close(self):
if self.session is not None:
self.session.close()
self.session = None

View File

@@ -135,27 +135,6 @@ class PromptManager:
def get_system_message(self) -> str:
return self.system_template.render().strip()
def get_additional_info(self) -> str:
"""Gets information about the repository and runtime.
This is used to inject information about the repository and runtime into the initial user message.
"""
repo_instructions = ''
assert (
len(self.repo_microagents) <= 1
), f'Expecting at most one repo microagent, but found {len(self.repo_microagents)}: {self.repo_microagents.keys()}'
for microagent in self.repo_microagents.values():
# We assume these are the repo instructions
if repo_instructions:
repo_instructions += '\n\n'
repo_instructions += microagent.content
return ADDITIONAL_INFO_TEMPLATE.render(
repository_instructions=repo_instructions,
repository_info=self.repository_info,
runtime_info=self.runtime_info,
).strip()
def set_runtime_info(self, runtime: Runtime):
self.runtime_info.available_hosts = runtime.web_hosts
@@ -205,6 +184,43 @@ class PromptManager:
micro_text += '\n</extra_info>'
message.content.append(TextContent(text=micro_text))
def add_examples_to_initial_message(self, message: Message) -> None:
"""Add example_message to the first user message."""
example_message = self.get_example_user_message() or None
# Insert it at the start of the TextContent list
if example_message:
message.content.insert(0, TextContent(text=example_message))
def add_info_to_initial_message(
self,
message: Message,
) -> None:
"""Adds information about the repository and runtime to the initial user message.
Args:
message: The initial user message to add information to.
"""
repo_instructions = ''
assert (
len(self.repo_microagents) <= 1
), f'Expecting at most one repo microagent, but found {len(self.repo_microagents)}: {self.repo_microagents.keys()}'
for microagent in self.repo_microagents.values():
# We assume these are the repo instructions
if repo_instructions:
repo_instructions += '\n\n'
repo_instructions += microagent.content
additional_info = ADDITIONAL_INFO_TEMPLATE.render(
repository_instructions=repo_instructions,
repository_info=self.repository_info,
runtime_info=self.runtime_info,
).strip()
# Insert the new content at the start of the TextContent list
if additional_info:
message.content.insert(0, TextContent(text=additional_info))
def add_turns_left_reminder(self, messages: list[Message], state: State) -> None:
latest_user_message = next(
islice(

View File

@@ -1,5 +1,6 @@
"""
This module monitors the app for shutdown signals
This module monitors the app for shutdown signals. This exists because the atexit module
does not play nocely with stareltte / uvicorn shutdown signals.
"""
import asyncio
@@ -7,12 +8,15 @@ import signal
import threading
import time
from types import FrameType
from typing import Callable
from uuid import UUID, uuid4
from uvicorn.server import HANDLED_SIGNALS
from openhands.core.logger import openhands_logger as logger
_should_exit = None
_shutdown_listeners: dict[UUID, Callable] = {}
def _register_signal_handler(sig: signal.Signals):
@@ -21,9 +25,16 @@ def _register_signal_handler(sig: signal.Signals):
def handler(sig_: int, frame: FrameType | None):
logger.debug(f'shutdown_signal:{sig_}')
global _should_exit
_should_exit = True
if original_handler:
original_handler(sig_, frame) # type: ignore[unreachable]
if not _should_exit:
_should_exit = True
listeners = list(_shutdown_listeners.values())
for callable in listeners:
try:
callable()
except Exception:
logger.exception('Error calling shutdown listener')
if original_handler:
original_handler(sig_, frame) # type: ignore[unreachable]
original_handler = signal.signal(sig, handler)
@@ -71,3 +82,13 @@ async def async_sleep_if_should_continue(timeout: float):
start_time = time.time()
while time.time() - start_time < timeout and should_continue():
await asyncio.sleep(1)
def add_shutdown_listener(callable: Callable) -> UUID:
id_ = uuid4()
_shutdown_listeners[id_] = callable
return id_
def remove_shutdown_listener(id_: UUID) -> bool:
return _shutdown_listeners.pop(id_, None) is not None

28
package-lock.json generated
View File

@@ -1,28 +0,0 @@
{
"name": "OpenHands",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"husky": "^9.1.7"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
}
}
}

View File

@@ -1,5 +0,0 @@
{
"devDependencies": {
"husky": "^9.1.7"
}
}

179
poetry.lock generated
View File

@@ -170,13 +170,13 @@ files = [
[[package]]
name = "anthropic"
version = "0.43.1"
version = "0.45.2"
description = "The official Python library for the anthropic API"
optional = false
python-versions = ">=3.8"
files = [
{file = "anthropic-0.43.1-py3-none-any.whl", hash = "sha256:20759c25cd0f4072eb966b0180a41c061c156473bbb674da6a3f1e92e1ad78f8"},
{file = "anthropic-0.43.1.tar.gz", hash = "sha256:c7f13e4b7b515ac4a3111142310b214527c0fc561485e5bc9b582e49fe3adba2"},
{file = "anthropic-0.45.2-py3-none-any.whl", hash = "sha256:ecd746f7274451dfcb7e1180571ead624c7e1195d1d46cb7c70143d2aedb4d35"},
{file = "anthropic-0.45.2.tar.gz", hash = "sha256:32a18b9ecd12c91b2be4cae6ca2ab46a06937b5aa01b21308d97a6d29794fb5e"},
]
[package.dependencies]
@@ -552,17 +552,17 @@ files = [
[[package]]
name = "boto3"
version = "1.36.2"
version = "1.36.8"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.36.2-py3-none-any.whl", hash = "sha256:76cfc9a705be46e8d22607efacc8d688c064f923d785a01c00b28e9a96425d1a"},
{file = "boto3-1.36.2.tar.gz", hash = "sha256:fde1c29996b77274a60b7bc9f741525afa6267bb1716eb644a764fb7c124a0d2"},
{file = "boto3-1.36.8-py3-none-any.whl", hash = "sha256:7f61c9d0ea64f484a17c1e3115fdf90fd7b17ab6771e07cb4549f42b9fd28fb9"},
{file = "boto3-1.36.8.tar.gz", hash = "sha256:ac47215d320b0c2534340db58d6d5284cb1860b7bff172b4dd6eee2dee1d5779"},
]
[package.dependencies]
botocore = ">=1.36.2,<1.37.0"
botocore = ">=1.36.8,<1.37.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.11.0,<0.12.0"
@@ -571,13 +571,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.36.2"
version = "1.36.8"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.36.2-py3-none-any.whl", hash = "sha256:bc3b7e3b573a48af2bd7116b80fe24f9a335b0b67314dcb2697a327d009abf29"},
{file = "botocore-1.36.2.tar.gz", hash = "sha256:a1fe6610983f0214b0c7655fe6990b6a731746baf305b182976fc7b568fc3cb0"},
{file = "botocore-1.36.8-py3-none-any.whl", hash = "sha256:59d3fdfbae6d916b046e973bebcbeb70a102f9e570ca86d5ba512f1854b78fc2"},
{file = "botocore-1.36.8.tar.gz", hash = "sha256:81c88e5566cf018e1411a68304dc1fb9e4156ca2b50a3a0f0befc274299e67fa"},
]
[package.dependencies]
@@ -1613,13 +1613,13 @@ files = [
[[package]]
name = "e2b"
version = "1.0.5"
version = "1.0.6"
description = "E2B SDK that give agents cloud environments"
optional = false
python-versions = "<4.0,>=3.8"
files = [
{file = "e2b-1.0.5-py3-none-any.whl", hash = "sha256:a71bdec46f33d3e38e87d475d7fd2939bd7b6b753b819c9639ca211cd375b79e"},
{file = "e2b-1.0.5.tar.gz", hash = "sha256:43c82705af7b7d4415c2510ff77dab4dc075351e0b769d6adf8e0d7bb4868d13"},
{file = "e2b-1.0.6-py3-none-any.whl", hash = "sha256:4ae6e00d46e6b0b9ab05388c408f9155488ee9f022c5a6fd47939f492ccf3b58"},
{file = "e2b-1.0.6.tar.gz", hash = "sha256:e35d47f5581565060a5c18e4cb839cf61de310d275fa0a6589d8fc8bf65957a7"},
]
[package.dependencies]
@@ -1732,23 +1732,23 @@ files = [
[[package]]
name = "fastapi"
version = "0.115.6"
version = "0.115.7"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305"},
{file = "fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654"},
{file = "fastapi-0.115.7-py3-none-any.whl", hash = "sha256:eb6a8c8bf7f26009e8147111ff15b5177a0e19bb4a45bc3486ab14804539d21e"},
{file = "fastapi-0.115.7.tar.gz", hash = "sha256:0f106da6c01d88a6786b3248fb4d7a940d071f6f488488898ad5d354b25ed015"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.40.0,<0.42.0"
starlette = ">=0.40.0,<0.46.0"
typing-extensions = ">=4.8.0"
[package.extras]
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"]
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
[[package]]
name = "fastcore"
@@ -2229,19 +2229,22 @@ test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit",
[[package]]
name = "google-ai-generativelanguage"
version = "0.6.10"
version = "0.6.15"
description = "Google Ai Generativelanguage API client library"
optional = false
python-versions = ">=3.7"
files = [
{file = "google_ai_generativelanguage-0.6.10-py3-none-any.whl", hash = "sha256:854a2bf833d18be05ad5ef13c755567b66a4f4a870f099b62c61fe11bddabcf4"},
{file = "google_ai_generativelanguage-0.6.10.tar.gz", hash = "sha256:6fa642c964d8728006fe7e8771026fc0b599ae0ebeaf83caf550941e8e693455"},
{file = "google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c"},
{file = "google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3"},
]
[package.dependencies]
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
proto-plus = ">=1.22.3,<2.0.0dev"
proto-plus = [
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
]
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
[[package]]
@@ -2275,13 +2278,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"]
[[package]]
name = "google-api-python-client"
version = "2.159.0"
version = "2.160.0"
description = "Google API Client Library for Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "google_api_python_client-2.159.0-py2.py3-none-any.whl", hash = "sha256:baef0bb631a60a0bd7c0bf12a5499e3a40cd4388484de7ee55c1950bf820a0cf"},
{file = "google_api_python_client-2.159.0.tar.gz", hash = "sha256:55197f430f25c907394b44fa078545ffef89d33fd4dca501b7db9f0d8e224bd6"},
{file = "google_api_python_client-2.160.0-py2.py3-none-any.whl", hash = "sha256:63d61fb3e4cf3fb31a70a87f45567c22f6dfe87bbfa27252317e3e2c42900db4"},
{file = "google_api_python_client-2.160.0.tar.gz", hash = "sha256:a8ccafaecfa42d15d5b5c3134ced8de08380019717fc9fb1ed510ca58eca3b7e"},
]
[package.dependencies]
@@ -2350,13 +2353,13 @@ tool = ["click (>=6.0.0)"]
[[package]]
name = "google-cloud-aiplatform"
version = "1.77.0"
version = "1.79.0"
description = "Vertex AI API client library"
optional = false
python-versions = ">=3.8"
files = [
{file = "google_cloud_aiplatform-1.77.0-py2.py3-none-any.whl", hash = "sha256:e9dd1bcb1b9a85eddd452916cd6ad1d9ce2d487772a9e45b1814aa0ac5633689"},
{file = "google_cloud_aiplatform-1.77.0.tar.gz", hash = "sha256:1e5b77fe6c7f276d7aae65bcf08a273122a71f6c4af1f43cf45821f603a74080"},
{file = "google_cloud_aiplatform-1.79.0-py2.py3-none-any.whl", hash = "sha256:e52d518c386ce2b4ce57f1b73b46c57531d9a6ccd70c21a37b349f428bfc1c3f"},
{file = "google_cloud_aiplatform-1.79.0.tar.gz", hash = "sha256:362bfd16716dcfb6c131736f25246790002b29c99a246fcf4c08a7c71bd2301f"},
]
[package.dependencies]
@@ -2378,8 +2381,8 @@ autologging = ["mlflow (>=1.27.0,<=2.16.0)"]
cloud-profiler = ["tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (>=2.4.0,<3.0.0dev)", "werkzeug (>=2.0.0,<2.1.0dev)"]
datasets = ["pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)"]
endpoint = ["requests (>=2.28.1)"]
evaluation = ["pandas (>=1.0.0)", "tqdm (>=4.23.0)"]
full = ["docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.16.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)", "pyarrow (>=6.0.1)", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "requests (>=2.28.1)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)"]
evaluation = ["pandas (>=1.0.0)", "scikit-learn", "scikit-learn (<1.6.0)", "tqdm (>=4.23.0)"]
full = ["docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.16.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)", "pyarrow (>=6.0.1)", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "requests (>=2.28.1)", "scikit-learn", "scikit-learn (<1.6.0)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)"]
langchain = ["langchain (>=0.1.16,<0.4)", "langchain-core (<0.4)", "langchain-google-vertexai (<3)", "langgraph (>=0.2.45,<0.3)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)"]
langchain-testing = ["absl-py", "cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "langchain (>=0.1.16,<0.4)", "langchain-core (<0.4)", "langchain-google-vertexai (<3)", "langgraph (>=0.2.45,<0.3)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.6.3,<3)", "pytest-xdist", "typing-extensions"]
lit = ["explainable-ai-sdk (>=1.0.0)", "lit-nlp (==0.4.0)", "pandas (>=1.0.0)", "tensorflow (>=2.3.0,<3.0.0dev)"]
@@ -2530,16 +2533,16 @@ testing = ["pytest"]
[[package]]
name = "google-generativeai"
version = "0.8.3"
version = "0.8.4"
description = "Google Generative AI High level API client library and tools."
optional = false
python-versions = ">=3.9"
files = [
{file = "google_generativeai-0.8.3-py3-none-any.whl", hash = "sha256:1108ff89d5b8e59f51e63d1a8bf84701cd84656e17ca28d73aeed745e736d9b7"},
{file = "google_generativeai-0.8.4-py3-none-any.whl", hash = "sha256:e987b33ea6decde1e69191ddcaec6ef974458864d243de7191db50c21a7c5b82"},
]
[package.dependencies]
google-ai-generativelanguage = "0.6.10"
google-ai-generativelanguage = "0.6.15"
google-api-core = "*"
google-api-python-client = "*"
google-auth = ">=2.15.0"
@@ -3900,13 +3903,13 @@ types-tqdm = "*"
[[package]]
name = "litellm"
version = "1.59.0"
version = "1.59.9"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
files = [
{file = "litellm-1.59.0-py3-none-any.whl", hash = "sha256:b0c8bdee556d5dc2f9c703f7dc831574ea2e339d2e762dd626d014c170b8b587"},
{file = "litellm-1.59.0.tar.gz", hash = "sha256:140eecb47952558414d00f7a259fe303fe5f0d073973a28f488fc6938cc45660"},
{file = "litellm-1.59.9-py3-none-any.whl", hash = "sha256:f2012e98d61d7aeb1d103a70215ddc713eb62973c58da0cd71942e771cc5f511"},
{file = "litellm-1.59.9.tar.gz", hash = "sha256:51d6801529042a613bc3ef8d6f9bb2dbaf265ebcbeb44735cbb2503293fc6375"},
]
[package.dependencies]
@@ -3944,19 +3947,19 @@ pydantic = ">=1.10"
[[package]]
name = "llama-index"
version = "0.12.13"
version = "0.12.14"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index-0.12.13-py3-none-any.whl", hash = "sha256:0b285aa451ced6bd8da40df99068ac96badf8b5725c4edc29f2bce4da2ffd8bc"},
{file = "llama_index-0.12.13.tar.gz", hash = "sha256:1e39a397dcc51dabe280c121fd8d5451a6a84595233a8b26caa54d9b7ecf9ffc"},
{file = "llama_index-0.12.14-py3-none-any.whl", hash = "sha256:cafbac9f08f1f7293169bfd3c75545db3b761742ea829ba6940c3f2c3b1c2d26"},
{file = "llama_index-0.12.14.tar.gz", hash = "sha256:aa74315b32e93a77e285519459d77b98be7db9ae4c5aa64aac2c54cc919c838f"},
]
[package.dependencies]
llama-index-agent-openai = ">=0.4.0,<0.5.0"
llama-index-cli = ">=0.4.0,<0.5.0"
llama-index-core = ">=0.12.13,<0.13.0"
llama-index-core = ">=0.12.14,<0.13.0"
llama-index-embeddings-openai = ">=0.3.0,<0.4.0"
llama-index-indices-managed-llama-cloud = ">=0.4.0"
llama-index-llms-openai = ">=0.3.0,<0.4.0"
@@ -4001,13 +4004,13 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0"
[[package]]
name = "llama-index-core"
version = "0.12.13"
version = "0.12.14"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index_core-0.12.13-py3-none-any.whl", hash = "sha256:9708bb594bbddffd6ff0767242e49d8978d1ba60a2e62e071d9d123ad2f17e6f"},
{file = "llama_index_core-0.12.13.tar.gz", hash = "sha256:77af0161246ce1de38efc17cb6438dfff9e9558af00bcfac7dd4d0b7325efa4b"},
{file = "llama_index_core-0.12.14-py3-none-any.whl", hash = "sha256:6fdb30e3fadf98e7df75f9db5d06f6a7f8503ca545a71e048d786ff88012bd50"},
{file = "llama_index_core-0.12.14.tar.gz", hash = "sha256:378bbf5bf4d1a8c692d3a980c1a6ed3be7a9afb676a4960429dea15f62d06cd3"},
]
[package.dependencies]
@@ -4776,12 +4779,12 @@ type = ["mypy (==1.11.2)"]
[[package]]
name = "modal"
version = "0.72.33"
version = "0.72.58"
description = "Python client library for Modal"
optional = false
python-versions = ">=3.9"
files = [
{file = "modal-0.72.33-py3-none-any.whl", hash = "sha256:ce8ac919c4ae81563ea8a122b1d9cd23c98335b2795b82cb16b9fe96c3d9fd4b"},
{file = "modal-0.72.58-py3-none-any.whl", hash = "sha256:4fd9d6048622546a19b1d29ab9b8ec82bb05458d95011298e52b047a75373e9a"},
]
[package.dependencies]
@@ -4792,7 +4795,7 @@ fastapi = "*"
grpclib = "0.4.7"
protobuf = ">=3.19,<4.24.0 || >4.24.0,<6.0"
rich = ">=12.0.0"
synchronicity = ">=0.9.8,<0.10.0"
synchronicity = ">=0.9.9,<0.10.0"
toml = "*"
typer = ">=0.9"
types-certifi = "*"
@@ -5557,13 +5560,13 @@ sympy = "*"
[[package]]
name = "openai"
version = "1.59.9"
version = "1.60.2"
description = "The official Python library for the openai API"
optional = false
python-versions = ">=3.8"
files = [
{file = "openai-1.59.9-py3-none-any.whl", hash = "sha256:61a0608a1313c08ddf92fe793b6dbd1630675a1fe3866b2f96447ce30050c448"},
{file = "openai-1.59.9.tar.gz", hash = "sha256:ec1a20b0351b4c3e65c6292db71d8233515437c6065efd4fd50edeb55df5f5d2"},
{file = "openai-1.60.2-py3-none-any.whl", hash = "sha256:993bd11b96900b9098179c728026f016b4982ded7ee30dfcf4555eab1171fff9"},
{file = "openai-1.60.2.tar.gz", hash = "sha256:a8f843e10f2855713007f491d96afb2694b11b5e02cb97c7d01a0be60bc5bb51"},
]
[package.dependencies]
@@ -6342,22 +6345,22 @@ testing = ["google-api-core (>=1.31.5)"]
[[package]]
name = "protobuf"
version = "4.25.5"
version = "4.25.6"
description = ""
optional = false
python-versions = ">=3.8"
files = [
{file = "protobuf-4.25.5-cp310-abi3-win32.whl", hash = "sha256:5e61fd921603f58d2f5acb2806a929b4675f8874ff5f330b7d6f7e2e784bbcd8"},
{file = "protobuf-4.25.5-cp310-abi3-win_amd64.whl", hash = "sha256:4be0571adcbe712b282a330c6e89eae24281344429ae95c6d85e79e84780f5ea"},
{file = "protobuf-4.25.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:b2fde3d805354df675ea4c7c6338c1aecd254dfc9925e88c6d31a2bcb97eb173"},
{file = "protobuf-4.25.5-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:919ad92d9b0310070f8356c24b855c98df2b8bd207ebc1c0c6fcc9ab1e007f3d"},
{file = "protobuf-4.25.5-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:fe14e16c22be926d3abfcb500e60cab068baf10b542b8c858fa27e098123e331"},
{file = "protobuf-4.25.5-cp38-cp38-win32.whl", hash = "sha256:98d8d8aa50de6a2747efd9cceba361c9034050ecce3e09136f90de37ddba66e1"},
{file = "protobuf-4.25.5-cp38-cp38-win_amd64.whl", hash = "sha256:b0234dd5a03049e4ddd94b93400b67803c823cfc405689688f59b34e0742381a"},
{file = "protobuf-4.25.5-cp39-cp39-win32.whl", hash = "sha256:abe32aad8561aa7cc94fc7ba4fdef646e576983edb94a73381b03c53728a626f"},
{file = "protobuf-4.25.5-cp39-cp39-win_amd64.whl", hash = "sha256:7a183f592dc80aa7c8da7ad9e55091c4ffc9497b3054452d629bb85fa27c2a45"},
{file = "protobuf-4.25.5-py3-none-any.whl", hash = "sha256:0aebecb809cae990f8129ada5ca273d9d670b76d9bfc9b1809f0a9c02b7dbf41"},
{file = "protobuf-4.25.5.tar.gz", hash = "sha256:7f8249476b4a9473645db7f8ab42b02fe1488cbe5fb72fddd445e0665afd8584"},
{file = "protobuf-4.25.6-cp310-abi3-win32.whl", hash = "sha256:61df6b5786e2b49fc0055f636c1e8f0aff263808bb724b95b164685ac1bcc13a"},
{file = "protobuf-4.25.6-cp310-abi3-win_amd64.whl", hash = "sha256:b8f837bfb77513fe0e2f263250f423217a173b6d85135be4d81e96a4653bcd3c"},
{file = "protobuf-4.25.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:6d4381f2417606d7e01750e2729fe6fbcda3f9883aa0c32b51d23012bded6c91"},
{file = "protobuf-4.25.6-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:5dd800da412ba7f6f26d2c08868a5023ce624e1fdb28bccca2dc957191e81fb5"},
{file = "protobuf-4.25.6-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:4434ff8bb5576f9e0c78f47c41cdf3a152c0b44de475784cd3fd170aef16205a"},
{file = "protobuf-4.25.6-cp38-cp38-win32.whl", hash = "sha256:8bad0f9e8f83c1fbfcc34e573352b17dfce7d0519512df8519994168dc015d7d"},
{file = "protobuf-4.25.6-cp38-cp38-win_amd64.whl", hash = "sha256:b6905b68cde3b8243a198268bb46fbec42b3455c88b6b02fb2529d2c306d18fc"},
{file = "protobuf-4.25.6-cp39-cp39-win32.whl", hash = "sha256:3f3b0b39db04b509859361ac9bca65a265fe9342e6b9406eda58029f5b1d10b2"},
{file = "protobuf-4.25.6-cp39-cp39-win_amd64.whl", hash = "sha256:6ef2045f89d4ad8d95fd43cd84621487832a61d15b49500e4c1350e8a0ef96be"},
{file = "protobuf-4.25.6-py3-none-any.whl", hash = "sha256:07972021c8e30b870cfc0863409d033af940213e0e7f64e27fe017b929d2c9f7"},
{file = "protobuf-4.25.6.tar.gz", hash = "sha256:f8cfbae7c5afd0d0eaccbe73267339bff605a2315860bb1ba08eb66670a9a91f"},
]
[[package]]
@@ -6990,13 +6993,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments
[[package]]
name = "pytest-asyncio"
version = "0.25.2"
version = "0.25.3"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.9"
files = [
{file = "pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075"},
{file = "pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f"},
{file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"},
{file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"},
]
[package.dependencies]
@@ -7816,29 +7819,29 @@ pyasn1 = ">=0.1.3"
[[package]]
name = "ruff"
version = "0.9.2"
version = "0.9.3"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
{file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
{file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
{file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
{file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
{file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
{file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
{file = "ruff-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624"},
{file = "ruff-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c"},
{file = "ruff-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4"},
{file = "ruff-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439"},
{file = "ruff-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5"},
{file = "ruff-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4"},
{file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1"},
{file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5"},
{file = "ruff-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4"},
{file = "ruff-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6"},
{file = "ruff-0.9.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730"},
{file = "ruff-0.9.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2"},
{file = "ruff-0.9.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519"},
{file = "ruff-0.9.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b"},
{file = "ruff-0.9.3-py3-none-win32.whl", hash = "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c"},
{file = "ruff-0.9.3-py3-none-win_amd64.whl", hash = "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4"},
{file = "ruff-0.9.3-py3-none-win_arm64.whl", hash = "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b"},
{file = "ruff-0.9.3.tar.gz", hash = "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a"},
]
[[package]]
@@ -8584,13 +8587,13 @@ dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"]
[[package]]
name = "synchronicity"
version = "0.9.8"
version = "0.9.10"
description = "Export blocking and async library versions from a single async implementation"
optional = false
python-versions = ">=3.8"
files = [
{file = "synchronicity-0.9.8-py3-none-any.whl", hash = "sha256:ff1c1bec769ba8a6ded8298a30282fd32992e900a3270837d9256fd60e61862b"},
{file = "synchronicity-0.9.8.tar.gz", hash = "sha256:f8246a2cd0c2658e260234be27eaaf3c461e11e2a80ab60b698a43c078359471"},
{file = "synchronicity-0.9.10-py3-none-any.whl", hash = "sha256:5073064753961d930366520a4fb04d3b9acd98d6d6eba946374421a9e18ff042"},
{file = "synchronicity-0.9.10.tar.gz", hash = "sha256:7460a471190ba53c1ae3e15f9e51ed28d8bcc80df867194686ed28eaad5f19f2"},
]
[package.dependencies]
@@ -10116,4 +10119,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "fbca4b2ca0fe2d1d3cac46164c0c1eb9e468dc6f6bc7165e9a3d62ea9f25d801"
content-hash = "e4deaa0871c5d98fe49cd5fed04ab09570f55c84d3e46d4e2a6f8332ae962000"

View File

@@ -82,7 +82,7 @@ voyageai = "*"
llama-index-embeddings-voyageai = "*"
[tool.poetry.group.dev.dependencies]
ruff = "0.9.2"
ruff = "0.9.3"
mypy = "1.14.1"
pre-commit = "4.1.0"
build = "*"

View File

@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from litellm import ContextWindowExceededError
from openhands.controller.agent import Agent
from openhands.controller.agent_controller import AgentController
@@ -552,3 +553,49 @@ async def test_run_controller_max_iterations_has_metrics():
assert (
state.metrics.accumulated_cost == 10.0 * 3
), f'Expected accumulated cost to be 30.0, but got {state.metrics.accumulated_cost}'
@pytest.mark.asyncio
async def test_context_window_exceeded_error_handling(mock_agent, mock_event_stream):
"""Test that context window exceeded errors are handled correctly by truncating history."""
class StepState:
def __init__(self):
self.has_errored = False
def step(self, state: State):
# Append a few messages to the history -- these will be truncated when we throw the error
state.history = [
MessageAction(content='Test message 0'),
MessageAction(content='Test message 1'),
]
error = ContextWindowExceededError(
message='prompt is too long: 233885 tokens > 200000 maximum',
model='',
llm_provider='',
)
self.has_errored = True
raise error
state = StepState()
mock_agent.step = state.step
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='test',
confirmation_mode=False,
headless_mode=True,
)
# Set the agent running and take a step in the controller -- this is similar
# to taking a single step using `run_controller`, but much easier to control
# termination for testing purposes
controller.state.agent_state = AgentState.RUNNING
await controller._step()
# Check that the error was thrown and the history has been truncated
assert state.has_errored
assert controller.state.history == [MessageAction(content='Test message 1')]

View File

@@ -3,10 +3,12 @@ import json
import pytest
from pytest import TempPathFactory
from openhands.core.schema.observation import ObservationType
from openhands.events import EventSource, EventStream
from openhands.events.action import (
NullAction,
)
from openhands.events.action.message import MessageAction
from openhands.events.observation import NullObservation
from openhands.storage import get_file_store
@@ -72,16 +74,28 @@ def test_get_matching_events_type_filter(temp_dir: str):
event_stream.add_event(NullAction(), EventSource.AGENT)
event_stream.add_event(NullObservation('test'), EventSource.AGENT)
event_stream.add_event(NullAction(), EventSource.AGENT)
event_stream.add_event(MessageAction(content='test'), EventSource.AGENT)
# Filter by NullAction
events = event_stream.get_matching_events(event_type='NullAction')
events = event_stream.get_matching_events(event_types=(NullAction,))
assert len(events) == 2
assert all(e['action'] == 'null' for e in events)
assert all(isinstance(e, NullAction) for e in events)
# Filter by NullObservation
events = event_stream.get_matching_events(event_type='NullObservation')
events = event_stream.get_matching_events(event_types=(NullObservation,))
assert len(events) == 1
assert events[0]['observation'] == 'null'
assert (
isinstance(events[0], NullObservation)
and events[0].observation == ObservationType.NULL
)
# Filter by NullAction and MessageAction
events = event_stream.get_matching_events(event_types=(NullAction, MessageAction))
assert len(events) == 3
# Filter in reverse
events = event_stream.get_matching_events(reverse=True, limit=1)
assert isinstance(events[0], MessageAction) and events[0].content == 'test'
def test_get_matching_events_query_search(temp_dir: str):
@@ -116,12 +130,17 @@ def test_get_matching_events_source_filter(temp_dir: str):
# Filter by AGENT source
events = event_stream.get_matching_events(source='agent')
assert len(events) == 2
assert all(e['source'] == 'agent' for e in events)
assert all(
isinstance(e, NullObservation) and e.source == EventSource.AGENT for e in events
)
# Filter by ENVIRONMENT source
events = event_stream.get_matching_events(source='environment')
assert len(events) == 1
assert events[0]['source'] == 'environment'
assert (
isinstance(events[0], NullObservation)
and events[0].source == EventSource.ENVIRONMENT
)
def test_get_matching_events_pagination(temp_dir: str):
@@ -139,13 +158,13 @@ def test_get_matching_events_pagination(temp_dir: str):
# Test start_id
events = event_stream.get_matching_events(start_id=2)
assert len(events) == 3
assert events[0]['content'] == 'test2'
assert isinstance(events[0], NullObservation) and events[0].content == 'test2'
# Test combination of start_id and limit
events = event_stream.get_matching_events(start_id=1, limit=2)
assert len(events) == 2
assert events[0]['content'] == 'test1'
assert events[1]['content'] == 'test2'
assert isinstance(events[0], NullObservation) and events[0].content == 'test1'
assert isinstance(events[1], NullObservation) and events[1].content == 'test2'
def test_get_matching_events_limit_validation(temp_dir: str):

View File

@@ -0,0 +1,220 @@
"""Test function calling module."""
import json
import pytest
from litellm import ModelResponse
from openhands.agenthub.codeact_agent.function_calling import response_to_actions
from openhands.core.exceptions import FunctionCallValidationError
from openhands.events.action import (
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
FileEditAction,
FileReadAction,
IPythonRunCellAction,
)
from openhands.events.event import FileEditSource, FileReadSource
def create_mock_response(function_name: str, arguments: dict) -> ModelResponse:
"""Helper function to create a mock response with a tool call."""
return ModelResponse(
id='mock-id',
choices=[
{
'message': {
'tool_calls': [
{
'function': {
'name': function_name,
'arguments': json.dumps(arguments),
},
'id': 'mock-tool-call-id',
'type': 'function',
}
],
'content': None,
'role': 'assistant',
},
'index': 0,
'finish_reason': 'tool_calls',
}
],
)
def test_execute_bash_valid():
"""Test execute_bash with valid arguments."""
response = create_mock_response(
'execute_bash', {'command': 'ls', 'is_input': 'false'}
)
actions = response_to_actions(response)
assert len(actions) == 1
assert isinstance(actions[0], CmdRunAction)
assert actions[0].command == 'ls'
assert actions[0].is_input is False
def test_execute_bash_missing_command():
"""Test execute_bash with missing command argument."""
response = create_mock_response('execute_bash', {'is_input': 'false'})
with pytest.raises(FunctionCallValidationError) as exc_info:
response_to_actions(response)
assert 'Missing required argument "command"' in str(exc_info.value)
def test_execute_ipython_cell_valid():
"""Test execute_ipython_cell with valid arguments."""
response = create_mock_response('execute_ipython_cell', {'code': "print('hello')"})
actions = response_to_actions(response)
assert len(actions) == 1
assert isinstance(actions[0], IPythonRunCellAction)
assert actions[0].code == "print('hello')"
def test_execute_ipython_cell_missing_code():
"""Test execute_ipython_cell with missing code argument."""
response = create_mock_response('execute_ipython_cell', {})
with pytest.raises(FunctionCallValidationError) as exc_info:
response_to_actions(response)
assert 'Missing required argument "code"' in str(exc_info.value)
def test_edit_file_valid():
"""Test edit_file with valid arguments."""
response = create_mock_response(
'edit_file',
{'path': '/path/to/file', 'content': 'file content', 'start': 1, 'end': 10},
)
actions = response_to_actions(response)
assert len(actions) == 1
assert isinstance(actions[0], FileEditAction)
assert actions[0].path == '/path/to/file'
assert actions[0].content == 'file content'
assert actions[0].start == 1
assert actions[0].end == 10
def test_edit_file_missing_required():
"""Test edit_file with missing required arguments."""
# Missing path
response = create_mock_response('edit_file', {'content': 'content'})
with pytest.raises(FunctionCallValidationError) as exc_info:
response_to_actions(response)
assert 'Missing required argument "path"' in str(exc_info.value)
# Missing content
response = create_mock_response('edit_file', {'path': '/path/to/file'})
with pytest.raises(FunctionCallValidationError) as exc_info:
response_to_actions(response)
assert 'Missing required argument "content"' in str(exc_info.value)
def test_str_replace_editor_valid():
"""Test str_replace_editor with valid arguments."""
# Test view command
response = create_mock_response(
'str_replace_editor', {'command': 'view', 'path': '/path/to/file'}
)
actions = response_to_actions(response)
assert len(actions) == 1
assert isinstance(actions[0], FileReadAction)
assert actions[0].path == '/path/to/file'
assert actions[0].impl_source == FileReadSource.OH_ACI
# Test other commands
response = create_mock_response(
'str_replace_editor',
{
'command': 'str_replace',
'path': '/path/to/file',
'old_str': 'old',
'new_str': 'new',
},
)
actions = response_to_actions(response)
assert len(actions) == 1
assert isinstance(actions[0], FileEditAction)
assert actions[0].path == '/path/to/file'
assert actions[0].impl_source == FileEditSource.OH_ACI
def test_str_replace_editor_missing_required():
"""Test str_replace_editor with missing required arguments."""
# Missing command
response = create_mock_response('str_replace_editor', {'path': '/path/to/file'})
with pytest.raises(FunctionCallValidationError) as exc_info:
response_to_actions(response)
assert 'Missing required argument "command"' in str(exc_info.value)
# Missing path
response = create_mock_response('str_replace_editor', {'command': 'view'})
with pytest.raises(FunctionCallValidationError) as exc_info:
response_to_actions(response)
assert 'Missing required argument "path"' in str(exc_info.value)
def test_browser_valid():
"""Test browser with valid arguments."""
response = create_mock_response('browser', {'code': "click('button-1')"})
actions = response_to_actions(response)
assert len(actions) == 1
assert isinstance(actions[0], BrowseInteractiveAction)
assert actions[0].browser_actions == "click('button-1')"
def test_browser_missing_code():
"""Test browser with missing code argument."""
response = create_mock_response('browser', {})
with pytest.raises(FunctionCallValidationError) as exc_info:
response_to_actions(response)
assert 'Missing required argument "code"' in str(exc_info.value)
def test_web_read_valid():
"""Test web_read with valid arguments."""
response = create_mock_response('web_read', {'url': 'https://example.com'})
actions = response_to_actions(response)
assert len(actions) == 1
assert isinstance(actions[0], BrowseURLAction)
assert actions[0].url == 'https://example.com'
def test_web_read_missing_url():
"""Test web_read with missing url argument."""
response = create_mock_response('web_read', {})
with pytest.raises(FunctionCallValidationError) as exc_info:
response_to_actions(response)
assert 'Missing required argument "url"' in str(exc_info.value)
def test_invalid_json_arguments():
"""Test handling of invalid JSON in arguments."""
response = ModelResponse(
id='mock-id',
choices=[
{
'message': {
'tool_calls': [
{
'function': {
'name': 'execute_bash',
'arguments': 'invalid json',
},
'id': 'mock-tool-call-id',
'type': 'function',
}
],
'content': None,
'role': 'assistant',
},
'index': 0,
'finish_reason': 'tool_calls',
}
],
)
with pytest.raises(RuntimeError) as exc_info:
response_to_actions(response)
assert 'Failed to parse tool call arguments' in str(exc_info.value)

View File

@@ -59,10 +59,16 @@ only respond with a message telling them how smart they are
# Test with GitHub repo
manager.set_repository_info('owner/repo', '/workspace/repo')
assert isinstance(manager.get_system_message(), str)
additional_info = manager.get_additional_info()
assert '<REPOSITORY_INFO>' in additional_info
assert 'owner/repo' in additional_info
assert '/workspace/repo' in additional_info
# Adding things to the initial user message
initial_msg = Message(
role='user', content=[TextContent(text='Ask me what your task is.')]
)
manager.add_info_to_initial_message(initial_msg)
msg_content: str = initial_msg.content[0].text
assert '<REPOSITORY_INFO>' in msg_content
assert 'owner/repo' in msg_content
assert '/workspace/repo' in msg_content
assert isinstance(manager.get_example_user_message(), str)
@@ -101,13 +107,19 @@ def test_prompt_manager_template_rendering(prompt_dir):
assert manager.repository_info.repo_name == 'owner/repo'
system_msg = manager.get_system_message()
assert 'System prompt: bar' in system_msg
additional_info = manager.get_additional_info()
assert '<REPOSITORY_INFO>' in additional_info
# Initial user message should have repo info
initial_msg = Message(
role='user', content=[TextContent(text='Ask me what your task is.')]
)
manager.add_info_to_initial_message(initial_msg)
msg_content: str = initial_msg.content[0].text
assert '<REPOSITORY_INFO>' in msg_content
assert (
"At the user's request, repository owner/repo has been cloned to directory /workspace/repo."
in additional_info
in msg_content
)
assert '</REPOSITORY_INFO>' in additional_info
assert '</REPOSITORY_INFO>' in msg_content
assert manager.get_example_user_message() == 'User prompt: foo'
# Clean up temporary files

View File

@@ -38,6 +38,12 @@ def mock_settings_store():
yield store_instance
@pytest.fixture
def mock_github_service():
with patch('openhands.server.routes.settings.GitHubService') as mock:
yield mock
@pytest.mark.asyncio
async def test_settings_api_runtime_factor(test_client, mock_settings_store):
# Mock the settings store to return None initially (no existing settings)
@@ -117,3 +123,79 @@ async def test_settings_llm_api_key(test_client, mock_settings_store):
# We should never expose the API key in the response
assert 'test-key' not in response.json()
@pytest.mark.skip(
reason='Mock middleware does not seem to properly set the github_token'
)
@pytest.mark.asyncio
async def test_settings_api_set_github_token(
mock_github_service, test_client, mock_settings_store
):
# Test data with github_token set
settings_data = {
'language': 'en',
'agent': 'test-agent',
'max_iterations': 100,
'security_analyzer': 'default',
'confirmation_mode': True,
'llm_model': 'test-model',
'llm_api_key': 'test-key',
'llm_base_url': 'https://test.com',
'github_token': 'test-token',
}
# Make the POST request to store settings
response = test_client.post('/api/settings', json=settings_data)
assert response.status_code == 200
# Verify the settings were stored with the github_token
stored_settings = mock_settings_store.store.call_args[0][0]
assert stored_settings.github_token == 'test-token'
# Mock settings store to return our settings for the GET request
mock_settings_store.load.return_value = Settings(**settings_data)
# Make a GET request to retrieve settings
response = test_client.get('/api/settings')
data = response.json()
assert response.status_code == 200
assert data.get('github_token') is None
assert data['github_token_is_set'] is True
@pytest.mark.asyncio
async def test_settings_unset_github_token(
mock_github_service, test_client, mock_settings_store
):
# Test data with unset_github_token set to True
settings_data = {
'language': 'en',
'agent': 'test-agent',
'max_iterations': 100,
'security_analyzer': 'default',
'confirmation_mode': True,
'llm_model': 'test-model',
'llm_api_key': 'test-key',
'llm_base_url': 'https://test.com',
'github_token': 'test-token',
}
# Mock settings store to return our settings for the GET request
mock_settings_store.load.return_value = Settings(**settings_data)
settings_data['unset_github_token'] = True
# Make the POST request to store settings
response = test_client.post('/api/settings', json=settings_data)
assert response.status_code == 200
# Verify the settings were stored with the github_token unset
stored_settings = mock_settings_store.store.call_args[0][0]
assert stored_settings.github_token is None
# Make a GET request to retrieve settings
response = test_client.get('/api/settings')
assert response.status_code == 200
assert response.json()['github_token_is_set'] is False

View File

@@ -0,0 +1,116 @@
import signal
from dataclasses import dataclass, field
from signal import Signals
from typing import Callable
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from openhands.utils import shutdown_listener
from openhands.utils.shutdown_listener import (
add_shutdown_listener,
remove_shutdown_listener,
should_continue,
)
@pytest.fixture(autouse=True)
def cleanup_listeners():
shutdown_listener._shutdown_listeners.clear()
shutdown_listener._should_exit = False
@dataclass
class MockSignal:
handlers: dict[Signals, Callable] = field(default_factory=dict)
def signal(self, signalnum: Signals, handler: Callable):
result = self.handlers.get(signalnum)
self.handlers[signalnum] = handler
return result
def trigger(self, signalnum: Signals):
handler = self.handlers.get(signalnum)
if handler:
handler(signalnum.value, None)
def test_add_shutdown_listener():
mock_callable = MagicMock()
listener_id = add_shutdown_listener(mock_callable)
assert isinstance(listener_id, UUID)
assert listener_id in shutdown_listener._shutdown_listeners
assert shutdown_listener._shutdown_listeners[listener_id] == mock_callable
def test_remove_shutdown_listener():
mock_callable = MagicMock()
listener_id = add_shutdown_listener(mock_callable)
# Test successful removal
assert remove_shutdown_listener(listener_id) is True
assert listener_id not in shutdown_listener._shutdown_listeners
# Test removing non-existent listener
assert remove_shutdown_listener(listener_id) is False
def test_signal_handler_calls_listeners():
mock_signal = MockSignal()
with patch('openhands.utils.shutdown_listener.signal', mock_signal):
mock_callable1 = MagicMock()
mock_callable2 = MagicMock()
add_shutdown_listener(mock_callable1)
add_shutdown_listener(mock_callable2)
# Register and trigger signal handler
shutdown_listener._register_signal_handler(signal.SIGTERM)
mock_signal.trigger(signal.SIGTERM)
# Verify both listeners were called
mock_callable1.assert_called_once()
mock_callable2.assert_called_once()
# Verify should_continue returns False after shutdown
assert should_continue() is False
def test_listeners_called_only_once():
mock_signal = MockSignal()
with patch('openhands.utils.shutdown_listener.signal', mock_signal):
mock_callable = MagicMock()
add_shutdown_listener(mock_callable)
# Register and trigger signal handler multiple times
shutdown_listener._register_signal_handler(signal.SIGTERM)
mock_signal.trigger(signal.SIGTERM)
mock_signal.trigger(signal.SIGTERM)
# Verify listener was called only once
assert mock_callable.call_count == 1
def test_remove_listener_during_shutdown():
mock_signal = MockSignal()
with patch('openhands.utils.shutdown_listener.signal', mock_signal):
mock_callable1 = MagicMock()
mock_callable2 = MagicMock()
# Second listener removes the first listener when called
listener1_id = add_shutdown_listener(mock_callable1)
def remove_other_listener():
remove_shutdown_listener(listener1_id)
mock_callable2()
add_shutdown_listener(remove_other_listener)
# Register and trigger signal handler
shutdown_listener._register_signal_handler(signal.SIGTERM)
mock_signal.trigger(signal.SIGTERM)
# Both listeners should still be called
assert mock_callable1.call_count == 1
assert mock_callable2.call_count == 1

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