mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
273 Commits
debug-visu
...
search_eng
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c09a8062f | ||
|
|
7051669835 | ||
|
|
c1c7e8dc72 | ||
|
|
ecff077b5e | ||
|
|
4972b20986 | ||
|
|
48db2a1c09 | ||
|
|
e6ea2c8a9b | ||
|
|
b8f04eea42 | ||
|
|
2f3b9cfe4a | ||
|
|
bc4e8632e1 | ||
|
|
2015c13826 | ||
|
|
1c72e2bb31 | ||
|
|
22e13a0a4d | ||
|
|
05dabbbb13 | ||
|
|
de312941ee | ||
|
|
a4b836b5f9 | ||
|
|
a4d632498c | ||
|
|
4f017081fc | ||
|
|
51fb1fae88 | ||
|
|
106b230fea | ||
|
|
9b262dd057 | ||
|
|
8074b261d3 | ||
|
|
999a59f938 | ||
|
|
fbba57d3b5 | ||
|
|
3f6c8a2338 | ||
|
|
dd09d46ccb | ||
|
|
8897b45eeb | ||
|
|
30109e8f20 | ||
|
|
635dc5098a | ||
|
|
3a765db531 | ||
|
|
929b971ef5 | ||
|
|
d518fc8f8c | ||
|
|
00d7425f4c | ||
|
|
b24e34c3eb | ||
|
|
a05b39d5c5 | ||
|
|
a880f55a63 | ||
|
|
35ab168b88 | ||
|
|
a578cf39a9 | ||
|
|
d071acf501 | ||
|
|
f73edd7220 | ||
|
|
869e2911c6 | ||
|
|
3004073d39 | ||
|
|
d050f481e8 | ||
|
|
96654069f7 | ||
|
|
78db84fe38 | ||
|
|
d6605dee56 | ||
|
|
92cc51951f | ||
|
|
72953443bc | ||
|
|
96e4831379 | ||
|
|
06e698818f | ||
|
|
ea95639d2b | ||
|
|
3e4dd38212 | ||
|
|
de09b368ba | ||
|
|
b69ecc5cc2 | ||
|
|
979135b5e1 | ||
|
|
0dec0fbab0 | ||
|
|
69a1c9abc7 | ||
|
|
8a9fdbe7c5 | ||
|
|
fae10c8856 | ||
|
|
21ef0890bc | ||
|
|
80b955279e | ||
|
|
8d0e4235b4 | ||
|
|
cdd9f5860d | ||
|
|
f5c794212c | ||
|
|
7612a56a76 | ||
|
|
9db439f2c0 | ||
|
|
a74d972d99 | ||
|
|
f2503fe392 | ||
|
|
fd27d4ffa3 | ||
|
|
033f004952 | ||
|
|
242e5b1ec6 | ||
|
|
9c48604410 | ||
|
|
065ab7bdf5 | ||
|
|
57a1768a3e | ||
|
|
82abb2326d | ||
|
|
17dda08bb1 | ||
|
|
c839a5f0cd | ||
|
|
d1546f4cbe | ||
|
|
cb8cfe06a6 | ||
|
|
7d0befc429 | ||
|
|
2ebc8169c0 | ||
|
|
39ae2bd48c | ||
|
|
32fda5fce4 | ||
|
|
855ccb8c63 | ||
|
|
994f4f8d23 | ||
|
|
288f46ab4d | ||
|
|
16c54e02c9 | ||
|
|
4f5c7d26c7 | ||
|
|
3815cfc318 | ||
|
|
a44fd2c7ca | ||
|
|
1a9b284b9b | ||
|
|
04175c747e | ||
|
|
7fa0959681 | ||
|
|
9858c89155 | ||
|
|
e2fb79620a | ||
|
|
218dc545bf | ||
|
|
693da0a052 | ||
|
|
facd03aa29 | ||
|
|
aebfa7666f | ||
|
|
6d248eee06 | ||
|
|
53f8dc1061 | ||
|
|
584aa7c8b0 | ||
|
|
2422d703e3 | ||
|
|
03cdded50b | ||
|
|
3e455ad323 | ||
|
|
591e0d91a3 | ||
|
|
bae3887040 | ||
|
|
5a1697940b | ||
|
|
8ba8990a52 | ||
|
|
974a46c582 | ||
|
|
30d76c3733 | ||
|
|
8cd6e60415 | ||
|
|
cb3edf2885 | ||
|
|
65524f171d | ||
|
|
c26c7478c2 | ||
|
|
cf1265dec2 | ||
|
|
aa640afbe0 | ||
|
|
afc76055d8 | ||
|
|
11c37e04c1 | ||
|
|
b32b4beb73 | ||
|
|
d36a477d3c | ||
|
|
25b01925fe | ||
|
|
4915d20cdd | ||
|
|
fe42b7e88f | ||
|
|
d26d925e04 | ||
|
|
f8d468cfff | ||
|
|
58cfd16c53 | ||
|
|
316322a942 | ||
|
|
aaf3e0819b | ||
|
|
18d6deeb6c | ||
|
|
cf727c9ac6 | ||
|
|
85c51410d7 | ||
|
|
5a8056bf27 | ||
|
|
2984ed740b | ||
|
|
dc045d2cde | ||
|
|
507aa39c80 | ||
|
|
58ff252f50 | ||
|
|
1ddb68e0fa | ||
|
|
e5fc1afb4e | ||
|
|
336eb98ea3 | ||
|
|
a25324304a | ||
|
|
659ca022f2 | ||
|
|
578bf144d5 | ||
|
|
ec1ce2f9fa | ||
|
|
83ecc2049e | ||
|
|
bb559a2dca | ||
|
|
d0ca8db912 | ||
|
|
6108434f06 | ||
|
|
ce218a167b | ||
|
|
843f092b76 | ||
|
|
bf70965565 | ||
|
|
d5ee33811a | ||
|
|
a4c60d9986 | ||
|
|
4f00bf4846 | ||
|
|
5ac5e229d6 | ||
|
|
ffa5fbe9c2 | ||
|
|
d2398fef4b | ||
|
|
a5ea1db1af | ||
|
|
a195ada8bf | ||
|
|
1ef0d8fa21 | ||
|
|
dbe2b333a0 | ||
|
|
e5679f6450 | ||
|
|
7aeaac5261 | ||
|
|
2f3bd3d808 | ||
|
|
c42c9cbaf8 | ||
|
|
a1ffdcea34 | ||
|
|
2988b10d00 | ||
|
|
849d2719bf | ||
|
|
cb9eeae313 | ||
|
|
e5599ef094 | ||
|
|
a3e134bdf4 | ||
|
|
ee0225c062 | ||
|
|
debd00075d | ||
|
|
c6f4dddf70 | ||
|
|
af2e91d6da | ||
|
|
643664a0a9 | ||
|
|
67fbf5906f | ||
|
|
42ff2b70ed | ||
|
|
5e469c588b | ||
|
|
93e54418be | ||
|
|
3ea122fa30 | ||
|
|
1227e503b2 | ||
|
|
f16bab079c | ||
|
|
c50bc33cb5 | ||
|
|
227f2f7dec | ||
|
|
72237e4557 | ||
|
|
a418d0dacf | ||
|
|
871095184d | ||
|
|
dc4ca3ad91 | ||
|
|
3626e29150 | ||
|
|
6c49c5e8e3 | ||
|
|
759a03f406 | ||
|
|
bc188694af | ||
|
|
7f5d7675ee | ||
|
|
1f01edb0da | ||
|
|
70d7982548 | ||
|
|
dfbd928b20 | ||
|
|
e59878af5f | ||
|
|
e2add5c57e | ||
|
|
26f235ff00 | ||
|
|
b71b723e91 | ||
|
|
25b73f9606 | ||
|
|
807b0bfbfe | ||
|
|
a98f31fbdb | ||
|
|
508dea41a0 | ||
|
|
fba1f1b7e7 | ||
|
|
b60acd5073 | ||
|
|
27e39fb377 | ||
|
|
a0c9451a32 | ||
|
|
fcfc807bf1 | ||
|
|
1a5c252043 | ||
|
|
8e55ced1a9 | ||
|
|
7113d387cb | ||
|
|
01d23ae05e | ||
|
|
477b369f65 | ||
|
|
0588311628 | ||
|
|
6dce886b63 | ||
|
|
3d2268ef35 | ||
|
|
4b23a77464 | ||
|
|
04d5691cf7 | ||
|
|
102f2e4d60 | ||
|
|
f2781a52c1 | ||
|
|
8cf58c62ba | ||
|
|
72a4ece435 | ||
|
|
edea1e967e | ||
|
|
840b35097b | ||
|
|
e9c32f708f | ||
|
|
19e5e5bbc1 | ||
|
|
e561aa974b | ||
|
|
3b7d86ec59 | ||
|
|
677488d450 | ||
|
|
3b504e10fd | ||
|
|
b5391f5b77 | ||
|
|
3fa1fb72ac | ||
|
|
03f4745f7e | ||
|
|
945bdd7a6a | ||
|
|
7c1c19c095 | ||
|
|
f1911bec24 | ||
|
|
7e08383168 | ||
|
|
0b361f318e | ||
|
|
6e7aa1531e | ||
|
|
904b2b3f9f | ||
|
|
0f24032f05 | ||
|
|
f49b7a98d1 | ||
|
|
0b1ec8a621 | ||
|
|
d35a225729 | ||
|
|
3b05b68d3b | ||
|
|
65bf992263 | ||
|
|
83fa5b08c0 | ||
|
|
ee1173e841 | ||
|
|
e66a1132c5 | ||
|
|
d34c41261b | ||
|
|
a699a0d306 | ||
|
|
818533f402 | ||
|
|
26c4f72e21 | ||
|
|
a7d38cd421 | ||
|
|
70772ccf31 | ||
|
|
6b94c97cc2 | ||
|
|
9b742c5042 | ||
|
|
f45e7ec29f | ||
|
|
dfee306e06 | ||
|
|
4315c228fb | ||
|
|
f5eed29995 | ||
|
|
ce0979f715 | ||
|
|
072d956f3b | ||
|
|
dcdb44863e | ||
|
|
5258fe8d29 | ||
|
|
bad7ccfee4 | ||
|
|
1fef52a152 | ||
|
|
6aba4621da | ||
|
|
c2505c0e34 | ||
|
|
a3c8bcc2b9 | ||
|
|
965cee7d48 |
6
.github/workflows/deploy-docs.yml
vendored
6
.github/workflows/deploy-docs.yml
vendored
@@ -11,6 +11,7 @@ on:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
- 'pydoc-markdown.yml'
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -39,7 +40,10 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Generate Python Docs
|
||||
run: rm -rf docs/modules/python && pip install pydoc-markdown && pydoc-markdown
|
||||
run: |
|
||||
rm -rf docs/modules/python
|
||||
pip install pydoc-markdown
|
||||
pydoc-markdown
|
||||
- name: Install dependencies
|
||||
run: cd docs && npm ci
|
||||
- name: Build website
|
||||
|
||||
@@ -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-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://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw"><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-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw) - 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.
|
||||
|
||||
|
||||
@@ -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-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw">
|
||||
<img
|
||||
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
|
||||
alt="Join our Slack community"
|
||||
|
||||
@@ -42,7 +42,7 @@ OpenHands 是一个**自主 AI 软件工程师**,能够执行复杂的工程
|
||||
/>
|
||||
</a>
|
||||
<br></br>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw">
|
||||
<img
|
||||
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
|
||||
alt="Join our Slack community"
|
||||
|
||||
@@ -308,6 +308,11 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
|
||||
- Default: `false`
|
||||
- Description: Whether Jupyter is enabled in the action space
|
||||
|
||||
- `enable_search_engine`
|
||||
- Type: `bool`
|
||||
- Default: `false`
|
||||
- Description: Whether the search engine tool is enabled in the action space. See [Search Configuration](./search/search-configuration.md) for details.
|
||||
|
||||
- `enable_history_truncation`
|
||||
- Type: `bool`
|
||||
- Default: `true`
|
||||
|
||||
113
docs/modules/usage/search/search-configuration.md
Normal file
113
docs/modules/usage/search/search-configuration.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Search Configuration
|
||||
|
||||
OpenHands provides a search engine capability that allows agents to perform web searches using the Brave Search API. This guide explains how to configure and use the search feature.
|
||||
|
||||
## Overview
|
||||
|
||||
The search engine feature enables agents to:
|
||||
- Execute web search queries programmatically
|
||||
- Get structured results including web pages, news, videos, and FAQs
|
||||
- Avoid CAPTCHA challenges that often occur when using browser-based search
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enabling Search
|
||||
|
||||
To enable the search engine feature, set the following in your `config.toml`:
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
enable_search_engine = true
|
||||
```
|
||||
|
||||
Or when using Docker, set the environment variable:
|
||||
```bash
|
||||
-e AGENT_ENABLE_SEARCH_ENGINE=true
|
||||
```
|
||||
|
||||
### API Key Setup
|
||||
|
||||
The search feature requires a Brave Search API key. You can obtain one from the [Brave Search API Dashboard](https://api.search.brave.com/app/keys).
|
||||
|
||||
Set the API key in your `config.toml`:
|
||||
```toml
|
||||
[search]
|
||||
enabled = true
|
||||
api_key = "your-api-key-here"
|
||||
```
|
||||
|
||||
Or when using Docker:
|
||||
```bash
|
||||
-e SEARCH_ENABLED=true
|
||||
-e SEARCH_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
## Search Results
|
||||
|
||||
When a search is performed, the results are returned in a structured format that includes:
|
||||
|
||||
- Web search results
|
||||
- News articles
|
||||
- Video content
|
||||
- FAQ entries
|
||||
- Discussion threads
|
||||
- Infoboxes (when available)
|
||||
- Location information (when relevant)
|
||||
|
||||
Each result type includes:
|
||||
- Title
|
||||
- URL (when applicable)
|
||||
- Description or snippet
|
||||
- Additional metadata specific to the result type
|
||||
|
||||
## Usage Example
|
||||
|
||||
When the search feature is enabled, agents can use the `search_engine` tool to perform searches. For example:
|
||||
|
||||
```python
|
||||
# The agent can make a tool call like this:
|
||||
{
|
||||
"name": "search_engine",
|
||||
"arguments": {
|
||||
"query": "latest developments in AI"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The search results will be returned in a markdown-formatted structure that's easy for the agent to parse and understand.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Query Formulation**
|
||||
- Keep queries focused and specific
|
||||
- Include relevant keywords
|
||||
- Avoid overly complex or compound queries
|
||||
|
||||
2. **Rate Limiting**
|
||||
- Be mindful of API rate limits
|
||||
- Cache results when appropriate
|
||||
- Implement retries with exponential backoff for failed requests
|
||||
|
||||
3. **Error Handling**
|
||||
- Handle API errors gracefully
|
||||
- Provide meaningful feedback when searches fail
|
||||
- Have fallback strategies when search is unavailable
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
1. **Search Not Working**
|
||||
- Verify `enable_search_engine` is set to `true`
|
||||
- Confirm the Brave API key is correctly set
|
||||
- Check API key permissions and quotas
|
||||
|
||||
2. **No Results**
|
||||
- Verify the query is not empty
|
||||
- Try reformulating the search query
|
||||
- Check for any API response errors
|
||||
|
||||
3. **Rate Limiting**
|
||||
- Monitor API usage
|
||||
- Implement caching if needed
|
||||
- Consider upgrading API tier if limits are consistently hit
|
||||
@@ -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-2ypg5jweb-d~6hObZDbXi_HEL8PDrbHg" target="_blank" rel="noopener noreferrer">
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw" target="_blank" rel="noopener noreferrer">
|
||||
<FaSlack />
|
||||
</a>
|
||||
<a href="https://discord.gg/ESHStjSjD4" target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -46,7 +46,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-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://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw"><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/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -175,6 +176,11 @@ def process_instance(
|
||||
logger.warning(
|
||||
f'This is the {runtime_failure_count + 1}th attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
|
||||
)
|
||||
metadata = copy.deepcopy(metadata)
|
||||
metadata.details['runtime_failure_count'] = runtime_failure_count
|
||||
metadata.details['remote_runtime_resource_factor'] = (
|
||||
config.sandbox.remote_runtime_resource_factor
|
||||
)
|
||||
|
||||
try:
|
||||
runtime = create_runtime(config)
|
||||
@@ -296,14 +302,20 @@ def process_instance(
|
||||
with open(test_output_path, 'w') as f:
|
||||
f.write(test_output)
|
||||
try:
|
||||
extra_kwargs = {}
|
||||
if 'SWE-Gym' in metadata.dataset:
|
||||
# SWE-Gym uses a different version of the package, hence a different eval report argument
|
||||
extra_kwargs['log_path'] = test_output_path
|
||||
else:
|
||||
extra_kwargs['test_log_path'] = test_output_path
|
||||
_report = conditional_imports.get_eval_report(
|
||||
test_spec=test_spec,
|
||||
prediction={
|
||||
'model_patch': model_patch,
|
||||
'instance_id': instance_id,
|
||||
},
|
||||
test_log_path=test_output_path,
|
||||
include_tests_status=True,
|
||||
**extra_kwargs,
|
||||
)
|
||||
report = _report[instance_id]
|
||||
logger.info(
|
||||
@@ -463,6 +475,7 @@ if __name__ == '__main__':
|
||||
.decode('utf-8')
|
||||
.strip(), # Current commit
|
||||
dataset=args.dataset, # Dataset name from args
|
||||
details={},
|
||||
)
|
||||
|
||||
# The evaluation harness constrains the signature of `process_instance_func` but we need to
|
||||
|
||||
2121
evaluation/benchmarks/swe_bench/resource/SWE-Gym__SWE-Gym-train.json
Normal file
2121
evaluation/benchmarks/swe_bench/resource/SWE-Gym__SWE-Gym-train.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ def get_resource_mapping(dataset_name: str) -> dict[str, float]:
|
||||
if dataset_name not in _global_resource_mapping:
|
||||
file_path = os.path.join(CUR_DIR, f'{dataset_name}.json')
|
||||
if not os.path.exists(file_path):
|
||||
logger.warning(f'Resource mapping for {dataset_name} not found.')
|
||||
logger.info(f'Resource mapping for {dataset_name} not found.')
|
||||
return None
|
||||
|
||||
with open(file_path, 'r') as f:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
@@ -149,7 +150,8 @@ def get_config(
|
||||
) -> AppConfig:
|
||||
# We use a different instance image for the each instance of swe-bench eval
|
||||
use_official_image = bool(
|
||||
'verified' in metadata.dataset.lower() or 'lite' in metadata.dataset.lower()
|
||||
('verified' in metadata.dataset.lower() or 'lite' in metadata.dataset.lower())
|
||||
and 'swe-gym' not in metadata.dataset.lower()
|
||||
)
|
||||
base_container_image = get_instance_docker_image(
|
||||
instance['instance_id'], use_official_image
|
||||
@@ -475,6 +477,13 @@ def process_instance(
|
||||
logger.warning(
|
||||
f'This is the {runtime_failure_count + 1}th attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
|
||||
)
|
||||
|
||||
metadata = copy.deepcopy(metadata)
|
||||
metadata.details['runtime_failure_count'] = runtime_failure_count
|
||||
metadata.details['remote_runtime_resource_factor'] = (
|
||||
config.sandbox.remote_runtime_resource_factor
|
||||
)
|
||||
|
||||
runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
@@ -560,20 +569,6 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
|
||||
return dataset
|
||||
|
||||
|
||||
# A list of instances that are known to be tricky to infer
|
||||
# (will cause runtime failure even with resource factor = 8)
|
||||
SWEGYM_EXCLUDE_IDS = [
|
||||
'dask__dask-10422',
|
||||
'pandas-dev__pandas-50548',
|
||||
'pandas-dev__pandas-53672',
|
||||
'pandas-dev__pandas-54174',
|
||||
'pandas-dev__pandas-55518',
|
||||
'pandas-dev__pandas-58383',
|
||||
'pydata__xarray-6721',
|
||||
'pytest-dev__pytest-10081',
|
||||
'pytest-dev__pytest-7236',
|
||||
]
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_parser()
|
||||
parser.add_argument(
|
||||
@@ -598,11 +593,20 @@ if __name__ == '__main__':
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
|
||||
)
|
||||
if 'SWE-Gym' in args.dataset:
|
||||
swe_bench_tests = swe_bench_tests[
|
||||
~swe_bench_tests['instance_id'].isin(SWEGYM_EXCLUDE_IDS)
|
||||
]
|
||||
with open(
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
'split',
|
||||
'swegym_verified_instances.json',
|
||||
),
|
||||
'r',
|
||||
) as f:
|
||||
swegym_verified_instances = json.load(f)
|
||||
swe_bench_tests = swe_bench_tests[
|
||||
swe_bench_tests['instance_id'].isin(swegym_verified_instances)
|
||||
]
|
||||
logger.info(
|
||||
f'{len(swe_bench_tests)} tasks left after excluding SWE-Gym excluded tasks'
|
||||
f'{len(swe_bench_tests)} tasks left after filtering for SWE-Gym verified instances'
|
||||
)
|
||||
|
||||
llm_config = None
|
||||
|
||||
@@ -9,7 +9,7 @@ parser.add_argument(
|
||||
'--dataset_name',
|
||||
type=str,
|
||||
help='Name of the dataset to download',
|
||||
default='princeton-nlp/SWE-bench_Lite',
|
||||
default='princeton-nlp/SWE-bench_Verified',
|
||||
)
|
||||
parser.add_argument('--split', type=str, help='Split to download', default='test')
|
||||
args = parser.parse_args()
|
||||
@@ -20,7 +20,12 @@ print(
|
||||
f'Downloading gold patches from {args.dataset_name} (split: {args.split}) to {output_filepath}'
|
||||
)
|
||||
patches = [
|
||||
{'instance_id': row['instance_id'], 'model_patch': row['patch']} for row in dataset
|
||||
{
|
||||
'instance_id': row['instance_id'],
|
||||
'model_patch': row['patch'],
|
||||
'model_name_or_path': 'gold',
|
||||
}
|
||||
for row in dataset
|
||||
]
|
||||
print(f'{len(patches)} gold patches loaded')
|
||||
pd.DataFrame(patches).to_json(output_filepath, lines=True, orient='records')
|
||||
|
||||
2121
evaluation/benchmarks/swe_bench/split/swegym_verified_instances.json
Normal file
2121
evaluation/benchmarks/swe_bench/split/swegym_verified_instances.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,6 @@ from openhands.utils.async_utils import call_async_from_sync
|
||||
|
||||
FAKE_RESPONSES = {
|
||||
'CodeActAgent': fake_user_response,
|
||||
'DelegatorAgent': fake_user_response,
|
||||
'VisualBrowsingAgent': fake_user_response,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ load_dotenv()
|
||||
from openhands.agenthub import ( # noqa: E402
|
||||
browsing_agent,
|
||||
codeact_agent,
|
||||
delegator_agent,
|
||||
dummy_agent,
|
||||
visualbrowsing_agent,
|
||||
)
|
||||
@@ -15,7 +14,6 @@ from openhands.controller.agent import Agent # noqa: E402
|
||||
__all__ = [
|
||||
'Agent',
|
||||
'codeact_agent',
|
||||
'delegator_agent',
|
||||
'dummy_agent',
|
||||
'browsing_agent',
|
||||
'visualbrowsing_agent',
|
||||
|
||||
@@ -70,9 +70,11 @@ class CodeActAgent(Agent):
|
||||
codeact_enable_browsing=self.config.codeact_enable_browsing,
|
||||
codeact_enable_jupyter=self.config.codeact_enable_jupyter,
|
||||
codeact_enable_llm_editor=self.config.codeact_enable_llm_editor,
|
||||
codeact_enable_search_engine=self.config.enable_search_engine,
|
||||
llm=self.llm,
|
||||
)
|
||||
logger.debug(
|
||||
f'TOOLS loaded for CodeActAgent: {', '.join([tool.get('function').get('name') for tool in self.tools])}'
|
||||
f"TOOLS loaded for CodeActAgent: {', '.join([tool.get('function').get('name') for tool in self.tools])}"
|
||||
)
|
||||
self.prompt_manager = PromptManager(
|
||||
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
|
||||
|
||||
@@ -12,13 +12,14 @@ from litellm import (
|
||||
|
||||
from openhands.agenthub.codeact_agent.tools import (
|
||||
BrowserTool,
|
||||
CmdRunTool,
|
||||
FinishTool,
|
||||
IPythonTool,
|
||||
LLMBasedFileEditTool,
|
||||
StrReplaceEditorTool,
|
||||
SearchEngineTool,
|
||||
ThinkTool,
|
||||
WebReadTool,
|
||||
create_cmd_run_tool,
|
||||
create_str_replace_editor_tool,
|
||||
)
|
||||
from openhands.core.exceptions import (
|
||||
FunctionCallNotExistsError,
|
||||
@@ -36,9 +37,11 @@ from openhands.events.action import (
|
||||
FileReadAction,
|
||||
IPythonRunCellAction,
|
||||
MessageAction,
|
||||
SearchAction,
|
||||
)
|
||||
from openhands.events.event import FileEditSource, FileReadSource
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
from openhands.llm import LLM
|
||||
|
||||
|
||||
def combine_thought(action: Action, thought: str) -> Action:
|
||||
@@ -80,7 +83,7 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
# CmdRunTool (Bash)
|
||||
# ================================================
|
||||
|
||||
if tool_call.function.name == CmdRunTool['function']['name']:
|
||||
if tool_call.function.name == create_cmd_run_tool()['function']['name']:
|
||||
if 'command' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "command" in tool call {tool_call.function.name}'
|
||||
@@ -131,7 +134,10 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
start=arguments.get('start', 1),
|
||||
end=arguments.get('end', -1),
|
||||
)
|
||||
elif tool_call.function.name == StrReplaceEditorTool['function']['name']:
|
||||
elif (
|
||||
tool_call.function.name
|
||||
== create_str_replace_editor_tool()['function']['name']
|
||||
):
|
||||
if 'command' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "command" in tool call {tool_call.function.name}'
|
||||
@@ -187,6 +193,15 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
|
||||
f'Missing required argument "url" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = BrowseURLAction(url=arguments['url'])
|
||||
# ================================================
|
||||
# SearchEngineTool (search the web using text queries)
|
||||
# ================================================
|
||||
elif tool_call.function.name == SearchEngineTool['function']['name']:
|
||||
if 'query' not in arguments:
|
||||
raise FunctionCallNotExistsError(
|
||||
f'Missing required argument "query" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = SearchAction(query=arguments['query'])
|
||||
else:
|
||||
raise FunctionCallNotExistsError(
|
||||
f'Tool {tool_call.function.name} is not registered. (arguments: {arguments}). Please check the tool name and retry with an existing tool.'
|
||||
@@ -219,8 +234,25 @@ def get_tools(
|
||||
codeact_enable_browsing: bool = False,
|
||||
codeact_enable_llm_editor: bool = False,
|
||||
codeact_enable_jupyter: bool = False,
|
||||
codeact_enable_search_engine: bool = False,
|
||||
llm: LLM | None = None,
|
||||
) -> list[ChatCompletionToolParam]:
|
||||
tools = [CmdRunTool, ThinkTool, FinishTool]
|
||||
SIMPLIFIED_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1']
|
||||
|
||||
use_simplified_tool_desc = False
|
||||
if llm is not None:
|
||||
use_simplified_tool_desc = any(
|
||||
model_substr in llm.config.model
|
||||
for model_substr in SIMPLIFIED_TOOL_DESCRIPTION_LLM_SUBSTRS
|
||||
)
|
||||
|
||||
tools = [
|
||||
create_cmd_run_tool(use_simplified_description=use_simplified_tool_desc),
|
||||
ThinkTool,
|
||||
FinishTool,
|
||||
]
|
||||
if codeact_enable_search_engine:
|
||||
tools.append(SearchEngineTool)
|
||||
if codeact_enable_browsing:
|
||||
tools.append(WebReadTool)
|
||||
tools.append(BrowserTool)
|
||||
@@ -229,5 +261,9 @@ def get_tools(
|
||||
if codeact_enable_llm_editor:
|
||||
tools.append(LLMBasedFileEditTool)
|
||||
else:
|
||||
tools.append(StrReplaceEditorTool)
|
||||
tools.append(
|
||||
create_str_replace_editor_tool(
|
||||
use_simplified_description=use_simplified_tool_desc
|
||||
)
|
||||
)
|
||||
return tools
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
from .bash import CmdRunTool
|
||||
from .bash import create_cmd_run_tool
|
||||
from .browser import BrowserTool
|
||||
from .finish import FinishTool
|
||||
from .ipython import IPythonTool
|
||||
from .llm_based_edit import LLMBasedFileEditTool
|
||||
from .str_replace_editor import StrReplaceEditorTool
|
||||
from .search_engine import SearchEngineTool
|
||||
from .str_replace_editor import create_str_replace_editor_tool
|
||||
from .think import ThinkTool
|
||||
from .web_read import WebReadTool
|
||||
|
||||
__all__ = [
|
||||
'BrowserTool',
|
||||
'CmdRunTool',
|
||||
'create_cmd_run_tool',
|
||||
'FinishTool',
|
||||
'IPythonTool',
|
||||
'LLMBasedFileEditTool',
|
||||
'StrReplaceEditorTool',
|
||||
'SearchEngineTool',
|
||||
'create_str_replace_editor_tool',
|
||||
'WebReadTool',
|
||||
'ThinkTool',
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
_BASH_DESCRIPTION = """Execute a bash command in the terminal within a persistent shell session.
|
||||
_DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a persistent shell session.
|
||||
|
||||
### Command Execution
|
||||
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, use `&&` or `;` to chain them together.
|
||||
@@ -22,25 +22,39 @@ _BASH_DESCRIPTION = """Execute a bash command in the terminal within a persisten
|
||||
* Output truncation: If the output exceeds a maximum length, it will be truncated before being returned.
|
||||
"""
|
||||
|
||||
CmdRunTool = ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='execute_bash',
|
||||
description=_BASH_DESCRIPTION,
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'command': {
|
||||
'type': 'string',
|
||||
'description': 'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process. Note: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.',
|
||||
},
|
||||
'is_input': {
|
||||
'type': 'string',
|
||||
'description': 'If True, the command is an input to the running process. If False, the command is a bash command to be executed in the terminal. Default is False.',
|
||||
'enum': ['true', 'false'],
|
||||
_SIMPLIFIED_BASH_DESCRIPTION = """Execute a bash command in the terminal.
|
||||
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
|
||||
* Interact with running process: If a bash command returns exit code `-1`, this means the process is not yet finished. By setting `is_input` to `true`, the assistant can interact with the running process and send empty `command` to retrieve any additional logs, or send additional text (set `command` to the text) to STDIN of the running process, or send command like `C-c` (Ctrl+C), `C-d` (Ctrl+D), `C-z` (Ctrl+Z) to interrupt the process.
|
||||
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together."""
|
||||
|
||||
|
||||
def create_cmd_run_tool(
|
||||
use_simplified_description: bool = False,
|
||||
) -> ChatCompletionToolParam:
|
||||
description = (
|
||||
_SIMPLIFIED_BASH_DESCRIPTION
|
||||
if use_simplified_description
|
||||
else _DETAILED_BASH_DESCRIPTION
|
||||
)
|
||||
return ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='execute_bash',
|
||||
description=description,
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'command': {
|
||||
'type': 'string',
|
||||
'description': 'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process. Note: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.',
|
||||
},
|
||||
'is_input': {
|
||||
'type': 'string',
|
||||
'description': 'If True, the command is an input to the running process. If False, the command is a bash command to be executed in the terminal. Default is False.',
|
||||
'enum': ['true', 'false'],
|
||||
},
|
||||
},
|
||||
'required': ['command'],
|
||||
},
|
||||
'required': ['command'],
|
||||
},
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
24
openhands/agenthub/codeact_agent/tools/search_engine.py
Normal file
24
openhands/agenthub/codeact_agent/tools/search_engine.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
_SEARCH_ENGINE_DESCRIPTION = """Execute a web search query (similar to Google search).
|
||||
|
||||
NOTE: When you need to search for information online, please use the `search_engine` tool rather than the `browser` or `web_read` tools. The `search_engine` tool connects directly to a search engine, which will help avoid CAPTCHA challenges that would otherwise block your access.
|
||||
"""
|
||||
|
||||
SearchEngineTool = ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='search_engine',
|
||||
description=_SEARCH_ENGINE_DESCRIPTION,
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'query': {
|
||||
'type': 'string',
|
||||
'description': 'The web search query (must be a non-empty string).',
|
||||
},
|
||||
},
|
||||
'required': ['query'],
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
|
||||
_DETAILED_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
|
||||
* State is persistent across command calls and discussions with the user
|
||||
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
|
||||
* The `create` command cannot be used if the specified `path` already exists as a file
|
||||
@@ -31,46 +31,73 @@ CRITICAL REQUIREMENTS FOR USING THIS TOOL:
|
||||
Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.
|
||||
"""
|
||||
|
||||
StrReplaceEditorTool = ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='str_replace_editor',
|
||||
description=_STR_REPLACE_EDITOR_DESCRIPTION,
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'command': {
|
||||
'description': 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.',
|
||||
'enum': ['view', 'create', 'str_replace', 'insert', 'undo_edit'],
|
||||
'type': 'string',
|
||||
},
|
||||
'path': {
|
||||
'description': 'Absolute path to file or directory, e.g. `/workspace/file.py` or `/workspace`.',
|
||||
'type': 'string',
|
||||
},
|
||||
'file_text': {
|
||||
'description': 'Required parameter of `create` command, with the content of the file to be created.',
|
||||
'type': 'string',
|
||||
},
|
||||
'old_str': {
|
||||
'description': 'Required parameter of `str_replace` command containing the string in `path` to replace.',
|
||||
'type': 'string',
|
||||
},
|
||||
'new_str': {
|
||||
'description': 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.',
|
||||
'type': 'string',
|
||||
},
|
||||
'insert_line': {
|
||||
'description': 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.',
|
||||
'type': 'integer',
|
||||
},
|
||||
'view_range': {
|
||||
'description': 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.',
|
||||
'items': {'type': 'integer'},
|
||||
'type': 'array',
|
||||
_SIMPLIFIED_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
|
||||
* State is persistent across command calls and discussions with the user
|
||||
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
|
||||
* The `create` command cannot be used if the specified `path` already exists as a file
|
||||
* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`
|
||||
* The `undo_edit` command will revert the last edit made to the file at `path`
|
||||
Notes for using the `str_replace` command:
|
||||
* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
|
||||
* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique
|
||||
* The `new_str` parameter should contain the edited lines that should replace the `old_str`
|
||||
"""
|
||||
|
||||
|
||||
def create_str_replace_editor_tool(
|
||||
use_simplified_description: bool = False,
|
||||
) -> ChatCompletionToolParam:
|
||||
description = (
|
||||
_SIMPLIFIED_STR_REPLACE_EDITOR_DESCRIPTION
|
||||
if use_simplified_description
|
||||
else _DETAILED_STR_REPLACE_EDITOR_DESCRIPTION
|
||||
)
|
||||
return ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='str_replace_editor',
|
||||
description=description,
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'command': {
|
||||
'description': 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.',
|
||||
'enum': [
|
||||
'view',
|
||||
'create',
|
||||
'str_replace',
|
||||
'insert',
|
||||
'undo_edit',
|
||||
],
|
||||
'type': 'string',
|
||||
},
|
||||
'path': {
|
||||
'description': 'Absolute path to file or directory, e.g. `/workspace/file.py` or `/workspace`.',
|
||||
'type': 'string',
|
||||
},
|
||||
'file_text': {
|
||||
'description': 'Required parameter of `create` command, with the content of the file to be created.',
|
||||
'type': 'string',
|
||||
},
|
||||
'old_str': {
|
||||
'description': 'Required parameter of `str_replace` command containing the string in `path` to replace.',
|
||||
'type': 'string',
|
||||
},
|
||||
'new_str': {
|
||||
'description': 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.',
|
||||
'type': 'string',
|
||||
},
|
||||
'insert_line': {
|
||||
'description': 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.',
|
||||
'type': 'integer',
|
||||
},
|
||||
'view_range': {
|
||||
'description': 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.',
|
||||
'items': {'type': 'integer'},
|
||||
'type': 'array',
|
||||
},
|
||||
},
|
||||
'required': ['command', 'path'],
|
||||
},
|
||||
'required': ['command', 'path'],
|
||||
},
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from openhands.agenthub.delegator_agent.agent import DelegatorAgent
|
||||
from openhands.controller.agent import Agent
|
||||
|
||||
Agent.register('DelegatorAgent', DelegatorAgent)
|
||||
@@ -1,87 +0,0 @@
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.events.action import Action, AgentDelegateAction, AgentFinishAction
|
||||
from openhands.events.observation import AgentDelegateObservation, Observation
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
|
||||
class DelegatorAgent(Agent):
|
||||
VERSION = '1.0'
|
||||
"""
|
||||
The Delegator Agent is responsible for delegating tasks to other agents based on the current task.
|
||||
"""
|
||||
|
||||
current_delegate: str = ''
|
||||
|
||||
def __init__(self, llm: LLM, config: AgentConfig):
|
||||
"""Initialize the Delegator Agent with an LLM
|
||||
|
||||
Parameters:
|
||||
- llm (LLM): The llm to be used by this agent
|
||||
"""
|
||||
super().__init__(llm, config)
|
||||
|
||||
def step(self, state: State) -> Action:
|
||||
"""Checks to see if current step is completed, returns AgentFinishAction if True.
|
||||
Otherwise, delegates the task to the next agent in the pipeline.
|
||||
|
||||
Parameters:
|
||||
- state (State): The current state given the previous actions and observations
|
||||
|
||||
Returns:
|
||||
- AgentFinishAction: If the last state was 'completed', 'verified', or 'abandoned'
|
||||
- AgentDelegateAction: The next agent to delegate the task to
|
||||
"""
|
||||
if self.current_delegate == '':
|
||||
self.current_delegate = 'study'
|
||||
task, _ = state.get_current_user_intent()
|
||||
return AgentDelegateAction(
|
||||
agent='StudyRepoForTaskAgent', inputs={'task': task}
|
||||
)
|
||||
|
||||
# last observation in history should be from the delegate
|
||||
last_observation = None
|
||||
for event in reversed(state.history):
|
||||
if isinstance(event, Observation):
|
||||
last_observation = event
|
||||
break
|
||||
|
||||
if not isinstance(last_observation, AgentDelegateObservation):
|
||||
raise Exception('Last observation is not an AgentDelegateObservation')
|
||||
|
||||
goal, _ = state.get_current_user_intent()
|
||||
if self.current_delegate == 'study':
|
||||
self.current_delegate = 'coder'
|
||||
return AgentDelegateAction(
|
||||
agent='CoderAgent',
|
||||
inputs={
|
||||
'task': goal,
|
||||
'summary': last_observation.outputs['summary'],
|
||||
},
|
||||
)
|
||||
elif self.current_delegate == 'coder':
|
||||
self.current_delegate = 'verifier'
|
||||
return AgentDelegateAction(
|
||||
agent='VerifierAgent',
|
||||
inputs={
|
||||
'task': goal,
|
||||
},
|
||||
)
|
||||
elif self.current_delegate == 'verifier':
|
||||
if (
|
||||
'completed' in last_observation.outputs
|
||||
and last_observation.outputs['completed']
|
||||
):
|
||||
return AgentFinishAction()
|
||||
else:
|
||||
self.current_delegate = 'coder'
|
||||
return AgentDelegateAction(
|
||||
agent='CoderAgent',
|
||||
inputs={
|
||||
'task': goal,
|
||||
'summary': last_observation.outputs['summary'],
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise Exception('Invalid delegate state')
|
||||
@@ -202,6 +202,7 @@ Note:
|
||||
tabs = ''
|
||||
last_obs = None
|
||||
last_action = None
|
||||
set_of_marks = None # Initialize set_of_marks to None
|
||||
|
||||
if len(state.history) == 1:
|
||||
# for visualwebarena, webarena and miniwob++ eval, we need to retrieve the initial observation already in browser env
|
||||
@@ -217,6 +218,9 @@ Note:
|
||||
# agent has responded, task finished.
|
||||
return AgentFinishAction(outputs={'content': event.content})
|
||||
elif isinstance(event, Observation):
|
||||
# Only process BrowserOutputObservation and skip other observation types
|
||||
if not isinstance(event, BrowserOutputObservation):
|
||||
continue
|
||||
last_obs = event
|
||||
|
||||
if len(prev_actions) >= 1: # ignore noop()
|
||||
|
||||
@@ -8,6 +8,7 @@ from openhands.core.config.config_utils import (
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.core.config.search_config import SearchConfig
|
||||
from openhands.core.config.security_config import SecurityConfig
|
||||
from openhands.core.config.utils import (
|
||||
finalize_config,
|
||||
@@ -28,6 +29,7 @@ __all__ = [
|
||||
'AppConfig',
|
||||
'LLMConfig',
|
||||
'SandboxConfig',
|
||||
'SearchConfig',
|
||||
'SecurityConfig',
|
||||
'ExtendedConfig',
|
||||
'load_app_config',
|
||||
|
||||
@@ -2,7 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from openhands.core.config.condenser_config import CondenserConfig, NoOpCondenserConfig
|
||||
from openhands.core.config.condenser_config import (
|
||||
CondenserConfig,
|
||||
NoOpCondenserConfig,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@@ -30,6 +33,7 @@ class AgentConfig(BaseModel):
|
||||
disabled_microagents: list[str] = Field(default_factory=list)
|
||||
enable_history_truncation: bool = Field(default=True)
|
||||
enable_som_visual_browsing: bool = Field(default=False)
|
||||
enable_search_engine: bool = Field(default=False)
|
||||
condenser: CondenserConfig = Field(default_factory=NoOpCondenserConfig)
|
||||
|
||||
model_config = {'extra': 'forbid'}
|
||||
|
||||
@@ -12,6 +12,7 @@ from openhands.core.config.config_utils import (
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.core.config.search_config import SearchConfig
|
||||
from openhands.core.config.security_config import SecurityConfig
|
||||
|
||||
|
||||
@@ -53,6 +54,7 @@ class AppConfig(BaseModel):
|
||||
default_agent: str = Field(default=OH_DEFAULT_AGENT)
|
||||
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
|
||||
security: SecurityConfig = Field(default_factory=SecurityConfig)
|
||||
search: SearchConfig = Field(default_factory=SearchConfig)
|
||||
extended: ExtendedConfig = Field(default_factory=lambda: ExtendedConfig({}))
|
||||
runtime: str = Field(default='docker')
|
||||
file_store: str = Field(default='local')
|
||||
|
||||
@@ -15,6 +15,7 @@ class SandboxConfig(BaseModel):
|
||||
timeout: The timeout for the default sandbox action execution.
|
||||
remote_runtime_init_timeout: The timeout for the remote runtime to start.
|
||||
remote_runtime_api_timeout: The timeout for the remote runtime API requests.
|
||||
remote_runtime_enable_retries: Whether to enable retries (on recoverable errors like requests.ConnectionError) for the remote runtime API requests.
|
||||
enable_auto_lint: Whether to enable auto-lint.
|
||||
use_host_network: Whether to use the host network.
|
||||
runtime_binding_address: The binding address for the runtime ports. It specifies which network interface on the host machine Docker should bind the runtime ports to.
|
||||
@@ -53,7 +54,7 @@ class SandboxConfig(BaseModel):
|
||||
timeout: int = Field(default=120)
|
||||
remote_runtime_init_timeout: int = Field(default=180)
|
||||
remote_runtime_api_timeout: int = Field(default=10)
|
||||
remote_runtime_enable_retries: bool = Field(default=False)
|
||||
remote_runtime_enable_retries: bool = Field(default=True)
|
||||
remote_runtime_class: str | None = Field(
|
||||
default=None
|
||||
) # can be "None" (default to gvisor) or "sysbox" (support docker inside runtime + more stable)
|
||||
|
||||
35
openhands/core/config/search_config.py
Normal file
35
openhands/core/config/search_config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Configuration for search engine functionality."""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
|
||||
|
||||
class SearchConfig(BaseModel):
|
||||
"""Configuration for search engine functionality.
|
||||
|
||||
Attributes:
|
||||
enabled: Whether search engine functionality is enabled.
|
||||
api_key: The API key for the search engine.
|
||||
api_url: The base URL for the search API.
|
||||
"""
|
||||
|
||||
enabled: bool = Field(default=False)
|
||||
api_key: SecretStr | None = Field(default=None)
|
||||
api_url: str = Field(default="https://api.search.brave.com/res/v1/web/search")
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Post-initialization hook to assign search-related variables to environment variables.
|
||||
|
||||
This ensures that these values are accessible to the search engine at runtime.
|
||||
"""
|
||||
super().model_post_init(__context)
|
||||
|
||||
# Set environment variables for search engine
|
||||
if self.api_key:
|
||||
os.environ["BRAVE_API_KEY"] = self.api_key.get_secret_value()
|
||||
if self.api_url:
|
||||
os.environ["BRAVE_API_URL"] = self.api_url
|
||||
@@ -240,7 +240,7 @@ class SensitiveDataFilter(logging.Filter):
|
||||
if (
|
||||
len(value) > 2
|
||||
and value != 'default'
|
||||
and any(s in key_upper for s in ('SECRET', 'KEY', 'CODE', 'TOKEN'))
|
||||
and any(s in key_upper for s in ('SECRET', '_KEY', '_CODE', '_TOKEN'))
|
||||
):
|
||||
sensitive_values.append(value)
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ class ActionTypeSchema(BaseModel):
|
||||
SEND_PR: str = Field(default='send_pr')
|
||||
"""Send a PR to github."""
|
||||
|
||||
SEARCH: str = Field(default='search')
|
||||
"""Queries a search engine."""
|
||||
|
||||
RECALL: str = Field(default='recall')
|
||||
"""Retrieves content from a user workspace, microagent, or other source."""
|
||||
|
||||
|
||||
@@ -49,8 +49,11 @@ class ObservationTypeSchema(BaseModel):
|
||||
CONDENSE: str = Field(default='condense')
|
||||
"""Result of a condensation operation."""
|
||||
|
||||
MICROAGENT: str = Field(default='microagent')
|
||||
"""Result of a microagent retrieval operation."""
|
||||
SEARCH: str = Field(default='search')
|
||||
"""Result of querying a search engine."""
|
||||
|
||||
RECALL: str = Field(default='recall')
|
||||
"""Result of a recall operation. This can be the workspace context, a microagent, or other types of information."""
|
||||
|
||||
|
||||
ObservationType = ObservationTypeSchema()
|
||||
|
||||
@@ -17,6 +17,7 @@ from openhands.events.action.files import (
|
||||
FileWriteAction,
|
||||
)
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.action.search_engine import SearchAction
|
||||
|
||||
__all__ = [
|
||||
'Action',
|
||||
@@ -36,5 +37,6 @@ __all__ = [
|
||||
'MessageAction',
|
||||
'ActionConfirmationStatus',
|
||||
'AgentThinkAction',
|
||||
'SearchAction',
|
||||
'RecallAction',
|
||||
]
|
||||
|
||||
24
openhands/events/action/search_engine.py
Normal file
24
openhands/events/action/search_engine.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.events.action.action import Action
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchAction(Action):
|
||||
query: str
|
||||
thought: str = ''
|
||||
action: str = ActionType.SEARCH
|
||||
runnable: ClassVar[bool] = True
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return f'I am querying the search engine to search for {self.query}'
|
||||
|
||||
def __str__(self) -> str:
|
||||
ret = '**SearchAction**\n'
|
||||
if self.thought:
|
||||
ret += f'THOUGHT: {self.thought}\n'
|
||||
ret += f'QUERY: {self.query}'
|
||||
return ret
|
||||
@@ -3,8 +3,9 @@ from openhands.events.observation.agent import (
|
||||
AgentCondensationObservation,
|
||||
AgentStateChangedObservation,
|
||||
AgentThinkObservation,
|
||||
MicroagentObservation,
|
||||
RecallObservation,
|
||||
)
|
||||
from openhands.events.observation.search_engine import SearchEngineObservation
|
||||
from openhands.events.observation.browse import BrowserOutputObservation
|
||||
from openhands.events.observation.commands import (
|
||||
CmdOutputMetadata,
|
||||
@@ -42,6 +43,7 @@ __all__ = [
|
||||
'SuccessObservation',
|
||||
'UserRejectObservation',
|
||||
'AgentCondensationObservation',
|
||||
'MicroagentObservation',
|
||||
'SearchEngineObservation',
|
||||
'RecallObservation',
|
||||
'RecallType',
|
||||
]
|
||||
|
||||
@@ -60,13 +60,13 @@ class MicroagentKnowledge:
|
||||
|
||||
|
||||
@dataclass
|
||||
class MicroagentObservation(Observation):
|
||||
class RecallObservation(Observation):
|
||||
"""The retrieval of content from a microagent or more microagents."""
|
||||
|
||||
recall_type: RecallType
|
||||
observation: str = ObservationType.MICROAGENT
|
||||
observation: str = ObservationType.RECALL
|
||||
|
||||
# environment
|
||||
# workspace context
|
||||
repo_name: str = ''
|
||||
repo_directory: str = ''
|
||||
repo_instructions: str = ''
|
||||
@@ -95,22 +95,36 @@ class MicroagentObservation(Observation):
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return self.__str__()
|
||||
return (
|
||||
'Added workspace context'
|
||||
if self.recall_type == RecallType.WORKSPACE_CONTEXT
|
||||
else 'Added microagent knowledge'
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
# Build a string representation of all fields
|
||||
fields = [
|
||||
f'recall_type={self.recall_type}',
|
||||
f'repo_name={self.repo_name}',
|
||||
f'repo_instructions={self.repo_instructions[:20]}...',
|
||||
f'runtime_hosts={self.runtime_hosts}',
|
||||
f'additional_agent_instructions={self.additional_agent_instructions[:20]}...',
|
||||
]
|
||||
|
||||
# Only include microagent_knowledge if it's not empty
|
||||
# Build a string representation
|
||||
fields = []
|
||||
if self.recall_type == RecallType.WORKSPACE_CONTEXT:
|
||||
fields.extend(
|
||||
[
|
||||
f'recall_type={self.recall_type}',
|
||||
f'repo_name={self.repo_name}',
|
||||
f'repo_instructions={self.repo_instructions[:20]}...',
|
||||
f'runtime_hosts={self.runtime_hosts}',
|
||||
f'additional_agent_instructions={self.additional_agent_instructions[:20]}...',
|
||||
]
|
||||
)
|
||||
else:
|
||||
fields.extend(
|
||||
[
|
||||
f'recall_type={self.recall_type}',
|
||||
]
|
||||
)
|
||||
if self.microagent_knowledge:
|
||||
fields.append(
|
||||
f'microagent_knowledge={", ".join([m.name for m in self.microagent_knowledge])}'
|
||||
fields.extend(
|
||||
[
|
||||
f'microagent_knowledge={", ".join([m.name for m in self.microagent_knowledge])}',
|
||||
]
|
||||
)
|
||||
|
||||
return f'**MicroagentObservation**\n{", ".join(fields)}'
|
||||
return f'**RecallObservation**\n{", ".join(fields)}'
|
||||
|
||||
22
openhands/events/observation/search_engine.py
Normal file
22
openhands/events/observation/search_engine.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openhands.core.schema import ObservationType
|
||||
from openhands.events.observation.observation import Observation
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchEngineObservation(Observation):
|
||||
query: str
|
||||
observation: str = ObservationType.SEARCH
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return f'Searched for: {self.query}'
|
||||
|
||||
def __str__(self) -> str:
|
||||
ret = (
|
||||
'**SearchEngineObservation**\n'
|
||||
f'Query: {self.query}\n'
|
||||
f'Search Results: {self.content}\n'
|
||||
)
|
||||
return ret
|
||||
@@ -22,6 +22,7 @@ from openhands.events.action.files import (
|
||||
FileWriteAction,
|
||||
)
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.action.search_engine import SearchAction
|
||||
|
||||
actions = (
|
||||
NullAction,
|
||||
@@ -39,6 +40,7 @@ actions = (
|
||||
RecallAction,
|
||||
ChangeAgentStateAction,
|
||||
MessageAction,
|
||||
SearchAction,
|
||||
)
|
||||
|
||||
ACTION_TYPE_TO_CLASS = {action_class.action: action_class for action_class in actions} # type: ignore[attr-defined]
|
||||
|
||||
@@ -122,7 +122,7 @@ def event_to_dict(event: 'Event') -> dict:
|
||||
# props is a dict whose values can include a complex object like an instance of a BaseModel subclass
|
||||
# such as CmdOutputMetadata
|
||||
# we serialize it along with the rest
|
||||
# we also handle the Enum conversion for MicroagentObservation
|
||||
# we also handle the Enum conversion for RecallObservation
|
||||
d['extras'] = {
|
||||
k: (v.value if isinstance(v, Enum) else _convert_pydantic_to_dict(v))
|
||||
for k, v in props.items()
|
||||
|
||||
@@ -6,7 +6,7 @@ from openhands.events.observation.agent import (
|
||||
AgentStateChangedObservation,
|
||||
AgentThinkObservation,
|
||||
MicroagentKnowledge,
|
||||
MicroagentObservation,
|
||||
RecallObservation,
|
||||
)
|
||||
from openhands.events.observation.browse import BrowserOutputObservation
|
||||
from openhands.events.observation.commands import (
|
||||
@@ -43,7 +43,7 @@ observations = (
|
||||
UserRejectObservation,
|
||||
AgentCondensationObservation,
|
||||
AgentThinkObservation,
|
||||
MicroagentObservation,
|
||||
RecallObservation,
|
||||
)
|
||||
|
||||
OBSERVATION_TYPE_TO_CLASS = {
|
||||
@@ -114,7 +114,7 @@ def observation_from_dict(observation: dict) -> Observation:
|
||||
else:
|
||||
extras['metadata'] = CmdOutputMetadata()
|
||||
|
||||
if observation_class is MicroagentObservation:
|
||||
if observation_class is RecallObservation:
|
||||
# handle the Enum conversion
|
||||
if 'recall_type' in extras:
|
||||
extras['recall_type'] = RecallType(extras['recall_type'])
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
GitService,
|
||||
@@ -15,7 +16,7 @@ from openhands.integrations.service_types import (
|
||||
User,
|
||||
)
|
||||
from openhands.utils.import_utils import get_impl
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class GitHubService(GitService):
|
||||
BASE_URL = 'https://api.github.com'
|
||||
@@ -25,6 +26,7 @@ class GitHubService(GitService):
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
external_auth_id: str | None = None,
|
||||
external_auth_token: SecretStr | None = None,
|
||||
token: SecretStr | None = None,
|
||||
external_token_manager: bool = False,
|
||||
|
||||
@@ -27,11 +27,12 @@ from openhands.events.observation import (
|
||||
FileEditObservation,
|
||||
FileReadObservation,
|
||||
IPythonRunCellObservation,
|
||||
SearchEngineObservation,
|
||||
UserRejectObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import (
|
||||
MicroagentKnowledge,
|
||||
MicroagentObservation,
|
||||
RecallObservation,
|
||||
)
|
||||
from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
@@ -52,7 +53,6 @@ class ConversationMemory:
|
||||
initial_messages: list[Message],
|
||||
max_message_chars: int | None = None,
|
||||
vision_is_active: bool = False,
|
||||
enable_som_visual_browsing: bool = False,
|
||||
) -> list[Message]:
|
||||
"""Process state history into a list of messages for the LLM.
|
||||
|
||||
@@ -64,11 +64,13 @@ class ConversationMemory:
|
||||
max_message_chars: The maximum number of characters in the content of an event included
|
||||
in the prompt to the LLM. Larger observations are truncated.
|
||||
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included.
|
||||
enable_som_visual_browsing: Whether to enable visual browsing for the SOM model.
|
||||
"""
|
||||
|
||||
events = condensed_history
|
||||
|
||||
# log visual browsing status
|
||||
logger.debug(f'Visual browsing: {self.agent_config.enable_som_visual_browsing}')
|
||||
|
||||
# Process special events first (system prompts, etc.)
|
||||
messages = initial_messages
|
||||
|
||||
@@ -384,20 +386,23 @@ class ConversationMemory:
|
||||
elif isinstance(obs, AgentCondensationObservation):
|
||||
text = truncate_content(obs.content, max_message_chars)
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif isinstance(obs, SearchEngineObservation):
|
||||
text = truncate_content(obs.content, max_message_chars)
|
||||
message = Message(role='user', content=[TextContent(text=text)])
|
||||
elif (
|
||||
isinstance(obs, MicroagentObservation)
|
||||
isinstance(obs, RecallObservation)
|
||||
and self.agent_config.enable_prompt_extensions
|
||||
):
|
||||
if obs.recall_type == RecallType.WORKSPACE_CONTEXT:
|
||||
# everything is optional, check if they are present
|
||||
repo_info = (
|
||||
RepositoryInfo(
|
||||
if obs.repo_name or obs.repo_directory:
|
||||
repo_info = RepositoryInfo(
|
||||
repo_name=obs.repo_name or '',
|
||||
repo_directory=obs.repo_directory or '',
|
||||
)
|
||||
if obs.repo_name or obs.repo_directory
|
||||
else None
|
||||
)
|
||||
else:
|
||||
repo_info = None
|
||||
|
||||
if obs.runtime_hosts or obs.additional_agent_instructions:
|
||||
runtime_info = RuntimeInfo(
|
||||
available_hosts=obs.runtime_hosts,
|
||||
@@ -420,22 +425,49 @@ class ConversationMemory:
|
||||
)
|
||||
has_repo_instructions = bool(repo_instructions.strip())
|
||||
|
||||
# Build additional info if we have something to render
|
||||
# Filter and process microagent knowledge
|
||||
filtered_agents = []
|
||||
if obs.microagent_knowledge:
|
||||
# Exclude disabled microagents
|
||||
filtered_agents = [
|
||||
agent
|
||||
for agent in obs.microagent_knowledge
|
||||
if agent.name not in self.agent_config.disabled_microagents
|
||||
]
|
||||
|
||||
has_microagent_knowledge = bool(filtered_agents)
|
||||
|
||||
# Generate appropriate content based on what is present
|
||||
message_content = []
|
||||
|
||||
# Build the workspace context information
|
||||
if has_repo_info or has_runtime_info or has_repo_instructions:
|
||||
# ok, now we can build the additional info
|
||||
formatted_text = self.prompt_manager.build_additional_info(
|
||||
repository_info=repo_info,
|
||||
runtime_info=runtime_info,
|
||||
repo_instructions=repo_instructions,
|
||||
formatted_workspace_text = (
|
||||
self.prompt_manager.build_workspace_context(
|
||||
repository_info=repo_info,
|
||||
runtime_info=runtime_info,
|
||||
repo_instructions=repo_instructions,
|
||||
)
|
||||
)
|
||||
message = Message(
|
||||
role='user', content=[TextContent(text=formatted_text)]
|
||||
message_content.append(TextContent(text=formatted_workspace_text))
|
||||
|
||||
# Add microagent knowledge if present
|
||||
if has_microagent_knowledge:
|
||||
formatted_microagent_text = (
|
||||
self.prompt_manager.build_microagent_info(
|
||||
triggered_agents=filtered_agents,
|
||||
)
|
||||
)
|
||||
message_content.append(TextContent(text=formatted_microagent_text))
|
||||
|
||||
# Return the combined message if we have any content
|
||||
if message_content:
|
||||
message = Message(role='user', content=message_content)
|
||||
else:
|
||||
return []
|
||||
elif obs.recall_type == RecallType.KNOWLEDGE:
|
||||
# Use prompt manager to build the microagent info
|
||||
# First, filter out agents that appear in earlier MicroagentObservations
|
||||
# First, filter out agents that appear in earlier RecallObservations
|
||||
filtered_agents = self._filter_agents_in_microagent_obs(
|
||||
obs, current_index, events or []
|
||||
)
|
||||
@@ -464,7 +496,7 @@ class ConversationMemory:
|
||||
# Return empty list if no microagents to include or all were disabled
|
||||
return []
|
||||
elif (
|
||||
isinstance(obs, MicroagentObservation)
|
||||
isinstance(obs, RecallObservation)
|
||||
and not self.agent_config.enable_prompt_extensions
|
||||
):
|
||||
# If prompt extensions are disabled, we don't add any additional info
|
||||
@@ -504,12 +536,12 @@ class ConversationMemory:
|
||||
break
|
||||
|
||||
def _filter_agents_in_microagent_obs(
|
||||
self, obs: MicroagentObservation, current_index: int, events: list[Event]
|
||||
self, obs: RecallObservation, current_index: int, events: list[Event]
|
||||
) -> list[MicroagentKnowledge]:
|
||||
"""Filter out agents that appear in earlier MicroagentObservations.
|
||||
"""Filter out agents that appear in earlier RecallObservations.
|
||||
|
||||
Args:
|
||||
obs: The current MicroagentObservation to filter
|
||||
obs: The current RecallObservation to filter
|
||||
current_index: The index of the current event in the events list
|
||||
events: The list of all events
|
||||
|
||||
@@ -532,7 +564,7 @@ class ConversationMemory:
|
||||
def _has_agent_in_earlier_events(
|
||||
self, agent_name: str, current_index: int, events: list[Event]
|
||||
) -> bool:
|
||||
"""Check if an agent appears in any earlier MicroagentObservation in the event list.
|
||||
"""Check if an agent appears in any earlier RecallObservation in the event list.
|
||||
|
||||
Args:
|
||||
agent_name: The name of the agent to look for
|
||||
@@ -540,13 +572,11 @@ class ConversationMemory:
|
||||
events: The list of all events
|
||||
|
||||
Returns:
|
||||
bool: True if the agent appears in an earlier MicroagentObservation, False otherwise
|
||||
bool: True if the agent appears in an earlier RecallObservation, False otherwise
|
||||
"""
|
||||
for event in events[:current_index]:
|
||||
if (
|
||||
isinstance(event, MicroagentObservation)
|
||||
and event.recall_type == RecallType.KNOWLEDGE
|
||||
):
|
||||
# Note that this check includes the WORKSPACE_CONTEXT
|
||||
if isinstance(event, RecallObservation):
|
||||
if any(
|
||||
agent.name == agent_name for agent in event.microagent_knowledge
|
||||
):
|
||||
|
||||
@@ -9,7 +9,7 @@ from openhands.events.action.agent import RecallAction
|
||||
from openhands.events.event import Event, EventSource, RecallType
|
||||
from openhands.events.observation.agent import (
|
||||
MicroagentKnowledge,
|
||||
MicroagentObservation,
|
||||
RecallObservation,
|
||||
)
|
||||
from openhands.events.observation.empty import NullObservation
|
||||
from openhands.events.stream import EventStream, EventStreamSubscriber
|
||||
@@ -31,7 +31,7 @@ GLOBAL_MICROAGENTS_DIR = os.path.join(
|
||||
class Memory:
|
||||
"""
|
||||
Memory is a component that listens to the EventStream for information retrieval actions
|
||||
(a RecallAction) and publishes observations with the content (such as MicroagentObservation).
|
||||
(a RecallAction) and publishes observations with the content (such as RecallObservation).
|
||||
"""
|
||||
|
||||
sid: str
|
||||
@@ -75,48 +75,59 @@ class Memory:
|
||||
async def _on_event(self, event: Event):
|
||||
"""Handle an event from the event stream asynchronously."""
|
||||
try:
|
||||
observation: MicroagentObservation | NullObservation | None = None
|
||||
|
||||
if isinstance(event, RecallAction):
|
||||
# if this is a workspace context recall (on first user message)
|
||||
# create and add a MicroagentObservation
|
||||
# with info about repo and runtime.
|
||||
# create and add a RecallObservation
|
||||
# with info about repo, runtime, instructions, etc. including microagent knowledge if any
|
||||
if (
|
||||
event.source == EventSource.USER
|
||||
and event.recall_type == RecallType.WORKSPACE_CONTEXT
|
||||
):
|
||||
observation = self._on_first_microagent_action(event)
|
||||
logger.debug('Workspace context recall')
|
||||
workspace_obs: RecallObservation | NullObservation | None = None
|
||||
|
||||
# continue with the next handler, to include knowledge microagents if suitable for this query
|
||||
assert observation is None or isinstance(
|
||||
observation, MicroagentObservation
|
||||
), f'Expected a MicroagentObservation, but got {type(observation)}'
|
||||
observation = self._on_microagent_action(
|
||||
event, prev_observation=observation
|
||||
)
|
||||
workspace_obs = self._on_workspace_context_recall(event)
|
||||
if workspace_obs is None:
|
||||
workspace_obs = NullObservation(content='')
|
||||
|
||||
if observation is None:
|
||||
observation = NullObservation(content='')
|
||||
# important: this will release the execution flow from waiting for the retrieval to complete
|
||||
workspace_obs._cause = event.id # type: ignore[union-attr]
|
||||
|
||||
# important: this will release the execution flow from waiting for the retrieval to complete
|
||||
observation._cause = event.id # type: ignore[union-attr]
|
||||
self.event_stream.add_event(workspace_obs, EventSource.ENVIRONMENT)
|
||||
return
|
||||
|
||||
self.event_stream.add_event(observation, EventSource.ENVIRONMENT)
|
||||
# Handle knowledge recall (triggered microagents)
|
||||
elif (
|
||||
event.source == EventSource.USER
|
||||
and event.recall_type == RecallType.KNOWLEDGE
|
||||
):
|
||||
logger.debug('Microagent knowledge recall')
|
||||
microagent_obs: RecallObservation | NullObservation | None = None
|
||||
microagent_obs = self._on_microagent_recall(event)
|
||||
if microagent_obs is None:
|
||||
microagent_obs = NullObservation(content='')
|
||||
|
||||
# important: this will release the execution flow from waiting for the retrieval to complete
|
||||
microagent_obs._cause = event.id # type: ignore[union-attr]
|
||||
|
||||
self.event_stream.add_event(microagent_obs, EventSource.ENVIRONMENT)
|
||||
return
|
||||
except Exception as e:
|
||||
error_str = f'Error: {str(e.__class__.__name__)}'
|
||||
logger.error(error_str)
|
||||
self.send_error_message('STATUS$ERROR_MEMORY', error_str)
|
||||
return
|
||||
|
||||
def _on_first_microagent_action(
|
||||
def _on_workspace_context_recall(
|
||||
self, event: RecallAction
|
||||
) -> MicroagentObservation | None:
|
||||
"""Add repository and runtime information to the stream as a MicroagentObservation."""
|
||||
) -> RecallObservation | None:
|
||||
"""Add repository and runtime information to the stream as a RecallObservation."""
|
||||
|
||||
# Create ENVIRONMENT info:
|
||||
# Create WORKSPACE_CONTEXT info:
|
||||
# - repository_info
|
||||
# - runtime_info
|
||||
# - repository_instructions
|
||||
# - microagent_knowledge
|
||||
|
||||
# Collect raw repository instructions
|
||||
repo_instructions = ''
|
||||
@@ -130,9 +141,17 @@ class Memory:
|
||||
repo_instructions += '\n\n'
|
||||
repo_instructions += microagent.content
|
||||
|
||||
# Find any matched microagents based on the query
|
||||
microagent_knowledge = self._find_microagent_knowledge(event.query)
|
||||
|
||||
# Create observation if we have anything
|
||||
if self.repository_info or self.runtime_info or repo_instructions:
|
||||
obs = MicroagentObservation(
|
||||
if (
|
||||
self.repository_info
|
||||
or self.runtime_info
|
||||
or repo_instructions
|
||||
or microagent_knowledge
|
||||
):
|
||||
obs = RecallObservation(
|
||||
recall_type=RecallType.WORKSPACE_CONTEXT,
|
||||
repo_name=self.repository_info.repo_name
|
||||
if self.repository_info and self.repository_info.repo_name is not None
|
||||
@@ -149,29 +168,47 @@ class Memory:
|
||||
if self.runtime_info
|
||||
and self.runtime_info.additional_agent_instructions is not None
|
||||
else '',
|
||||
microagent_knowledge=[],
|
||||
content='Retrieved environment info',
|
||||
microagent_knowledge=microagent_knowledge,
|
||||
content='Added workspace context',
|
||||
)
|
||||
return obs
|
||||
return None
|
||||
|
||||
def _on_microagent_action(
|
||||
def _on_microagent_recall(
|
||||
self,
|
||||
event: RecallAction,
|
||||
prev_observation: MicroagentObservation | None = None,
|
||||
) -> MicroagentObservation | None:
|
||||
"""When a microagent action triggers microagents, create a MicroagentObservation with structured data."""
|
||||
# If there's no query, do nothing
|
||||
query = event.query.strip()
|
||||
if not query:
|
||||
return prev_observation
|
||||
) -> RecallObservation | None:
|
||||
"""When a microagent action triggers microagents, create a RecallObservation with structured data."""
|
||||
|
||||
assert prev_observation is None or isinstance(
|
||||
prev_observation, MicroagentObservation
|
||||
), f'Expected a MicroagentObservation, but got {type(prev_observation)}'
|
||||
# Find any matched microagents based on the query
|
||||
microagent_knowledge = self._find_microagent_knowledge(event.query)
|
||||
|
||||
# Process text to find suitable microagents and create a MicroagentObservation.
|
||||
# Create observation if we have anything
|
||||
if microagent_knowledge:
|
||||
obs = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=microagent_knowledge,
|
||||
content='Retrieved knowledge from microagents',
|
||||
)
|
||||
return obs
|
||||
return None
|
||||
|
||||
def _find_microagent_knowledge(self, query: str) -> list[MicroagentKnowledge]:
|
||||
"""Find microagent knowledge based on a query.
|
||||
|
||||
Args:
|
||||
query: The query to search for microagent triggers
|
||||
|
||||
Returns:
|
||||
A list of MicroagentKnowledge objects for matched triggers
|
||||
"""
|
||||
recalled_content: list[MicroagentKnowledge] = []
|
||||
|
||||
# skip empty queries
|
||||
if not query:
|
||||
return recalled_content
|
||||
|
||||
# Search for microagent triggers in the query
|
||||
for name, microagent in self.knowledge_microagents.items():
|
||||
trigger = microagent.match_trigger(query)
|
||||
if trigger:
|
||||
@@ -183,22 +220,7 @@ class Memory:
|
||||
content=microagent.content,
|
||||
)
|
||||
)
|
||||
|
||||
if recalled_content:
|
||||
if prev_observation is not None:
|
||||
# it may be on the first user message that already found some repo info etc
|
||||
prev_observation.microagent_knowledge.extend(recalled_content)
|
||||
else:
|
||||
# if it's not the first user message, we may not have found any information this step
|
||||
obs = MicroagentObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=recalled_content,
|
||||
content='Retrieved knowledge from microagents',
|
||||
)
|
||||
|
||||
return obs
|
||||
|
||||
return prev_observation
|
||||
return recalled_content
|
||||
|
||||
def load_user_workspace_microagents(
|
||||
self, user_microagents: list[BaseMicroAgent]
|
||||
|
||||
@@ -41,6 +41,7 @@ from openhands.events.action import (
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
IPythonRunCellAction,
|
||||
SearchAction,
|
||||
)
|
||||
from openhands.events.event import FileEditSource, FileReadSource
|
||||
from openhands.events.observation import (
|
||||
@@ -56,6 +57,7 @@ from openhands.events.serialization import event_from_dict, event_to_dict
|
||||
from openhands.runtime.browser import browse
|
||||
from openhands.runtime.browser.browser_env import BrowserEnv
|
||||
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
|
||||
from openhands.runtime.search_engine.brave_search import search
|
||||
from openhands.runtime.utils.bash import BashSession
|
||||
from openhands.runtime.utils.files import insert_lines, read_lines
|
||||
from openhands.runtime.utils.memory_monitor import MemoryMonitor
|
||||
@@ -163,7 +165,6 @@ class ActionExecutor:
|
||||
self.start_time = time.time()
|
||||
self.last_execution_time = self.start_time
|
||||
self._initialized = False
|
||||
|
||||
self.max_memory_gb: int | None = None
|
||||
if _override_max_memory_gb := os.environ.get('RUNTIME_MAX_MEMORY_GB', None):
|
||||
self.max_memory_gb = int(_override_max_memory_gb)
|
||||
@@ -464,6 +465,10 @@ class ActionExecutor:
|
||||
async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
|
||||
return await browse(action, self.browser)
|
||||
|
||||
async def search(self, action: SearchAction) -> Observation:
|
||||
obs = await call_sync_from_async(search, action)
|
||||
return obs
|
||||
|
||||
def close(self):
|
||||
self.memory_monitor.stop_monitoring()
|
||||
if self.bash_session is not None:
|
||||
|
||||
@@ -97,7 +97,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = False,
|
||||
github_user_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
):
|
||||
self.sid = sid
|
||||
self.event_stream = event_stream
|
||||
@@ -130,7 +130,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
self, enable_llm_editor=config.get_agent_config().codeact_enable_llm_editor
|
||||
)
|
||||
|
||||
self.github_user_id = github_user_id
|
||||
self.user_id = user_id
|
||||
|
||||
def setup_initial_env(self) -> None:
|
||||
if self.attach_to_existing:
|
||||
@@ -220,9 +220,9 @@ class Runtime(FileEditRuntimeMixin):
|
||||
assert event.timeout is not None
|
||||
try:
|
||||
if isinstance(event, CmdRunAction):
|
||||
if self.github_user_id and '$GITHUB_TOKEN' in event.command:
|
||||
if self.user_id and '$GITHUB_TOKEN' in event.command:
|
||||
gh_client = GithubServiceImpl(
|
||||
user_id=self.github_user_id, external_token_manager=True
|
||||
external_auth_id=self.user_id, external_token_manager=True
|
||||
)
|
||||
token = await gh_client.get_latest_token()
|
||||
if token:
|
||||
|
||||
@@ -24,6 +24,7 @@ from openhands.events.action import (
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
IPythonRunCellAction,
|
||||
SearchAction,
|
||||
)
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.files import FileEditSource
|
||||
@@ -59,7 +60,7 @@ class ActionExecutionClient(Runtime):
|
||||
status_callback: Any | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
github_user_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
):
|
||||
self.session = HttpSession()
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
@@ -75,7 +76,7 @@ class ActionExecutionClient(Runtime):
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
github_user_id,
|
||||
user_id,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
@@ -297,6 +298,9 @@ class ActionExecutionClient(Runtime):
|
||||
def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def search(self, action: SearchAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def close(self) -> None:
|
||||
# Make sure we don't close the session multiple times
|
||||
# Can happen in evaluation
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
@@ -45,7 +46,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
github_user_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
config,
|
||||
@@ -56,7 +57,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
github_user_id,
|
||||
user_id,
|
||||
)
|
||||
if self.config.sandbox.api_key is None:
|
||||
raise ValueError(
|
||||
@@ -425,10 +426,11 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
return self._send_action_server_request_impl(method, url, **kwargs)
|
||||
|
||||
retry_decorator = tenacity.retry(
|
||||
retry=tenacity.retry_if_exception_type(ConnectionError),
|
||||
retry=tenacity.retry_if_exception_type(requests.ConnectionError),
|
||||
stop=tenacity.stop_after_attempt(3)
|
||||
| stop_if_should_exit()
|
||||
| self._stop_if_closed,
|
||||
before_sleep=tenacity.before_sleep_log(logger, logging.WARNING),
|
||||
wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
|
||||
)
|
||||
return retry_decorator(self._send_action_server_request_impl)(
|
||||
|
||||
3
openhands/runtime/search_engine/__init__.py
Normal file
3
openhands/runtime/search_engine/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from openhands.runtime.search_engine.brave_search import search
|
||||
|
||||
__all__ = ['search']
|
||||
239
openhands/runtime/search_engine/brave_search.py
Normal file
239
openhands/runtime/search_engine/brave_search.py
Normal file
@@ -0,0 +1,239 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
import tenacity
|
||||
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.events.action import SearchAction
|
||||
from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.observation.search_engine import SearchEngineObservation
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
|
||||
def get_title(result):
|
||||
return f"### Title: {result['title']}\n" if 'title' in result else ''
|
||||
|
||||
|
||||
def get_url(result):
|
||||
return f"### URL: {result['url']}\n" if 'url' in result else ''
|
||||
|
||||
|
||||
def get_description(result):
|
||||
return (
|
||||
f"### Description: {result['description']}\n" if 'description' in result else ''
|
||||
)
|
||||
|
||||
|
||||
def get_question(result):
|
||||
return f"### Question: {result['question']}\n" if 'question' in result else ''
|
||||
|
||||
|
||||
def get_answer(result):
|
||||
return f"### Answer: {result['answer']}\n" if 'answer' in result else ''
|
||||
|
||||
|
||||
def get_cluster(result):
|
||||
if 'cluster' in result:
|
||||
output = ''
|
||||
for i, result_obj in enumerate(result['cluster']):
|
||||
title = get_title(result_obj)
|
||||
url = get_url(result_obj)
|
||||
description = get_description(result_obj)
|
||||
discussion_output = (
|
||||
f'### Related webpage\n#{title}#{url}#{description}\n'
|
||||
if url != ''
|
||||
else ''
|
||||
)
|
||||
output += discussion_output
|
||||
return output
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
def response_to_markdown(results, query):
|
||||
all_results = {}
|
||||
|
||||
# discussions
|
||||
discussion_results = []
|
||||
if 'discussions' in results and 'results' in results['discussions']['results']:
|
||||
for result in results['discussions']['results']:
|
||||
title = get_title(result)
|
||||
url = get_url(result)
|
||||
description = get_description(result)
|
||||
cluster = get_cluster(result)
|
||||
discussion_output = f'## Discussion\n{title}{url}{description}{cluster}\n'
|
||||
discussion_results.append(discussion_output)
|
||||
all_results['discussions'] = discussion_results
|
||||
|
||||
# FAQs
|
||||
faq_results = []
|
||||
if 'faq' in results and 'results' in results['faq']:
|
||||
for result in results['faq']['results']:
|
||||
title = get_title(result)
|
||||
url = get_url(result)
|
||||
question = get_question(result)
|
||||
answer = get_answer(result)
|
||||
faq_output = f'## FAQ\n{title}{url}{question}{answer}\n'
|
||||
faq_results.append(faq_output)
|
||||
all_results['faq'] = faq_results
|
||||
|
||||
# News
|
||||
news_results = []
|
||||
if 'news' in results and 'results' in results['news']:
|
||||
for result in results['news']['results']:
|
||||
title = get_title(result)
|
||||
url = get_url(result)
|
||||
description = get_description(result)
|
||||
news_output = f'## News\n{title}{url}{description}\n'
|
||||
news_results.append(news_output)
|
||||
all_results['news'] = news_results
|
||||
|
||||
# Videos
|
||||
video_results = []
|
||||
if 'videos' in results and 'results' in results['videos']:
|
||||
for result in results['videos']['results']:
|
||||
title = get_title(result)
|
||||
url = get_url(result)
|
||||
description = get_description(result)
|
||||
video_output = f'## Video\n{title}{url}{description}\n'
|
||||
video_results.append(video_output)
|
||||
all_results['videos'] = video_results
|
||||
|
||||
# Web Search Results
|
||||
websearch_results = []
|
||||
if 'web' in results and 'results' in results['web']:
|
||||
for result in results['web']['results']:
|
||||
title = get_title(result)
|
||||
url = get_url(result)
|
||||
description = get_description(result)
|
||||
cluster = get_cluster(result)
|
||||
if cluster:
|
||||
websearch_output = f'## Webpage\n{title}{url}{description}\n{cluster}\n'
|
||||
else:
|
||||
websearch_output = f'## Webpage\n{title}{url}{description}\n'
|
||||
websearch_results.append(websearch_output)
|
||||
all_results['web'] = websearch_results
|
||||
|
||||
# infobox
|
||||
infobox_results = []
|
||||
if 'infobox' in results and 'results' in results['infobox']:
|
||||
for result in results['infobox']['results']:
|
||||
title = get_title(result)
|
||||
url = get_url(result)
|
||||
description = get_description(result)
|
||||
infobox_output = f'## Infobox\n{title}{url}{description}\n'
|
||||
infobox_results.append(infobox_output)
|
||||
all_results['infobox'] = infobox_results
|
||||
|
||||
# locations
|
||||
location_results = []
|
||||
if 'locations' in results and 'results' in results['location']:
|
||||
for result in results['locations']['results']:
|
||||
title = get_title(result)
|
||||
url = get_url(result)
|
||||
description = get_description(result)
|
||||
location_output = f'## Location\n{title}{url}{description}\n'
|
||||
location_results.append(location_output)
|
||||
all_results['locations'] = location_results
|
||||
|
||||
markdown = '# Search Results\n\n'
|
||||
markdown += f'**Searched query**: {query}\n\n'
|
||||
|
||||
# ranked results if available
|
||||
if 'mixed' in results:
|
||||
for rank_type in ['main', 'top', 'side']:
|
||||
if rank_type not in results['mixed']:
|
||||
continue
|
||||
for ranked_result in results['mixed'][rank_type]:
|
||||
result_type = ranked_result['type']
|
||||
if result_type in all_results:
|
||||
include_all = ranked_result['all']
|
||||
idx = ranked_result.get('index', None)
|
||||
if include_all:
|
||||
markdown += ''.join(all_results[result_type])
|
||||
elif idx is not None and idx < len(all_results[result_type]):
|
||||
markdown += all_results[result_type][idx]
|
||||
for result_list in all_results.values():
|
||||
for result in result_list:
|
||||
if result in markdown:
|
||||
continue
|
||||
else:
|
||||
markdown += result
|
||||
else:
|
||||
markdown += ''.join(
|
||||
websearch_results
|
||||
+ video_results
|
||||
+ news_results
|
||||
+ infobox_results
|
||||
+ faq_results
|
||||
+ discussion_results
|
||||
+ location_results
|
||||
)
|
||||
return markdown
|
||||
|
||||
|
||||
def return_error(retry_state: tenacity.RetryCallState):
|
||||
return ErrorObservation('Failed to query Brave Search API.')
|
||||
|
||||
|
||||
@tenacity.retry(
|
||||
wait=tenacity.wait_exponential(min=2, max=10),
|
||||
stop=tenacity.stop_after_attempt(5) | stop_if_should_exit(),
|
||||
retry_error_callback=return_error,
|
||||
)
|
||||
def query_api(query: str, API_KEY, BRAVE_SEARCH_URL):
|
||||
headers = {'Accept': 'application/json', 'X-Subscription-Token': API_KEY}
|
||||
|
||||
params: list[tuple[str, str | int | bool]] = [
|
||||
('q', query),
|
||||
('count', 20), # Number of results to return, max allowed = 20
|
||||
('extra_snippets', False), # TODO: Should we keep it as true?
|
||||
]
|
||||
|
||||
response = requests.get(
|
||||
BRAVE_SEARCH_URL,
|
||||
headers=headers,
|
||||
params=params, # type: ignore
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status() # Raise exception for 4XX/5XX responses
|
||||
results = response.json()
|
||||
markdown_content = response_to_markdown(results, query)
|
||||
# TODO: Handle other types of HTML tags? I couldn't find any other tags in brave search responses for the queries I tried.
|
||||
markdown_content = re.sub(r'</?strong>', '', markdown_content)
|
||||
return SearchEngineObservation(query=query, content=markdown_content)
|
||||
|
||||
|
||||
def search(action: SearchAction, config: AppConfig):
|
||||
"""Execute a search query using the Brave Search API.
|
||||
|
||||
Args:
|
||||
action: The search action containing the query.
|
||||
config: The application configuration.
|
||||
|
||||
Returns:
|
||||
SearchEngineObservation: The search results in markdown format.
|
||||
ErrorObservation: If the query is empty or search is not enabled.
|
||||
"""
|
||||
if not config.search.enabled:
|
||||
return ErrorObservation(
|
||||
content='Search engine functionality is not enabled. Enable it by setting search.enabled=true in config.'
|
||||
)
|
||||
|
||||
query = action.query
|
||||
if query is None or len(query.strip()) == 0:
|
||||
return ErrorObservation(
|
||||
content='The query string for search_engine tool must be a non-empty string.'
|
||||
)
|
||||
|
||||
if config.search.api_key is None:
|
||||
return ErrorObservation(
|
||||
content='Search API key not configured. Set search.api_key in config.'
|
||||
)
|
||||
|
||||
return query_api(
|
||||
query=query,
|
||||
API_KEY=config.search.api_key.get_secret_value(),
|
||||
BRAVE_SEARCH_URL=config.search.api_url
|
||||
)
|
||||
@@ -46,7 +46,12 @@ class ConversationManager(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def join_conversation(
|
||||
self, sid: str, connection_id: str, settings: Settings, user_id: str | None
|
||||
self,
|
||||
sid: str,
|
||||
connection_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
github_user_id: str | None,
|
||||
) -> EventStream | None:
|
||||
"""Join a conversation and return its event stream."""
|
||||
|
||||
@@ -74,6 +79,7 @@ class ConversationManager(ABC):
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
github_user_id: str | None = None,
|
||||
) -> EventStream:
|
||||
"""Start an event loop if one is not already running"""
|
||||
|
||||
|
||||
@@ -106,7 +106,12 @@ class StandaloneConversationManager(ConversationManager):
|
||||
return c
|
||||
|
||||
async def join_conversation(
|
||||
self, sid: str, connection_id: str, settings: Settings, user_id: str | None
|
||||
self,
|
||||
sid: str,
|
||||
connection_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
github_user_id: str | None,
|
||||
):
|
||||
logger.info(
|
||||
f'join_conversation:{sid}:{connection_id}',
|
||||
@@ -116,7 +121,9 @@ class StandaloneConversationManager(ConversationManager):
|
||||
self._local_connection_id_to_session_id[connection_id] = sid
|
||||
event_stream = await self._get_event_stream(sid)
|
||||
if not event_stream:
|
||||
return await self.maybe_start_agent_loop(sid, settings, user_id)
|
||||
return await self.maybe_start_agent_loop(
|
||||
sid, settings, user_id, github_user_id=github_user_id
|
||||
)
|
||||
for event in event_stream.get_events(reverse=True):
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
if event.agent_state in (
|
||||
@@ -187,14 +194,18 @@ class StandaloneConversationManager(ConversationManager):
|
||||
logger.error('error_cleaning_stale')
|
||||
await asyncio.sleep(_CLEANUP_INTERVAL)
|
||||
|
||||
async def _get_conversation_store(self, user_id: str | None) -> ConversationStore:
|
||||
async def _get_conversation_store(
|
||||
self, user_id: str | None, github_user_id: str | None
|
||||
) -> ConversationStore:
|
||||
conversation_store_class = self._conversation_store_class
|
||||
if not conversation_store_class:
|
||||
self._conversation_store_class = conversation_store_class = get_impl(
|
||||
ConversationStore, # type: ignore
|
||||
self.server_config.conversation_store_class,
|
||||
)
|
||||
store = await conversation_store_class.get_instance(self.config, user_id)
|
||||
store = await conversation_store_class.get_instance(
|
||||
self.config, user_id, github_user_id
|
||||
)
|
||||
return store
|
||||
|
||||
async def get_running_agent_loops(
|
||||
@@ -243,6 +254,7 @@ class StandaloneConversationManager(ConversationManager):
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
github_user_id: str | None = None,
|
||||
) -> EventStream:
|
||||
logger.info(f'maybe_start_agent_loop:{sid}', extra={'session_id': sid})
|
||||
session: Session | None = None
|
||||
@@ -256,7 +268,9 @@ class StandaloneConversationManager(ConversationManager):
|
||||
extra={'session_id': sid, 'user_id': user_id},
|
||||
)
|
||||
# Get the conversations sorted (oldest first)
|
||||
conversation_store = await self._get_conversation_store(user_id)
|
||||
conversation_store = await self._get_conversation_store(
|
||||
user_id, github_user_id
|
||||
)
|
||||
conversations = await conversation_store.get_all_metadata(response_ids)
|
||||
conversations.sort(key=_last_updated_at_key, reverse=True)
|
||||
|
||||
@@ -277,7 +291,9 @@ class StandaloneConversationManager(ConversationManager):
|
||||
try:
|
||||
session.agent_session.event_stream.subscribe(
|
||||
EventStreamSubscriber.SERVER,
|
||||
self._create_conversation_update_callback(user_id, sid),
|
||||
self._create_conversation_update_callback(
|
||||
user_id, github_user_id, sid
|
||||
),
|
||||
UPDATED_AT_CALLBACK_ID,
|
||||
)
|
||||
except ValueError:
|
||||
@@ -374,22 +390,23 @@ class StandaloneConversationManager(ConversationManager):
|
||||
)
|
||||
|
||||
def _create_conversation_update_callback(
|
||||
self, user_id: str | None, conversation_id: str
|
||||
self, user_id: str | None, github_user_id: str | None, conversation_id: str
|
||||
) -> Callable:
|
||||
def callback(*args, **kwargs):
|
||||
call_async_from_sync(
|
||||
self._update_timestamp_for_conversation,
|
||||
GENERAL_TIMEOUT,
|
||||
user_id,
|
||||
github_user_id,
|
||||
conversation_id,
|
||||
)
|
||||
|
||||
return callback
|
||||
|
||||
async def _update_timestamp_for_conversation(
|
||||
self, user_id: str, conversation_id: str
|
||||
self, user_id: str, github_user_id: str, conversation_id: str
|
||||
):
|
||||
conversation_store = await self._get_conversation_store(user_id)
|
||||
conversation_store = await self._get_conversation_store(user_id, github_user_id)
|
||||
conversation = await conversation_store.get_metadata(conversation_id)
|
||||
conversation.last_updated_at = datetime.now(timezone.utc)
|
||||
await conversation_store.save_metadata(conversation)
|
||||
|
||||
@@ -6,10 +6,14 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
NullAction,
|
||||
)
|
||||
from openhands.events.action.agent import RecallAction
|
||||
from openhands.events.observation import (
|
||||
NullObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.observation.agent import (
|
||||
AgentStateChangedObservation,
|
||||
RecallObservation,
|
||||
)
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.events.stream import AsyncEventStreamWrapper
|
||||
from openhands.server.shared import (
|
||||
@@ -35,7 +39,9 @@ async def connect(connection_id: str, environ):
|
||||
|
||||
cookies_str = environ.get('HTTP_COOKIE', '')
|
||||
conversation_validator = ConversationValidatorImpl()
|
||||
user_id = await conversation_validator.validate(conversation_id, cookies_str)
|
||||
user_id, github_user_id = await conversation_validator.validate(
|
||||
conversation_id, cookies_str
|
||||
)
|
||||
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
@@ -46,7 +52,7 @@ async def connect(connection_id: str, environ):
|
||||
)
|
||||
|
||||
event_stream = await conversation_manager.join_conversation(
|
||||
conversation_id, connection_id, settings, user_id
|
||||
conversation_id, connection_id, settings, user_id, github_user_id
|
||||
)
|
||||
|
||||
agent_state_changed = None
|
||||
@@ -54,10 +60,7 @@ async def connect(connection_id: str, environ):
|
||||
async for event in async_stream:
|
||||
if isinstance(
|
||||
event,
|
||||
(
|
||||
NullAction,
|
||||
NullObservation,
|
||||
),
|
||||
(NullAction, NullObservation, RecallAction, RecallObservation),
|
||||
):
|
||||
continue
|
||||
elif isinstance(event, AgentStateChangedObservation):
|
||||
|
||||
@@ -10,7 +10,12 @@ from openhands.events.action.message import MessageAction
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.runtime import get_runtime_cls
|
||||
from openhands.server.auth import get_provider_tokens, get_access_token, get_github_user_id
|
||||
from openhands.server.auth import (
|
||||
get_access_token,
|
||||
get_github_user_id,
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
@@ -73,12 +78,12 @@ async def _create_new_conversation(
|
||||
logger.warn('Settings not present, not starting conversation')
|
||||
raise MissingSettingsError('Settings not found')
|
||||
|
||||
session_init_args['github_token'] = token or SecretStr('')
|
||||
session_init_args['provider_token'] = token
|
||||
session_init_args['selected_repository'] = selected_repository
|
||||
session_init_args['selected_branch'] = selected_branch
|
||||
conversation_init_data = ConversationInitData(**session_init_args)
|
||||
logger.info('Loading conversation store')
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id, None)
|
||||
logger.info('Conversation store loaded')
|
||||
|
||||
conversation_id = uuid.uuid4().hex
|
||||
@@ -100,7 +105,8 @@ async def _create_new_conversation(
|
||||
ConversationMetadata(
|
||||
conversation_id=conversation_id,
|
||||
title=conversation_title,
|
||||
github_user_id=user_id,
|
||||
user_id=user_id,
|
||||
github_user_id=None,
|
||||
selected_repository=selected_repository,
|
||||
selected_branch=selected_branch,
|
||||
)
|
||||
@@ -122,7 +128,10 @@ async def _create_new_conversation(
|
||||
image_urls=image_urls or [],
|
||||
)
|
||||
await conversation_manager.maybe_start_agent_loop(
|
||||
conversation_id, conversation_init_data, user_id, initial_message_action
|
||||
conversation_id,
|
||||
conversation_init_data,
|
||||
user_id,
|
||||
initial_user_msg=initial_message_action,
|
||||
)
|
||||
logger.info(f'Finished initializing conversation {conversation_id}')
|
||||
|
||||
@@ -158,7 +167,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
try:
|
||||
# Create conversation with initial message
|
||||
conversation_id = await _create_new_conversation(
|
||||
user_id,
|
||||
get_user_id(request),
|
||||
github_token,
|
||||
selected_repository,
|
||||
selected_branch,
|
||||
@@ -197,7 +206,7 @@ async def search_conversations(
|
||||
limit: int = 20,
|
||||
) -> ConversationInfoResultSet:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_github_user_id(request)
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
|
||||
@@ -216,7 +225,7 @@ async def search_conversations(
|
||||
conversation.conversation_id for conversation in filtered_results
|
||||
)
|
||||
running_conversations = await conversation_manager.get_running_agent_loops(
|
||||
get_github_user_id(request), set(conversation_ids)
|
||||
get_user_id(request), set(conversation_ids)
|
||||
)
|
||||
result = ConversationInfoResultSet(
|
||||
results=await wait_all(
|
||||
@@ -236,7 +245,7 @@ async def get_conversation(
|
||||
conversation_id: str, request: Request
|
||||
) -> ConversationInfo | None:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_github_user_id(request)
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
try:
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
@@ -252,7 +261,7 @@ async def update_conversation(
|
||||
request: Request, conversation_id: str, title: str = Body(embed=True)
|
||||
) -> bool:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_github_user_id(request)
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
if not metadata:
|
||||
@@ -268,7 +277,7 @@ async def delete_conversation(
|
||||
request: Request,
|
||||
) -> bool:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_github_user_id(request)
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
try:
|
||||
await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
@@ -90,30 +90,38 @@ async def store_settings(
|
||||
existing_settings.user_consents_to_analytics
|
||||
)
|
||||
|
||||
if existing_settings.secrets_store:
|
||||
existing_providers = [
|
||||
provider.value
|
||||
for provider in existing_settings.secrets_store.provider_tokens
|
||||
]
|
||||
|
||||
# Merge incoming settings store with the existing one
|
||||
for provider, token_value in settings.provider_tokens.items():
|
||||
if provider in existing_providers and not token_value:
|
||||
provider_type = ProviderType(provider)
|
||||
existing_token = (
|
||||
existing_settings.secrets_store.provider_tokens.get(
|
||||
provider_type
|
||||
)
|
||||
)
|
||||
if existing_token and existing_token.token:
|
||||
settings.provider_tokens[provider] = (
|
||||
existing_token.token.get_secret_value()
|
||||
)
|
||||
|
||||
# Merge provider tokens with existing ones
|
||||
if settings.unset_github_token: # Only merge if not unsetting tokens
|
||||
if settings.unset_github_token:
|
||||
settings.secrets_store.provider_tokens = {}
|
||||
settings.provider_tokens = {}
|
||||
else: # Only merge if not unsetting tokens
|
||||
if settings.provider_tokens:
|
||||
if existing_settings.secrets_store:
|
||||
existing_providers = [
|
||||
provider.value
|
||||
for provider in existing_settings.secrets_store.provider_tokens
|
||||
]
|
||||
|
||||
# Merge incoming settings store with the existing one
|
||||
for provider, token_value in settings.provider_tokens.items():
|
||||
if provider in existing_providers and not token_value:
|
||||
provider_type = ProviderType(provider)
|
||||
existing_token = (
|
||||
existing_settings.secrets_store.provider_tokens.get(
|
||||
provider_type
|
||||
)
|
||||
)
|
||||
if existing_token and existing_token.token:
|
||||
settings.provider_tokens[provider] = (
|
||||
existing_token.token.get_secret_value()
|
||||
)
|
||||
else: # nothing passed in means keep current settings
|
||||
provider_tokens = existing_settings.secrets_store.provider_tokens
|
||||
settings.provider_tokens = {
|
||||
provider.value: data.token.get_secret_value()
|
||||
if data.token
|
||||
else None
|
||||
for provider, data in provider_tokens.items()
|
||||
}
|
||||
|
||||
# Update sandbox config with new settings
|
||||
if settings.remote_runtime_resource_factor is not None:
|
||||
|
||||
@@ -53,7 +53,7 @@ class AgentSession:
|
||||
sid: str,
|
||||
file_store: FileStore,
|
||||
status_callback: Callable | None = None,
|
||||
github_user_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
):
|
||||
"""Initializes a new instance of the Session class
|
||||
|
||||
@@ -66,9 +66,9 @@ class AgentSession:
|
||||
self.event_stream = EventStream(sid, file_store)
|
||||
self.file_store = file_store
|
||||
self._status_callback = status_callback
|
||||
self.github_user_id = github_user_id
|
||||
self.user_id = user_id
|
||||
self.logger = OpenHandsLoggerAdapter(
|
||||
extra={'session_id': sid, 'user_id': github_user_id}
|
||||
extra={'session_id': sid, 'user_id': user_id}
|
||||
)
|
||||
|
||||
async def start(
|
||||
@@ -241,7 +241,7 @@ class AgentSession:
|
||||
|
||||
kwargs = {}
|
||||
if runtime_cls == RemoteRuntime:
|
||||
kwargs['github_user_id'] = self.github_user_id
|
||||
kwargs['user_id'] = self.user_id
|
||||
|
||||
self.runtime = runtime_cls(
|
||||
config=config,
|
||||
|
||||
@@ -8,6 +8,6 @@ class ConversationInitData(Settings):
|
||||
Session initialization data for the web environment - a deep copy of the global config is made and then overridden with this data.
|
||||
"""
|
||||
|
||||
github_token: SecretStr | None = Field(default=None)
|
||||
provider_token: SecretStr | None = Field(default=None)
|
||||
selected_repository: str | None = Field(default=None)
|
||||
selected_branch: str | None = Field(default=None)
|
||||
|
||||
@@ -61,7 +61,7 @@ class Session:
|
||||
sid,
|
||||
file_store,
|
||||
status_callback=self.queue_status_message,
|
||||
github_user_id=user_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
self.agent_session.event_stream.subscribe(
|
||||
EventStreamSubscriber.SERVER, self.on_event, self.sid
|
||||
@@ -123,11 +123,11 @@ class Session:
|
||||
|
||||
agent = Agent.get_cls(agent_cls)(llm, agent_config)
|
||||
|
||||
github_token = None
|
||||
provider_token = None
|
||||
selected_repository = None
|
||||
selected_branch = None
|
||||
if isinstance(settings, ConversationInitData):
|
||||
github_token = settings.github_token
|
||||
provider_token = settings.provider_token
|
||||
selected_repository = settings.selected_repository
|
||||
selected_branch = settings.selected_branch
|
||||
|
||||
@@ -140,7 +140,7 @@ class Session:
|
||||
max_budget_per_task=self.config.max_budget_per_task,
|
||||
agent_to_llm_config=self.config.get_agent_to_llm_config_map(),
|
||||
agent_configs=self.config.get_agent_configs(),
|
||||
github_token=github_token,
|
||||
github_token=provider_token,
|
||||
selected_repository=selected_repository,
|
||||
selected_branch=selected_branch,
|
||||
initial_message=initial_message,
|
||||
|
||||
@@ -43,7 +43,7 @@ class Settings(BaseModel):
|
||||
if context and context.get('expose_secrets', False):
|
||||
return llm_api_key.get_secret_value()
|
||||
|
||||
return pydantic_encoder(llm_api_key)
|
||||
return pydantic_encoder(llm_api_key) if llm_api_key else None
|
||||
|
||||
@staticmethod
|
||||
def _convert_token_value(
|
||||
|
||||
@@ -12,25 +12,36 @@ from openhands.utils.async_utils import wait_all
|
||||
|
||||
|
||||
class ConversationStore(ABC):
|
||||
"""
|
||||
Storage for conversation metadata. May or may not support multiple users depending on the environment
|
||||
"""
|
||||
"""Storage for conversation metadata. May or may not support multiple users depending on the environment."""
|
||||
|
||||
@abstractmethod
|
||||
async def save_metadata(self, metadata: ConversationMetadata) -> None:
|
||||
"""Store conversation metadata"""
|
||||
"""Store conversation metadata."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_metadata(self, conversation_id: str) -> ConversationMetadata:
|
||||
"""Load conversation metadata"""
|
||||
"""Load conversation metadata."""
|
||||
|
||||
async def validate_metadata(
|
||||
self, conversation_id: str, user_id: str, github_user_id: str
|
||||
) -> bool:
|
||||
"""Validate that conversation belongs to the current user."""
|
||||
# TODO: remove github_user_id after transition to Keycloak is complete.
|
||||
metadata = await self.get_metadata(conversation_id)
|
||||
if (not metadata.user_id and not metadata.github_user_id) or (
|
||||
metadata.user_id != user_id and metadata.github_user_id != github_user_id
|
||||
):
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
async def delete_metadata(self, conversation_id: str) -> None:
|
||||
"""delete conversation metadata"""
|
||||
"""Delete conversation metadata."""
|
||||
|
||||
@abstractmethod
|
||||
async def exists(self, conversation_id: str) -> bool:
|
||||
"""Check if conversation exists"""
|
||||
"""Check if conversation exists."""
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
@@ -49,6 +60,6 @@ class ConversationStore(ABC):
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def get_instance(
|
||||
cls, config: AppConfig, user_id: str | None
|
||||
cls, config: AppConfig, user_id: str | None, github_user_id: str | None
|
||||
) -> ConversationStore:
|
||||
"""Get a store for the user represented by the token given"""
|
||||
|
||||
@@ -7,7 +7,7 @@ class ConversationValidator:
|
||||
"""Storage for conversation metadata. May or may not support multiple users depending on the environment."""
|
||||
|
||||
async def validate(self, conversation_id: str, cookies_str: str):
|
||||
return None
|
||||
return None, None
|
||||
|
||||
|
||||
conversation_validator_cls = os.environ.get(
|
||||
|
||||
@@ -101,7 +101,7 @@ class FileConversationStore(ConversationStore):
|
||||
|
||||
@classmethod
|
||||
async def get_instance(
|
||||
cls, config: AppConfig, user_id: str | None
|
||||
cls, config: AppConfig, user_id: str | None, github_user_id: str | None
|
||||
) -> FileConversationStore:
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
return FileConversationStore(file_store)
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
@dataclass
|
||||
class ConversationMetadata:
|
||||
conversation_id: str
|
||||
user_id: str | None
|
||||
github_user_id: str | None
|
||||
selected_repository: str | None
|
||||
selected_branch: str | None = None
|
||||
|
||||
@@ -76,7 +76,7 @@ class PromptManager:
|
||||
if example_message:
|
||||
message.content.insert(0, TextContent(text=example_message))
|
||||
|
||||
def build_additional_info(
|
||||
def build_workspace_context(
|
||||
self,
|
||||
repository_info: RepositoryInfo | None,
|
||||
runtime_info: RuntimeInfo | None,
|
||||
|
||||
74
poetry.lock
generated
74
poetry.lock
generated
@@ -496,18 +496,18 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.37.11"
|
||||
version = "1.37.12"
|
||||
description = "The AWS SDK for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "boto3-1.37.11-py3-none-any.whl", hash = "sha256:da6c22fc8a7e9bca5d7fc465a877ac3d45b6b086d776bd1a6c55bdde60523741"},
|
||||
{file = "boto3-1.37.11.tar.gz", hash = "sha256:8eec08363ef5db05c2fbf58e89f0c0de6276cda2fdce01e76b3b5f423cd5c0f4"},
|
||||
{file = "boto3-1.37.12-py3-none-any.whl", hash = "sha256:516feaa0d2afaeda1515216fd09291368a1215754bbccb0f28414c0a91a830a2"},
|
||||
{file = "boto3-1.37.12.tar.gz", hash = "sha256:9412d404f103ad6d14f033eb29cd5e0cdca2b9b08cbfa9d4dabd1d7be2de2625"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.11,<1.38.0"
|
||||
botocore = ">=1.37.12,<1.38.0"
|
||||
jmespath = ">=0.7.1,<2.0.0"
|
||||
s3transfer = ">=0.11.0,<0.12.0"
|
||||
|
||||
@@ -516,14 +516,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.37.11"
|
||||
version = "1.37.12"
|
||||
description = "Low-level, data-driven core of boto 3."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "botocore-1.37.11-py3-none-any.whl", hash = "sha256:02505309b1235f9f15a6da79103ca224b3f3dc5f6a62f8630fbb2c6ed05e2da8"},
|
||||
{file = "botocore-1.37.11.tar.gz", hash = "sha256:72eb3a9a58b064be26ba154e5e56373633b58f951941c340ace0d379590d98b5"},
|
||||
{file = "botocore-1.37.12-py3-none-any.whl", hash = "sha256:ba1948c883bbabe20d95ff62c3e36954c9269686f7db9361857835677ca3e676"},
|
||||
{file = "botocore-1.37.12.tar.gz", hash = "sha256:ae2d5328ce6ad02eb615270507235a6e90fd3eeed615a6c0732b5a68b12f2017"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3547,14 +3547,14 @@ test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (>
|
||||
|
||||
[[package]]
|
||||
name = "jupyterlab"
|
||||
version = "4.3.5"
|
||||
version = "4.3.6"
|
||||
description = "JupyterLab computational environment"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["runtime"]
|
||||
files = [
|
||||
{file = "jupyterlab-4.3.5-py3-none-any.whl", hash = "sha256:571bbdee20e4c5321ab5195bc41cf92a75a5cff886be5e57ce78dfa37a5e9fdb"},
|
||||
{file = "jupyterlab-4.3.5.tar.gz", hash = "sha256:c779bf72ced007d7d29d5bcef128e7fdda96ea69299e19b04a43635a7d641f9d"},
|
||||
{file = "jupyterlab-4.3.6-py3-none-any.whl", hash = "sha256:fc9eb0455562a56a9bd6d2977cf090842f321fa1a298fcee9bf8c19de353d5fd"},
|
||||
{file = "jupyterlab-4.3.6.tar.gz", hash = "sha256:2900ffdbfca9ed37c4ad7fdda3eb76582fd945d46962af3ac64741ae2d6b2ff4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4251,14 +4251,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "modal"
|
||||
version = "0.73.98"
|
||||
version = "0.73.102"
|
||||
description = "Python client library for Modal"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "modal-0.73.98-py3-none-any.whl", hash = "sha256:a49cd5f5b46d1a6c6a0d528618d3cbb73ac2908e199716590ec3a5275d79ed98"},
|
||||
{file = "modal-0.73.98.tar.gz", hash = "sha256:817f73c222fa39a16d6888a92eb7a6847ecae574e44ef04e2dce5e534bdd2df9"},
|
||||
{file = "modal-0.73.102-py3-none-any.whl", hash = "sha256:26151ef6164e0b93b0d1961f73d5a715deb72f23e2641215f5410cf58bf403d3"},
|
||||
{file = "modal-0.73.102.tar.gz", hash = "sha256:198876cf94ff13633283e251d8b37cc1f1bb5e27a7aa547e02072def1f29b66e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4670,19 +4670,19 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "notebook"
|
||||
version = "7.3.2"
|
||||
version = "7.3.3"
|
||||
description = "Jupyter Notebook - A web-based notebook environment for interactive computing"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["runtime"]
|
||||
files = [
|
||||
{file = "notebook-7.3.2-py3-none-any.whl", hash = "sha256:e5f85fc59b69d3618d73cf27544418193ff8e8058d5bf61d315ce4f473556288"},
|
||||
{file = "notebook-7.3.2.tar.gz", hash = "sha256:705e83a1785f45b383bf3ee13cb76680b92d24f56fb0c7d2136fe1d850cd3ca8"},
|
||||
{file = "notebook-7.3.3-py3-none-any.whl", hash = "sha256:b193df0878956562d5171c8e25c9252b8e86c9fcc16163b8ee3fe6c5e3f422f7"},
|
||||
{file = "notebook-7.3.3.tar.gz", hash = "sha256:707a313fb882d35f921989eb3d204de942ed5132a44e4aa1fe0e8f24bb9dc25d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
jupyter-server = ">=2.4.0,<3"
|
||||
jupyterlab = ">=4.3.4,<4.4"
|
||||
jupyterlab = ">=4.3.6,<4.4"
|
||||
jupyterlab-server = ">=2.27.1,<3"
|
||||
notebook-shim = ">=0.2,<0.3"
|
||||
tornado = ">=6.2.0"
|
||||
@@ -6947,30 +6947,30 @@ pyasn1 = ">=0.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.9.10"
|
||||
version = "0.11.0"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev", "evaluation"]
|
||||
files = [
|
||||
{file = "ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d"},
|
||||
{file = "ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d"},
|
||||
{file = "ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d"},
|
||||
{file = "ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c"},
|
||||
{file = "ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e"},
|
||||
{file = "ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12"},
|
||||
{file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16"},
|
||||
{file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52"},
|
||||
{file = "ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1"},
|
||||
{file = "ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c"},
|
||||
{file = "ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43"},
|
||||
{file = "ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c"},
|
||||
{file = "ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5"},
|
||||
{file = "ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8"},
|
||||
{file = "ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029"},
|
||||
{file = "ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1"},
|
||||
{file = "ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69"},
|
||||
{file = "ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7"},
|
||||
{file = "ruff-0.11.0-py3-none-linux_armv6l.whl", hash = "sha256:dc67e32bc3b29557513eb7eeabb23efdb25753684b913bebb8a0c62495095acb"},
|
||||
{file = "ruff-0.11.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38c23fd9bdec4eb437b4c1e3595905a0a8edfccd63a790f818b28c78fe345639"},
|
||||
{file = "ruff-0.11.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c8661b0be91a38bd56db593e9331beaf9064a79028adee2d5f392674bbc5e88"},
|
||||
{file = "ruff-0.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c0e8d3d2db7e9f6efd884f44b8dc542d5b6b590fc4bb334fdbc624d93a29a2"},
|
||||
{file = "ruff-0.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c3156d3f4b42e57247275a0a7e15a851c165a4fc89c5e8fa30ea6da4f7407b8"},
|
||||
{file = "ruff-0.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490b1e147c1260545f6d041c4092483e3f6d8eba81dc2875eaebcf9140b53905"},
|
||||
{file = "ruff-0.11.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1bc09a7419e09662983b1312f6fa5dab829d6ab5d11f18c3760be7ca521c9329"},
|
||||
{file = "ruff-0.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfa478daf61ac8002214eb2ca5f3e9365048506a9d52b11bea3ecea822bb844"},
|
||||
{file = "ruff-0.11.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fbb2aed66fe742a6a3a0075ed467a459b7cedc5ae01008340075909d819df1e"},
|
||||
{file = "ruff-0.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92c0c1ff014351c0b0cdfdb1e35fa83b780f1e065667167bb9502d47ca41e6db"},
|
||||
{file = "ruff-0.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e4fd5ff5de5f83e0458a138e8a869c7c5e907541aec32b707f57cf9a5e124445"},
|
||||
{file = "ruff-0.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:96bc89a5c5fd21a04939773f9e0e276308be0935de06845110f43fd5c2e4ead7"},
|
||||
{file = "ruff-0.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a9352b9d767889ec5df1483f94870564e8102d4d7e99da52ebf564b882cdc2c7"},
|
||||
{file = "ruff-0.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:049a191969a10897fe052ef9cc7491b3ef6de79acd7790af7d7897b7a9bfbcb6"},
|
||||
{file = "ruff-0.11.0-py3-none-win32.whl", hash = "sha256:3191e9116b6b5bbe187447656f0c8526f0d36b6fd89ad78ccaad6bdc2fad7df2"},
|
||||
{file = "ruff-0.11.0-py3-none-win_amd64.whl", hash = "sha256:c58bfa00e740ca0a6c43d41fb004cd22d165302f360aaa56f7126d544db31a21"},
|
||||
{file = "ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657"},
|
||||
{file = "ruff-0.11.0.tar.gz", hash = "sha256:e55c620690a4a7ee6f1cccb256ec2157dc597d109400ae75bbf944fc9d6462e2"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9056,4 +9056,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "6a644bc65782a717a49718496bd279ecb888807ec625d992af4448cc5d9271c1"
|
||||
content-hash = "9b74f62a4afa719a1f7167e0b3b45cdaf282c2e18fd2931da91c0f1b22776178"
|
||||
|
||||
@@ -80,7 +80,7 @@ daytona-sdk = "0.10.2"
|
||||
python-json-logger = "^3.2.1"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.9.10"
|
||||
ruff = "0.11.0"
|
||||
mypy = "1.15.0"
|
||||
pre-commit = "4.1.0"
|
||||
build = "*"
|
||||
|
||||
@@ -19,7 +19,7 @@ from openhands.events.event import RecallType
|
||||
from openhands.events.observation import (
|
||||
ErrorObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import MicroagentObservation
|
||||
from openhands.events.observation.agent import RecallObservation
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.llm import LLM
|
||||
from openhands.llm.metrics import Metrics, TokenUsage
|
||||
@@ -192,7 +192,7 @@ async def test_run_controller_with_fatal_error(test_event_stream, mock_memory):
|
||||
|
||||
def on_event_memory(event: Event):
|
||||
if isinstance(event, RecallAction):
|
||||
microagent_obs = MicroagentObservation(
|
||||
microagent_obs = RecallObservation(
|
||||
content='Test microagent content',
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
)
|
||||
@@ -249,7 +249,7 @@ async def test_run_controller_stop_with_stuck(test_event_stream, mock_memory):
|
||||
|
||||
def on_event_memory(event: Event):
|
||||
if isinstance(event, RecallAction):
|
||||
microagent_obs = MicroagentObservation(
|
||||
microagent_obs = RecallObservation(
|
||||
content='Test microagent content',
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
)
|
||||
@@ -596,7 +596,7 @@ async def test_run_controller_max_iterations_has_metrics(
|
||||
|
||||
def on_event_memory(event: Event):
|
||||
if isinstance(event, RecallAction):
|
||||
microagent_obs = MicroagentObservation(
|
||||
microagent_obs = RecallObservation(
|
||||
content='Test microagent content',
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
)
|
||||
@@ -718,7 +718,7 @@ async def test_run_controller_with_context_window_exceeded_with_truncation(
|
||||
|
||||
def on_event_memory(event: Event):
|
||||
if isinstance(event, RecallAction):
|
||||
microagent_obs = MicroagentObservation(
|
||||
microagent_obs = RecallObservation(
|
||||
content='Test microagent content',
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
)
|
||||
@@ -795,7 +795,7 @@ async def test_run_controller_with_context_window_exceeded_without_truncation(
|
||||
|
||||
def on_event_memory(event: Event):
|
||||
if isinstance(event, RecallAction):
|
||||
microagent_obs = MicroagentObservation(
|
||||
microagent_obs = RecallObservation(
|
||||
content='Test microagent content',
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
)
|
||||
@@ -845,23 +845,30 @@ async def test_run_controller_with_memory_error(test_event_stream):
|
||||
config = AppConfig()
|
||||
event_stream = test_event_stream
|
||||
|
||||
# Create a propert agent that returns an action without an ID
|
||||
agent = MagicMock(spec=Agent)
|
||||
agent.llm = MagicMock(spec=LLM)
|
||||
agent.llm.metrics = Metrics()
|
||||
agent.llm.config = config.get_llm_config()
|
||||
|
||||
# Create a real action to return from the mocked step function
|
||||
def agent_step_fn(state):
|
||||
return MessageAction(content='Agent returned a message')
|
||||
|
||||
agent.step = agent_step_fn
|
||||
|
||||
runtime = MagicMock(spec=Runtime)
|
||||
runtime.event_stream = event_stream
|
||||
|
||||
# Create a real Memory instance
|
||||
memory = Memory(event_stream=event_stream, sid='test-memory')
|
||||
|
||||
# Patch the _on_microagent_action method to raise our test exception
|
||||
def mock_on_microagent_action(*args, **kwargs):
|
||||
# Patch the _find_microagent_knowledge method to raise our test exception
|
||||
def mock_find_microagent_knowledge(*args, **kwargs):
|
||||
raise RuntimeError('Test memory error')
|
||||
|
||||
with patch.object(
|
||||
memory, '_on_microagent_action', side_effect=mock_on_microagent_action
|
||||
memory, '_find_microagent_knowledge', side_effect=mock_find_microagent_knowledge
|
||||
):
|
||||
state = await run_controller(
|
||||
config=config,
|
||||
|
||||
@@ -19,7 +19,7 @@ from openhands.events.action import (
|
||||
)
|
||||
from openhands.events.action.agent import RecallAction
|
||||
from openhands.events.event import Event, RecallType
|
||||
from openhands.events.observation.agent import MicroagentObservation
|
||||
from openhands.events.observation.agent import RecallObservation
|
||||
from openhands.events.stream import EventStreamSubscriber
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.metrics import Metrics
|
||||
@@ -86,10 +86,10 @@ async def test_delegation_flow(mock_parent_agent, mock_child_agent, mock_event_s
|
||||
|
||||
def on_event(event: Event):
|
||||
if isinstance(event, RecallAction):
|
||||
# create a MicroagentObservation
|
||||
microagent_observation = MicroagentObservation(
|
||||
# create a RecallObservation
|
||||
microagent_observation = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
content='microagent',
|
||||
content='Found info',
|
||||
)
|
||||
microagent_observation._cause = event.id # ignore attr-defined warning
|
||||
mock_event_stream.add_event(microagent_observation, EventSource.ENVIRONMENT)
|
||||
@@ -111,14 +111,14 @@ async def test_delegation_flow(mock_parent_agent, mock_child_agent, mock_event_s
|
||||
# Give time for the async step() to execute
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Verify that a MicroagentObservation was added to the event stream
|
||||
# Verify that a RecallObservation was added to the event stream
|
||||
events = list(mock_event_stream.get_events())
|
||||
assert (
|
||||
mock_event_stream.get_latest_event_id() == 3
|
||||
) # Microagents and AgentChangeState
|
||||
|
||||
# a MicroagentObservation and an AgentDelegateAction should be in the list
|
||||
assert any(isinstance(event, MicroagentObservation) for event in events)
|
||||
# a RecallObservation and an AgentDelegateAction should be in the list
|
||||
assert any(isinstance(event, RecallObservation) for event in events)
|
||||
assert any(isinstance(event, AgentDelegateAction) for event in events)
|
||||
|
||||
# Verify that a delegate agent controller is created
|
||||
|
||||
83
tests/unit/test_brave_search.py
Normal file
83
tests/unit/test_brave_search.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Tests for the Brave Search functionality."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.core.config import AppConfig, SearchConfig
|
||||
from openhands.events.action import SearchAction
|
||||
from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.observation.search_engine import SearchEngineObservation
|
||||
from openhands.runtime.search_engine.brave_search import search
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock config with search enabled."""
|
||||
config = AppConfig()
|
||||
config.search = SearchConfig(
|
||||
enabled=True,
|
||||
api_key="test_key",
|
||||
api_url="https://test.url"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_query_api():
|
||||
"""Create a mock query_api function."""
|
||||
with patch("openhands.runtime.search_engine.brave_search.query_api") as mock:
|
||||
mock.return_value = SearchEngineObservation(
|
||||
query="test query",
|
||||
content="test content"
|
||||
)
|
||||
yield mock
|
||||
|
||||
|
||||
def test_search_disabled(mock_query_api):
|
||||
"""Test that search returns error when disabled."""
|
||||
config = AppConfig()
|
||||
config.search = SearchConfig(enabled=False)
|
||||
action = SearchAction(query="test query")
|
||||
|
||||
result = search(action, config)
|
||||
assert isinstance(result, ErrorObservation)
|
||||
assert "not enabled" in result.content
|
||||
mock_query_api.assert_not_called()
|
||||
|
||||
|
||||
def test_search_no_api_key(mock_query_api):
|
||||
"""Test that search returns error when API key is not set."""
|
||||
config = AppConfig()
|
||||
config.search = SearchConfig(enabled=True)
|
||||
action = SearchAction(query="test query")
|
||||
|
||||
result = search(action, config)
|
||||
assert isinstance(result, ErrorObservation)
|
||||
assert "API key not configured" in result.content
|
||||
mock_query_api.assert_not_called()
|
||||
|
||||
|
||||
def test_search_empty_query(mock_query_api, mock_config):
|
||||
"""Test that search returns error when query is empty."""
|
||||
action = SearchAction(query="")
|
||||
|
||||
result = search(action, mock_config)
|
||||
assert isinstance(result, ErrorObservation)
|
||||
assert "must be a non-empty string" in result.content
|
||||
mock_query_api.assert_not_called()
|
||||
|
||||
|
||||
def test_search_success(mock_query_api, mock_config):
|
||||
"""Test that search returns results when everything is configured correctly."""
|
||||
action = SearchAction(query="test query")
|
||||
|
||||
result = search(action, mock_config)
|
||||
assert isinstance(result, SearchEngineObservation)
|
||||
assert result.query == "test query"
|
||||
assert result.content == "test content"
|
||||
mock_query_api.assert_called_once_with(
|
||||
query="test query",
|
||||
API_KEY="test_key",
|
||||
BRAVE_SEARCH_URL="https://test.url"
|
||||
)
|
||||
@@ -6,11 +6,11 @@ from litellm import ChatCompletionMessageToolCall
|
||||
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
|
||||
from openhands.agenthub.codeact_agent.function_calling import (
|
||||
BrowserTool,
|
||||
CmdRunTool,
|
||||
IPythonTool,
|
||||
LLMBasedFileEditTool,
|
||||
StrReplaceEditorTool,
|
||||
WebReadTool,
|
||||
create_cmd_run_tool,
|
||||
create_str_replace_editor_tool,
|
||||
get_tools,
|
||||
response_to_actions,
|
||||
)
|
||||
@@ -25,6 +25,7 @@ from openhands.core.message import ImageContent, Message, TextContent
|
||||
from openhands.events.action import (
|
||||
CmdRunAction,
|
||||
MessageAction,
|
||||
SearchAction,
|
||||
)
|
||||
from openhands.events.event import EventSource
|
||||
from openhands.events.observation.commands import (
|
||||
@@ -100,25 +101,30 @@ def test_get_tools_with_options():
|
||||
codeact_enable_browsing=True,
|
||||
codeact_enable_jupyter=True,
|
||||
codeact_enable_llm_editor=True,
|
||||
codeact_enable_search_engine=True,
|
||||
)
|
||||
tool_names = [tool['function']['name'] for tool in tools]
|
||||
assert 'browser' in tool_names
|
||||
assert 'execute_ipython_cell' in tool_names
|
||||
assert 'edit_file' in tool_names
|
||||
assert 'search_engine' in tool_names
|
||||
|
||||
# Test with all options disabled
|
||||
tools = get_tools(
|
||||
codeact_enable_browsing=False,
|
||||
codeact_enable_jupyter=False,
|
||||
codeact_enable_llm_editor=False,
|
||||
codeact_enable_search_engine=False,
|
||||
)
|
||||
tool_names = [tool['function']['name'] for tool in tools]
|
||||
assert 'browser' not in tool_names
|
||||
assert 'execute_ipython_cell' not in tool_names
|
||||
assert 'edit_file' not in tool_names
|
||||
assert 'search_engine' not in tool_names
|
||||
|
||||
|
||||
def test_cmd_run_tool():
|
||||
CmdRunTool = create_cmd_run_tool()
|
||||
assert CmdRunTool['type'] == 'function'
|
||||
assert CmdRunTool['function']['name'] == 'execute_bash'
|
||||
assert 'command' in CmdRunTool['function']['parameters']['properties']
|
||||
@@ -149,6 +155,7 @@ def test_llm_based_file_edit_tool():
|
||||
|
||||
|
||||
def test_str_replace_editor_tool():
|
||||
StrReplaceEditorTool = create_str_replace_editor_tool()
|
||||
assert StrReplaceEditorTool['type'] == 'function'
|
||||
assert StrReplaceEditorTool['function']['name'] == 'str_replace_editor'
|
||||
|
||||
@@ -174,6 +181,15 @@ def test_web_read_tool():
|
||||
assert WebReadTool['function']['parameters']['required'] == ['url']
|
||||
|
||||
|
||||
def test_search_engine_tool():
|
||||
from openhands.agenthub.codeact_agent.tools import SearchEngineTool
|
||||
|
||||
assert SearchEngineTool['type'] == 'function'
|
||||
assert SearchEngineTool['function']['name'] == 'search_engine'
|
||||
assert 'query' in SearchEngineTool['function']['parameters']['properties']
|
||||
assert SearchEngineTool['function']['parameters']['required'] == ['query']
|
||||
|
||||
|
||||
def test_browser_tool():
|
||||
assert BrowserTool['type'] == 'function'
|
||||
assert BrowserTool['function']['name'] == 'browser'
|
||||
@@ -210,6 +226,42 @@ def test_browser_tool():
|
||||
assert 'description' in BrowserTool['function']['parameters']['properties']['code']
|
||||
|
||||
|
||||
def test_response_to_actions_search_engine():
|
||||
# Test response with search engine tool call
|
||||
from litellm import ChatCompletionMessageToolCall, Choices, Message, ModelResponse
|
||||
|
||||
mock_response = ModelResponse(
|
||||
id='mock_id',
|
||||
choices=[
|
||||
Choices(
|
||||
message=Message(
|
||||
content='Let me search for that',
|
||||
tool_calls=[
|
||||
ChatCompletionMessageToolCall(
|
||||
id='tool_call_10',
|
||||
function={
|
||||
'name': 'search_engine',
|
||||
'arguments': '{"query": "test query"}',
|
||||
},
|
||||
type='function',
|
||||
)
|
||||
],
|
||||
role='assistant',
|
||||
),
|
||||
index=0,
|
||||
finish_reason='tool_calls',
|
||||
)
|
||||
],
|
||||
model='mock_model',
|
||||
usage={'total_tokens': 100},
|
||||
)
|
||||
|
||||
actions = response_to_actions(mock_response)
|
||||
assert len(actions) == 1
|
||||
assert isinstance(actions[0], SearchAction)
|
||||
assert actions[0].query == 'test query'
|
||||
|
||||
|
||||
def test_response_to_actions_invalid_tool():
|
||||
# Test response with invalid tool call
|
||||
mock_response = Mock()
|
||||
@@ -236,7 +288,11 @@ def test_step_with_no_pending_actions(mock_state: State):
|
||||
mock_response.choices[0].message.content = 'Task completed'
|
||||
mock_response.choices[0].message.tool_calls = []
|
||||
|
||||
mock_config = Mock()
|
||||
mock_config.model = 'mock_model'
|
||||
|
||||
llm = Mock()
|
||||
llm.config = mock_config
|
||||
llm.completion = Mock(return_value=mock_response)
|
||||
llm.is_function_calling_active = Mock(return_value=True) # Enable function calling
|
||||
llm.is_caching_prompt_active = Mock(return_value=False)
|
||||
@@ -260,6 +316,28 @@ def test_step_with_no_pending_actions(mock_state: State):
|
||||
assert action.content == 'Task completed'
|
||||
|
||||
|
||||
def test_correct_tool_description_loaded_based_on_model_name(mock_state: State):
|
||||
"""Tests that the simplified tool descriptions are loaded for specific models."""
|
||||
o3_mock_config = Mock()
|
||||
o3_mock_config.model = 'mock_o3_model'
|
||||
|
||||
llm = Mock()
|
||||
llm.config = o3_mock_config
|
||||
|
||||
agent = CodeActAgent(llm=llm, config=AgentConfig())
|
||||
for tool in agent.tools:
|
||||
# Assert all descriptions have less than 1024 characters
|
||||
assert len(tool['function']['description']) < 1024
|
||||
|
||||
sonnet_mock_config = Mock()
|
||||
sonnet_mock_config.model = 'mock_sonnet_model'
|
||||
|
||||
llm.config = sonnet_mock_config
|
||||
agent = CodeActAgent(llm=llm, config=AgentConfig())
|
||||
# Assert existence of the detailed tool descriptions that are longer than 1024 characters
|
||||
assert any(len(tool['function']['description']) > 1024 for tool in agent.tools)
|
||||
|
||||
|
||||
def test_mismatched_tool_call_events(mock_state: State):
|
||||
"""Tests that the agent can convert mismatched tool call events (i.e., an observation with no corresponding action) into messages."""
|
||||
agent = CodeActAgent(llm=LLM(LLMConfig()), config=AgentConfig())
|
||||
|
||||
@@ -32,6 +32,7 @@ def _patch_store():
|
||||
'selected_repository': 'foobar',
|
||||
'conversation_id': 'some_conversation_id',
|
||||
'github_user_id': '12345',
|
||||
'user_id': '12345',
|
||||
'created_at': '2025-01-01T00:00:00+00:00',
|
||||
'last_updated_at': '2025-01-01T00:01:00+00:00',
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ from openhands.events.event import (
|
||||
from openhands.events.observation import CmdOutputObservation
|
||||
from openhands.events.observation.agent import (
|
||||
MicroagentKnowledge,
|
||||
MicroagentObservation,
|
||||
RecallObservation,
|
||||
)
|
||||
from openhands.events.observation.browse import BrowserOutputObservation
|
||||
from openhands.events.observation.commands import (
|
||||
@@ -51,7 +51,7 @@ def agent_config():
|
||||
def conversation_memory(agent_config):
|
||||
prompt_manager = MagicMock(spec=PromptManager)
|
||||
prompt_manager.get_system_message.return_value = 'System message'
|
||||
prompt_manager.build_additional_info.return_value = (
|
||||
prompt_manager.build_workspace_context.return_value = (
|
||||
'Formatted repository and runtime info'
|
||||
)
|
||||
|
||||
@@ -353,10 +353,10 @@ def test_process_events_with_user_reject_observation(conversation_memory):
|
||||
|
||||
|
||||
def test_process_events_with_empty_environment_info(conversation_memory):
|
||||
"""Test that empty environment info observations return an empty list of messages without calling build_additional_info."""
|
||||
# Create a MicroagentObservation with empty info
|
||||
"""Test that empty environment info observations return an empty list of messages without calling build_workspace_context."""
|
||||
# Create a RecallObservation with empty info
|
||||
|
||||
empty_obs = MicroagentObservation(
|
||||
empty_obs = RecallObservation(
|
||||
recall_type=RecallType.WORKSPACE_CONTEXT,
|
||||
repo_name='',
|
||||
repo_directory='',
|
||||
@@ -382,8 +382,8 @@ def test_process_events_with_empty_environment_info(conversation_memory):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == 'system'
|
||||
|
||||
# Verify that build_additional_info was NOT called since all input values were empty
|
||||
conversation_memory.prompt_manager.build_additional_info.assert_not_called()
|
||||
# Verify that build_workspace_context was NOT called since all input values were empty
|
||||
conversation_memory.prompt_manager.build_workspace_context.assert_not_called()
|
||||
|
||||
|
||||
def test_process_events_with_function_calling_observation(conversation_memory):
|
||||
@@ -527,8 +527,8 @@ def test_apply_prompt_caching(conversation_memory):
|
||||
|
||||
|
||||
def test_process_events_with_environment_microagent_observation(conversation_memory):
|
||||
"""Test processing a MicroagentObservation with ENVIRONMENT info type."""
|
||||
obs = MicroagentObservation(
|
||||
"""Test processing a RecallObservation with ENVIRONMENT info type."""
|
||||
obs = RecallObservation(
|
||||
recall_type=RecallType.WORKSPACE_CONTEXT,
|
||||
repo_name='test-repo',
|
||||
repo_directory='/path/to/repo',
|
||||
@@ -556,8 +556,8 @@ def test_process_events_with_environment_microagent_observation(conversation_mem
|
||||
assert result.content[0].text == 'Formatted repository and runtime info'
|
||||
|
||||
# Verify the prompt_manager was called with the correct parameters
|
||||
conversation_memory.prompt_manager.build_additional_info.assert_called_once()
|
||||
call_args = conversation_memory.prompt_manager.build_additional_info.call_args[1]
|
||||
conversation_memory.prompt_manager.build_workspace_context.assert_called_once()
|
||||
call_args = conversation_memory.prompt_manager.build_workspace_context.call_args[1]
|
||||
assert isinstance(call_args['repository_info'], RepositoryInfo)
|
||||
assert call_args['repository_info'].repo_name == 'test-repo'
|
||||
assert call_args['repository_info'].repo_directory == '/path/to/repo'
|
||||
@@ -572,7 +572,7 @@ def test_process_events_with_environment_microagent_observation(conversation_mem
|
||||
def test_process_events_with_knowledge_microagent_microagent_observation(
|
||||
conversation_memory,
|
||||
):
|
||||
"""Test processing a MicroagentObservation with KNOWLEDGE type."""
|
||||
"""Test processing a RecallObservation with KNOWLEDGE type."""
|
||||
microagent_knowledge = [
|
||||
MicroagentKnowledge(
|
||||
name='test_agent',
|
||||
@@ -591,7 +591,7 @@ def test_process_events_with_knowledge_microagent_microagent_observation(
|
||||
),
|
||||
]
|
||||
|
||||
obs = MicroagentObservation(
|
||||
obs = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=microagent_knowledge,
|
||||
content='Retrieved knowledge from microagents',
|
||||
@@ -634,11 +634,11 @@ def test_process_events_with_knowledge_microagent_microagent_observation(
|
||||
def test_process_events_with_microagent_observation_extensions_disabled(
|
||||
agent_config, conversation_memory
|
||||
):
|
||||
"""Test processing a MicroagentObservation when prompt extensions are disabled."""
|
||||
"""Test processing a RecallObservation when prompt extensions are disabled."""
|
||||
# Modify the agent config to disable prompt extensions
|
||||
agent_config.enable_prompt_extensions = False
|
||||
|
||||
obs = MicroagentObservation(
|
||||
obs = RecallObservation(
|
||||
recall_type=RecallType.WORKSPACE_CONTEXT,
|
||||
repo_name='test-repo',
|
||||
repo_directory='/path/to/repo',
|
||||
@@ -656,18 +656,18 @@ def test_process_events_with_microagent_observation_extensions_disabled(
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# When prompt extensions are disabled, the MicroagentObservation should be ignored
|
||||
# When prompt extensions are disabled, the RecallObservation should be ignored
|
||||
assert len(messages) == 1 # Only the initial system message
|
||||
assert messages[0].role == 'system'
|
||||
|
||||
# Verify the prompt_manager was not called
|
||||
conversation_memory.prompt_manager.build_additional_info.assert_not_called()
|
||||
conversation_memory.prompt_manager.build_workspace_context.assert_not_called()
|
||||
conversation_memory.prompt_manager.build_microagent_info.assert_not_called()
|
||||
|
||||
|
||||
def test_process_events_with_empty_microagent_knowledge(conversation_memory):
|
||||
"""Test processing a MicroagentObservation with empty microagent knowledge."""
|
||||
obs = MicroagentObservation(
|
||||
"""Test processing a RecallObservation with empty microagent knowledge."""
|
||||
obs = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[],
|
||||
content='Retrieved knowledge from microagents',
|
||||
@@ -693,7 +693,7 @@ def test_process_events_with_empty_microagent_knowledge(conversation_memory):
|
||||
|
||||
|
||||
def test_conversation_memory_processes_microagent_observation(prompt_dir):
|
||||
"""Test that ConversationMemory processes MicroagentObservations correctly."""
|
||||
"""Test that ConversationMemory processes RecallObservations correctly."""
|
||||
# Create a microagent_info.j2 template file
|
||||
template_path = os.path.join(prompt_dir, 'microagent_info.j2')
|
||||
if not os.path.exists(template_path):
|
||||
@@ -722,8 +722,8 @@ It may or may not be relevant to the user's request.
|
||||
config=agent_config, prompt_manager=prompt_manager
|
||||
)
|
||||
|
||||
# Create a MicroagentObservation with microagent knowledge
|
||||
microagent_observation = MicroagentObservation(
|
||||
# Create a RecallObservation with microagent knowledge
|
||||
microagent_observation = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[
|
||||
MicroagentKnowledge(
|
||||
@@ -761,7 +761,7 @@ This is triggered content for testing.
|
||||
|
||||
|
||||
def test_conversation_memory_processes_environment_microagent_observation(prompt_dir):
|
||||
"""Test that ConversationMemory processes environment info MicroagentObservations correctly."""
|
||||
"""Test that ConversationMemory processes environment info RecallObservations correctly."""
|
||||
# Create an additional_info.j2 template file
|
||||
template_path = os.path.join(prompt_dir, 'additional_info.j2')
|
||||
if not os.path.exists(template_path):
|
||||
@@ -802,8 +802,8 @@ each of which has a corresponding port:
|
||||
config=agent_config, prompt_manager=prompt_manager
|
||||
)
|
||||
|
||||
# Create a MicroagentObservation with environment info
|
||||
microagent_observation = MicroagentObservation(
|
||||
# Create a RecallObservation with environment info
|
||||
microagent_observation = RecallObservation(
|
||||
recall_type=RecallType.WORKSPACE_CONTEXT,
|
||||
repo_name='owner/repo',
|
||||
repo_directory='/workspace/repo',
|
||||
@@ -839,13 +839,13 @@ each of which has a corresponding port:
|
||||
|
||||
|
||||
def test_process_events_with_microagent_observation_deduplication(conversation_memory):
|
||||
"""Test that MicroagentObservations are properly deduplicated based on agent name.
|
||||
"""Test that RecallObservations are properly deduplicated based on agent name.
|
||||
|
||||
The deduplication logic should keep the FIRST occurrence of each microagent
|
||||
and filter out later occurrences to avoid redundant information.
|
||||
"""
|
||||
# Create a sequence of MicroagentObservations with overlapping agents
|
||||
obs1 = MicroagentObservation(
|
||||
# Create a sequence of RecallObservations with overlapping agents
|
||||
obs1 = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[
|
||||
MicroagentKnowledge(
|
||||
@@ -867,7 +867,7 @@ def test_process_events_with_microagent_observation_deduplication(conversation_m
|
||||
content='First retrieval',
|
||||
)
|
||||
|
||||
obs2 = MicroagentObservation(
|
||||
obs2 = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[
|
||||
MicroagentKnowledge(
|
||||
@@ -879,7 +879,7 @@ def test_process_events_with_microagent_observation_deduplication(conversation_m
|
||||
content='Second retrieval',
|
||||
)
|
||||
|
||||
obs3 = MicroagentObservation(
|
||||
obs3 = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[
|
||||
MicroagentKnowledge(
|
||||
@@ -918,8 +918,8 @@ def test_process_events_with_microagent_observation_deduplication_disabled_agent
|
||||
conversation_memory,
|
||||
):
|
||||
"""Test that disabled agents are filtered out and deduplication keeps the first occurrence."""
|
||||
# Create a sequence of MicroagentObservations with disabled agents
|
||||
obs1 = MicroagentObservation(
|
||||
# Create a sequence of RecallObservations with disabled agents
|
||||
obs1 = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[
|
||||
MicroagentKnowledge(
|
||||
@@ -936,7 +936,7 @@ def test_process_events_with_microagent_observation_deduplication_disabled_agent
|
||||
content='First retrieval',
|
||||
)
|
||||
|
||||
obs2 = MicroagentObservation(
|
||||
obs2 = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[
|
||||
MicroagentKnowledge(
|
||||
@@ -973,8 +973,8 @@ def test_process_events_with_microagent_observation_deduplication_disabled_agent
|
||||
def test_process_events_with_microagent_observation_deduplication_empty(
|
||||
conversation_memory,
|
||||
):
|
||||
"""Test that empty MicroagentObservations are handled correctly."""
|
||||
obs = MicroagentObservation(
|
||||
"""Test that empty RecallObservations are handled correctly."""
|
||||
obs = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[],
|
||||
content='Empty retrieval',
|
||||
@@ -991,7 +991,7 @@ def test_process_events_with_microagent_observation_deduplication_empty(
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Verify that empty MicroagentObservations are handled gracefully
|
||||
# Verify that empty RecallObservations are handled gracefully
|
||||
assert (
|
||||
len(messages) == 1
|
||||
) # system message, because an empty microagent is not added to Messages
|
||||
@@ -999,8 +999,8 @@ def test_process_events_with_microagent_observation_deduplication_empty(
|
||||
|
||||
def test_has_agent_in_earlier_events(conversation_memory):
|
||||
"""Test the _has_agent_in_earlier_events helper method."""
|
||||
# Create test MicroagentObservations
|
||||
obs1 = MicroagentObservation(
|
||||
# Create test RecallObservations
|
||||
obs1 = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[
|
||||
MicroagentKnowledge(
|
||||
@@ -1012,7 +1012,7 @@ def test_has_agent_in_earlier_events(conversation_memory):
|
||||
content='First retrieval',
|
||||
)
|
||||
|
||||
obs2 = MicroagentObservation(
|
||||
obs2 = RecallObservation(
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[
|
||||
MicroagentKnowledge(
|
||||
@@ -1024,7 +1024,7 @@ def test_has_agent_in_earlier_events(conversation_memory):
|
||||
content='Second retrieval',
|
||||
)
|
||||
|
||||
obs3 = MicroagentObservation(
|
||||
obs3 = RecallObservation(
|
||||
recall_type=RecallType.WORKSPACE_CONTEXT,
|
||||
content='Environment info',
|
||||
)
|
||||
|
||||
@@ -13,7 +13,8 @@ async def test_load_store():
|
||||
store = FileConversationStore(InMemoryFileStore({}))
|
||||
expected = ConversationMetadata(
|
||||
conversation_id='some-conversation-id',
|
||||
github_user_id='some-user-id',
|
||||
user_id='some-user-id',
|
||||
github_user_id='12345',
|
||||
selected_repository='some-repo',
|
||||
title="Let's talk about trains",
|
||||
)
|
||||
@@ -31,6 +32,7 @@ async def test_load_int_user_id():
|
||||
{
|
||||
'conversation_id': 'some-conversation-id',
|
||||
'github_user_id': 12345,
|
||||
'user_id': '67890',
|
||||
'selected_repository': 'some-repo',
|
||||
'title': "Let's talk about trains",
|
||||
'created_at': '2025-01-16T19:51:04.886331Z',
|
||||
@@ -41,6 +43,7 @@ async def test_load_int_user_id():
|
||||
)
|
||||
found = await store.get_metadata('some-conversation-id')
|
||||
assert found.github_user_id == '12345'
|
||||
assert found.user_id == '67890'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -61,6 +64,7 @@ async def test_search_basic():
|
||||
{
|
||||
'conversation_id': 'conv1',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': 'First conversation',
|
||||
'created_at': '2025-01-16T19:51:04Z',
|
||||
@@ -70,6 +74,7 @@ async def test_search_basic():
|
||||
{
|
||||
'conversation_id': 'conv2',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': 'Second conversation',
|
||||
'created_at': '2025-01-17T19:51:04Z',
|
||||
@@ -79,6 +84,7 @@ async def test_search_basic():
|
||||
{
|
||||
'conversation_id': 'conv3',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': 'Third conversation',
|
||||
'created_at': '2025-01-15T19:51:04Z',
|
||||
@@ -107,6 +113,7 @@ async def test_search_pagination():
|
||||
{
|
||||
'conversation_id': f'conv{i}',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': f'Conversation {i}',
|
||||
'created_at': f'2025-01-{15+i}T19:51:04Z',
|
||||
@@ -148,6 +155,7 @@ async def test_search_with_invalid_conversation():
|
||||
{
|
||||
'conversation_id': 'conv1',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': 'Valid conversation',
|
||||
'created_at': '2025-01-16T19:51:04Z',
|
||||
@@ -176,6 +184,7 @@ async def test_get_all_metadata():
|
||||
{
|
||||
'conversation_id': 'conv1',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': 'First conversation',
|
||||
'created_at': '2025-01-16T19:51:04Z',
|
||||
@@ -185,6 +194,7 @@ async def test_get_all_metadata():
|
||||
{
|
||||
'conversation_id': 'conv2',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': 'Second conversation',
|
||||
'created_at': '2025-01-17T19:51:04Z',
|
||||
|
||||
@@ -14,7 +14,7 @@ from openhands.events.action.agent import RecallAction
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.event import EventSource
|
||||
from openhands.events.observation.agent import (
|
||||
MicroagentObservation,
|
||||
RecallObservation,
|
||||
RecallType,
|
||||
)
|
||||
from openhands.events.stream import EventStream
|
||||
@@ -74,7 +74,7 @@ async def test_memory_on_event_exception_handling(memory, event_stream):
|
||||
|
||||
# Mock Memory method to raise an exception
|
||||
with patch.object(
|
||||
memory, '_on_first_microagent_action', side_effect=Exception('Test error')
|
||||
memory, '_on_workspace_context_recall', side_effect=Exception('Test error')
|
||||
):
|
||||
state = await run_controller(
|
||||
config=AppConfig(),
|
||||
@@ -93,10 +93,10 @@ async def test_memory_on_event_exception_handling(memory, event_stream):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_on_first_microagent_action_exception_handling(
|
||||
async def test_memory_on_workspace_context_recall_exception_handling(
|
||||
memory, event_stream
|
||||
):
|
||||
"""Test that exceptions in Memory._on_first_microagent_action are properly handled via status callback."""
|
||||
"""Test that exceptions in Memory._on_workspace_context_recall are properly handled via status callback."""
|
||||
|
||||
# Create a dummy agent for the controller
|
||||
agent = MagicMock(spec=Agent)
|
||||
@@ -108,11 +108,11 @@ async def test_memory_on_first_microagent_action_exception_handling(
|
||||
runtime = MagicMock(spec=Runtime)
|
||||
runtime.event_stream = event_stream
|
||||
|
||||
# Mock Memory._on_first_microagent_action to raise an exception
|
||||
# Mock Memory._on_workspace_context_recall to raise an exception
|
||||
with patch.object(
|
||||
memory,
|
||||
'_on_first_microagent_action',
|
||||
side_effect=Exception('Test error from _on_first_microagent_action'),
|
||||
'_find_microagent_knowledge',
|
||||
side_effect=Exception('Test error from _find_microagent_knowledge'),
|
||||
):
|
||||
state = await run_controller(
|
||||
config=AppConfig(),
|
||||
@@ -130,12 +130,13 @@ async def test_memory_on_first_microagent_action_exception_handling(
|
||||
assert state.last_error == 'Error: Exception'
|
||||
|
||||
|
||||
def test_memory_with_microagents():
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_with_microagents():
|
||||
"""Test that Memory loads microagents from the global directory and processes microagent actions.
|
||||
|
||||
This test verifies that:
|
||||
1. Memory loads microagents from the global GLOBAL_MICROAGENTS_DIR
|
||||
2. When a microagent action with a trigger word is processed, a MicroagentObservation is created
|
||||
2. When a microagent action with a trigger word is processed, a RecallObservation is created
|
||||
"""
|
||||
# Create a mock event stream
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
@@ -158,6 +159,9 @@ def test_memory_with_microagents():
|
||||
query='Hello, flarglebargle!', recall_type=RecallType.KNOWLEDGE
|
||||
)
|
||||
|
||||
# Set the source to USER
|
||||
microagent_action._source = EventSource.USER # type: ignore[attr-defined]
|
||||
|
||||
# Mock the event_stream.add_event method
|
||||
added_events = []
|
||||
|
||||
@@ -173,12 +177,12 @@ def test_memory_with_microagents():
|
||||
added_events.clear()
|
||||
|
||||
# Process the microagent action
|
||||
memory.on_event(microagent_action)
|
||||
await memory._on_event(microagent_action)
|
||||
|
||||
# Verify a MicroagentObservation was added to the event stream
|
||||
# Verify a RecallObservation was added to the event stream
|
||||
assert len(added_events) == 1
|
||||
observation, source = added_events[0]
|
||||
assert isinstance(observation, MicroagentObservation)
|
||||
assert isinstance(observation, RecallObservation)
|
||||
assert source == EventSource.ENVIRONMENT
|
||||
assert observation.recall_type == RecallType.KNOWLEDGE
|
||||
assert len(observation.microagent_knowledge) == 1
|
||||
@@ -188,7 +192,7 @@ def test_memory_with_microagents():
|
||||
|
||||
|
||||
def test_memory_repository_info(prompt_dir):
|
||||
"""Test that Memory adds repository info to MicroagentObservations."""
|
||||
"""Test that Memory adds repository info to RecallObservations."""
|
||||
# Create an in-memory file store and real event stream
|
||||
file_store = InMemoryFileStore()
|
||||
event_stream = EventStream(sid='test-session', file_store=file_store)
|
||||
@@ -241,15 +245,15 @@ REPOSITORY INSTRUCTIONS: This is a test repository.
|
||||
# Get all events from the stream
|
||||
events = list(event_stream.get_events())
|
||||
|
||||
# Find the MicroagentObservation event
|
||||
# Find the RecallObservation event
|
||||
microagent_obs_events = [
|
||||
event for event in events if isinstance(event, MicroagentObservation)
|
||||
event for event in events if isinstance(event, RecallObservation)
|
||||
]
|
||||
|
||||
# We should have at least one MicroagentObservation
|
||||
# We should have at least one RecallObservation
|
||||
assert len(microagent_obs_events) > 0
|
||||
|
||||
# Get the first MicroagentObservation
|
||||
# Get the first RecallObservation
|
||||
observation = microagent_obs_events[0]
|
||||
assert observation.recall_type == RecallType.WORKSPACE_CONTEXT
|
||||
assert observation.repo_name == 'owner/repo'
|
||||
|
||||
@@ -5,8 +5,8 @@ from openhands.events.observation import (
|
||||
CmdOutputMetadata,
|
||||
CmdOutputObservation,
|
||||
FileEditObservation,
|
||||
MicroagentObservation,
|
||||
Observation,
|
||||
RecallObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import MicroagentKnowledge
|
||||
from openhands.events.serialization import (
|
||||
@@ -245,9 +245,9 @@ def test_file_edit_observation_legacy_serialization():
|
||||
|
||||
def test_microagent_observation_serialization():
|
||||
original_observation_dict = {
|
||||
'observation': 'microagent',
|
||||
'observation': 'recall',
|
||||
'content': '',
|
||||
'message': "**MicroagentObservation**\nrecall_type=RecallType.WORKSPACE_CONTEXT, repo_name=some_repo_name, repo_instructions=complex_repo_instruc..., runtime_hosts={'host1': 8080, 'host2': 8081}, additional_agent_instructions=You know it all abou...",
|
||||
'message': 'Added workspace context',
|
||||
'extras': {
|
||||
'recall_type': 'workspace_context',
|
||||
'repo_name': 'some_repo_name',
|
||||
@@ -258,14 +258,14 @@ def test_microagent_observation_serialization():
|
||||
'microagent_knowledge': [],
|
||||
},
|
||||
}
|
||||
serialization_deserialization(original_observation_dict, MicroagentObservation)
|
||||
serialization_deserialization(original_observation_dict, RecallObservation)
|
||||
|
||||
|
||||
def test_microagent_observation_microagent_knowledge_serialization():
|
||||
original_observation_dict = {
|
||||
'observation': 'microagent',
|
||||
'observation': 'recall',
|
||||
'content': '',
|
||||
'message': '**MicroagentObservation**\nrecall_type=RecallType.KNOWLEDGE, repo_name=, repo_instructions=..., runtime_hosts={}, additional_agent_instructions=..., microagent_knowledge=microagent1, microagent2',
|
||||
'message': 'Added microagent knowledge',
|
||||
'extras': {
|
||||
'recall_type': 'knowledge',
|
||||
'repo_name': '',
|
||||
@@ -287,13 +287,13 @@ def test_microagent_observation_microagent_knowledge_serialization():
|
||||
],
|
||||
},
|
||||
}
|
||||
serialization_deserialization(original_observation_dict, MicroagentObservation)
|
||||
serialization_deserialization(original_observation_dict, RecallObservation)
|
||||
|
||||
|
||||
def test_microagent_observation_knowledge_microagent_serialization():
|
||||
"""Test serialization of a MicroagentObservation with KNOWLEDGE_MICROAGENT type."""
|
||||
# Create a MicroagentObservation with microagent knowledge content
|
||||
original = MicroagentObservation(
|
||||
"""Test serialization of a RecallObservation with KNOWLEDGE_MICROAGENT type."""
|
||||
# Create a RecallObservation with microagent knowledge content
|
||||
original = RecallObservation(
|
||||
content='Knowledge microagent information',
|
||||
recall_type=RecallType.KNOWLEDGE,
|
||||
microagent_knowledge=[
|
||||
@@ -314,13 +314,13 @@ def test_microagent_observation_knowledge_microagent_serialization():
|
||||
serialized = event_to_dict(original)
|
||||
|
||||
# Verify serialized data structure
|
||||
assert serialized['observation'] == ObservationType.MICROAGENT
|
||||
assert serialized['observation'] == ObservationType.RECALL
|
||||
assert serialized['content'] == 'Knowledge microagent information'
|
||||
assert serialized['extras']['recall_type'] == RecallType.KNOWLEDGE.value
|
||||
assert len(serialized['extras']['microagent_knowledge']) == 2
|
||||
assert serialized['extras']['microagent_knowledge'][0]['trigger'] == 'python'
|
||||
|
||||
# Deserialize back to MicroagentObservation
|
||||
# Deserialize back to RecallObservation
|
||||
deserialized = observation_from_dict(serialized)
|
||||
|
||||
# Verify properties are preserved
|
||||
@@ -336,9 +336,9 @@ def test_microagent_observation_knowledge_microagent_serialization():
|
||||
|
||||
|
||||
def test_microagent_observation_environment_serialization():
|
||||
"""Test serialization of a MicroagentObservation with ENVIRONMENT type."""
|
||||
# Create a MicroagentObservation with environment info
|
||||
original = MicroagentObservation(
|
||||
"""Test serialization of a RecallObservation with ENVIRONMENT type."""
|
||||
# Create a RecallObservation with environment info
|
||||
original = RecallObservation(
|
||||
content='Environment information',
|
||||
recall_type=RecallType.WORKSPACE_CONTEXT,
|
||||
repo_name='OpenHands',
|
||||
@@ -352,7 +352,7 @@ def test_microagent_observation_environment_serialization():
|
||||
serialized = event_to_dict(original)
|
||||
|
||||
# Verify serialized data structure
|
||||
assert serialized['observation'] == ObservationType.MICROAGENT
|
||||
assert serialized['observation'] == ObservationType.RECALL
|
||||
assert serialized['content'] == 'Environment information'
|
||||
assert serialized['extras']['recall_type'] == RecallType.WORKSPACE_CONTEXT.value
|
||||
assert serialized['extras']['repo_name'] == 'OpenHands'
|
||||
@@ -364,7 +364,7 @@ def test_microagent_observation_environment_serialization():
|
||||
serialized['extras']['additional_agent_instructions']
|
||||
== 'You know it all about this runtime'
|
||||
)
|
||||
# Deserialize back to MicroagentObservation
|
||||
# Deserialize back to RecallObservation
|
||||
deserialized = observation_from_dict(serialized)
|
||||
|
||||
# Verify properties are preserved
|
||||
@@ -382,11 +382,11 @@ def test_microagent_observation_environment_serialization():
|
||||
|
||||
|
||||
def test_microagent_observation_combined_serialization():
|
||||
"""Test serialization of a MicroagentObservation with both types of information."""
|
||||
# Create a MicroagentObservation with both environment and microagent info
|
||||
"""Test serialization of a RecallObservation with both types of information."""
|
||||
# Create a RecallObservation with both environment and microagent info
|
||||
# Note: In practice, recall_type would still be one specific type,
|
||||
# but the object could contain both types of fields
|
||||
original = MicroagentObservation(
|
||||
original = RecallObservation(
|
||||
content='Combined information',
|
||||
recall_type=RecallType.WORKSPACE_CONTEXT,
|
||||
# Environment info
|
||||
@@ -419,7 +419,7 @@ def test_microagent_observation_combined_serialization():
|
||||
serialized['extras']['additional_agent_instructions']
|
||||
== 'You know it all about this runtime'
|
||||
)
|
||||
# Deserialize back to MicroagentObservation
|
||||
# Deserialize back to RecallObservation
|
||||
deserialized = observation_from_dict(serialized)
|
||||
|
||||
# Verify all properties are preserved
|
||||
|
||||
@@ -51,7 +51,7 @@ At the user's request, repository {{ repository_info.repo_name }} has been clone
|
||||
assert 'System prompt: bar' in system_msg
|
||||
|
||||
# Test building additional info
|
||||
additional_info = manager.build_additional_info(
|
||||
additional_info = manager.build_workspace_context(
|
||||
repository_info=repo_info, runtime_info=None, repo_instructions=''
|
||||
)
|
||||
assert '<REPOSITORY_INFO>' in additional_info
|
||||
@@ -199,7 +199,7 @@ def test_add_turns_left_reminder(prompt_dir):
|
||||
)
|
||||
|
||||
|
||||
def test_build_additional_info_with_repo_and_runtime(prompt_dir):
|
||||
def test_build_workspace_context_with_repo_and_runtime(prompt_dir):
|
||||
"""Test building additional info with repository and runtime information."""
|
||||
# Create an additional_info.j2 template file
|
||||
with open(os.path.join(prompt_dir, 'additional_info.j2'), 'w') as f:
|
||||
@@ -245,7 +245,7 @@ each of which has a corresponding port:
|
||||
repo_instructions = 'This repository contains important code.'
|
||||
|
||||
# Build additional info
|
||||
result = manager.build_additional_info(
|
||||
result = manager.build_workspace_context(
|
||||
repository_info=repo_info,
|
||||
runtime_info=runtime_info,
|
||||
repo_instructions=repo_instructions,
|
||||
|
||||
@@ -49,6 +49,7 @@ async def test_iterate_single_page():
|
||||
{
|
||||
'conversation_id': 'conv1',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': 'First conversation',
|
||||
'created_at': '2025-01-16T19:51:04Z',
|
||||
@@ -58,6 +59,7 @@ async def test_iterate_single_page():
|
||||
{
|
||||
'conversation_id': 'conv2',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': 'Second conversation',
|
||||
'created_at': '2025-01-17T19:51:04Z',
|
||||
@@ -86,6 +88,7 @@ async def test_iterate_multiple_pages():
|
||||
{
|
||||
'conversation_id': f'conv{i}',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': f'Conversation {i}',
|
||||
'created_at': f'2025-01-{15+i}T19:51:04Z',
|
||||
@@ -120,6 +123,7 @@ async def test_iterate_with_invalid_conversation():
|
||||
{
|
||||
'conversation_id': 'conv1',
|
||||
'github_user_id': '123',
|
||||
'user_id': '123',
|
||||
'selected_repository': 'repo1',
|
||||
'title': 'Valid conversation',
|
||||
'created_at': '2025-01-16T19:51:04Z',
|
||||
|
||||
@@ -61,7 +61,7 @@ async def test_init_new_local_session():
|
||||
'new-session-id', ConversationInitData(), 1
|
||||
)
|
||||
await conversation_manager.join_conversation(
|
||||
'new-session-id', 'new-session-id', ConversationInitData(), 1
|
||||
'new-session-id', 'new-session-id', ConversationInitData(), 1, '12345'
|
||||
)
|
||||
assert session_instance.initialize_agent.call_count == 1
|
||||
assert sio.enter_room.await_count == 1
|
||||
@@ -93,10 +93,18 @@ async def test_join_local_session():
|
||||
'new-session-id', ConversationInitData(), None
|
||||
)
|
||||
await conversation_manager.join_conversation(
|
||||
'new-session-id', 'new-session-id', ConversationInitData(), None
|
||||
'new-session-id',
|
||||
'new-session-id',
|
||||
ConversationInitData(),
|
||||
None,
|
||||
'12345',
|
||||
)
|
||||
await conversation_manager.join_conversation(
|
||||
'new-session-id', 'new-session-id', ConversationInitData(), None
|
||||
'new-session-id',
|
||||
'new-session-id',
|
||||
ConversationInitData(),
|
||||
None,
|
||||
'12345',
|
||||
)
|
||||
assert session_instance.initialize_agent.call_count == 1
|
||||
assert sio.enter_room.await_count == 2
|
||||
@@ -128,7 +136,7 @@ async def test_add_to_local_event_stream():
|
||||
'new-session-id', ConversationInitData(), 1
|
||||
)
|
||||
await conversation_manager.join_conversation(
|
||||
'new-session-id', 'connection-id', ConversationInitData(), 1
|
||||
'new-session-id', 'connection-id', ConversationInitData(), 1, '12345'
|
||||
)
|
||||
await conversation_manager.send_to_event_stream(
|
||||
'connection-id', {'event_type': 'some_event'}
|
||||
|
||||
@@ -23,7 +23,13 @@ def mock_event_stream():
|
||||
def mock_agent():
|
||||
agent = MagicMock()
|
||||
agent.llm = MagicMock()
|
||||
agent.llm.config = MagicMock()
|
||||
|
||||
# Create a step function that returns an action without an ID
|
||||
def agent_step_fn(state):
|
||||
return MessageAction(content='Agent returned a message')
|
||||
|
||||
agent.step = agent_step_fn
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user