Compare commits

..

39 Commits

Author SHA1 Message Date
openhands
98d68711df Fix issue #6237: [Bug]: Micro-agent doesn't work due to trailing slash in directory paths 2025-01-13 17:39:45 +00:00
tofarr
4fa5c329d6 Fix : minor updates to log messages (#6232) 2025-01-13 17:19:51 +00:00
tofarr
5b1dcf83a6 Fix for issue where S3FileStore does not delete directory objects (#6231) 2025-01-13 16:50:58 +00:00
tofarr
b9884f7609 Fixes for minor cases where FDs were not closed (#6228) 2025-01-13 09:15:23 -07:00
dependabot[bot]
99eda0e571 chore(deps-dev): bump the eslint group in /frontend with 2 updates (#6227)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-13 19:44:10 +04:00
Ryan H. Tran
5832463088 Revert openhands-resolver.yml change in #5972 (#6222) 2025-01-13 15:39:54 +00:00
tofarr
045ec2b95d Fix: Timezone should be UTC (#6225) 2025-01-13 08:24:26 -07:00
tofarr
23473070b9 Revert "Config objects as Pydantic BaseModels (#6176)" (#6214) 2025-01-13 07:36:25 -07:00
mamoodi
63133c0ba9 Document changes for Micro-Agents and some formatting changes (#6155)
Co-authored-by: Robert Brennan <accounts@rbren.io>
2025-01-13 09:24:10 -05:00
dependabot[bot]
2023fb767f chore(deps): bump the version-all group in /frontend with 2 updates (#6192)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-01-13 11:09:11 +00:00
Ryan H. Tran
23f40a1c01 Enable runtime image build for resolver's experimental feature (#5972) 2025-01-12 17:21:34 -05:00
Calvin Smith
873dddb4e8 Config objects as Pydantic BaseModels (#6176)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-01-12 15:09:45 -05:00
Ryan H. Tran
fe50cd1f9f Upgrade openhands-aci to 0.1.8 (#6123) 2025-01-12 07:26:57 +01:00
Boxuan Li
516e2da520 Custom runtime builder: fix NoEmptyContinuation error (#6211) 2025-01-11 15:58:08 -08:00
jmtatsch
1dd6f544bc Fix #6056 (#6203) 2025-01-11 23:32:12 +01:00
Graham Neubig
40c52feb5b fix: Handle empty lines in patch parser (#6208)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-12 06:43:08 +09:00
Xingyao Wang
f31ccad48b feat: misc bash improvements, set max value for action-exec timeout, retry on requests.ConnectionError (#6175) 2025-01-11 04:36:12 +08:00
Xingyao Wang
828d169b82 refactor: consolidate runtime startup command into an util function (#6199) 2025-01-11 04:27:13 +08:00
dependabot[bot]
a622d27016 chore(deps-dev): bump llama-index-embeddings-huggingface from 0.4.0 to 0.5.0 in the llama group (#6194)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-10 19:59:54 +01:00
sp.wack
5507b131fe hotfix(frontend): Add beta tag to new app tab (#6198) 2025-01-10 17:31:42 +00:00
sp.wack
0f102e4c71 hotfix(frontend): Get bottom right conversation card details even when multi convo is disabled (#6197) 2025-01-10 17:19:37 +00:00
sp.wack
157a1a24f6 fix(frontend): Wait for fetched settings instead of loading default ones (#6193) 2025-01-10 16:54:31 +00:00
dependabot[bot]
fcfbcb64d4 chore(deps): bump the version-all group in /frontend with 5 updates (#6170)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-01-10 13:51:03 +00:00
Xingyao Wang
931792e87a fix: UI terminal output incorrect newline (#6182) 2025-01-10 17:11:06 +04:00
Robert Brennan
ee701eacc2 fix: prevent race condition in session manager during disconnect (#6053)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-09 17:26:53 -07:00
Ray Myers
8907fed78e Provide a clearer error message when settings are missing midsession (#6158) 2025-01-09 19:09:34 +00:00
Robert Brennan
3cc20a2576 remove timeouts on remote runtime (#6171) 2025-01-09 12:39:40 -05:00
dependabot[bot]
01cf0d433c chore(deps): bump the version-all group with 5 updates (#6169)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-09 17:45:16 +01:00
sp.wack
f6bed82ae2 Add port mappings support (#5577)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: Robert Brennan <accounts@rbren.io>
Co-authored-by: Robert Brennan <contact@rbren.io>
2025-01-09 15:02:56 +00:00
sp.wack
3eae2e2aca chore(frontend): Optimize requests made to the backend (#6168) 2025-01-09 15:00:26 +00:00
sp.wack
b45fc522c7 feat(frontend): Display current conversation info in the bottom right (#6143) 2025-01-09 14:55:33 +00:00
sp.wack
0d409c8c24 fix(frontend): Prevent saving empty custom model (#6149) 2025-01-09 13:43:39 +00:00
Graham Neubig
5458ebbd7d Fix issue #6048: Update documentation of recommended models and add deepseek (#6050)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-01-09 02:39:53 +00:00
Robert Brennan
c411a29db4 Move GitHub Token export to backend (#6153)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-01-08 21:12:46 +00:00
ross
386e04a2ba Fix field deprecation in runloop runtime client (#6152) 2025-01-08 15:18:24 -05:00
sp.wack
62c4bab6ba hotfix(frontend): Prevent a redirect when clicking edit (#6151) 2025-01-08 19:53:24 +00:00
sp.wack
e308b6fb6f chore(backend): Update default conversation title logic (#6138) 2025-01-08 22:30:29 +04:00
Engel Nyst
27a660fb6b Make runtime logs optional (#6141) 2025-01-08 19:20:46 +01:00
sp.wack
27d761a1fe chore(frontend): Improve conversation card (#6121) 2025-01-08 21:57:57 +04:00
92 changed files with 1855 additions and 1274 deletions

View File

@@ -1,4 +1,4 @@
#
services:
openhands:
build:
@@ -7,8 +7,8 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.19-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.19-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
- "3000:3000"
@@ -16,6 +16,7 @@ services:
- "host.docker.internal:host-gateway"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ~/.openhands-state:/.openhands-state
- ${WORKSPACE_BASE:-$PWD/workspace}:/opt/workspace_base
pull_policy: build
stdin_open: true

View File

@@ -7,53 +7,11 @@ If you are running in [GUI Mode](https://docs.all-hands.dev/modules/usage/how-to
take precedence.
:::
---
# Table of Contents
- [Core Configuration](#core-configuration)
- [API Keys](#api-keys)
- [Workspace](#workspace)
- [Debugging and Logging](#debugging-and-logging)
- [Session Management](#session-management)
- [Trajectories](#trajectories)
- [File Store](#file-store)
- [Task Management](#task-management)
- [Sandbox Configuration](#sandbox-configuration)
- [Miscellaneous](#miscellaneous)
- [LLM Configuration](#llm-configuration)
- [AWS Credentials](#aws-credentials)
- [API Configuration](#api-configuration)
- [Custom LLM Provider](#custom-llm-provider)
- [Embeddings](#embeddings)
- [Message Handling](#message-handling)
- [Model Selection](#model-selection)
- [Retrying](#retrying)
- [Advanced Options](#advanced-options)
- [Agent Configuration](#agent-configuration)
- [Microagent Configuration](#microagent-configuration)
- [Memory Configuration](#memory-configuration)
- [LLM Configuration](#llm-configuration-2)
- [ActionSpace Configuration](#actionspace-configuration)
- [Microagent Usage](#microagent-usage)
- [Sandbox Configuration](#sandbox-configuration)
- [Execution](#execution)
- [Container Image](#container-image)
- [Networking](#networking)
- [Linting and Plugins](#linting-and-plugins)
- [Dependencies and Environment](#dependencies-and-environment)
- [Evaluation](#evaluation)
- [Security Configuration](#security-configuration)
- [Confirmation Mode](#confirmation-mode)
- [Security Analyzer](#security-analyzer)
---
## Core Configuration
The core configuration options are defined in the `[core]` section of the `config.toml` file.
**API Keys**
### API Keys
- `e2b_api_key`
- Type: `str`
- Default: `""`
@@ -69,7 +27,7 @@ The core configuration options are defined in the `[core]` section of the `confi
- Default: `""`
- Description: API token secret for Modal
**Workspace**
### Workspace
- `workspace_base`
- Type: `str`
- Default: `"./workspace"`
@@ -80,7 +38,7 @@ The core configuration options are defined in the `[core]` section of the `confi
- Default: `"/tmp/cache"`
- Description: Cache directory path
**Debugging and Logging**
### Debugging and Logging
- `debug`
- Type: `bool`
- Default: `false`
@@ -91,13 +49,13 @@ The core configuration options are defined in the `[core]` section of the `confi
- Default: `false`
- Description: Disable color in terminal output
**Trajectories**
### Trajectories
- `trajectories_path`
- Type: `str`
- Default: `"./trajectories"`
- Description: Path to store trajectories (can be a folder or a file). If it's a folder, the trajectories will be saved in a file named with the session id name and .json extension, in that folder.
**File Store**
### File Store
- `file_store_path`
- Type: `str`
- Default: `"/tmp/file_store"`
@@ -128,7 +86,7 @@ The core configuration options are defined in the `[core]` section of the `confi
- Default: `[".*"]`
- Description: List of allowed file extensions for uploads
**Task Management**
### Task Management
- `max_budget_per_task`
- Type: `float`
- Default: `0.0`
@@ -139,7 +97,7 @@ The core configuration options are defined in the `[core]` section of the `confi
- Default: `100`
- Description: Maximum number of iterations
**Sandbox Configuration**
### Sandbox Configuration
- `workspace_mount_path_in_sandbox`
- Type: `str`
- Default: `"/workspace"`
@@ -155,7 +113,7 @@ The core configuration options are defined in the `[core]` section of the `confi
- Default: `""`
- Description: Path to rewrite the workspace mount path to. You can usually ignore this, it refers to special cases of running inside another container.
**Miscellaneous**
### Miscellaneous
- `run_as_openhands`
- Type: `bool`
- Default: `true`
@@ -182,7 +140,7 @@ The LLM (Large Language Model) configuration options are defined in the `[llm]`
To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LLM_NUM_RETRIES`.
**AWS Credentials**
### AWS Credentials
- `aws_access_key_id`
- Type: `str`
- Default: `""`
@@ -198,7 +156,7 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
- Default: `""`
- Description: AWS secret access key
**API Configuration**
### API Configuration
- `api_key`
- Type: `str`
- Default: `None`
@@ -224,13 +182,13 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
- Default: `0.0`
- Description: Cost per output token
**Custom LLM Provider**
### Custom LLM Provider
- `custom_llm_provider`
- Type: `str`
- Default: `""`
- Description: Custom LLM provider
**Embeddings**
### Embeddings
- `embedding_base_url`
- Type: `str`
- Default: `""`
@@ -246,7 +204,7 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
- Default: `"local"`
- Description: Embedding model to use
**Message Handling**
### Message Handling
- `max_message_chars`
- Type: `int`
- Default: `30000`
@@ -262,13 +220,13 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
- Default: `0`
- Description: Maximum number of output tokens
**Model Selection**
### Model Selection
- `model`
- Type: `str`
- Default: `"claude-3-5-sonnet-20241022"`
- Description: Model to use
**Retrying**
### Retrying
- `num_retries`
- Type: `int`
- Default: `8`
@@ -289,7 +247,7 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
- Default: `2.0`
- Description: Multiplier for exponential backoff calculation
**Advanced Options**
### Advanced Options
- `drop_params`
- Type: `bool`
- Default: `false`
@@ -329,13 +287,13 @@ To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LL
The agent configuration options are defined in the `[agent]` and `[agent.<agent_name>]` sections of the `config.toml` file.
**Microagent Configuration**
### Microagent Configuration
- `micro_agent_name`
- Type: `str`
- Default: `""`
- Description: Name of the micro agent to use for this agent
**Memory Configuration**
### Memory Configuration
- `memory_enabled`
- Type: `bool`
- Default: `false`
@@ -346,13 +304,13 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
- Default: `3`
- Description: The maximum number of threads indexing at the same time for embeddings
**LLM Configuration**
### LLM Configuration
- `llm_config`
- Type: `str`
- Default: `'your-llm-config-group'`
- Description: The name of the LLM config to use
**ActionSpace Configuration**
### ActionSpace Configuration
- `function_calling`
- Type: `bool`
- Default: `true`
@@ -373,7 +331,7 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
- Default: `false`
- Description: Whether Jupyter is enabled in the action space
**Microagent Usage**
### Microagent Usage
- `use_microagents`
- Type: `bool`
- Default: `true`
@@ -390,7 +348,7 @@ The sandbox configuration options are defined in the `[sandbox]` section of the
To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-e SANDBOX_TIMEOUT`.
**Execution**
### Execution
- `timeout`
- Type: `int`
- Default: `120`
@@ -401,19 +359,19 @@ To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-
- Default: `1000`
- Description: Sandbox user ID
**Container Image**
### Container Image
- `base_container_image`
- Type: `str`
- Default: `"nikolaik/python-nodejs:python3.12-nodejs22"`
- Description: Container image to use for the sandbox
**Networking**
### Networking
- `use_host_network`
- Type: `bool`
- Default: `false`
- Description: Use host network
**Linting and Plugins**
### Linting and Plugins
- `enable_auto_lint`
- Type: `bool`
- Default: `false`
@@ -424,7 +382,7 @@ To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-
- Default: `true`
- Description: Whether to initialize plugins
**Dependencies and Environment**
### Dependencies and Environment
- `runtime_extra_deps`
- Type: `str`
- Default: `""`
@@ -435,7 +393,7 @@ To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-
- Default: `{}`
- Description: Environment variables to set at the launch of the runtime
**Evaluation**
### Evaluation
- `browsergym_eval_env`
- Type: `str`
- Default: `""`
@@ -447,13 +405,13 @@ The security configuration options are defined in the `[security]` section of th
To use these with the docker command, pass in `-e SECURITY_<option>`. Example: `-e SECURITY_CONFIRMATION_MODE`.
**Confirmation Mode**
### Confirmation Mode
- `confirmation_mode`
- Type: `bool`
- Default: `false`
- Description: Enable confirmation mode
**Security Analyzer**
### Security Analyzer
- `security_analyzer`
- Type: `str`
- Default: `""`

View File

@@ -58,7 +58,7 @@ Here are some examples of CLI commands and their expected outputs:
### Example 1: Simple Task
```bash
How can I help? >> Write a Python script that prints "Hello, World!"
>> Write a Python script that prints "Hello, World!"
```
Expected Output:
@@ -72,7 +72,7 @@ Expected Output:
### Example 2: Bash Command
```bash
How can I help? >> Create a directory named "test_dir"
>> Create a directory named "test_dir"
```
Expected Output:
@@ -86,7 +86,7 @@ Expected Output:
### Example 3: Error Handling
```bash
How can I help? >> Delete a non-existent file
>> Delete a non-existent file
```
Expected Output:

View File

@@ -76,18 +76,18 @@ When using OpenHands in online mode, the GitHub OAuth flow:
Common issues and solutions:
1. **Token Not Recognized**:
- **Token Not Recognized**:
- Ensure the token is properly saved in settings.
- Check that the token hasn't expired.
- Verify the token has the required scopes.
- Try regenerating the token.
2. **Organization Access Denied**:
- **Organization Access Denied**:
- Check if SSO is required but not enabled.
- Verify organization membership.
- Contact organization admin if token policies are blocking access.
3. **Verifying Token Works**:
- **Verifying Token Works**:
- The app will show a green checkmark if the token is valid.
- Try accessing a repository to confirm permissions.
- Check the browser console for any error messages.

View File

@@ -1,16 +0,0 @@
# Persisting Session Data
Using the standard Development Workflow, the session data is stored in memory. Currently, if OpenHands' service is restarted,
previous sessions become invalid (a new secret is generated) and thus not recoverable.
## How to Persist Session Data
### Development Workflow
In the `config.toml` file, specify the following:
```
[core]
...
file_store="local"
file_store_path="/absolute/path/to/openhands/cache/directory"
jwt_secret="secretpass"
```

View File

@@ -5,23 +5,14 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po
## Model Recommendations
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some
recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and
[this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent).
When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings:
- Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands.
- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task.
- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models.
Please refer to the [full article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) for more details.
recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
Based on these findings and community feedback, the following models have been verified to work reasonably well with OpenHands:
- claude-3-5-sonnet (recommended)
- gpt-4 / gpt-4o
- llama-3.1-405b
- deepseek-v2.5
- anthropic/claude-3-5-sonnet-20241022 (recommended)
- anthropic/claude-3-5-haiku-20241022
- deepseek/deepseek-chat
- gpt-4o
:::warning
OpenHands will issue many prompts to the LLM you configure. Most of these LLMs cost money, so be sure to set spending

View File

@@ -0,0 +1,36 @@
# Microagents Overview
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge, repository-specific context
and task-specific workflows. They help by providing expert guidance, automating common tasks, and ensuring
consistent practices across projects.
## Microagent Types
Currently OpenHands supports the following types of microagents:
* [Repository Microagents](./microagents-repo): Repository-specific context and guidelines for OpenHands.
* [Public Microagents](./microagents-public): General guidelines triggered by keywords for all OpenHands users.
When OpenHands works with a repository, it:
1. Loads repository-specific instructions from `.openhands/microagents/` if present in the repository.
2. Loads general guidelines triggered by keywords in conversations.
See current [Public Microagents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
## Microagent Format
All microagents use markdown files with YAML frontmatter that have special instructions to help OpenHands accomplish
tasks:
```
---
name: <Name of the microagent>
type: <MicroAgent type>
version: <MicroAgent version>
agent: <The agent type (Typically CodeActAgent)>
triggers:
- <Optional keywords triggering the microagent. If triggers are removed, it will always be included>
---
<Markdown with any special guidelines, instructions, and prompts that OpenHands should follow.
Check out the specific documentation for each microagent on best practices for more information.>
```

View File

@@ -1,31 +1,21 @@
# Public Micro-Agents
OpenHands uses specialized micro-agents to handle specific tasks and contexts efficiently. These micro-agents are small,
focused components that provide specialized behavior and knowledge for particular scenarios.
# Public Microagents
## Overview
Public micro-agents are defined in markdown files under the
Public microagents are specialized guidelines triggered by keywords for all OpenHands users.
They are defined in markdown files under the
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) directory.
Each micro-agent is configured with:
- A unique name.
- The agent type (typically CodeActAgent).
- Trigger keywords that activate the agent.
- Specific instructions and capabilities.
### Integration
Public micro-agents are automatically integrated into OpenHands' workflow. They:
Public microagents:
- Monitor incoming commands for their trigger words.
- Activate when relevant triggers are detected.
- Apply their specialized knowledge and capabilities.
- Follow their specific guidelines and restrictions.
## Available Public Micro-Agents
## Current Public Microagents
For more information about specific micro-agents, refer to their individual documentation files in
the [`micro-agents`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents) directory.
For more information about specific microagents, refer to their individual documentation files in
the [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/) directory.
### GitHub Agent
**File**: `github.md`
@@ -66,105 +56,54 @@ Usage Example:
yes | npm install package-name
```
### Custom Public Micro-Agents
## Contributing a Public Microagent
You can create your own public micro-agents by adding new markdown files to the `microagents/knowledge/` directory.
Each file should follow this structure:
You can create your own public microagents by adding new markdown files to the
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/) directory.
```markdown
---
name: agent_name
agent: CodeActAgent
triggers:
- trigger_word1
- trigger_word2
---
### Public Microagents Best Practices
Instructions and capabilities for the micro-agent...
```
## Working With Public Micro-Agents
When working with public micro-agents:
- **Use Appropriate Triggers**: Ensure your commands include the relevant trigger words to activate the correct micro-agent.
- **Follow Agent Guidelines**: Each agent has specific instructions and limitations. Respect these for optimal results.
- **API-First Approach**: When available, use API endpoints rather than web interfaces.
- **Automation Friendly**: Design commands that work well in non-interactive environments.
## Contributing a Public Micro-Agent
Best practices for creating public micro-agents:
- **Clear Scope**: Keep the micro-agent focused on a specific domain or task.
- **Clear Scope**: Keep the microagent focused on a specific domain or task.
- **Explicit Instructions**: Provide clear, unambiguous guidelines.
- **Useful Examples**: Include practical examples of common use cases.
- **Safety First**: Include necessary warnings and constraints.
- **Integration Awareness**: Consider how the micro-agent interacts with other components.
- **Integration Awareness**: Consider how the microagent interacts with other components.
To contribute a new micro-agent to OpenHands:
### Steps to Contribute a Public Microagent
### 1. Plan the Public Micro-Agent
#### 1. Plan the Public Microagent
Before creating a public micro-agent, consider:
Before creating a public microagent, consider:
- What specific problem or use case will it address?
- What unique capabilities or knowledge should it have?
- What trigger words make sense for activating it?
- What constraints or guidelines should it follow?
### 2. File Structure
#### 2. Create File
Create a new markdown file in `microagents/knowledge/` with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
Create a new markdown file in [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/)
with a descriptive name (e.g., `docker.md` for a Docker-focused agent).
### 3. Required Components
Update the file with the required frontmatter [according to the required format](./microagents-overview#microagent-format)
and the required specialized guidelines while following the [best practices above](#public-microagents-best-practices).
The micro-agent file must include:
#### 3. Testing the Public Microagent
- **Front Matter**: YAML metadata at the start of the file:
```markdown
---
name: your_agent_name
agent: CodeActAgent
triggers:
- trigger_word1
- trigger_word2
---
```
- **Instructions**: Clear, specific guidelines for the agent's behavior:
```markdown
You are responsible for [specific task/domain].
Key responsibilities:
1. [Responsibility 1]
2. [Responsibility 2]
Guidelines:
- [Guideline 1]
- [Guideline 2]
Examples of usage:
[Example 1]
[Example 2]
```
### 4. Testing the Public Micro-Agent
Before submitting:
- Test the agent with various prompts.
- Verify trigger words activate the agent correctly.
- Ensure instructions are clear and comprehensive.
- Check for potential conflicts with existing agents.
### 5. Submission Process
#### 4. Submission Process
Submit a pull request with:
- The new micro-agent file.
- The new microagent file.
- Updated documentation if needed.
- Description of the agent's purpose and capabilities.
### Example Public Micro-Agent Implementation
### Example Public Microagent Implementation
Here's a template for a new micro-agent:
Here's a template for a new microagent:
```markdown
---
@@ -210,5 +149,5 @@ Remember to:
- Optimize for build time and image size
```
Remember that micro-agents are a powerful way to extend OpenHands' capabilities in specific domains. Well-designed
agents can significantly improve the system's ability to handle specialized tasks.
See the [current public micro-agents](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for
more examples.

View File

@@ -1,25 +1,51 @@
# Repository Micro-Agents
# Repository Microagents
## Overview
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context
and guidelines. This section explains how to optimize OpenHands for your project.
## Repository Configuration
## Creating a Repository Micro-Agent
You can customize OpenHands' behavior for your repository by creating a `.openhands/microagents/` directory in your repository's root.
At minimum, it should contain the file
At minimum it should contain the file
`.openhands/microagents/repo.md`, which includes instructions that will
be given to the agent every time it works with this repository.
We suggest including the following information:
### Repository Microagents Best Practices
- **Keep Instructions Updated**: Regularly update your `.openhands/microagents/` directory as your project evolves.
- **Be Specific**: Include specific paths, patterns, and requirements unique to your project.
- **Document Dependencies**: List all tools and dependencies required for development.
- **Include Examples**: Provide examples of good code patterns from your project.
- **Specify Conventions**: Document naming conventions, file organization, and code style preferences.
### Steps to Create a Repository Microagent
#### 1. Plan the Repository Microagent
When creating a repository-specific micro-agent, we suggest including the following information:
- **Repository Overview**: A brief description of your project's purpose and architecture.
- **Directory Structure**: Key directories and their purposes.
- **Development Guidelines**: Project-specific coding standards and practices.
- **Testing Requirements**: How to run tests and what types of tests are required.
- **Setup Instructions**: Steps needed to build and run the project.
### Example Repository Configuration
Example `.openhands/microagents/repo.md` file:
#### 2. Create File
Create a file in your repository under `.openhands/microagents/` (Example: `.openhands/microagents/repo.md`)
Update the file with the required frontmatter [according to the required format](./microagents-overview#microagent-format)
and the required specialized guidelines for your repository.
### Example Repository Microagent
```
---
name: repo
type: repo
agent: CodeActAgent
---
Repository: MyProject
Description: A web application for task management
@@ -37,30 +63,6 @@ Guidelines:
- Follow ESLint configuration
- Write tests for all new features
- Use TypeScript for new code
If adding a new component in src/components, always add appropriate unit tests in tests/components/.
```
### Customizing Prompts
You may also add customized prompts to the `.openhands/microagents/repo.md` file when working with a repository.
These could:
- **Reference Project Standards**: Mention specific coding standards or patterns used in your project.
- **Include Context**: Reference relevant documentation or existing implementations.
- **Specify Testing Requirements**: Include project-specific testing requirements in your prompts.
Example customized prompt:
```
Add a new task completion feature to src/components/TaskList.tsx following our existing component patterns.
Include unit tests in tests/components/ and update the documentation in docs/features/.
The component should use our shared styling from src/styles/components.
```
### Best Practices for Repository Customization
- **Keep Instructions Updated**: Regularly update your `.openhands/microagents/` directory as your project evolves.
- **Be Specific**: Include specific paths, patterns, and requirements unique to your project.
- **Document Dependencies**: List all tools and dependencies required for development.
- **Include Examples**: Provide examples of good code patterns from your project.
- **Specify Conventions**: Document naming conventions, file organization, and code style preferences.
By customizing OpenHands for your repository, you'll get more accurate and consistent results that align with your project's standards and requirements.

View File

@@ -24,18 +24,23 @@ const sidebars: SidebarsConfig = {
},
{
type: 'category',
label: 'Micro-Agents',
label: 'Microagents',
items: [
{
type: 'doc',
label: 'Public',
id: 'usage/prompting/microagents-public',
label: 'Overview',
id: 'usage/prompting/microagents-overview',
},
{
type: 'doc',
label: 'Repository',
id: 'usage/prompting/microagents-repo',
},
{
type: 'doc',
label: 'Public',
id: 'usage/prompting/microagents-public',
},
],
}
],
@@ -132,11 +137,6 @@ const sidebars: SidebarsConfig = {
label: 'Custom Sandbox',
id: 'usage/how-to/custom-sandbox-guide',
},
{
type: 'doc',
label: 'Persist Session Data',
id: 'usage/how-to/persist-session-data',
},
],
},
{

View File

@@ -2,13 +2,13 @@ import { describe, expect, it, vi } from "vitest";
import { retrieveLatestGitHubCommit } from "../../src/api/github";
describe("retrieveLatestGitHubCommit", () => {
const { openHandsGetMock } = vi.hoisted(() => ({
openHandsGetMock: vi.fn(),
const { githubGetMock } = vi.hoisted(() => ({
githubGetMock: vi.fn(),
}));
vi.mock("../../src/api/open-hands-axios", () => ({
openHands: {
get: openHandsGetMock,
vi.mock("../../src/api/github-axios-instance", () => ({
github: {
get: githubGetMock,
},
}));
@@ -20,7 +20,7 @@ describe("retrieveLatestGitHubCommit", () => {
},
};
openHandsGetMock.mockResolvedValueOnce({
githubGetMock.mockResolvedValueOnce({
data: [mockCommit],
});
@@ -31,7 +31,7 @@ describe("retrieveLatestGitHubCommit", () => {
it("should return null when repository is empty", async () => {
const error = new Error("Repository is empty");
(error as any).response = { status: 409 };
openHandsGetMock.mockRejectedValueOnce(error);
githubGetMock.mockRejectedValueOnce(error);
const result = await retrieveLatestGitHubCommit("user/empty-repo");
expect(result).toBeNull();
@@ -40,7 +40,7 @@ describe("retrieveLatestGitHubCommit", () => {
it("should throw error for other error cases", async () => {
const error = new Error("Network error");
(error as any).response = { status: 500 };
openHandsGetMock.mockRejectedValueOnce(error);
githubGetMock.mockRejectedValueOnce(error);
await expect(retrieveLatestGitHubCommit("user/repo")).rejects.toThrow();
});

View File

@@ -9,6 +9,7 @@ describe("ConversationCard", () => {
const onClick = vi.fn();
const onDelete = vi.fn();
const onChangeTitle = vi.fn();
const onDownloadWorkspace = vi.fn();
afterEach(() => {
vi.clearAllMocks();
@@ -18,8 +19,8 @@ describe("ConversationCard", () => {
render(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -38,8 +39,8 @@ describe("ConversationCard", () => {
const { rerender } = render(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -53,8 +54,8 @@ describe("ConversationCard", () => {
rerender(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository="org/selectedRepository"
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -64,32 +65,13 @@ describe("ConversationCard", () => {
screen.getByTestId("conversation-card-selected-repository");
});
it("should call onClick when the card is clicked", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
const card = screen.getByTestId("conversation-card");
await user.click(card);
expect(onClick).toHaveBeenCalled();
});
it("should toggle a context menu when clicking the ellipsis button", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onDelete={onDelete}
onClick={onClick}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -112,8 +94,8 @@ describe("ConversationCard", () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -136,8 +118,8 @@ describe("ConversationCard", () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository="org/selectedRepository"
@@ -157,8 +139,8 @@ describe("ConversationCard", () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -189,8 +171,8 @@ describe("ConversationCard", () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -213,8 +195,8 @@ describe("ConversationCard", () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -232,8 +214,8 @@ describe("ConversationCard", () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -252,12 +234,126 @@ describe("ConversationCard", () => {
expect(onClick).not.toHaveBeenCalled();
});
it("should call onDownloadWorkspace when the download button is clicked", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const downloadButton = within(menu).getByTestId("download-button");
await user.click(downloadButton);
expect(onDownloadWorkspace).toHaveBeenCalled();
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const user = userEvent.setup();
const { rerender } = render(
<ConversationCard
onClick={onClick}
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
expect(screen.queryByTestId("edit-button")).toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument();
// toggle to hide the context menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
await user.click(ellipsisButton);
expect(screen.queryByTestId("edit-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).toBeInTheDocument();
});
it("should not render the ellipsis button if there are no actions", () => {
const { rerender } = render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
rerender(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
rerender(
<ConversationCard
onClick={onClick}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
expect(screen.queryByTestId("ellipsis-button")).toBeInTheDocument();
rerender(
<ConversationCard
onClick={onClick}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
});
describe("state indicator", () => {
it("should render the 'STOPPED' indicator by default", () => {
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
@@ -271,8 +367,8 @@ describe("ConversationCard", () => {
it("should render the other indicators when provided", () => {
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}

View File

@@ -6,6 +6,7 @@ import {
QueryClientConfig,
} from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
@@ -13,9 +14,15 @@ import { clickOnEditButton } from "./utils";
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
const RouterStub = createRoutesStub([
{
Component: () => <ConversationPanel onClose={onCloseMock} />,
path: "/",
},
]);
const renderConversationPanel = (config?: QueryClientConfig) =>
render(<ConversationPanel onClose={onCloseMock} />, {
render(<RouterStub />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient(config)}>

View File

@@ -1,10 +1,12 @@
import { screen } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import OpenHands from "#/api/open-hands";
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
const renderSidebar = () => {
const RouterStub = createRoutesStub([
@@ -43,4 +45,101 @@ describe("Sidebar", () => {
).not.toBeInTheDocument();
},
);
describe("Settings", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
afterEach(() => {
vi.clearAllMocks();
});
it("should fetch settings data on mount", () => {
renderSidebar();
expect(getSettingsSpy).toHaveBeenCalledOnce();
});
it("should send all settings data when saving AI configuration", async () => {
const user = userEvent.setup();
renderSidebar();
const settingsButton = screen.getByTestId("settings-button");
await user.click(settingsButton);
const settingsModal = screen.getByTestId("ai-config-modal");
const saveButton = within(settingsModal).getByTestId(
"save-settings-button",
);
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
// the actual values are falsey (null or "") but we're checking for undefined
llm_api_key: undefined,
llm_base_url: undefined,
security_analyzer: undefined,
});
});
it("should send all settings data when saving account settings", async () => {
const user = userEvent.setup();
renderSidebar();
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const menu = screen.getByTestId("account-settings-context-menu");
const accountSettingsButton = within(menu).getByTestId(
"account-settings-button",
);
await user.click(accountSettingsButton);
const accountSettingsModal = screen.getByTestId("account-settings-form");
const saveButton =
within(accountSettingsModal).getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
llm_api_key: undefined, // null or undefined
});
});
it("should not reset AI configuration when saving account settings", async () => {
const user = userEvent.setup();
renderSidebar();
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const menu = screen.getByTestId("account-settings-context-menu");
const accountSettingsButton = within(menu).getByTestId(
"account-settings-button",
);
await user.click(accountSettingsButton);
const accountSettingsModal = screen.getByTestId("account-settings-form");
const languageInput =
within(accountSettingsModal).getByLabelText(/language/i);
await user.click(languageInput);
const norskOption = screen.getByText(/norsk/i);
await user.click(norskOption);
const tokenInput =
within(accountSettingsModal).getByLabelText(/github token/i);
await user.type(tokenInput, "new-token");
const saveButton =
within(accountSettingsModal).getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
language: "no",
llm_api_key: undefined, // null or undefined
});
});
});
});

View File

@@ -1,13 +1,22 @@
import { screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import userEvent from "@testing-library/user-event";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import OpenHands from "#/api/open-hands";
describe("SettingsForm", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const onCloseMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
@@ -19,10 +28,10 @@ describe("SettingsForm", () => {
Component: () => (
<SettingsForm
settings={DEFAULT_SETTINGS}
models={[]}
agents={[]}
securityAnalyzers={[]}
onClose={() => {}}
models={["anthropic/claude-3-5-sonnet-20241022", "model2"]}
agents={["CodeActAgent", "agent2"]}
securityAnalyzers={["analyzer1", "analyzer2"]}
onClose={onCloseMock}
/>
),
path: "/",
@@ -35,11 +44,33 @@ describe("SettingsForm", () => {
});
it("should show runtime size selector when advanced options are enabled", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterStub />);
const advancedSwitch = screen.getByRole("switch", {
name: "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
});
fireEvent.click(advancedSwitch);
await screen.findByText("SETTINGS_FORM$RUNTIME_SIZE_LABEL");
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
await user.click(toggleAdvancedMode);
await screen.findByTestId("runtime-size");
});
it("should not submit the form if required fields are empty", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterStub />);
expect(screen.queryByTestId("custom-model-input")).not.toBeInTheDocument();
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
await user.click(toggleAdvancedMode);
const customModelInput = screen.getByTestId("custom-model-input");
expect(customModelInput).toBeInTheDocument();
await user.clear(customModelInput);
const saveButton = screen.getByTestId("save-settings-button");
await user.click(saveButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
expect(onCloseMock).not.toHaveBeenCalled();
});
});

View File

@@ -27,4 +27,17 @@ describe("Propagate error message", () => {
type: 'error'
});
});
it("should display error including translation id when present", () => {
const message = "We have a problem!"
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage")
updateStatusWhenErrorMessagePresent({message, data: {msg_id: '..id..'}})
expect(addErrorMessageSpy).toHaveBeenCalledWith({
message,
id: '..id..',
status_update: true,
type: 'error'
});
});
});

View File

@@ -24,7 +24,7 @@
"i18next": "^24.2.1",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"isbot": "^5.1.20",
"isbot": "^5.1.21",
"jose": "^5.9.4",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.205.0",
@@ -38,7 +38,7 @@
"react-redux": "^9.2.0",
"react-router": "^7.1.1",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.4",
"react-textarea-autosize": "^8.5.7",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
@@ -57,7 +57,7 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.5",
"@types/react": "^19.0.3",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -70,20 +70,20 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
"jsdom": "^26.0.0",
"lint-staged": "^15.3.0",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"typescript": "^5.7.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^1.6.0"
@@ -124,6 +124,28 @@
"node": ">=6.0.0"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.2.tgz",
"integrity": "sha512-RtWv9jFN2/bLExuZgFFZ0I3pWWeezAHGgrmjqGGWclATl1aDe3yhCUaI0Ilkp6OCk9zX7+FjvDasEX8Q9Rxc5w==",
"dev": true,
"dependencies": {
"@csstools/css-calc": "^2.1.1",
"@csstools/css-color-parser": "^3.0.7",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^11.0.2"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz",
"integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==",
"dev": true,
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -691,6 +713,116 @@
"node": ">= 4.0.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.1.tgz",
"integrity": "sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.1.tgz",
"integrity": "sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.7.tgz",
"integrity": "sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"dependencies": {
"@csstools/color-helpers": "^5.0.1",
"@csstools/css-calc": "^2.1.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz",
"integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.3"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz",
"integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -5447,9 +5579,9 @@
}
},
"node_modules/@types/react": {
"version": "19.0.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.3.tgz",
"integrity": "sha512-UavfHguIjnnuq9O67uXfgy/h3SRJbidAYvNjLceB+2RIKVRBzVsh0QO+Pw6BCSQqFS9xwzKfwstXx0m6AbAREA==",
"version": "19.0.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.4.tgz",
"integrity": "sha512-3O4QisJDYr1uTUMZHA2YswiQZRq+Pd8D+GdVFYikTutYsTz+QZgWkAPnP7rx9txoI6EXKcPiluMqWPFV3tT9Wg==",
"dependencies": {
"csstype": "^3.0.2"
}
@@ -7448,13 +7580,13 @@
}
},
"node_modules/cssstyle": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz",
"integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.2.1.tgz",
"integrity": "sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"rrweb-cssom": "^0.7.1"
"@asamuzakjp/css-color": "^2.8.2",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
@@ -8316,13 +8448,12 @@
}
},
"node_modules/eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz",
"integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
"eslint-config-prettier": "build/bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
@@ -8565,11 +8696,10 @@
}
},
"node_modules/eslint-plugin-react": {
"version": "7.37.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.3.tgz",
"integrity": "sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==",
"version": "7.37.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz",
"integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-includes": "^3.1.8",
"array.prototype.findlast": "^1.2.5",
@@ -10670,9 +10800,9 @@
"license": "MIT"
},
"node_modules/isbot": {
"version": "5.1.20",
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.20.tgz",
"integrity": "sha512-cW535S5c05UBfx8bTAZHACjEXyY/p10bvAx5YeqoLEFoGC1HQ6A5n3ScpZRYd1zSwwNF8yYkEOq2F7WjFhX2ig==",
"version": "5.1.21",
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.21.tgz",
"integrity": "sha512-0q3naRVpENL0ReKHeNcwn/G7BDynp0DqZUckKyFtM9+hmpnPqgm8+8wbjiVZ0XNhq1wPQV28/Pb8Snh5adeUHA==",
"engines": {
"node": ">=18"
}
@@ -10808,23 +10938,22 @@
}
},
"node_modules/jsdom": {
"version": "25.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.0.0.tgz",
"integrity": "sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.1.0",
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.4.3",
"form-data": "^4.0.0",
"form-data": "^4.0.1",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.5",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.12",
"parse5": "^7.1.2",
"rrweb-cssom": "^0.7.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.0.0",
@@ -10832,7 +10961,7 @@
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0",
"whatwg-url": "^14.1.0",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
@@ -10840,7 +10969,7 @@
"node": ">=18"
},
"peerDependencies": {
"canvas": "^2.11.2"
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
@@ -14103,10 +14232,9 @@
}
},
"node_modules/react-textarea-autosize": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.6.tgz",
"integrity": "sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==",
"license": "MIT",
"version": "8.5.7",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.7.tgz",
"integrity": "sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==",
"dependencies": {
"@babel/runtime": "^7.20.13",
"use-composed-ref": "^1.3.0",
@@ -14639,11 +14767,10 @@
}
},
"node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
"dev": true,
"license": "MIT"
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true
},
"node_modules/run-parallel": {
"version": "1.2.0",
@@ -16313,11 +16440,10 @@
}
},
"node_modules/typescript": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@@ -23,7 +23,7 @@
"i18next": "^24.2.1",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"isbot": "^5.1.20",
"isbot": "^5.1.21",
"jose": "^5.9.4",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.205.0",
@@ -37,7 +37,7 @@
"react-redux": "^9.2.0",
"react-router": "^7.1.1",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.4",
"react-textarea-autosize": "^8.5.7",
"remark-gfm": "^4.0.0",
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
@@ -84,7 +84,7 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.5",
"@types/react": "^19.0.3",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -97,20 +97,20 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
"jsdom": "^26.0.0",
"lint-staged": "^15.3.0",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"typescript": "^5.7.3",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^1.6.0"

View File

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

View File

@@ -1,18 +1,14 @@
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { github } from "./github-axios-instance";
import { openHands } from "./open-hands-axios";
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
data: T | GitHubErrorReponse | null,
): data is GitHubErrorReponse =>
!!data && "message" in data && data.message !== undefined;
/**
* Given the user, retrieves app installations IDs for OpenHands Github App
* Uses user access token for Github App
*/
export const retrieveGitHubAppInstallations = async (): Promise<number[]> => {
const response = await openHands.get<GithubAppInstallation>(
"/api/github/installations",
const response = await github.get<GithubAppInstallation>(
"/user/installations",
);
return response.data.installations.map((installation) => installation.id);
@@ -92,8 +88,20 @@ export const retrieveGitHubUserRepositories = async (
* @returns The authenticated user or an error response
*/
export const retrieveGitHubUser = async () => {
const response = await openHands.get<GitHubUser>("/api/github/user");
return response.data;
const response = await github.get<GitHubUser>("/user");
const { data } = response;
const user: GitHubUser = {
id: data.id,
login: data.login,
avatar_url: data.avatar_url,
company: data.company,
name: data.name,
email: data.email,
};
return user;
};
export const searchPublicRepositories = async (
@@ -102,11 +110,11 @@ export const searchPublicRepositories = async (
sort: "" | "updated" | "stars" | "forks" = "stars",
order: "desc" | "asc" = "desc",
): Promise<GitHubRepository[]> => {
const response = await openHands.get<{ items: GitHubRepository[] }>(
"/api/github/search/repositories",
const response = await github.get<{ items: GitHubRepository[] }>(
"/search/repositories",
{
params: {
query,
q: query,
per_page,
sort,
order,
@@ -120,9 +128,8 @@ export const retrieveLatestGitHubCommit = async (
repository: string,
): Promise<GitHubCommit | null> => {
try {
const [owner, repo] = repository.split("/");
const response = await openHands.get<GitHubCommit[]>(
`/api/github/repos/${owner}/${repo}/commits`,
const response = await github.get<GitHubCommit[]>(
`/repos/${repository}/commits`,
{
params: {
per_page: 1,

View File

@@ -27,7 +27,10 @@ export function AccountSettingsContextMenu({
ref={ref}
className="absolute left-full -top-1 z-10"
>
<ContextMenuListItem onClick={onClickAccountSettings}>
<ContextMenuListItem
testId="account-settings-button"
onClick={onClickAccountSettings}
>
{t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)}
</ContextMenuListItem>
<ContextMenuSeparator />

View File

@@ -1,39 +1,30 @@
import { useParams } from "react-router";
import React from "react";
import { useSelector } from "react-redux";
import posthog from "posthog-js";
import { AgentControlBar } from "./agent-control-bar";
import { AgentStatusBar } from "./agent-status-bar";
import { ProjectMenuCard } from "../project-menu/ProjectMenuCard";
import { useAuth } from "#/context/auth-context";
import { RootState } from "#/store";
import { SecurityLock } from "./security-lock";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { ConversationCard } from "../conversation-panel/conversation-card";
import { DownloadModal } from "#/components/shared/download-modal";
interface ControlsProps {
setSecurityOpen: (isOpen: boolean) => void;
showSecurityLock: boolean;
lastCommitData: GitHubCommit | null;
}
export function Controls({
setSecurityOpen,
showSecurityLock,
lastCommitData,
}: ControlsProps) {
const { gitHubToken } = useAuth();
const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery,
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
const params = useParams();
const { data: conversation } = useUserConversation(
params.conversationId ?? null,
);
const projectMenuCardData = React.useMemo(
() =>
selectedRepository && lastCommitData
? {
repoName: selectedRepository,
lastCommit: lastCommitData,
avatar: null, // TODO: fetch repo avatar
}
: null,
[selectedRepository, lastCommitData],
);
const [downloading, setDownloading] = React.useState(false);
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
setDownloading(true);
};
return (
<div className="flex items-center justify-between">
@@ -46,9 +37,19 @@ export function Controls({
)}
</div>
<ProjectMenuCard
isConnectedToGitHub={!!gitHubToken}
githubData={projectMenuCardData}
<ConversationCard
variant="compact"
onDownloadWorkspace={handleDownloadWorkspace}
title={conversation?.title ?? ""}
lastUpdatedAt={conversation?.created_at ?? ""}
selectedRepository={conversation?.selected_repository ?? null}
status={conversation?.status}
/>
<DownloadModal
initialPath=""
onClose={() => setDownloading(false)}
isOpen={downloading}
/>
</div>
);

View File

@@ -1,17 +1,22 @@
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
interface ConversationCardContextMenuProps {
onClose: () => void;
onDelete: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
export function ConversationCardContextMenu({
onClose,
onDelete,
onEdit,
onDownload,
position = "bottom",
}: ConversationCardContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
@@ -19,14 +24,27 @@ export function ConversationCardContextMenu({
<ContextMenu
ref={ref}
testId="context-menu"
className="left-full float-right"
className={cn(
"right-0 absolute",
position === "top" && "bottom-full",
position === "bottom" && "top-full",
)}
>
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
Delete
</ContextMenuListItem>
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
Edit Title
</ContextMenuListItem>
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
Delete
</ContextMenuListItem>
)}
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
Edit Title
</ContextMenuListItem>
)}
{onDownload && (
<ContextMenuListItem testId="download-button" onClick={onDownload}>
Download Workspace
</ContextMenuListItem>
)}
</ContextMenu>
);
}

View File

@@ -7,25 +7,32 @@ import {
} from "./conversation-state-indicator";
import { EllipsisButton } from "./ellipsis-button";
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
import { cn } from "#/utils/utils";
interface ConversationCardProps {
onClick: () => void;
onDelete: () => void;
onChangeTitle: (title: string) => void;
onClick?: () => void;
onDelete?: () => void;
onChangeTitle?: (title: string) => void;
onDownloadWorkspace?: () => void;
isActive?: boolean;
title: string;
selectedRepository: string | null;
lastUpdatedAt: string; // ISO 8601
status?: ProjectStatus;
variant?: "compact" | "default";
}
export function ConversationCard({
onClick,
onDelete,
onChangeTitle,
onDownloadWorkspace,
isActive,
title,
selectedRepository,
lastUpdatedAt,
status = "STOPPED",
variant = "default",
}: ConversationCardProps) {
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
@@ -34,7 +41,7 @@ export function ConversationCard({
const handleBlur = () => {
if (inputRef.current?.value) {
const trimmed = inputRef.current.value.trim();
onChangeTitle(trimmed);
onChangeTitle?.(trimmed);
inputRef.current!.value = trimmed;
} else {
// reset the value if it's empty
@@ -51,71 +58,100 @@ export function ConversationCard({
};
const handleInputClick = (event: React.MouseEvent<HTMLInputElement>) => {
event.preventDefault();
event.stopPropagation();
};
const handleDelete = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onDelete();
onDelete?.();
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setTitleMode("edit");
setContextMenuVisible(false);
};
const handleDownload = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onDownloadWorkspace?.();
};
React.useEffect(() => {
if (titleMode === "edit") {
inputRef.current?.focus();
}
}, [titleMode]);
const hasContextMenu = !!(onDelete || onChangeTitle || onDownloadWorkspace);
return (
<div
data-testid="conversation-card"
onClick={onClick}
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer"
className={cn(
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
variant === "compact" &&
"h-auto w-fit rounded-xl border border-[#525252]",
)}
>
<div className="flex items-center justify-between space-x-1">
<input
data-testid="conversation-card-title"
ref={inputRef}
disabled={titleMode === "view"}
onClick={handleInputClick}
onBlur={handleBlur}
onKeyUp={handleKeyUp}
type="text"
defaultValue={title}
className="text-sm leading-6 font-semibold bg-transparent w-full"
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 w-full">
{isActive && <span className="w-2 h-2 bg-blue-500 rounded-full" />}
<input
ref={inputRef}
disabled={titleMode === "view"}
data-testid="conversation-card-title"
onClick={handleInputClick}
onBlur={handleBlur}
onKeyUp={handleKeyUp}
type="text"
defaultValue={title}
className="text-sm leading-6 font-semibold bg-transparent w-full"
/>
</div>
<div className="flex items-center gap-2 relative">
<ConversationStateIndicator status={status} />
<EllipsisButton
onClick={(event) => {
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
}}
/>
{hasContextMenu && (
<EllipsisButton
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
}}
/>
)}
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onEdit={onChangeTitle && handleEdit}
onDownload={onDownloadWorkspace && handleDownload}
position={variant === "compact" ? "top" : "bottom"}
/>
)}
</div>
</div>
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={handleDelete}
onEdit={handleEdit}
/>
)}
{selectedRepository && (
<ConversationRepoLink
selectedRepository={selectedRepository}
onClick={(e) => e.stopPropagation()}
/>
)}
<p className="text-xs text-neutral-400">
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
</p>
<div
className={cn(
variant === "compact" && "flex items-center justify-between mt-1",
)}
>
{selectedRepository && (
<ConversationRepoLink
selectedRepository={selectedRepository}
onClick={(e) => e.stopPropagation()}
/>
)}
<p className="text-xs text-neutral-400">
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
</p>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React from "react";
import { useNavigate, useParams } from "react-router";
import { NavLink, useParams } from "react-router";
import { ConversationCard } from "./conversation-card";
import { useUserConversations } from "#/hooks/query/use-user-conversations";
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
@@ -16,7 +16,6 @@ interface ConversationPanelProps {
export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { conversationId: cid } = useParams();
const navigate = useNavigate();
const endSession = useEndSession();
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
@@ -63,11 +62,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
});
};
const handleClickCard = (conversationId: string) => {
navigate(`/conversations/${conversationId}`);
onClose();
};
return (
<div
ref={ref}
@@ -88,18 +82,25 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
</div>
)}
{conversations?.map((project) => (
<ConversationCard
<NavLink
key={project.conversation_id}
onClick={() => handleClickCard(project.conversation_id)}
onDelete={() => handleDeleteProject(project.conversation_id)}
onChangeTitle={(title) =>
handleChangeTitle(project.conversation_id, project.title, title)
}
title={project.title}
selectedRepository={project.selected_repository}
lastUpdatedAt={project.last_updated_at}
status={project.status}
/>
to={`/conversations/${project.conversation_id}`}
onClick={onClose}
>
{({ isActive }) => (
<ConversationCard
isActive={isActive}
onDelete={() => handleDeleteProject(project.conversation_id)}
onChangeTitle={(title) =>
handleChangeTitle(project.conversation_id, project.title, title)
}
title={project.title}
selectedRepository={project.selected_repository}
lastUpdatedAt={project.last_updated_at}
status={project.status}
/>
)}
</NavLink>
))}
{confirmDeleteModalVisible && (

View File

@@ -5,7 +5,7 @@ import { GitHubRepositorySelector } from "./github-repo-selector";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { isGitHubErrorReponse } from "#/api/github";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";

View File

@@ -1,92 +0,0 @@
import React from "react";
import posthog from "posthog-js";
import { useTranslation } from "react-i18next";
import EllipsisH from "#/icons/ellipsis-h.svg?react";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
import { ProjectMenuDetails } from "./project-menu-details";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { DownloadModal } from "#/components/shared/download-modal";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
githubData: {
avatar: string | null;
repoName: string;
lastCommit: GitHubCommit;
} | null;
}
export function ProjectMenuCard({
isConnectedToGitHub,
githubData,
}: ProjectMenuCardProps) {
const { t } = useTranslation();
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
const [downloading, setDownloading] = React.useState(false);
const toggleMenuVisibility = () => {
setContextMenuIsOpen((prev) => !prev);
};
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
setDownloading(true);
};
const handleDownloadClose = () => {
setDownloading(false);
};
return (
<div className="px-4 py-[10px] w-[337px] rounded-xl border border-[#525252] flex justify-between items-center relative">
{!downloading && contextMenuIsOpen && (
<ProjectMenuCardContextMenu
isConnectedToGitHub={isConnectedToGitHub}
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
onDownloadWorkspace={handleDownloadWorkspace}
onClose={() => setContextMenuIsOpen(false)}
/>
)}
{githubData && (
<ProjectMenuDetails
repoName={githubData.repoName}
avatar={githubData.avatar}
lastCommit={githubData.lastCommit}
/>
)}
{!githubData && (
<ProjectMenuDetailsPlaceholder
isConnectedToGitHub={isConnectedToGitHub}
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
/>
)}
<DownloadModal
initialPath=""
onClose={handleDownloadClose}
isOpen={downloading}
/>
{!downloading && (
<button
type="button"
onClick={toggleMenuVisibility}
aria-label={t(I18nKey.PROJECT_MENU_CARD$OPEN)}
>
<EllipsisH width={36} height={36} />
</button>
)}
{connectToGitHubModalOpen && (
<ModalBackdrop onClose={() => setConnectToGitHubModalOpen(false)}>
<ConnectToGitHubModal
onClose={() => setConnectToGitHubModalOpen(false)}
/>
</ModalBackdrop>
)}
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
import CloudConnection from "#/icons/cloud-connection.svg?react";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuDetailsPlaceholderProps {
isConnectedToGitHub: boolean;
onConnectToGitHub: () => void;
}
export function ProjectMenuDetailsPlaceholder({
isConnectedToGitHub,
onConnectToGitHub,
}: ProjectMenuDetailsPlaceholderProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col">
<span className="text-sm leading-6 font-semibold">
{t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL)}
</span>
<button
type="button"
onClick={onConnectToGitHub}
disabled={isConnectedToGitHub}
>
<span
className={cn(
"text-xs leading-4 text-[#A3A3A3] flex items-center gap-2",
"hover:underline hover:underline-offset-2",
)}
>
{!isConnectedToGitHub
? t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB)
: t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED)}
<CloudConnection width={12} height={12} />
</span>
</button>
</div>
);
}

View File

@@ -1,52 +0,0 @@
import { useTranslation } from "react-i18next";
import ExternalLinkIcon from "#/icons/external-link.svg?react";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuDetailsProps {
repoName: string;
avatar: string | null;
lastCommit: GitHubCommit;
}
export function ProjectMenuDetails({
repoName,
avatar,
lastCommit,
}: ProjectMenuDetailsProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col min-w-0">
<a
href={`https://github.com/${repoName}`}
target="_blank"
rel="noreferrer noopener"
className="flex items-center gap-2 min-w-0"
>
{avatar && (
<img
src={avatar}
alt=""
className="w-4 h-4 rounded-full flex-shrink-0"
/>
)}
<span className="text-sm leading-6 font-semibold truncate flex-1">
{repoName}
</span>
<ExternalLinkIcon width={16} height={16} className="flex-shrink-0" />
</a>
<a
href={lastCommit.html_url}
target="_blank"
rel="noreferrer noopener"
className="text-xs text-[#A3A3A3] hover:underline hover:underline-offset-2"
>
<span>{lastCommit.sha.slice(-7)}</span> <span>&middot;</span>{" "}
<span>
{formatTimeDelta(new Date(lastCommit.commit.author.date))}{" "}
{t(I18nKey.PROJECT_MENU_DETAILS$AGO_LABEL)}
</span>
</a>
</div>
);
}

View File

@@ -1,37 +0,0 @@
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuCardContextMenuProps {
isConnectedToGitHub: boolean;
onConnectToGitHub: () => void;
onDownloadWorkspace: () => void;
onClose: () => void;
}
export function ProjectMenuCardContextMenu({
isConnectedToGitHub,
onConnectToGitHub,
onDownloadWorkspace,
onClose,
}: ProjectMenuCardContextMenuProps) {
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
return (
<ContextMenu
ref={menuRef}
className="absolute right-0 bottom-[calc(100%+8px)]"
>
{!isConnectedToGitHub && (
<ContextMenuListItem onClick={onConnectToGitHub}>
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL)}
</ContextMenuListItem>
)}
<ContextMenuListItem onClick={onDownloadWorkspace}>
{t(I18nKey.PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL)}
</ContextMenuListItem>
</ContextMenu>
);
}

View File

@@ -0,0 +1,19 @@
interface PathFormProps {
ref: React.RefObject<HTMLFormElement | null>;
onBlur: () => void;
defaultValue: string;
}
export function PathForm({ ref, onBlur, defaultValue }: PathFormProps) {
return (
<form ref={ref} onSubmit={(e) => e.preventDefault()} className="flex-1">
<input
name="url"
type="text"
defaultValue={defaultValue}
className="w-full bg-transparent"
onBlur={onBlur}
/>
</form>
);
}

View File

@@ -12,7 +12,7 @@ import { SettingsButton } from "#/components/shared/buttons/settings-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
import { useCurrentSettings } from "#/context/settings-context";
import { useSettings } from "#/hooks/query/use-settings";
import { ConversationPanel } from "../conversation-panel/conversation-panel";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
@@ -28,8 +28,13 @@ export function Sidebar() {
const user = useGitHubUser();
const { data: isAuthed } = useIsAuthed();
const { logout } = useAuth();
const { data: settings, isError: settingsIsError } = useSettings();
const { isUpToDate: settingsAreUpToDate } = useSettingsUpToDate();
const {
data: settings,
isError: settingsIsError,
isSuccess: settingsSuccessfulyFetched,
} = useSettings();
const { isUpToDate: settingsAreUpToDate } = useCurrentSettings();
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
@@ -72,7 +77,7 @@ export function Sidebar() {
<ExitProjectButton onClick={handleEndSession} />
{MULTI_CONVERSATION_UI && (
<TooltipButton
data-testid="toggle-conversation-panel"
testId="toggle-conversation-panel"
tooltip="Conversations"
ariaLabel="Conversations"
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
@@ -106,7 +111,7 @@ export function Sidebar() {
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
)}
{settingsIsError ||
(showSettingsModal && (
(showSettingsModal && settingsSuccessfulyFetched && (
<SettingsModal
settings={settings}
onClose={() => setSettingsModalIsOpen(false)}

View File

@@ -0,0 +1,15 @@
import { useActiveHost } from "#/hooks/query/use-active-host";
export function ServedAppLabel() {
const { activeHost } = useActiveHost();
return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">App</div>
<span className="border rounded-md text- px-1 font-bold">BETA</span>
</div>
{activeHost && <div className="w-2 h-2 bg-green-500 rounded-full" />}
</div>
);
}

View File

@@ -18,6 +18,7 @@ export function AdvancedOptionSwitch({
return (
<Switch
data-testid="advanced-option-switch"
isDisabled={isDisabled}
name="use-advanced-options"
defaultSelected={showAdvancedOptions}

View File

@@ -22,7 +22,9 @@ export function CustomModelInput({
{t(I18nKey.SETTINGS_FORM$CUSTOM_MODEL_LABEL)}
</label>
<Input
data-testid="custom-model-input"
isDisabled={isDisabled}
isRequired
id="custom-model"
name="custom-model"
defaultValue={defaultValue}

View File

@@ -13,7 +13,7 @@ import { ModalButton } from "../../buttons/modal-button";
import { CustomInput } from "../../custom-input";
import { FormFieldset } from "../../form-fieldset";
import { useConfig } from "#/hooks/query/use-config";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useCurrentSettings } from "#/context/settings-context";
interface AccountSettingsFormProps {
onClose: () => void;
@@ -30,10 +30,10 @@ export function AccountSettingsForm({
}: AccountSettingsFormProps) {
const { gitHubToken, setGitHubToken, logout } = useAuth();
const { data: config } = useConfig();
const { mutate: saveSettings } = useSaveSettings();
const { saveUserSettings } = useCurrentSettings();
const { t } = useTranslation();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
@@ -50,7 +50,7 @@ export function AccountSettingsForm({
({ label }) => label === language,
)?.value;
if (languageKey) saveSettings({ LANGUAGE: languageKey });
if (languageKey) await saveUserSettings({ LANGUAGE: languageKey });
}
handleCaptureConsent(analytics);
@@ -61,7 +61,7 @@ export function AccountSettingsForm({
};
return (
<ModalBody>
<ModalBody testID="account-settings-form">
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
<div className="w-full flex flex-col gap-2">
<BaseModalTitle title="Account Settings" />
@@ -137,6 +137,7 @@ export function AccountSettingsForm({
<div className="flex flex-col gap-2 w-full">
<ModalButton
testId="save-settings"
type="submit"
intent="account"
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$SAVE)}

View File

@@ -18,7 +18,7 @@ export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
<ModalBackdrop onClose={onClose}>
<AccountSettingsForm
onClose={onClose}
selectedLanguage={settings.LANGUAGE}
selectedLanguage={settings?.LANGUAGE || "en"}
gitHubError={user.isError}
analyticsConsent={analyticsConsent}
/>

View File

@@ -21,6 +21,7 @@ export function RuntimeSizeSelector({
{t("SETTINGS_FORM$RUNTIME_SIZE_LABEL")}
</label>
<Select
data-testid="runtime-size"
id="runtime-size"
name="runtime-size"
defaultSelectedKeys={[String(defaultValue || 1)]}

View File

@@ -19,10 +19,10 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
import { ModalBackdrop } from "../modal-backdrop";
import { ModelSelector } from "./model-selector";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { RuntimeSizeSelector } from "./runtime-size-selector";
import { useConfig } from "#/hooks/query/use-config";
import { useCurrentSettings } from "#/context/settings-context";
interface SettingsFormProps {
disabled?: boolean;
@@ -41,7 +41,7 @@ export function SettingsForm({
securityAnalyzers,
onClose,
}: SettingsFormProps) {
const { mutateAsync: saveSettings } = useSaveSettings();
const { saveUserSettings } = useCurrentSettings();
const endSession = useEndSession();
const { data: config } = useConfig();
@@ -95,7 +95,8 @@ export function SettingsForm({
const newSettings = extractSettings(formData);
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
await saveSettings(newSettings, { onSuccess: onClose });
await saveUserSettings(newSettings);
onClose();
resetOngoingSession();
posthog.capture("settings_saved", {
@@ -107,7 +108,8 @@ export function SettingsForm({
};
const handleConfirmResetSettings = async () => {
await saveSettings(getDefaultSettings(), { onSuccess: onClose });
await saveUserSettings(getDefaultSettings());
onClose();
resetOngoingSession();
posthog.capture("settings_reset");
};
@@ -204,6 +206,7 @@ export function SettingsForm({
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<ModalButton
testId="save-settings-button"
disabled={disabled}
type="submit"
text={t(I18nKey.SETTINGS_FORM$SAVE_LABEL)}

View File

@@ -2,9 +2,14 @@ import posthog from "posthog-js";
import React from "react";
import OpenHands from "#/api/open-hands";
import {
removeGitHubTokenHeader,
setGitHubTokenHeader,
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
setGitHubTokenHeader as setOpenHandsGitHubTokenHeader,
} from "#/api/open-hands-axios";
import {
setAuthTokenHeader as setGitHubAuthTokenHeader,
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
setupAxiosInterceptors as setupGithubAxiosInterceptors,
} from "#/api/github-axios-instance";
interface AuthContextType {
gitHubToken: string | null;
@@ -32,7 +37,8 @@ function AuthProvider({ children }: React.PropsWithChildren) {
localStorage.removeItem("ghToken");
localStorage.removeItem("userId");
removeGitHubTokenHeader();
removeOpenHandsGitHubTokenHeader();
removeGitHubAuthTokenHeader();
};
const setGitHubToken = (token: string | null) => {
@@ -40,7 +46,8 @@ function AuthProvider({ children }: React.PropsWithChildren) {
if (token) {
localStorage.setItem("ghToken", token);
setGitHubTokenHeader(token);
setOpenHandsGitHubTokenHeader(token);
setGitHubAuthTokenHeader(token);
} else {
clearGitHubToken();
}
@@ -80,6 +87,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
setGitHubToken(storedGitHubToken);
setUserId(userId);
setupGithubAxiosInterceptors(refreshToken, logout);
}, []);
const value = React.useMemo(

View File

@@ -0,0 +1,70 @@
import React from "react";
import {
LATEST_SETTINGS_VERSION,
Settings,
settingsAreUpToDate,
} from "#/services/settings";
import { useSettings } from "#/hooks/query/use-settings";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
interface SettingsContextType {
isUpToDate: boolean;
setIsUpToDate: (value: boolean) => void;
saveUserSettings: (newSettings: Partial<Settings>) => Promise<void>;
settings: Settings | undefined;
}
const SettingsContext = React.createContext<SettingsContextType | undefined>(
undefined,
);
interface SettingsProviderProps {
children: React.ReactNode;
}
export function SettingsProvider({ children }: SettingsProviderProps) {
const { data: userSettings } = useSettings();
const { mutateAsync: saveSettings } = useSaveSettings();
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
const saveUserSettings = async (newSettings: Partial<Settings>) => {
const updatedSettings: Partial<Settings> = {
...userSettings,
...newSettings,
};
await saveSettings(updatedSettings, {
onSuccess: () => {
if (!isUpToDate) {
localStorage.setItem(
"SETTINGS_VERSION",
LATEST_SETTINGS_VERSION.toString(),
);
setIsUpToDate(true);
}
},
});
};
const value = React.useMemo(
() => ({
isUpToDate,
setIsUpToDate,
saveUserSettings,
settings: userSettings,
}),
[isUpToDate, setIsUpToDate, saveUserSettings, userSettings],
);
return <SettingsContext value={value}>{children}</SettingsContext>;
}
export function useCurrentSettings() {
const context = React.useContext(SettingsContext);
if (context === undefined) {
throw new Error(
"useCurrentSettings must be used within a SettingsProvider",
);
}
return context;
}

View File

@@ -1,40 +0,0 @@
import React from "react";
import { settingsAreUpToDate } from "#/services/settings";
interface SettingsUpToDateContextType {
isUpToDate: boolean;
setIsUpToDate: (value: boolean) => void;
}
const SettingsUpToDateContext = React.createContext<
SettingsUpToDateContextType | undefined
>(undefined);
interface SettingsUpToDateProviderProps {
children: React.ReactNode;
}
export function SettingsUpToDateProvider({
children,
}: SettingsUpToDateProviderProps) {
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
const value = React.useMemo(
() => ({ isUpToDate, setIsUpToDate }),
[isUpToDate, setIsUpToDate],
);
return (
<SettingsUpToDateContext value={value}>{children}</SettingsUpToDateContext>
);
}
export function useSettingsUpToDate() {
const context = React.useContext(SettingsUpToDateContext);
if (context === undefined) {
throw new Error(
"useSettingsUpToDate must be used within a SettingsUpToDateProvider",
);
}
return context;
}

View File

@@ -67,16 +67,33 @@ interface WsClientProviderProps {
conversationId: string;
}
export function updateStatusWhenErrorMessagePresent(data: unknown) {
if (
data &&
typeof data === "object" &&
"message" in data &&
typeof data.message === "string"
) {
interface ErrorArg {
message?: string;
data?: ErrorArgData | unknown;
}
interface ErrorArgData {
msg_id: string;
}
export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
const isObject = (val: unknown): val is object =>
!!val && typeof val === "object";
const isString = (val: unknown): val is string => typeof val === "string";
if (isObject(data) && "message" in data && isString(data.message)) {
let msgId: string | undefined;
if (
"data" in data &&
isObject(data.data) &&
"msg_id" in data.data &&
isString(data.data.msg_id)
) {
msgId = data.data.msg_id;
}
handleStatusMessage({
type: "error",
message: data.message,
id: msgId,
status_update: true,
});
}

View File

@@ -20,7 +20,7 @@ import toast from "react-hot-toast";
import store from "./store";
import { useConfig } from "./hooks/query/use-config";
import { AuthProvider } from "./context/auth-context";
import { SettingsUpToDateProvider } from "./context/settings-up-to-date-context";
import { SettingsProvider } from "./context/settings-context";
function PosthogInit() {
const { data: config } = useConfig();
@@ -50,13 +50,20 @@ async function prepareApp() {
}
}
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (!query.queryKey.includes("authenticated")) toast.error(error.message);
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
toast.error(error.message);
}
},
}),
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
},
mutations: {
onError: (error) => {
toast.error(error.message);
@@ -72,12 +79,12 @@ prepareApp().then(() =>
<StrictMode>
<Provider store={store}>
<AuthProvider>
<SettingsUpToDateProvider>
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}>
<SettingsProvider>
<HydratedRouter />
<PosthogInit />
</QueryClientProvider>
</SettingsUpToDateProvider>
</SettingsProvider>
</QueryClientProvider>
</AuthProvider>
</Provider>
</StrictMode>,

View File

@@ -1,12 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ApiSettings,
DEFAULT_SETTINGS,
LATEST_SETTINGS_VERSION,
Settings,
} from "#/services/settings";
import { ApiSettings, DEFAULT_SETTINGS, Settings } from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
const apiSettings: Partial<ApiSettings> = {
@@ -24,19 +18,11 @@ const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
export const useSaveSettings = () => {
const queryClient = useQueryClient();
const { isUpToDate, setIsUpToDate } = useSettingsUpToDate();
return useMutation({
mutationFn: saveSettingsMutationFn,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
if (!isUpToDate) {
localStorage.setItem(
"SETTINGS_VERSION",
LATEST_SETTINGS_VERSION.toString(),
);
setIsUpToDate(true);
}
},
});
};

View File

@@ -0,0 +1,51 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import axios from "axios";
import React from "react";
import { useSelector } from "react-redux";
import { openHands } from "#/api/open-hands-axios";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RootState } from "#/store";
import { useConversation } from "#/context/conversation-context";
export const useActiveHost = () => {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [activeHost, setActiveHost] = React.useState<string | null>(null);
const { conversationId } = useConversation();
const { data } = useQuery({
queryKey: [conversationId, "hosts"],
queryFn: async () => {
const response = await openHands.get<{ hosts: string[] }>(
`/api/conversations/${conversationId}/web-hosts`,
);
return { hosts: Object.keys(response.data.hosts) };
},
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
initialData: { hosts: [] },
});
const apps = useQueries({
queries: data.hosts.map((host) => ({
queryKey: [conversationId, "hosts", host],
queryFn: async () => {
try {
await axios.get(host);
return host;
} catch (e) {
return "";
}
},
refetchInterval: 3000,
})),
});
const appsData = apps.map((app) => app.data);
React.useEffect(() => {
const successfulApp = appsData.find((app) => app);
setActiveHost(successfulApp || "");
}, [appsData]);
return { activeHost };
};

View File

@@ -39,7 +39,6 @@ export const useSettings = () => {
const query = useQuery({
queryKey: ["settings"],
queryFn: getSettingsQueryFn,
initialData: DEFAULT_SETTINGS,
});
React.useEffect(() => {

View File

@@ -1,11 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
export const useUserConversation = (cid: string | null) =>
useQuery({
queryKey: ["user", "conversation", cid],
queryFn: () => OpenHands.getConversation(cid!),
enabled: MULTI_CONVERSATION_UI && !!cid,
enabled: !!cid,
retry: false,
});

View File

@@ -1,7 +1,7 @@
// Sometimes we ship major changes, like a new default agent.
import React from "react";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
import { useCurrentSettings } from "#/context/settings-context";
import {
getCurrentSettingsVersion,
DEFAULT_SETTINGS,
@@ -12,7 +12,7 @@ import { useSaveSettings } from "./mutation/use-save-settings";
// In this case, we may want to override a previous choice made by the user.
export const useMaybeMigrateSettings = () => {
const { mutateAsync: saveSettings } = useSaveSettings();
const { isUpToDate } = useSettingsUpToDate();
const { isUpToDate } = useCurrentSettings();
const maybeMigrateSettings = async () => {
const currentVersion = getCurrentSettingsVersion();

View File

@@ -131,7 +131,9 @@ export const useTerminal = ({
content = content.replaceAll(secret, "*".repeat(10));
});
terminal.current?.writeln(parseTerminalOutput(content));
terminal.current?.writeln(
parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()),
);
if (type === "output") {
terminal.current.write(`\n$ `);

View File

@@ -454,6 +454,10 @@
"fr": "Nous avons modifié certains paramètres dans la dernière mise à jour. Prenez un moment pour les examiner.",
"tr": "Son güncellemede bazı ayarları değiştirdik. Gözden geçirmek için bir dakikanızı ayırın."
},
"CONFIGURATION$SETTINGS_NOT_FOUND": {
"en": "Settings not found. Please check your API key",
"es": "Configuraciones no encontradas. Por favor revisa tu API key"
},
"CONFIGURATION$AGENT_LOADING": {
"en": "Please wait while the agent loads. This may take a few minutes...",
"de": "Bitte warten Sie, während der Agent lädt. Das kann ein paar Minuten dauern...",

View File

@@ -6,7 +6,7 @@ import {
} from "#/api/open-hands.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
const userPreferences = {
export const MOCK_USER_PREFERENCES = {
settings: {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
@@ -169,14 +169,14 @@ export const handlers = [
return HttpResponse.json(config);
}),
http.get("/api/settings", async () =>
HttpResponse.json(userPreferences.settings),
HttpResponse.json(MOCK_USER_PREFERENCES.settings),
),
http.post("/api/settings", async ({ request }) => {
const body = await request.json();
if (body) {
userPreferences.settings = {
...userPreferences.settings,
MOCK_USER_PREFERENCES.settings = {
...MOCK_USER_PREFERENCES.settings,
// @ts-expect-error - We know this is a settings object
...body,
};

View File

@@ -12,6 +12,7 @@ export default [
index("routes/_oh.app._index/route.tsx"),
route("browser", "routes/_oh.app.browser.tsx"),
route("jupyter", "routes/_oh.app.jupyter.tsx"),
route("served", "routes/app.tsx"),
]),
]),

View File

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

View File

@@ -2,6 +2,7 @@ import { useDisclosure } from "@nextui-org/react";
import React from "react";
import { Outlet } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { FaServer } from "react-icons/fa";
import toast from "react-hot-toast";
import {
ConversationProvider,
@@ -20,7 +21,6 @@ import { FilesProvider } from "#/context/files";
import { ChatInterface } from "../../components/features/chat/chat-interface";
import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "./event-handler";
import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit";
import { useAuth } from "#/context/auth-context";
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
import { Container } from "#/components/layout/container";
@@ -30,38 +30,27 @@ import {
} from "#/components/layout/resizable-panel";
import Security from "#/components/shared/modals/security/security";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserConversation } from "#/hooks/query/get-conversation-permissions";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { CountBadge } from "#/components/layout/count-badge";
import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { useSettings } from "#/hooks/query/use-settings";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
function AppContent() {
useConversationConfig();
const { gitHubToken } = useAuth();
const { data: settings } = useSettings();
const endSession = useEndSession();
const [width, setWidth] = React.useState(window.innerWidth);
const { conversationId } = useConversation();
const dispatch = useDispatch();
useConversationConfig();
const { data: conversation, isFetched } = useUserConversation(
conversationId || null,
);
const dispatch = useDispatch();
const endSession = useEndSession();
const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery,
);
const [width, setWidth] = React.useState(window.innerWidth);
const { updateCount } = useSelector((state: RootState) => state.browser);
const { data: latestGitHubCommit } = useLatestRepoCommit({
repository: selectedRepository,
});
const secrets = React.useMemo(
() => [gitHubToken].filter((secret) => secret !== null),
[gitHubToken],
@@ -139,6 +128,11 @@ function AppContent() {
labels={[
{ label: "Workspace", to: "", icon: <CodeIcon /> },
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
{
label: <ServedAppLabel />,
to: "served",
icon: <FaServer />,
},
{
label: (
<div className="flex items-center gap-1">
@@ -180,18 +174,17 @@ function AppContent() {
<div data-testid="app-route" className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto">{renderMain()}</div>
<div className="h-[60px]">
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings.SECURITY_ANALYZER}
lastCommitData={latestGitHubCommit || null}
/>
</div>
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings?.SECURITY_ANALYZER}
/>
{settings && (
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
/>
)}
</div>
</EventHandler>
</WsClientProvider>

View File

@@ -63,10 +63,10 @@ export default function MainApp() {
});
React.useEffect(() => {
if (settings.LANGUAGE) {
if (settings?.LANGUAGE) {
i18n.changeLanguage(settings.LANGUAGE);
}
}, [settings.LANGUAGE]);
}, [settings?.LANGUAGE]);
const isInWaitlist =
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";

View File

@@ -0,0 +1,95 @@
import React from "react";
import { FaArrowRotateRight } from "react-icons/fa6";
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
import { useActiveHost } from "#/hooks/query/use-active-host";
import { PathForm } from "#/components/features/served-host/path-form";
function ServedApp() {
const { activeHost } = useActiveHost();
const [refreshKey, setRefreshKey] = React.useState(0);
const [currentActiveHost, setCurrentActiveHost] = React.useState<
string | null
>(null);
const [path, setPath] = React.useState<string>("hello");
const formRef = React.useRef<HTMLFormElement>(null);
const handleOnBlur = () => {
if (formRef.current) {
const formData = new FormData(formRef.current);
const urlInputValue = formData.get("url")?.toString();
if (urlInputValue) {
const url = new URL(urlInputValue);
setCurrentActiveHost(url.origin);
setPath(url.pathname);
}
}
};
const resetUrl = () => {
setCurrentActiveHost(activeHost);
setPath("");
if (formRef.current) {
formRef.current.reset();
}
};
React.useEffect(() => {
resetUrl();
}, [activeHost]);
const fullUrl = `${currentActiveHost}/${path}`;
if (!currentActiveHost) {
return (
<div className="flex items-center justify-center w-full h-full p-10">
<span className="text-neutral-400 font-bold">
If you tell OpenHands to start a web server, the app will appear here.
</span>
</div>
);
}
return (
<div className="h-full w-full">
<div className="w-full p-2 flex items-center gap-4 border-b border-neutral-600">
<button
type="button"
onClick={() => window.open(fullUrl, "_blank")}
className="text-sm"
>
<FaExternalLinkAlt className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setRefreshKey((prev) => prev + 1)}
className="text-sm"
>
<FaArrowRotateRight className="w-4 h-4" />
</button>
<button type="button" onClick={() => resetUrl()} className="text-sm">
<FaHome className="w-4 h-4" />
</button>
<div className="w-full flex">
<PathForm
ref={formRef}
onBlur={handleOnBlur}
defaultValue={fullUrl}
/>
</div>
</div>
<iframe
key={refreshKey}
title="Served App"
src={fullUrl}
className="w-full h-full"
/>
</div>
);
}
export default ServedApp;

View File

@@ -4,9 +4,3 @@ export function getTerminalCommand(command: string, hidden: boolean = false) {
const event = { action: ActionType.RUN, args: { command, hidden } };
return event;
}
export function getGitHubTokenCommand(gitHubToken: string) {
const command = `export GITHUB_TOKEN=${gitHubToken}`;
const event = getTerminalCommand(command, true);
return event;
}

View File

@@ -1,6 +1,10 @@
// Here are the list of verified models and providers that we know work well with OpenHands.
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic"];
export const VERIFIED_MODELS = ["gpt-4o", "claude-3-5-sonnet-20241022"];
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic", "deepseek"];
export const VERIFIED_MODELS = [
"gpt-4o",
"claude-3-5-sonnet-20241022",
"deepseek-chat",
];
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
@@ -21,6 +25,7 @@ export const VERIFIED_ANTHROPIC_MODELS = [
"claude-2.1",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",

View File

@@ -11,7 +11,7 @@ import { vi } from "vitest";
import { AppStore, RootState, rootReducer } from "./src/store";
import { AuthProvider } from "#/context/auth-context";
import { ConversationProvider } from "#/context/conversation-context";
import { SettingsUpToDateProvider } from "#/context/settings-up-to-date-context";
import { SettingsProvider } from "#/context/settings-context";
// Mock useParams before importing components
vi.mock("react-router", async () => {
@@ -67,19 +67,19 @@ export function renderWithProviders(
return (
<Provider store={store}>
<AuthProvider>
<SettingsUpToDateProvider>
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
<SettingsProvider>
<ConversationProvider>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</ConversationProvider>
</QueryClientProvider>
</SettingsUpToDateProvider>
</SettingsProvider>
</QueryClientProvider>
</AuthProvider>
</Provider>
);

View File

@@ -32,6 +32,7 @@ const selectConversationCard = async (page: Page, index: number) => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
localStorage.setItem("FEATURE_MULTI_CONVERSATION_UI", "true");
localStorage.setItem("analytics-consent", "true");
localStorage.setItem("SETTINGS_VERSION", "5");
});
@@ -111,3 +112,28 @@ test("should redirect to home screen if conversation deos not exist", async ({
await page.goto("/conversations/9999");
await page.waitForURL("/");
});
test("display the conversation details during a conversation", async ({
page,
}) => {
const conversationPanelButton = page.getByTestId("toggle-conversation-panel");
await expect(conversationPanelButton).toBeVisible();
await conversationPanelButton.click();
const panel = page.getByTestId("conversation-panel");
// select a conversation
const conversationItem = panel.getByTestId("conversation-card").first();
await conversationItem.click();
// panel should close
await expect(panel).not.toBeVisible();
await page.waitForURL("/conversations/1");
expect(page.url()).toBe("http://localhost:3001/conversations/1");
const conversationDetails = page.getByTestId("conversation-card");
await expect(conversationDetails).toBeVisible();
await expect(conversationDetails).toHaveText("Conversation 1");
});

View File

@@ -3,9 +3,21 @@ You are OpenHands agent, a helpful AI assistant that can interact with a compute
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
{{ runtime_info }}
</IMPORTANT>
{% if repo_instructions %}
{% if repo_instructions -%}
<REPOSITORY_INSTRUCTIONS>
{{ repo_instructions }}
</REPOSITORY_INSTRUCTIONS>
{% endif %}
{% if runtime_info and runtime_info.available_hosts -%}
<RUNTIME_INFORMATION>
The user has access to the following hosts for accessing a web application,
each of which has a corresponding port:
{% for host, port in runtime_info.available_hosts.items() -%}
* {{ host }} (port {{ port }})
{% endfor %}
When starting a web server, use the corresponding ports. You should also
set any options to allow iframes and CORS requests.
</RUNTIME_INFORMATION>
{% endif %}

View File

@@ -20,6 +20,9 @@ DISABLE_COLOR_PRINTING = False
LOG_ALL_EVENTS = os.getenv('LOG_ALL_EVENTS', 'False').lower() in ['true', '1', 'yes']
# Controls whether to stream Docker container logs
DEBUG_RUNTIME = os.getenv('DEBUG_RUNTIME', 'False').lower() in ['true', '1', 'yes']
ColorType = Literal[
'red',
'green',

View File

@@ -67,6 +67,13 @@ class Event:
@timeout.setter
def timeout(self, value: int | None) -> None:
self._timeout = value
if value is not None and value > 600:
from openhands.core.logger import openhands_logger as logger
logger.warning(
'Timeout greater than 600 seconds may not be supported by '
'the runtime. Consider setting a lower timeout.'
)
# Check if .blocking is an attribute of the event
if hasattr(self, 'blocking'):

View File

@@ -24,7 +24,7 @@ unified_header_index = re.compile('^Index: (.+)$')
unified_header_old_line = re.compile(r'^--- ' + file_timestamp_str + '$')
unified_header_new_line = re.compile(r'^\+\+\+ ' + file_timestamp_str + '$')
unified_hunk_start = re.compile(r'^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)$')
unified_change = re.compile('^([-+ ])(.*)$')
unified_change = re.compile('^([-+ ])(.*)$', re.MULTILINE)
context_header_old_line = re.compile(r'^\*\*\* ' + file_timestamp_str + '$')
context_header_new_line = re.compile('^--- ' + file_timestamp_str + '$')
@@ -606,38 +606,39 @@ def parse_unified_diff(text):
h = unified_hunk_start.match(hunk[0])
del hunk[0]
if h:
old = int(h.group(1))
if len(h.group(2)) > 0:
old_len = int(h.group(2))
else:
old_len = 0
# The hunk header @@ -1,6 +1,6 @@ means:
# - Start at line 1 in the old file and show 6 lines
# - Start at line 1 in the new file and show 6 lines
old = int(h.group(1)) # Starting line in old file
old_len = int(h.group(2)) if len(h.group(2)) > 0 else 1 # Number of lines in old file
new = int(h.group(3))
if len(h.group(4)) > 0:
new_len = int(h.group(4))
else:
new_len = 0
new = int(h.group(3)) # Starting line in new file
new_len = int(h.group(4)) if len(h.group(4)) > 0 else 1 # Number of lines in new file
h = None
break
# Process each line in the hunk
for n in hunk:
c = unified_change.match(n)
if c:
kind = c.group(1)
line = c.group(2)
# Each line in a unified diff starts with a space (context), + (addition), or - (deletion)
# The first character is the kind, the rest is the line content
kind = n[0] if len(n) > 0 else ' ' # Empty lines in the hunk are treated as context lines
line = n[1:] if len(n) > 1 else ''
if kind == '-' and (r != old_len or r == 0):
changes.append(Change(old + r, None, line, hunk_n))
r += 1
elif kind == '+' and (i != new_len or i == 0):
changes.append(Change(None, new + i, line, hunk_n))
i += 1
elif kind == ' ':
if r != old_len and i != new_len:
changes.append(Change(old + r, new + i, line, hunk_n))
r += 1
i += 1
# Process the line based on its kind
if kind == '-' and (r != old_len or r == 0):
# Line was removed from the old file
changes.append(Change(old + r, None, line, hunk_n))
r += 1
elif kind == '+' and (i != new_len or i == 0):
# Line was added in the new file
changes.append(Change(None, new + i, line, hunk_n))
i += 1
elif kind == ' ':
# Context line - exists in both old and new file
changes.append(Change(old + r, new + i, line, hunk_n))
r += 1
i += 1
if len(changes) > 0:
return changes

View File

@@ -14,12 +14,7 @@ from termcolor import colored
import openhands
from openhands.controller.state.state import State
from openhands.core.config import (
AgentConfig,
AppConfig,
LLMConfig,
SandboxConfig,
)
from openhands.core.config import AgentConfig, AppConfig, LLMConfig, SandboxConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action import CmdRunAction, MessageAction
@@ -153,7 +148,7 @@ async def process_issue(
max_iterations: int,
llm_config: LLMConfig,
output_dir: str,
runtime_container_image: str,
runtime_container_image: str | None,
prompt_template: str,
issue_handler: IssueHandlerInterface,
repo_instruction: str | None = None,
@@ -306,7 +301,7 @@ async def resolve_issue(
max_iterations: int,
output_dir: str,
llm_config: LLMConfig,
runtime_container_image: str,
runtime_container_image: str | None,
prompt_template: str,
issue_type: str,
repo_instruction: str | None,
@@ -583,11 +578,16 @@ def main():
default=None,
help="Target branch to pull and create PR against (for PRs). If not specified, uses the PR's base branch.",
)
parser.add_argument(
'--is-experimental',
type=lambda x: x.lower() == 'true',
help='Whether to run in experimental mode.',
)
my_args = parser.parse_args()
runtime_container_image = my_args.runtime_container_image
if runtime_container_image is None:
if runtime_container_image is None and not my_args.is_experimental:
runtime_container_image = (
f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik'
)

View File

@@ -57,7 +57,6 @@ from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCode
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system import check_port_available
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
@@ -435,8 +434,6 @@ if __name__ == '__main__':
)
# example: python client.py 8000 --working-dir /workspace --plugins JupyterRequirement
args = parser.parse_args()
os.environ['VSCODE_PORT'] = str(int(args.port) + 1)
assert check_port_available(int(os.environ['VSCODE_PORT']))
plugins_to_load: list[Plugin] = []
if args.plugins:

View File

@@ -269,7 +269,10 @@ class Runtime(FileEditRuntimeMixin):
)
)
if 'knowledge' in files:
def _is_dir_in_files(dir_name: str, files: list[str]) -> bool:
return dir_name in files or f"{dir_name}/" in files
if _is_dir_in_files('knowledge', files):
knowledge_dir = dir_name / 'knowledge'
_knowledge_microagents_files = self.list_files(str(knowledge_dir))
for fname in _knowledge_microagents_files:
@@ -282,7 +285,7 @@ class Runtime(FileEditRuntimeMixin):
)
)
if 'tasks' in files:
if _is_dir_in_files('tasks', files):
tasks_dir = dir_name / 'tasks'
_tasks_microagents_files = self.list_files(str(tasks_dir))
for fname in _tasks_microagents_files:
@@ -400,3 +403,7 @@ class Runtime(FileEditRuntimeMixin):
@property
def vscode_url(self) -> str | None:
raise NotImplementedError('This method is not implemented in the base class.')
@property
def web_hosts(self) -> dict[str, int]:
return {}

View File

@@ -18,10 +18,12 @@ from openhands.utils.shutdown_listener import (
class RemoteRuntimeBuilder(RuntimeBuilder):
"""This class interacts with the remote Runtime API for building and managing container images."""
def __init__(self, api_url: str, api_key: str):
def __init__(
self, api_url: str, api_key: str, session: requests.Session | None = None
):
self.api_url = api_url
self.api_key = api_key
self.session = requests.Session()
self.session = session or requests.Session()
self.session.headers.update({'X-API-Key': self.api_key})
def build(
@@ -116,9 +118,7 @@ class RemoteRuntimeBuilder(RuntimeBuilder):
# Wait before polling again
sleep_if_should_continue(30)
raise AgentRuntimeBuildError(
'Build interrupted (likely received SIGTERM or SIGINT).'
)
raise AgentRuntimeBuildError('Build interrupted')
def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
"""Checks if an image exists in the remote registry using the /image_exists endpoint."""

View File

@@ -10,9 +10,8 @@ from openhands.core.config import AppConfig
from openhands.core.exceptions import (
AgentRuntimeDisconnectedError,
AgentRuntimeNotFoundError,
AgentRuntimeNotReadyError,
)
from openhands.core.logger import DEBUG
from openhands.core.logger import DEBUG, DEBUG_RUNTIME
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.runtime.builder import DockerRuntimeBuilder
@@ -22,6 +21,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
from openhands.runtime.impl.docker.containers import remove_all_containers
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.runtime.utils.log_streamer import LogStreamer
from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
@@ -29,6 +29,11 @@ from openhands.utils.tenacity_stop import stop_if_should_exit
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
EXECUTION_SERVER_PORT_RANGE = (30000, 39999)
VSCODE_PORT_RANGE = (40000, 49999)
APP_PORT_RANGE_1 = (50000, 54999)
APP_PORT_RANGE_2 = (55000, 59999)
def remove_all_runtime_containers():
remove_all_containers(CONTAINER_NAME_PREFIX)
@@ -66,13 +71,17 @@ class DockerRuntime(ActionExecutionClient):
atexit.register(remove_all_runtime_containers)
self.config = config
self._host_port = 30000 # initial dummy value
self._container_port = 30001 # initial dummy value
self._runtime_initialized: bool = False
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.status_callback = status_callback
self._host_port = -1
self._container_port = -1
self._vscode_port = -1
self._app_ports: list[int] = []
self.docker_client: docker.DockerClient = self._init_docker_client()
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.base_container_image = self.config.sandbox.base_container_image
self.runtime_container_image = self.config.sandbox.runtime_container_image
self.container_name = CONTAINER_NAME_PREFIX + sid
@@ -139,7 +148,10 @@ class DockerRuntime(ActionExecutionClient):
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
)
self.log_streamer = LogStreamer(self.container, self.log)
if DEBUG_RUNTIME:
self.log_streamer = LogStreamer(self.container, self.log)
else:
self.log_streamer = None
if not self.attach_to_existing:
self.log('info', f'Waiting for client to become ready at {self.api_url}...')
@@ -175,27 +187,34 @@ class DockerRuntime(ActionExecutionClient):
def _init_container(self):
self.log('debug', 'Preparing to start container...')
self.send_status_message('STATUS$PREPARING_CONTAINER')
plugin_arg = ''
if self.plugins is not None and len(self.plugins) > 0:
plugin_arg = (
f'--plugins {" ".join([plugin.name for plugin in self.plugins])} '
)
self._host_port = self._find_available_port()
self._container_port = (
self._host_port
) # in future this might differ from host port
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._container_port = self._host_port
self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE)
self._app_ports = [
self._find_available_port(APP_PORT_RANGE_1),
self._find_available_port(APP_PORT_RANGE_2),
]
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
use_host_network = self.config.sandbox.use_host_network
network_mode: str | None = 'host' if use_host_network else None
port_mapping: dict[str, list[dict[str, str]]] | None = (
None
if use_host_network
else {f'{self._container_port}/tcp': [{'HostPort': str(self._host_port)}]}
)
# Initialize port mappings
port_mapping: dict[str, list[dict[str, str]]] | None = None
if not use_host_network:
port_mapping = {
f'{self._container_port}/tcp': [{'HostPort': str(self._host_port)}],
}
if use_host_network:
if self.vscode_enabled:
port_mapping[f'{self._vscode_port}/tcp'] = [
{'HostPort': str(self._vscode_port)}
]
for port in self._app_ports:
port_mapping[f'{port}/tcp'] = [{'HostPort': str(port)}]
else:
self.log(
'warn',
'Using host network mode. If you are using MacOS, please make sure you have the latest version of Docker Desktop and enabled host network feature: https://docs.docker.com/network/drivers/host/#docker-desktop',
@@ -205,17 +224,11 @@ class DockerRuntime(ActionExecutionClient):
environment = {
'port': str(self._container_port),
'PYTHONUNBUFFERED': 1,
'VSCODE_PORT': str(self._vscode_port),
}
if self.config.debug or DEBUG:
environment['DEBUG'] = 'true'
if self.vscode_enabled:
# vscode is on port +1 from container port
if isinstance(port_mapping, dict):
port_mapping[f'{self._container_port + 1}/tcp'] = [
{'HostPort': str(self._host_port + 1)}
]
self.log('debug', f'Workspace Base: {self.config.workspace_base}')
if (
self.config.workspace_mount_path is not None
@@ -239,26 +252,17 @@ class DockerRuntime(ActionExecutionClient):
f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}',
)
if self.config.sandbox.browsergym_eval_env is not None:
browsergym_arg = (
f'--browsergym-eval-env {self.config.sandbox.browsergym_eval_env}'
)
else:
browsergym_arg = ''
command = get_action_execution_server_startup_command(
server_port=self._container_port,
plugins=self.plugins,
app_config=self.config,
use_nice_for_root=False,
)
try:
self.container = self.docker_client.containers.run(
self.runtime_container_image,
command=(
f'/openhands/micromamba/bin/micromamba run -n openhands '
f'poetry run '
f'python -u -m openhands.runtime.action_execution_server {self._container_port} '
f'--working-dir "{self.config.workspace_mount_path_in_sandbox}" '
f'{plugin_arg}'
f'--username {"openhands" if self.config.run_as_openhands else "root"} '
f'--user-id {self.config.sandbox.user_id} '
f'{browsergym_arg}'
),
command=command,
network_mode=network_mode,
ports=port_mapping,
working_dir='/openhands/code/', # do not change this!
@@ -289,6 +293,8 @@ class DockerRuntime(ActionExecutionClient):
'error',
f'Error: Instance {self.container_name} FAILED to start container!\n',
)
self.log('error', str(e))
raise e
except Exception as e:
self.log(
'error',
@@ -299,11 +305,20 @@ class DockerRuntime(ActionExecutionClient):
raise e
def _attach_to_container(self):
self._container_port = 0
self.container = self.docker_client.containers.get(self.container_name)
for port in self.container.attrs['NetworkSettings']['Ports']: # type: ignore
self._container_port = int(port.split('/')[0])
break
port = int(port.split('/')[0])
if (
port >= EXECUTION_SERVER_PORT_RANGE[0]
and port <= EXECUTION_SERVER_PORT_RANGE[1]
):
self._container_port = port
if port >= VSCODE_PORT_RANGE[0] and port <= VSCODE_PORT_RANGE[1]:
self._vscode_port = port
elif port >= APP_PORT_RANGE_1[0] and port <= APP_PORT_RANGE_1[1]:
self._app_ports.append(port)
elif port >= APP_PORT_RANGE_2[0] and port <= APP_PORT_RANGE_2[1]:
self._app_ports.append(port)
self._host_port = self._container_port
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.log(
@@ -331,9 +346,6 @@ class DockerRuntime(ActionExecutionClient):
f'Container {self.container_name} not found.'
)
if not self.log_streamer:
raise AgentRuntimeNotReadyError('Runtime client is not ready.')
self.check_if_alive()
def close(self, rm_all_containers: bool | None = None):
@@ -364,10 +376,10 @@ class DockerRuntime(ActionExecutionClient):
return True
return False
def _find_available_port(self, max_attempts=5):
port = 39999
def _find_available_port(self, port_range, max_attempts=5):
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(30000, 39999)
port = find_available_tcp_port(port_range[0], port_range[1])
if not self._is_port_in_use_docker(port):
return port
# If no port is found after max_attempts, return the last tried port
@@ -378,5 +390,15 @@ class DockerRuntime(ActionExecutionClient):
token = super().get_vscode_token()
if not token:
return None
vscode_url = f'http://localhost:{self._host_port + 1}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
vscode_url = f'http://localhost:{self._vscode_port}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
return vscode_url
@property
def web_hosts(self):
hosts: dict[str, int] = {}
for port in self._app_ports:
hosts[f'http://localhost:{port}'] = port
return hosts

View File

@@ -13,7 +13,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.command import get_remote_startup_command
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.runtime.utils.runtime_build import (
BuildFromImageType,
prep_build_folder,
@@ -203,11 +203,6 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
):
try:
self.log('debug', 'Preparing to start container...')
plugin_args = []
if plugins is not None and len(plugins) > 0:
plugin_args.append('--plugins')
plugin_args.extend([plugin.name for plugin in plugins])
# Combine environment variables
environment: dict[str, str | None] = {
'port': str(self.container_port),
@@ -216,24 +211,13 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
if self.config.debug:
environment['DEBUG'] = 'true'
browsergym_args = []
if self.config.sandbox.browsergym_eval_env is not None:
browsergym_args = [
'-browsergym-eval-env',
self.config.sandbox.browsergym_eval_env,
]
env_secret = modal.Secret.from_dict(environment)
self.log('debug', f'Sandbox workspace: {sandbox_workspace_dir}')
sandbox_start_cmd = get_remote_startup_command(
self.container_port,
sandbox_workspace_dir,
'openhands' if self.config.run_as_openhands else 'root',
self.config.sandbox.user_id,
plugin_args,
browsergym_args,
is_root=not self.config.run_as_openhands, # is_root=True when running as root
sandbox_start_cmd = get_action_execution_server_startup_command(
server_port=self.container_port,
plugins=self.plugins,
app_config=self.config,
)
self.log('debug', f'Starting container with command: {sandbox_start_cmd}')
self.sandbox = modal.Sandbox.create(

View File

@@ -19,7 +19,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.command import get_remote_startup_command
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.runtime.utils.request import send_request
from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
@@ -66,10 +66,13 @@ class RemoteRuntime(ActionExecutionClient):
)
self.runtime_builder = RemoteRuntimeBuilder(
self.config.sandbox.remote_runtime_api_url, self.config.sandbox.api_key
self.config.sandbox.remote_runtime_api_url,
self.config.sandbox.api_key,
self.session,
)
self.runtime_id: str | None = None
self.runtime_url: str | None = None
self.available_hosts: dict[str, int] = {}
self._runtime_initialized: bool = False
def _get_action_execution_server_host(self):
@@ -126,7 +129,6 @@ class RemoteRuntime(ActionExecutionClient):
with self._send_runtime_api_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
timeout=60,
) as response:
data = response.json()
status = data.get('status')
@@ -156,7 +158,6 @@ class RemoteRuntime(ActionExecutionClient):
with self._send_runtime_api_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/registry_prefix',
timeout=60,
) as response:
response_json = response.json()
registry_prefix = response_json['registry_prefix']
@@ -187,7 +188,6 @@ class RemoteRuntime(ActionExecutionClient):
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/image_exists',
params={'image': self.container_image},
timeout=60,
) as response:
if not response.json()['exists']:
raise AgentRuntimeError(
@@ -196,22 +196,10 @@ class RemoteRuntime(ActionExecutionClient):
def _start_runtime(self):
# Prepare the request body for the /start endpoint
plugin_args = []
if self.plugins is not None and len(self.plugins) > 0:
plugin_args = ['--plugins'] + [plugin.name for plugin in self.plugins]
browsergym_args = []
if self.config.sandbox.browsergym_eval_env is not None:
browsergym_args = [
'--browsergym-eval-env'
] + self.config.sandbox.browsergym_eval_env.split(' ')
command = get_remote_startup_command(
self.port,
self.config.workspace_mount_path_in_sandbox,
'openhands' if self.config.run_as_openhands else 'root',
self.config.sandbox.user_id,
plugin_args,
browsergym_args,
is_root=not self.config.run_as_openhands, # is_root=True when running as root
command = get_action_execution_server_startup_command(
server_port=self.port,
plugins=self.plugins,
app_config=self.config,
)
start_request = {
'image': self.container_image,
@@ -230,7 +218,6 @@ class RemoteRuntime(ActionExecutionClient):
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/start',
json=start_request,
timeout=60,
) as response:
self._parse_runtime_response(response)
self.log(
@@ -246,15 +233,18 @@ class RemoteRuntime(ActionExecutionClient):
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/resume',
json={'runtime_id': self.runtime_id},
timeout=60,
):
pass
self._wait_until_alive()
self.setup_initial_env()
self.log('debug', 'Runtime resumed.')
def _parse_runtime_response(self, response: requests.Response):
start_response = response.json()
self.runtime_id = start_response['runtime_id']
self.runtime_url = start_response['url']
self.available_hosts = start_response.get('work_hosts', {})
if 'session_api_key' in start_response:
self.session.headers.update(
{'X-Session-API-Key': start_response['session_api_key']}
@@ -276,6 +266,10 @@ class RemoteRuntime(ActionExecutionClient):
)
return vscode_url
@property
def web_hosts(self) -> dict[str, int]:
return self.available_hosts
def _wait_until_alive(self):
retry_decorator = tenacity.retry(
stop=tenacity.stop_after_delay(
@@ -293,7 +287,6 @@ class RemoteRuntime(ActionExecutionClient):
with self._send_runtime_api_request(
'GET',
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
timeout=60,
) as runtime_info_response:
runtime_data = runtime_info_response.json()
assert 'runtime_id' in runtime_data
@@ -345,7 +338,7 @@ class RemoteRuntime(ActionExecutionClient):
)
raise AgentRuntimeNotReadyError()
def close(self, timeout: int = 10):
def close(self):
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
super().close()
return
@@ -354,7 +347,6 @@ class RemoteRuntime(ActionExecutionClient):
'POST',
f'{self.config.sandbox.remote_runtime_api_url}/stop',
json={'runtime_id': self.runtime_id},
timeout=timeout,
):
self.log('debug', 'Runtime stopped.')
except Exception as e:
@@ -388,7 +380,6 @@ class RemoteRuntime(ActionExecutionClient):
elif e.response.status_code == 503:
self.log('warning', 'Runtime appears to be paused. Resuming...')
self._resume_runtime()
self._wait_until_alive()
return super()._send_action_server_request(method, url, **kwargs)
else:
raise e

View File

@@ -13,7 +13,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.command import get_remote_startup_command
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.utils.tenacity_stop import stop_if_should_exit
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
@@ -78,28 +78,10 @@ class RunloopRuntime(ActionExecutionClient):
def _create_new_devbox(self) -> DevboxView:
# Note: Runloop connect
sandbox_workspace_dir = self.config.workspace_mount_path_in_sandbox
plugin_args = []
if self.plugins is not None and len(self.plugins) > 0:
plugin_args.append('--plugins')
plugin_args.extend([plugin.name for plugin in self.plugins])
browsergym_args = []
if self.config.sandbox.browsergym_eval_env is not None:
browsergym_args = [
'-browsergym-eval-env',
self.config.sandbox.browsergym_eval_env,
]
# Copied from EventstreamRuntime
start_command = get_remote_startup_command(
self._sandbox_port,
sandbox_workspace_dir,
'openhands' if self.config.run_as_openhands else 'root',
self.config.sandbox.user_id,
plugin_args,
browsergym_args,
is_root=not self.config.run_as_openhands, # is_root=True when running as root
start_command = get_action_execution_server_startup_command(
server_port=self._sandbox_port,
plugins=self.plugins,
app_config=self.config,
)
# Add some additional commands based on our image
@@ -115,13 +97,15 @@ class RunloopRuntime(ActionExecutionClient):
devbox = self.runloop_api_client.devboxes.create(
entrypoint=entrypoint,
setup_commands=[f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'],
name=self.sid,
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
prebuilt='openhands',
launch_parameters=LaunchParameters(
available_ports=[self._sandbox_port, self._vscode_port],
resource_size_request='LARGE',
launch_commands=[
f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'
],
),
metadata={'container-name': self.container_name},
)

View File

@@ -486,18 +486,6 @@ class BashSession:
last_change_time = start_time
last_pane_output = self._get_pane_content()
_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
assert len(_ps1_matches) >= 1, (
'Expected at least one PS1 metadata block BEFORE the execution of a command, '
f'but got {len(_ps1_matches)} PS1 metadata blocks:\n---\n{last_pane_output!r}\n---'
)
if len(_ps1_matches) > 1:
logger.warning(
'Found multiple PS1 metadata blocks BEFORE the execution of a command. '
'Only the last one will be used.'
)
_ps1_matches = [_ps1_matches[-1]]
if command != '':
# convert command to raw string
command = escape_bash_special_chars(command)

View File

@@ -1,35 +1,57 @@
def get_remote_startup_command(
port: int,
sandbox_workspace_dir: str,
username: str,
user_id: int,
plugin_args: list[str],
browsergym_args: list[str],
is_root: bool = False,
from openhands.core.config import AppConfig
from openhands.runtime.plugins import PluginRequirement
DEFAULT_PYTHON_PREFIX = [
'/openhands/micromamba/bin/micromamba',
'run',
'-n',
'openhands',
'poetry',
'run',
]
def get_action_execution_server_startup_command(
server_port: int,
plugins: list[PluginRequirement],
app_config: AppConfig,
python_prefix: list[str] = DEFAULT_PYTHON_PREFIX,
use_nice_for_root: bool = True,
):
sandbox_config = app_config.sandbox
# Plugin args
plugin_args = []
if plugins is not None and len(plugins) > 0:
plugin_args = ['--plugins'] + [plugin.name for plugin in plugins]
# Browsergym stuffs
browsergym_args = []
if sandbox_config.browsergym_eval_env is not None:
browsergym_args = [
'--browsergym-eval-env'
] + sandbox_config.browsergym_eval_env.split(' ')
is_root = not app_config.run_as_openhands
base_cmd = [
'/openhands/micromamba/bin/micromamba',
'run',
'-n',
'openhands',
'poetry',
'run',
*python_prefix,
'python',
'-u',
'-m',
'openhands.runtime.action_execution_server',
str(port),
str(server_port),
'--working-dir',
sandbox_workspace_dir,
app_config.workspace_mount_path_in_sandbox,
*plugin_args,
'--username',
username,
'openhands' if app_config.run_as_openhands else 'root',
'--user-id',
str(user_id),
str(sandbox_config.user_id),
*browsergym_args,
]
if is_root:
if is_root and use_nice_for_root:
# If running as root, set highest priority and lowest OOM score
cmd_str = ' '.join(base_cmd)
return [
@@ -41,5 +63,5 @@ def get_remote_startup_command(
f'echo -1000 > /proc/self/oom_score_adj && exec {cmd_str}',
]
else:
# If not root, run with normal priority
# If not root OR not using nice for root, run with normal priority
return base_cmd

View File

@@ -21,7 +21,7 @@ class RequestHTTPError(requests.HTTPError):
return s
def is_rate_limit_error(exception):
def is_retryable_error(exception):
return (
isinstance(exception, requests.HTTPError)
and exception.response.status_code == 429
@@ -29,7 +29,7 @@ def is_rate_limit_error(exception):
@retry(
retry=retry_if_exception(is_rate_limit_error),
retry=retry_if_exception(is_retryable_error),
stop=stop_after_attempt(3) | stop_if_should_exit(),
wait=wait_exponential(multiplier=1, min=4, max=60),
)
@@ -48,6 +48,8 @@ def send_request(
_json = response.json()
except (requests.exceptions.JSONDecodeError, json.decoder.JSONDecodeError):
_json = None
finally:
response.close()
raise RequestHTTPError(
e,
response=e.response,

View File

@@ -16,12 +16,12 @@ ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget curl sudo apt-utils git jq tmux \
{% if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) %}
{%- if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) -%}
libgl1 \
{% else %}
{%- else %}
libgl1-mesa-glx \
{% endif %}
libasound2-plugins libatomic1 curl && \
{% endif -%}
libasound2-plugins libatomic1 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

View File

@@ -57,7 +57,9 @@ async def connect(connection_id: str, environ, auth):
settings = await settings_store.load()
if not settings:
raise ConnectionRefusedError('Settings not found')
raise ConnectionRefusedError(
'Settings not found', {'msg_id': 'CONFIGURATION$SETTINGS_NOT_FOUND'}
)
try:
event_stream = await session_manager.join_conversation(

View File

@@ -52,6 +52,45 @@ async def get_vscode_url(request: Request):
)
@app.get('/web-hosts')
async def get_hosts(request: Request):
"""Get the hosts used by the runtime.
This endpoint allows getting the hosts used by the runtime.
Args:
request (Request): The incoming FastAPI request object.
Returns:
JSONResponse: A JSON response indicating the success of the operation.
"""
try:
if not hasattr(request.state, 'conversation'):
return JSONResponse(
status_code=500,
content={'error': 'No conversation found in request state'},
)
if not hasattr(request.state.conversation, 'runtime'):
return JSONResponse(
status_code=500, content={'error': 'No runtime found in conversation'}
)
runtime: Runtime = request.state.conversation.runtime
logger.debug(f'Runtime type: {type(runtime)}')
logger.debug(f'Runtime hosts: {runtime.web_hosts}')
return JSONResponse(status_code=200, content={'hosts': runtime.web_hosts})
except Exception as e:
logger.error(f'Error getting runtime hosts: {e}', exc_info=True)
return JSONResponse(
status_code=500,
content={
'hosts': None,
'error': f'Error getting runtime hosts: {e}',
},
)
@app.get('/events/search')
async def search_events(
request: Request,

View File

@@ -1,7 +1,5 @@
from typing import Literal
import requests
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from openhands.server.shared import openhands_config
@@ -9,9 +7,6 @@ from openhands.utils.async_utils import call_sync_from_async
app = APIRouter(prefix='/api')
GITHUB_API_BASE = 'https://api.github.com'
GITHUB_API_VERSION = '2022-11-28'
@app.get('/github/repositories')
async def get_github_repositories(
@@ -69,143 +64,3 @@ async def get_github_repositories(
json_response.headers['Link'] = response.headers['Link']
return json_response
@app.get('/github/installations')
async def get_github_installations(request: Request):
"""Get GitHub App installations for the authenticated user"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
try:
response = await call_sync_from_async(
requests.get, f'{GITHUB_API_BASE}/user/installations', headers=headers
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error fetching installations: {str(e)}',
)
return JSONResponse(content=response.json())
@app.get('/github/user')
async def get_github_user(request: Request):
"""Get authenticated GitHub user information"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
try:
response = await call_sync_from_async(
requests.get, f'{GITHUB_API_BASE}/user', headers=headers
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error fetching user: {str(e)}',
)
data = response.json()
return JSONResponse(
content={
'id': data['id'],
'login': data['login'],
'avatar_url': data['avatar_url'],
'company': data.get('company'),
'name': data.get('name'),
'email': data.get('email'),
}
)
@app.get('/github/search/repositories')
async def search_github_repositories(
request: Request,
query: str,
per_page: int = Query(default=5, le=100),
sort: Literal['', 'updated', 'stars', 'forks'] = 'stars',
order: Literal['desc', 'asc'] = 'desc',
):
"""Search public GitHub repositories"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
params = {'q': query, 'per_page': str(per_page), 'sort': sort, 'order': order}
try:
response = await call_sync_from_async(
requests.get,
f'{GITHUB_API_BASE}/search/repositories',
headers=headers,
params=params,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error searching repositories: {str(e)}',
)
return JSONResponse(content=response.json())
@app.get('/github/repos/{owner}/{repo}/commits')
async def get_github_commits(
request: Request, owner: str, repo: str, per_page: int = Query(default=1, le=100)
):
"""Get latest commits for a GitHub repository"""
github_token = request.headers.get('X-GitHub-Token')
if not github_token:
raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
headers = {
'Authorization': f'Bearer {github_token}',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': GITHUB_API_VERSION,
}
params = {'per_page': str(per_page)}
try:
response = await call_sync_from_async(
requests.get,
f'{GITHUB_API_BASE}/repos/{owner}/{repo}/commits',
headers=headers,
params=params,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
response = getattr(e, 'response', None)
if response and response.status_code == 409:
# Repository is empty, no commits yet
return JSONResponse(content=[])
raise HTTPException(
status_code=getattr(getattr(e, 'response', None), 'status_code', 500),
detail=f'Error fetching commits: {str(e)}',
)
return JSONResponse(content=response.json())

View File

@@ -1,5 +1,5 @@
import uuid
from datetime import datetime
from datetime import datetime, timezone
from typing import Callable
from fastapi import APIRouter, Body, Request
@@ -66,9 +66,10 @@ async def new_conversation(request: Request, data: InitSessionRequest):
conversation_id = uuid.uuid4().hex
logger.info(f'New conversation ID: {conversation_id}')
conversation_title = (
data.selected_repository or f'Conversation {conversation_id[:5]}'
repository_title = (
data.selected_repository.split('/')[-1] if data.selected_repository else None
)
conversation_title = f'{repository_title or "Conversation"} {conversation_id[:5]}'
logger.info(f'Saving metadata for conversation {conversation_id}')
await conversation_store.save_metadata(
@@ -221,5 +222,5 @@ def _create_conversation_update_callback(
async def _update_timestamp_for_conversation(user_id: int, conversation_id: str):
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
conversation = await conversation_store.get_metadata(conversation_id)
conversation.last_updated_at = datetime.now()
conversation.last_updated_at = datetime.now(timezone.utc)
await conversation_store.save_metadata(conversation)

View File

@@ -180,6 +180,13 @@ class AgentSession:
logger.debug(f'Initializing runtime `{runtime_name}` now...')
runtime_cls = get_runtime_cls(runtime_name)
env_vars = (
{
'GITHUB_TOKEN': github_token,
}
if github_token
else None
)
self.runtime = runtime_cls(
config=config,
event_stream=self.event_stream,
@@ -187,6 +194,7 @@ class AgentSession:
plugins=agent.sandbox_plugins,
status_callback=self._status_callback,
headless_mode=False,
env_vars=env_vars,
)
# FIXME: this sleep is a terrible hack.
@@ -208,7 +216,9 @@ class AgentSession:
await call_sync_from_async(
self.runtime.clone_repo, github_token, selected_repository
)
if agent.prompt_manager:
agent.prompt_manager.set_runtime_info(self.runtime)
microagents: list[BaseMicroAgent] = await call_sync_from_async(
self.runtime.get_microagents_from_selected_repo, selected_repository
)

View File

@@ -165,10 +165,9 @@ class SessionManager:
# which can't be guaranteed - nodes can simply vanish unexpectedly!
sid = data['sid']
logger.debug(f'session_closing:{sid}')
for (
connection_id,
local_sid,
) in self.local_connection_id_to_session_id.items():
# Create a list of items to process to avoid modifying dict during iteration
items = list(self.local_connection_id_to_session_id.items())
for connection_id, local_sid in items:
if sid == local_sid:
logger.warning(
'local_connection_to_closing_session:{connection_id}:{sid}'

View File

@@ -113,9 +113,9 @@ class Session:
selected_repository=selected_repository,
)
except Exception as e:
logger.exception(f'Error creating controller: {e}')
logger.exception(f'Error creating agent_session: {e}')
await self.send_error(
f'Error creating controller. Please check Docker is running and visit `{TROUBLESHOOTING_URL}` for more debugging information..'
f'Error creating agent_session. Please check Docker is running and visit `{TROUBLESHOOTING_URL}` for more debugging information..'
)
return

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from openhands.storage.data_models.conversation_status import ConversationStatus
@@ -13,4 +13,4 @@ class ConversationInfo:
last_updated_at: datetime | None = None
status: ConversationStatus = ConversationStatus.STOPPED
selected_repository: str | None = None
created_at: datetime = field(default_factory=datetime.now)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
@dataclass
@@ -9,4 +9,4 @@ class ConversationMetadata:
selected_repository: str | None
title: str | None = None
last_updated_at: datetime | None = None
created_at: datetime = field(default_factory=datetime.now)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

View File

@@ -41,6 +41,10 @@ class S3FileStore(FileStore):
def delete(self, path: str) -> None:
try:
self.client.remove_object(self.bucket, path)
client = self.client
bucket = self.bucket
objects_to_delete = client.list_objects(bucket, prefix=path, recursive=True)
for obj in objects_to_delete:
client.remove_object(bucket, obj.object_name)
except Exception as e:
raise FileNotFoundError(f'Failed to delete S3 object at path {path}: {e}')

View File

@@ -1,4 +1,5 @@
import os
from dataclasses import dataclass
from itertools import islice
from jinja2 import Template
@@ -11,6 +12,12 @@ from openhands.microagent import (
RepoMicroAgent,
load_microagents_from_dir,
)
from openhands.runtime.base import Runtime
@dataclass
class RuntimeInfo:
available_hosts: dict[str, int]
class PromptManager:
@@ -38,6 +45,7 @@ class PromptManager:
self.system_template: Template = self._load_template('system_prompt')
self.user_template: Template = self._load_template('user_prompt')
self.runtime_info = RuntimeInfo(available_hosts={})
self.knowledge_microagents: dict[str, KnowledgeMicroAgent] = {}
self.repo_microagents: dict[str, RepoMicroAgent] = {}
@@ -72,6 +80,9 @@ class PromptManager:
elif isinstance(microagent, RepoMicroAgent):
self.repo_microagents[microagent.name] = microagent
def set_runtime_info(self, runtime: Runtime):
self.runtime_info.available_hosts = runtime.web_hosts
def _load_template(self, template_name: str) -> Template:
if self.prompt_dir is None:
raise ValueError('Prompt directory is not set')
@@ -91,7 +102,9 @@ class PromptManager:
if repo_instructions:
repo_instructions += '\n\n'
repo_instructions += microagent.content
return self.system_template.render(repo_instructions=repo_instructions).strip()
return self.system_template.render(
runtime_info=self.runtime_info, repo_instructions=repo_instructions
).strip()
def get_example_user_message(self) -> str:
"""This is the initial user message provided to the agent
@@ -103,6 +116,7 @@ class PromptManager:
These additional context will convert the current generic agent
into a more specialized agent that is tailored to the user's task.
"""
return self.user_template.render().strip()
def enhance_message(self, message: Message) -> None:

98
poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -552,17 +552,17 @@ files = [
[[package]]
name = "boto3"
version = "1.35.94"
version = "1.35.95"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.35.94-py3-none-any.whl", hash = "sha256:516c514fb447d6f216833d06a0781c003fcf43099a4ca2f5a363a8afe0942070"},
{file = "boto3-1.35.94.tar.gz", hash = "sha256:5aa606239f0fe0dca0506e0ad6bbe4c589048e7e6c2486cee5ec22b6aa7ec2f8"},
{file = "boto3-1.35.95-py3-none-any.whl", hash = "sha256:c81223488607457dacb7829ee0c99803c664593b34a2b0f86c71d421e7c3469a"},
{file = "boto3-1.35.95.tar.gz", hash = "sha256:d5671226819f6a78e31b1f37bd60f194afb8203254a543d06bdfb76de7d79236"},
]
[package.dependencies]
botocore = ">=1.35.94,<1.36.0"
botocore = ">=1.35.95,<1.36.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@@ -571,13 +571,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.35.94"
version = "1.35.95"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.35.94-py3-none-any.whl", hash = "sha256:d784d944865d8279c79d2301fc09ac28b5221d4e7328fb4e23c642c253b9932c"},
{file = "botocore-1.35.94.tar.gz", hash = "sha256:2b3309b356541faa4d88bb957dcac1d8004aa44953c0b7d4521a6cc5d3d5d6ba"},
{file = "botocore-1.35.95-py3-none-any.whl", hash = "sha256:a672406f748ad6a5b2fb7ea0d8394539eb4fda5332fc5373467d232c4bb27b12"},
{file = "botocore-1.35.95.tar.gz", hash = "sha256:b03d2d7cc58a16aa96a7e8f21941b766e98abc6ea74f61a63de7dc26c03641d3"},
]
[package.dependencies]
@@ -3707,13 +3707,13 @@ types-tqdm = "*"
[[package]]
name = "litellm"
version = "1.57.2"
version = "1.57.4"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
files = [
{file = "litellm-1.57.2-py3-none-any.whl", hash = "sha256:b572c0d3d3c33ff3a4d18928ac6f051d10ac159814017a817d88ec7af9a8600c"},
{file = "litellm-1.57.2.tar.gz", hash = "sha256:0a07c4e288f4bd9033335d5606d7da497f1193d51cf262b96812f40b8842a842"},
{file = "litellm-1.57.4-py3-none-any.whl", hash = "sha256:afe48924d8a36db801018970a101622fce33d117fe9c54441c0095c491511abb"},
{file = "litellm-1.57.4.tar.gz", hash = "sha256:747a870ddee9c71f9560fc68ad02485bc1008fcad7d7a43e87867a59b8ed0669"},
]
[package.dependencies]
@@ -3859,13 +3859,13 @@ llama-index-llms-azure-openai = ">=0.3.0,<0.4.0"
[[package]]
name = "llama-index-embeddings-huggingface"
version = "0.4.0"
version = "0.5.0"
description = "llama-index embeddings huggingface integration"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index_embeddings_huggingface-0.4.0-py3-none-any.whl", hash = "sha256:a5890bab349b118398054138b298a9e429776b85bcf8017fdf01cd5d60fbba12"},
{file = "llama_index_embeddings_huggingface-0.4.0.tar.gz", hash = "sha256:ce8f8b30b29cff85401aba2118285fb63fb8147a56b656ee20f7e8510ca085a2"},
{file = "llama_index_embeddings_huggingface-0.5.0-py3-none-any.whl", hash = "sha256:70634b2cfaad28103b5125971fc98118f1bc404cb6145744b55de4ed54b0ad99"},
{file = "llama_index_embeddings_huggingface-0.5.0.tar.gz", hash = "sha256:bb75924bd52631364bd3b1a4b0ab78753a0bef00210f2762b425cbd05f4ea60e"},
]
[package.dependencies]
@@ -4583,12 +4583,12 @@ type = ["mypy (==1.11.2)"]
[[package]]
name = "modal"
version = "0.71.8"
version = "0.72.0"
description = "Python client library for Modal"
optional = false
python-versions = ">=3.9"
files = [
{file = "modal-0.71.8-py3-none-any.whl", hash = "sha256:48bd0e9b4f951495fdd1172b8243d477631440a8c01dd13fcab5dd46b36d8a62"},
{file = "modal-0.72.0-py3-none-any.whl", hash = "sha256:d09952ac907357d30a314fbf0b759c781ab24677aaf338f5775c9bcab26e4b78"},
]
[package.dependencies]
@@ -5364,13 +5364,13 @@ sympy = "*"
[[package]]
name = "openai"
version = "1.59.4"
version = "1.59.5"
description = "The official Python library for the openai API"
optional = false
python-versions = ">=3.8"
files = [
{file = "openai-1.59.4-py3-none-any.whl", hash = "sha256:82113498699998e98104f87c19a890e82df9b01251a0395484360575d3a1d98a"},
{file = "openai-1.59.4.tar.gz", hash = "sha256:b946dc5a2308dc1e03efbda80bf1cd64b6053b536851ad519f57ee44401663d2"},
{file = "openai-1.59.5-py3-none-any.whl", hash = "sha256:e646b44856b0dda9345d3c43639e056334d792d1690e99690313c0ef7ca4d8cc"},
{file = "openai-1.59.5.tar.gz", hash = "sha256:9886e77c02dad9dc6a7b67a11ab372a56842a9b5d376aa476672175ab10e83a0"},
]
[package.dependencies]
@@ -5389,17 +5389,15 @@ realtime = ["websockets (>=13,<15)"]
[[package]]
name = "openhands-aci"
version = "0.1.6"
version = "0.1.8"
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
optional = false
python-versions = "<4.0,>=3.12"
files = [
{file = "openhands_aci-0.1.6-py3-none-any.whl", hash = "sha256:e9589d959a146fad3e6935be1f80b7a4368dd7aa2ba38ad267862c4f8a246e72"},
{file = "openhands_aci-0.1.6.tar.gz", hash = "sha256:6edf4d6478a349140a324c4a0c4be6d1e9a7acce1739a37d02eecbb9006a2ce7"},
]
python-versions = "^3.12"
files = []
develop = false
[package.dependencies]
diskcache = ">=5.6.3,<6.0.0"
diskcache = "^5.6.3"
flake8 = "*"
gitpython = "*"
grep-ast = "0.3.3"
@@ -5409,7 +5407,13 @@ numpy = "*"
pandas = "*"
scipy = "*"
tree-sitter = "0.21.3"
whatthepatch = ">=1.0.6,<2.0.0"
whatthepatch = "^1.0.6"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/openhands-aci.git"
reference = "fix-find-show-only-hidden-subpaths"
resolved_reference = "910e8c470aff0e496bf262bc673c7ee7b4531159"
[[package]]
name = "opentelemetry-api"
@@ -7623,29 +7627,29 @@ pyasn1 = ">=0.1.3"
[[package]]
name = "ruff"
version = "0.8.6"
version = "0.9.0"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3"},
{file = "ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1"},
{file = "ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf"},
{file = "ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117"},
{file = "ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe"},
{file = "ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d"},
{file = "ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a"},
{file = "ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76"},
{file = "ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764"},
{file = "ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905"},
{file = "ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162"},
{file = "ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5"},
{file = "ruff-0.9.0-py3-none-linux_armv6l.whl", hash = "sha256:949b3513f931741e006cf267bf89611edff04e1f012013424022add3ce78f319"},
{file = "ruff-0.9.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:99fbcb8c7fe94ae1e462ab2a1ef17cb20b25fb6438b9f198b1bcf5207a0a7916"},
{file = "ruff-0.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0b022afd8eb0fcfce1e0adec84322abf4d6ce3cd285b3b99c4f17aae7decf749"},
{file = "ruff-0.9.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:336567ce92c9ca8ec62780d07b5fa11fbc881dc7bb40958f93a7d621e7ab4589"},
{file = "ruff-0.9.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d338336c44bda602dc8e8766836ac0441e5b0dfeac3af1bd311a97ebaf087a75"},
{file = "ruff-0.9.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9b3ececf523d733e90b540e7afcc0494189e8999847f8855747acd5a9a8c45f"},
{file = "ruff-0.9.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a11c0872a31232e473e2e0e2107f3d294dbadd2f83fb281c3eb1c22a24866924"},
{file = "ruff-0.9.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5fd06220c17a9cc0dc7fc6552f2ac4db74e8e8bff9c401d160ac59d00566f54"},
{file = "ruff-0.9.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0457e775c74bf3976243f910805242b7dcd389e1d440deccbd1194ca17a5728c"},
{file = "ruff-0.9.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05415599bbcb318f730ea1b46a39e4fbf71f6a63fdbfa1dda92efb55f19d7ecf"},
{file = "ruff-0.9.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fbf9864b009e43cfc1c8bed1a6a4c529156913105780af4141ca4342148517f5"},
{file = "ruff-0.9.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:37b3da222b12e2bb2ce628e02586ab4846b1ed7f31f42a5a0683b213453b2d49"},
{file = "ruff-0.9.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:733c0fcf2eb0c90055100b4ed1af9c9d87305b901a8feb6a0451fa53ed88199d"},
{file = "ruff-0.9.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8221a454bfe5ccdf8017512fd6bb60e6ec30f9ea252b8a80e5b73619f6c3cefd"},
{file = "ruff-0.9.0-py3-none-win32.whl", hash = "sha256:d345f2178afd192c7991ddee59155c58145e12ad81310b509bd2e25c5b0247b3"},
{file = "ruff-0.9.0-py3-none-win_amd64.whl", hash = "sha256:0cbc0905d94d21305872f7f8224e30f4bbcd532bc21b2225b2446d8fc7220d19"},
{file = "ruff-0.9.0-py3-none-win_arm64.whl", hash = "sha256:7b1148771c6ca88f820d761350a053a5794bc58e0867739ea93eb5e41ad978cd"},
{file = "ruff-0.9.0.tar.gz", hash = "sha256:143f68fa5560ecf10fc49878b73cee3eab98b777fcf43b0e62d43d42f5ef9d8b"},
]
[[package]]
@@ -9853,4 +9857,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "0d22154d7688e70fbd1a45fc7262b27126cce241e4f0b6739584d80bf863656e"
content-hash = "8320b6c6bb05538516a965589ce03fec4d30df38fb7b47fc934258f1d8d47e30"

View File

@@ -60,12 +60,12 @@ whatthepatch = "^1.0.6"
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = ">=0.66.26,<0.72.0"
modal = ">=0.66.26,<0.73.0"
runloop-api-client = "0.12.0"
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
joblib = "*"
openhands-aci = "0.1.6"
openhands-aci = "0.1.8"
python-socketio = "^5.11.4"
redis = "^5.2.0"
sse-starlette = "^2.1.3"
@@ -82,7 +82,7 @@ voyageai = "*"
llama-index-embeddings-voyageai = "*"
[tool.poetry.group.dev.dependencies]
ruff = "0.8.6"
ruff = "0.9.0"
mypy = "1.14.1"
pre-commit = "4.0.1"
build = "*"

View File

@@ -0,0 +1,47 @@
import pytest
from openhands.resolver.patching.apply import apply_diff
from openhands.resolver.patching.exceptions import HunkApplyException
from openhands.resolver.patching.patch import parse_diff, diffobj
def test_patch_apply_with_empty_lines():
# The original file has no indentation and uses \n line endings
original_content = "# PR Viewer\n\nThis React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.\n\n## Setup"
# The patch has spaces at the start of each line and uses \n line endings
patch = """diff --git a/README.md b/README.md
index b760a53..5071727 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@
# PR Viewer
-This React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.
+This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization."""
print("Original content lines:")
for i, line in enumerate(original_content.splitlines(), 1):
print(f"{i}: {repr(line)}")
print("\nPatch lines:")
for i, line in enumerate(patch.splitlines(), 1):
print(f"{i}: {repr(line)}")
changes = parse_diff(patch)
print("\nParsed changes:")
for change in changes:
print(f"Change(old={change.old}, new={change.new}, line={repr(change.line)}, hunk={change.hunk})")
diff = diffobj(header=None, changes=changes, text=patch)
# Apply the patch
result = apply_diff(diff, original_content)
# The patch should be applied successfully
expected_result = [
"# PR Viewer",
"",
"This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.",
"",
"## Setup"
]
assert result == expected_result

View File

@@ -0,0 +1,112 @@
from pathlib import Path
import pytest
from unittest.mock import MagicMock, patch
from openhands.core.config import AppConfig
from openhands.events import EventStream
from openhands.events.observation import FileReadObservation
from openhands.runtime.base import Runtime
from openhands.storage.local import LocalFileStore
class MockRuntime(Runtime):
async def connect(self) -> None:
pass
def run(self, action):
return FileReadObservation('test')
def run_ipython(self, action):
return FileReadObservation('test')
def read(self, action):
return FileReadObservation('test')
def write(self, action):
return FileReadObservation('test')
def browse(self, action):
return FileReadObservation('test')
def browse_interactive(self, action):
return FileReadObservation('test')
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
pass
def list_files(self, path: str | None = None) -> list[str]:
return []
def copy_from(self, path: str):
return Path('test')
class TestRuntime:
@pytest.fixture
def mock_runtime(self, tmp_path):
config = AppConfig()
file_store = LocalFileStore(str(tmp_path))
event_stream = EventStream('test_sid', file_store)
runtime = MockRuntime(config, event_stream)
return runtime
def test_get_microagents_with_trailing_slash(self, mock_runtime):
# Mock list_files to return directories with trailing slashes
mock_runtime.list_files = MagicMock(side_effect=[
['repo.md', 'knowledge/', 'tasks/'], # First call for root dir
['test1.md'], # Second call for knowledge dir
['test2.md'], # Third call for tasks dir
])
# Mock file read to return some content
def mock_read(action):
if '.openhands_instructions' in action.path:
from openhands.events.observation import ErrorObservation
return ErrorObservation('File not found')
return FileReadObservation(path='test.md', content="""---
name: test
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers: []
---
test content""")
mock_runtime.read = MagicMock(side_effect=mock_read)
# Get microagents
microagents = mock_runtime.get_microagents_from_selected_repo(None)
# Verify that microagents were loaded from both knowledge and tasks directories
assert len(microagents) == 3 # 1 from repo.md, 1 from knowledge, 1 from tasks
def test_get_microagents_without_trailing_slash(self, mock_runtime):
# Mock list_files to return directories without trailing slashes
mock_runtime.list_files = MagicMock(side_effect=[
['repo.md', 'knowledge', 'tasks'], # First call for root dir
['test1.md'], # Second call for knowledge dir
['test2.md'], # Third call for tasks dir
])
# Mock file read to return some content
def mock_read(action):
if '.openhands_instructions' in action.path:
from openhands.events.observation import ErrorObservation
return ErrorObservation('File not found')
return FileReadObservation(path='test.md', content="""---
name: test
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers: []
---
test content""")
mock_runtime.read = MagicMock(side_effect=mock_read)
# Get microagents
microagents = mock_runtime.get_microagents_from_selected_repo(None)
# Verify that microagents were loaded from both knowledge and tasks directories
assert len(microagents) == 3 # 1 from repo.md, 1 from knowledge, 1 from tasks