mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12b81eb75b | |||
| 4e99cb9a4c | |||
| b629483a26 | |||
| 68a3854fa8 | |||
| fe2716ff48 | |||
| 6f83a215a8 |
@@ -45,7 +45,6 @@ jobs:
|
||||
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
|
||||
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
|
||||
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
|
||||
"Feel free to join our developer community on [Slack](dub.sh/openhands). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.54-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
Portions of this software are licensed as follows:
|
||||
* All content that resides under the enterprise/ directory is licensed under the license defined in "enterprise/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below.
|
||||
|
||||
The MIT License (MIT)
|
||||
=====================
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2025
|
||||
Copyright © 2023
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
# Migration Guide: From Shared Globals to Context System
|
||||
|
||||
This guide explains how to migrate from the deprecated `openhands.server.shared` globals to the new context system.
|
||||
|
||||
## Overview
|
||||
|
||||
The new context system replaces global variables with dependency injection, providing:
|
||||
|
||||
- **Better testability**: Easy to mock dependencies in tests
|
||||
- **SaaS extensibility**: Custom contexts for multi-tenant scenarios
|
||||
- **Per-request contexts**: Different configurations per request
|
||||
- **No import-time side effects**: Lazy initialization of dependencies
|
||||
- **Type safety**: Better IDE support and type checking
|
||||
|
||||
## Quick Migration
|
||||
|
||||
### Before (Deprecated)
|
||||
```python
|
||||
from openhands.server.shared import config, server_config, file_store, sio
|
||||
|
||||
def my_function():
|
||||
# Use global variables
|
||||
workspace_dir = config.workspace_dir
|
||||
app_mode = server_config.app_mode
|
||||
file_store.save_file(...)
|
||||
```
|
||||
|
||||
### After (Recommended)
|
||||
```python
|
||||
from fastapi import Depends, Request
|
||||
from openhands.server.context import get_server_context, ServerContext
|
||||
|
||||
@app.get('/my-endpoint')
|
||||
async def my_endpoint(
|
||||
request: Request,
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
# Use context instead of globals
|
||||
config = context.get_config()
|
||||
server_config = context.get_server_config()
|
||||
file_store = context.get_file_store()
|
||||
|
||||
workspace_dir = config.workspace_dir
|
||||
app_mode = server_config.app_mode
|
||||
file_store.save_file(...)
|
||||
```
|
||||
|
||||
## Detailed Migration Steps
|
||||
|
||||
### 1. Route Handlers
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from openhands.server.shared import config, conversation_manager
|
||||
|
||||
@app.post('/conversations')
|
||||
async def create_conversation(request: ConversationRequest):
|
||||
conversation = conversation_manager.create_conversation(
|
||||
request.user_id,
|
||||
config.default_agent
|
||||
)
|
||||
return conversation
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from fastapi import Depends
|
||||
from openhands.server.context import get_server_context, ServerContext
|
||||
|
||||
@app.post('/conversations')
|
||||
async def create_conversation(
|
||||
request: ConversationRequest,
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
config = context.get_config()
|
||||
conversation_manager = context.get_conversation_manager()
|
||||
|
||||
conversation = conversation_manager.create_conversation(
|
||||
request.user_id,
|
||||
config.default_agent
|
||||
)
|
||||
return conversation
|
||||
```
|
||||
|
||||
### 2. Service Classes
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from openhands.server.shared import file_store, monitoring_listener
|
||||
|
||||
class MyService:
|
||||
def process_file(self, file_path: str):
|
||||
content = file_store.read(file_path)
|
||||
monitoring_listener.log_event('file_processed')
|
||||
return content
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from openhands.server.context import ServerContext
|
||||
|
||||
class MyService:
|
||||
def __init__(self, context: ServerContext):
|
||||
self.context = context
|
||||
|
||||
def process_file(self, file_path: str):
|
||||
file_store = self.context.get_file_store()
|
||||
monitoring_listener = self.context.get_monitoring_listener()
|
||||
|
||||
content = file_store.read(file_path)
|
||||
monitoring_listener.log_event('file_processed')
|
||||
return content
|
||||
|
||||
# In route handler:
|
||||
@app.post('/process')
|
||||
async def process_endpoint(
|
||||
request: ProcessRequest,
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
service = MyService(context)
|
||||
return service.process_file(request.file_path)
|
||||
```
|
||||
|
||||
### 3. Store Classes
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from openhands.server.shared import SettingsStoreImpl
|
||||
|
||||
def get_user_settings(user_id: str):
|
||||
store = SettingsStoreImpl(user_id)
|
||||
return store.load()
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from openhands.server.context import ServerContext
|
||||
|
||||
def get_user_settings(user_id: str, context: ServerContext):
|
||||
SettingsStoreClass = context.get_settings_store_class()
|
||||
store = SettingsStoreClass(user_id)
|
||||
return store.load()
|
||||
|
||||
# In route handler:
|
||||
@app.get('/settings/{user_id}')
|
||||
async def get_settings(
|
||||
user_id: str,
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
return get_user_settings(user_id, context)
|
||||
```
|
||||
|
||||
### 4. Testing
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_my_function():
|
||||
with patch('openhands.server.shared.config') as mock_config:
|
||||
mock_config.workspace_dir = '/test'
|
||||
result = my_function()
|
||||
assert result == expected
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
import pytest
|
||||
from openhands.server.context import create_server_context
|
||||
|
||||
class MockServerContext:
|
||||
def get_config(self):
|
||||
mock_config = Mock()
|
||||
mock_config.workspace_dir = '/test'
|
||||
return mock_config
|
||||
|
||||
def test_my_function():
|
||||
context = MockServerContext()
|
||||
result = my_function(context)
|
||||
assert result == expected
|
||||
```
|
||||
|
||||
## SaaS Extension Example
|
||||
|
||||
The new context system makes it easy to extend OpenHands for SaaS scenarios:
|
||||
|
||||
```python
|
||||
from openhands.server.context import ServerContext, set_context_class
|
||||
|
||||
class SaaSServerContext(ServerContext):
|
||||
def __init__(self, user_id: str, org_id: str):
|
||||
self.user_id = user_id
|
||||
self.org_id = org_id
|
||||
|
||||
def get_file_store(self):
|
||||
# Return tenant-isolated file store
|
||||
return MultiTenantFileStore(self.user_id, self.org_id)
|
||||
|
||||
def get_server_config(self):
|
||||
# Return SaaS-specific configuration
|
||||
return SaaSServerConfig(org_id=self.org_id)
|
||||
|
||||
# Configure globally
|
||||
set_context_class('myapp.context.SaaSServerContext')
|
||||
|
||||
# Use in routes with tenant context
|
||||
@app.get('/tenant/{org_id}/files')
|
||||
async def get_tenant_files(
|
||||
org_id: str,
|
||||
context: SaaSServerContext = Depends(get_server_context)
|
||||
):
|
||||
file_store = context.get_file_store()
|
||||
return file_store.list_files()
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Replace `from openhands.server.shared import ...` with context injection
|
||||
- [ ] Update route handlers to use `Depends(get_server_context)`
|
||||
- [ ] Modify service classes to accept `ServerContext` parameter
|
||||
- [ ] Update tests to use mock contexts instead of patching globals
|
||||
- [ ] Remove direct imports of shared globals
|
||||
- [ ] Test that all functionality still works
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The old `openhands.server.shared` module still works but is deprecated. It will show deprecation warnings when imported. The globals are now implemented using the new context system internally.
|
||||
|
||||
## Benefits After Migration
|
||||
|
||||
1. **Better Testing**: Easy to mock dependencies without patching globals
|
||||
2. **Type Safety**: Better IDE support and type checking
|
||||
3. **Extensibility**: Easy to create custom contexts for different scenarios
|
||||
4. **Performance**: Lazy initialization reduces startup time
|
||||
5. **Maintainability**: Clear dependency relationships
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue: Import errors during migration
|
||||
**Solution**: Make sure to import the context system correctly:
|
||||
```python
|
||||
from openhands.server.context import get_server_context, ServerContext
|
||||
```
|
||||
|
||||
### Issue: Context not available in non-route functions
|
||||
**Solution**: Pass the context as a parameter:
|
||||
```python
|
||||
def helper_function(data: str, context: ServerContext):
|
||||
config = context.get_config()
|
||||
# ... use config
|
||||
```
|
||||
|
||||
### Issue: Testing becomes more complex
|
||||
**Solution**: Create reusable mock contexts:
|
||||
```python
|
||||
# test_utils.py
|
||||
class TestServerContext(ServerContext):
|
||||
def __init__(self):
|
||||
self.mock_config = create_mock_config()
|
||||
self.mock_file_store = create_mock_file_store()
|
||||
|
||||
def get_config(self):
|
||||
return self.mock_config
|
||||
|
||||
def get_file_store(self):
|
||||
return self.mock_file_store
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter issues during migration:
|
||||
|
||||
1. Check the examples in `examples/saas_extension.py`
|
||||
2. Look at the implementation in `openhands/server/context/`
|
||||
3. Review existing route handlers that have been migrated
|
||||
4. Create an issue if you find bugs or need clarification
|
||||
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
+3
-3
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
# OpenHands Server Context Refactoring Plan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current OpenHands architecture has globals in `server/shared.py` that are initialized at import time based on environment variables. This creates several issues for the SaaS version:
|
||||
|
||||
1. **Import-time dependencies**: All globals are created when modules are imported
|
||||
2. **Hard to extend**: SaaS can't easily override or extend components
|
||||
3. **CI/CD issues**: Everything depends on env vars being set correctly at import time
|
||||
4. **Per-user behavior**: Difficult to implement per-user/per-request behavior
|
||||
5. **Outside repo issues**: Hard to run SaaS from outside repo due to import dependencies
|
||||
|
||||
## Current Problematic Globals
|
||||
|
||||
From `openhands/server/shared.py`:
|
||||
- `config: OpenHandsConfig` - Core app configuration
|
||||
- `server_config: ServerConfig` - Server-specific configuration
|
||||
- `file_store: FileStore` - File storage implementation
|
||||
- `sio: socketio.AsyncServer` - Socket.IO server instance
|
||||
- `conversation_manager` - Conversation management implementation
|
||||
- `monitoring_listener` - Monitoring implementation
|
||||
- `SettingsStoreImpl`, `SecretsStoreImpl`, `ConversationStoreImpl` - Storage implementations
|
||||
|
||||
## Solution: ServerContext Pattern
|
||||
|
||||
### 1. Create ServerContext Base Class
|
||||
|
||||
Create `openhands/server/context/server_context.py`:
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
import socketio
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.storage.files import FileStore
|
||||
# ... other imports
|
||||
|
||||
class ServerContext(ABC):
|
||||
"""Base class for server context that holds all server dependencies.
|
||||
|
||||
This replaces the global variables in shared.py and allows for:
|
||||
- Dependency injection
|
||||
- Easy extensibility for SaaS
|
||||
- Per-request contexts
|
||||
- Testability
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._config: Optional[OpenHandsConfig] = None
|
||||
self._server_config: Optional[ServerConfig] = None
|
||||
self._file_store: Optional[FileStore] = None
|
||||
# ... other cached instances
|
||||
|
||||
@abstractmethod
|
||||
def get_config(self) -> OpenHandsConfig:
|
||||
"""Get the OpenHands configuration"""
|
||||
|
||||
@abstractmethod
|
||||
def get_server_config(self) -> ServerConfig:
|
||||
"""Get the server configuration"""
|
||||
|
||||
@abstractmethod
|
||||
def get_file_store(self) -> FileStore:
|
||||
"""Get the file store implementation"""
|
||||
|
||||
# ... other abstract methods for all current globals
|
||||
```
|
||||
|
||||
### 2. Create Default Implementation
|
||||
|
||||
Create `openhands/server/context/default_server_context.py`:
|
||||
|
||||
```python
|
||||
class DefaultServerContext(ServerContext):
|
||||
"""Default implementation that maintains current behavior"""
|
||||
|
||||
def get_config(self) -> OpenHandsConfig:
|
||||
if self._config is None:
|
||||
self._config = load_openhands_config()
|
||||
return self._config
|
||||
|
||||
def get_server_config(self) -> ServerConfig:
|
||||
if self._server_config is None:
|
||||
self._server_config = load_server_config()
|
||||
return self._server_config
|
||||
|
||||
# ... implement all methods with current logic
|
||||
```
|
||||
|
||||
### 3. Context Provider System
|
||||
|
||||
Create `openhands/server/context/context_provider.py`:
|
||||
|
||||
```python
|
||||
from fastapi import Request
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
_context_class: Optional[str] = None
|
||||
|
||||
def set_context_class(context_class: str):
|
||||
"""Set the server context class to use"""
|
||||
global _context_class
|
||||
_context_class = context_class
|
||||
|
||||
async def get_server_context(request: Request) -> ServerContext:
|
||||
"""Get server context from request, with caching"""
|
||||
context = getattr(request.state, 'server_context', None)
|
||||
if context:
|
||||
return context
|
||||
|
||||
# Use configured context class or default
|
||||
context_cls_name = _context_class or 'openhands.server.context.default_server_context.DefaultServerContext'
|
||||
context_cls = get_impl(ServerContext, context_cls_name)
|
||||
context = context_cls()
|
||||
|
||||
request.state.server_context = context
|
||||
return context
|
||||
```
|
||||
|
||||
### 4. Update Shared.py (Backward Compatibility)
|
||||
|
||||
Keep `shared.py` for backward compatibility but make it use the context:
|
||||
|
||||
```python
|
||||
# openhands/server/shared.py
|
||||
from openhands.server.context.default_server_context import DefaultServerContext
|
||||
|
||||
# Create default context for backward compatibility
|
||||
_default_context = DefaultServerContext()
|
||||
|
||||
# Expose globals for backward compatibility
|
||||
config = _default_context.get_config()
|
||||
server_config = _default_context.get_server_config()
|
||||
file_store = _default_context.get_file_store()
|
||||
# ... etc
|
||||
```
|
||||
|
||||
### 5. Update Routes to Use Context
|
||||
|
||||
Update all route files to use dependency injection:
|
||||
|
||||
```python
|
||||
# Example: openhands/server/routes/settings.py
|
||||
from openhands.server.context import get_server_context
|
||||
|
||||
@app.get('/settings')
|
||||
async def get_settings(
|
||||
request: Request,
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
config = context.get_config()
|
||||
# ... use config instead of importing from shared
|
||||
```
|
||||
|
||||
## Benefits for SaaS
|
||||
|
||||
### 1. Easy Extension
|
||||
|
||||
SaaS can create their own context:
|
||||
|
||||
```python
|
||||
# In SaaS repo: saas/server_context.py
|
||||
from openhands.server.context import ServerContext
|
||||
|
||||
class SaaSServerContext(ServerContext):
|
||||
def get_server_config(self) -> ServerConfig:
|
||||
# Return SaaS-specific config with enterprise features
|
||||
return SaaSServerConfig()
|
||||
|
||||
def get_conversation_manager(self) -> ConversationManager:
|
||||
# Return multi-tenant conversation manager
|
||||
return MultiTenantConversationManager()
|
||||
```
|
||||
|
||||
### 2. Per-Request Contexts
|
||||
|
||||
SaaS can implement per-user contexts:
|
||||
|
||||
```python
|
||||
class PerUserServerContext(ServerContext):
|
||||
def __init__(self, user_id: str, org_id: str):
|
||||
super().__init__()
|
||||
self.user_id = user_id
|
||||
self.org_id = org_id
|
||||
|
||||
def get_file_store(self) -> FileStore:
|
||||
# Return user-specific file store
|
||||
return UserFileStore(self.user_id, self.org_id)
|
||||
```
|
||||
|
||||
### 3. No Import-Time Dependencies
|
||||
|
||||
SaaS can run without setting environment variables at import time:
|
||||
|
||||
```python
|
||||
# In SaaS startup
|
||||
from openhands.server.context import set_context_class
|
||||
set_context_class('saas.server_context.SaaSServerContext')
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Create Context System
|
||||
1. Create ServerContext base class and default implementation
|
||||
2. Create context provider system
|
||||
3. Update shared.py for backward compatibility
|
||||
|
||||
### Phase 2: Update Routes Gradually
|
||||
1. Update one route at a time to use context injection
|
||||
2. Test each route to ensure no regressions
|
||||
3. Keep backward compatibility during transition
|
||||
|
||||
### Phase 3: Clean Up
|
||||
1. Remove globals from shared.py once all routes are updated
|
||||
2. Update documentation
|
||||
3. Create examples for SaaS extension
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `openhands/server/context/server_context.py` - Base class
|
||||
2. `openhands/server/context/default_server_context.py` - Default implementation
|
||||
3. `openhands/server/context/context_provider.py` - Provider system
|
||||
4. `openhands/server/context/__init__.py` - Public API
|
||||
5. Update `openhands/server/shared.py` for backward compatibility
|
||||
6. Update routes one by one to use context injection
|
||||
7. Update tests to use context system
|
||||
8. Documentation and examples
|
||||
|
||||
This approach provides a clean migration path while maintaining backward compatibility and enabling the SaaS extensibility requirements.
|
||||
@@ -1,206 +0,0 @@
|
||||
# OpenHands Server Globals Refactoring - Summary
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully refactored OpenHands server globals in `shared.py` and `server_config.py` to enable SaaS extensibility without import-time dependencies. The refactoring introduces a dependency injection pattern using a `ServerContext` system that maintains backward compatibility while enabling multi-tenant SaaS scenarios.
|
||||
|
||||
## Problem Solved
|
||||
|
||||
### Before Refactoring
|
||||
- **Global variables on import**: `shared.py` created globals like `config`, `server_config`, `file_store`, `sio`, etc. on module import
|
||||
- **Import-time side effects**: Loading the module triggered configuration loading and dependency initialization
|
||||
- **SaaS integration issues**: External SaaS repos had CI/CD problems due to environment variable dependencies
|
||||
- **Testing difficulties**: Hard to mock dependencies due to global state
|
||||
- **No extensibility**: Impossible to customize behavior for different tenants or environments
|
||||
|
||||
### After Refactoring
|
||||
- **Dependency injection**: Clean `ServerContext` pattern with lazy initialization
|
||||
- **No import-time side effects**: Dependencies only loaded when actually needed
|
||||
- **SaaS extensibility**: Easy to create custom contexts for multi-tenant scenarios
|
||||
- **Better testability**: Easy to mock contexts for testing
|
||||
- **Backward compatibility**: Existing code continues to work with deprecation warnings
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
### New Context System
|
||||
|
||||
```
|
||||
openhands/server/context/
|
||||
├── __init__.py # Public API
|
||||
├── server_context.py # Abstract base class
|
||||
├── default_server_context.py # Default implementation
|
||||
└── context_provider.py # Dependency injection system
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **ServerContext (Abstract Base Class)**
|
||||
- Defines interface for all server dependencies
|
||||
- 9 abstract methods for different dependency types
|
||||
- Extensible for SaaS implementations
|
||||
|
||||
2. **DefaultServerContext**
|
||||
- Maintains exact behavior of original shared.py
|
||||
- Lazy initialization of all dependencies
|
||||
- No import-time side effects
|
||||
|
||||
3. **Context Provider System**
|
||||
- `get_server_context()` for FastAPI dependency injection
|
||||
- `set_context_class()` for global configuration
|
||||
- `create_server_context()` for testing/CLI usage
|
||||
|
||||
4. **Backward Compatibility Layer**
|
||||
- `shared.py` now uses `__getattr__` for lazy loading
|
||||
- All existing imports continue to work
|
||||
- Deprecation warnings guide migration
|
||||
|
||||
## SaaS Extensibility
|
||||
|
||||
### Multi-Tenant Context Example
|
||||
|
||||
```python
|
||||
class SaaSServerContext(ServerContext):
|
||||
def __init__(self, user_id: str, org_id: str):
|
||||
self.user_id = user_id
|
||||
self.org_id = org_id
|
||||
|
||||
def get_file_store(self):
|
||||
# Return tenant-isolated file store
|
||||
return MultiTenantFileStore(self.user_id, self.org_id)
|
||||
|
||||
def get_server_config(self):
|
||||
# Return SaaS-specific configuration
|
||||
return SaaSServerConfig(org_id=self.org_id)
|
||||
|
||||
# Configure globally
|
||||
set_context_class('myapp.context.SaaSServerContext')
|
||||
```
|
||||
|
||||
### Benefits for SaaS
|
||||
- **Per-tenant isolation**: Different storage, config, and features per organization
|
||||
- **Enterprise features**: Easy to add billing, advanced monitoring, etc.
|
||||
- **Scalable architecture**: Context per request enables horizontal scaling
|
||||
- **Clean separation**: SaaS code stays in external repo, extends OpenHands cleanly
|
||||
|
||||
## Migration Path
|
||||
|
||||
### For OpenHands Core
|
||||
- **Phase 1**: Refactoring complete, backward compatibility maintained
|
||||
- **Phase 2**: Gradually migrate routes to use dependency injection
|
||||
- **Phase 3**: Remove deprecated shared.py (future release)
|
||||
|
||||
### For SaaS Implementations
|
||||
- **Immediate**: Can use new context system for new features
|
||||
- **Gradual**: Migrate existing code using migration guide
|
||||
- **Benefits**: Cleaner architecture, better testing, easier deployment
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files
|
||||
- `openhands/server/context/__init__.py` - Public API
|
||||
- `openhands/server/context/server_context.py` - Abstract base class
|
||||
- `openhands/server/context/default_server_context.py` - Default implementation
|
||||
- `openhands/server/context/context_provider.py` - Dependency injection
|
||||
- `examples/saas_extension.py` - SaaS extension example
|
||||
- `MIGRATION_GUIDE.md` - Detailed migration instructions
|
||||
- `test_refactor.py` - Comprehensive test suite
|
||||
|
||||
### Modified Files
|
||||
- `openhands/server/shared.py` - Backward compatibility layer
|
||||
|
||||
## Testing Results
|
||||
|
||||
Comprehensive test suite with 5 test categories:
|
||||
|
||||
1. ✅ **Context System**: Import, creation, class switching
|
||||
2. ✅ **Backward Compatibility**: Lazy loading, attribute access
|
||||
3. ✅ **Abstract Base Class**: Proper abstraction, required methods
|
||||
4. ✅ **Default Context**: Instantiation, method availability
|
||||
5. ✅ **SaaS Example**: Multi-tenant context structure
|
||||
|
||||
**Result: 5/5 tests passed** 🎉
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### New Way (Recommended)
|
||||
```python
|
||||
from fastapi import Depends
|
||||
from openhands.server.context import get_server_context, ServerContext
|
||||
|
||||
@app.get('/conversations')
|
||||
async def get_conversations(
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
config = context.get_config()
|
||||
conversation_manager = context.get_conversation_manager()
|
||||
return conversation_manager.list_conversations()
|
||||
```
|
||||
|
||||
### Old Way (Still Works)
|
||||
```python
|
||||
from openhands.server.shared import config, conversation_manager
|
||||
|
||||
@app.get('/conversations')
|
||||
async def get_conversations():
|
||||
# Shows deprecation warning but works
|
||||
return conversation_manager.list_conversations()
|
||||
```
|
||||
|
||||
### SaaS Extension
|
||||
```python
|
||||
# In SaaS application startup
|
||||
from openhands.server.context import set_context_class
|
||||
set_context_class('myapp.context.SaaSServerContext')
|
||||
|
||||
# Routes automatically get tenant-aware context
|
||||
@app.get('/tenant/{org_id}/conversations')
|
||||
async def get_tenant_conversations(
|
||||
org_id: str,
|
||||
context: SaaSServerContext = Depends(get_server_context)
|
||||
):
|
||||
# context.org_id and context.user_id available
|
||||
# All dependencies are tenant-isolated
|
||||
conversation_manager = context.get_conversation_manager()
|
||||
return conversation_manager.list_conversations()
|
||||
```
|
||||
|
||||
## Benefits Achieved
|
||||
|
||||
### For OpenHands Core
|
||||
- ✅ **Better Architecture**: Clean dependency injection pattern
|
||||
- ✅ **Improved Testing**: Easy to mock dependencies
|
||||
- ✅ **No Breaking Changes**: Full backward compatibility
|
||||
- ✅ **Performance**: Lazy loading reduces startup time
|
||||
- ✅ **Type Safety**: Better IDE support and type checking
|
||||
|
||||
### For SaaS Implementations
|
||||
- ✅ **Multi-Tenancy**: Per-organization contexts and isolation
|
||||
- ✅ **Extensibility**: Easy to add enterprise features
|
||||
- ✅ **Clean Integration**: No need to fork OpenHands
|
||||
- ✅ **Deployment Flexibility**: Can run from external repos
|
||||
- ✅ **CI/CD Fixes**: No more environment variable dependencies
|
||||
|
||||
### For Development
|
||||
- ✅ **Maintainability**: Clear dependency relationships
|
||||
- ✅ **Debugging**: Easier to trace dependency issues
|
||||
- ✅ **Documentation**: Clear migration path and examples
|
||||
- ✅ **Future-Proof**: Extensible architecture for new features
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate**: Refactoring is complete and tested
|
||||
2. **Short-term**: Begin migrating core routes to use dependency injection
|
||||
3. **Medium-term**: SaaS implementations can adopt new context system
|
||||
4. **Long-term**: Remove deprecated shared.py in future major release
|
||||
|
||||
## Conclusion
|
||||
|
||||
The refactoring successfully addresses all the original problems:
|
||||
|
||||
- ❌ **Import-time dependencies** → ✅ **Lazy initialization**
|
||||
- ❌ **Global state pollution** → ✅ **Clean dependency injection**
|
||||
- ❌ **SaaS integration issues** → ✅ **Multi-tenant context system**
|
||||
- ❌ **Testing difficulties** → ✅ **Easy mocking and testing**
|
||||
- ❌ **No extensibility** → ✅ **Pluggable context implementations**
|
||||
|
||||
The new architecture enables OpenHands to support SaaS scenarios while maintaining full backward compatibility and improving the overall codebase quality.
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.55-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.54-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 for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
# OpenHands Extensibility Migration Guide
|
||||
|
||||
This guide explains how to migrate from the old global variable approach to the new factory-based extensibility system.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenHands has been refactored to eliminate import-time dependencies on environment variables and global state. This enables external repositories to cleanly extend OpenHands without configuration conflicts.
|
||||
|
||||
## The Problem We Solved
|
||||
|
||||
### Before (Problematic)
|
||||
```python
|
||||
# In OpenHands shared.py - loaded at import time
|
||||
config = Config() # Reads environment variables
|
||||
server_config = ServerConfig() # More environment variables
|
||||
|
||||
# External repos had to:
|
||||
# 1. Set environment variables before importing OpenHands
|
||||
# 2. Deal with global state conflicts
|
||||
# 3. Couldn't easily override specific behaviors
|
||||
```
|
||||
|
||||
### After (Clean)
|
||||
```python
|
||||
# External repos can now:
|
||||
from openhands.server.factory import create_openhands_app
|
||||
|
||||
app = create_openhands_app(
|
||||
context_factory=lambda: MyCustomContext(),
|
||||
include_oss_routes=False
|
||||
)
|
||||
```
|
||||
|
||||
## Migration Paths
|
||||
|
||||
### 1. For External Repositories (Recommended)
|
||||
|
||||
**Old Way (Don't do this):**
|
||||
```python
|
||||
# external_repo/main.py
|
||||
import os
|
||||
os.environ['OPENHANDS_CONFIG_CLS'] = 'my_config.MyConfig'
|
||||
os.environ['CONVERSATION_MANAGER_CLASS'] = 'my_manager.MyManager'
|
||||
|
||||
from openhands.server.app import app # Imports with global state
|
||||
```
|
||||
|
||||
**New Way (Recommended):**
|
||||
```python
|
||||
# external_repo/main.py
|
||||
from openhands.server.factory import create_openhands_app
|
||||
from external_repo.context import ExternalRepoContext
|
||||
|
||||
def create_app():
|
||||
return create_openhands_app(
|
||||
context_factory=lambda: ExternalRepoContext(),
|
||||
include_oss_routes=False, # Skip OSS-specific routes
|
||||
title='My Enterprise Platform'
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
|
||||
# Add your own routes
|
||||
@app.get('/enterprise/dashboard')
|
||||
async def dashboard():
|
||||
return {'status': 'enterprise'}
|
||||
```
|
||||
|
||||
### 2. For OpenHands Core Development
|
||||
|
||||
**Old Way:**
|
||||
```python
|
||||
# In route handlers
|
||||
from openhands.server.shared import config, server_config
|
||||
|
||||
@app.get('/example')
|
||||
async def example_route():
|
||||
storage_path = config.workspace_base
|
||||
app_mode = server_config.app_mode
|
||||
```
|
||||
|
||||
**New Way:**
|
||||
```python
|
||||
# In route handlers
|
||||
from fastapi import Depends
|
||||
from openhands.server.context import get_server_context, ServerContext
|
||||
|
||||
@app.get('/example')
|
||||
async def example_route(
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
config = context.get_config()
|
||||
server_config = context.get_server_config()
|
||||
storage_path = config.workspace_base
|
||||
app_mode = server_config.app_mode
|
||||
```
|
||||
|
||||
## Custom Context Implementation
|
||||
|
||||
### Step 1: Create Your Context Class
|
||||
|
||||
```python
|
||||
# my_extension/context.py
|
||||
from openhands.server.context.server_context import ServerContext
|
||||
|
||||
class MyCustomContext(ServerContext):
|
||||
def __init__(self, tenant_id: str = 'default'):
|
||||
super().__init__()
|
||||
self.tenant_id = tenant_id
|
||||
|
||||
def get_config(self):
|
||||
"""Override with tenant-specific configuration."""
|
||||
config = super().get_config()
|
||||
config.workspace_base = f'/data/tenants/{self.tenant_id}/workspace'
|
||||
return config
|
||||
|
||||
def get_server_config(self):
|
||||
"""Override server configuration."""
|
||||
server_config = super().get_server_config()
|
||||
server_config.app_mode = 'ENTERPRISE'
|
||||
server_config.enable_billing = True
|
||||
return server_config
|
||||
```
|
||||
|
||||
### Step 2: Create Your FastAPI App
|
||||
|
||||
```python
|
||||
# my_extension/app.py
|
||||
from openhands.server.factory import create_openhands_app
|
||||
from my_extension.context import MyCustomContext
|
||||
|
||||
def create_my_app():
|
||||
# Option A: Extend OpenHands app directly
|
||||
app = create_openhands_app(
|
||||
context_factory=lambda: MyCustomContext(),
|
||||
title='My Enterprise Platform'
|
||||
)
|
||||
|
||||
# Add your routes
|
||||
@app.get('/enterprise/status')
|
||||
async def enterprise_status():
|
||||
return {'mode': 'enterprise'}
|
||||
|
||||
return app
|
||||
|
||||
# Option B: Create your own app and mount OpenHands
|
||||
from fastapi import FastAPI
|
||||
|
||||
def create_my_app_with_mount():
|
||||
main_app = FastAPI(title='My Platform')
|
||||
|
||||
openhands_app = create_openhands_app(
|
||||
context_factory=lambda: MyCustomContext()
|
||||
)
|
||||
|
||||
main_app.mount('/openhands', openhands_app)
|
||||
|
||||
@main_app.get('/my-dashboard')
|
||||
async def dashboard():
|
||||
return {'dashboard': 'data'}
|
||||
|
||||
return main_app
|
||||
```
|
||||
|
||||
### Step 3: Run Your Application
|
||||
|
||||
```python
|
||||
# my_extension/main.py
|
||||
import uvicorn
|
||||
from my_extension.app import create_my_app
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_my_app()
|
||||
uvicorn.run(app, host='0.0.0.0', port=8000)
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Multi-Tenant Context
|
||||
|
||||
```python
|
||||
class MultiTenantContext(ServerContext):
|
||||
def __init__(self, request: Request):
|
||||
super().__init__()
|
||||
# Extract tenant from request
|
||||
self.tenant_id = request.headers.get('X-Tenant-ID', 'default')
|
||||
|
||||
def get_file_store(self):
|
||||
# Return tenant-isolated file store
|
||||
return TenantFileStore(tenant_id=self.tenant_id)
|
||||
|
||||
# Use with factory
|
||||
def create_tenant_context(request: Request):
|
||||
return MultiTenantContext(request)
|
||||
|
||||
app = create_openhands_app(
|
||||
context_factory=create_tenant_context
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Lifespan Management
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def my_lifespan(app: FastAPI):
|
||||
# Startup
|
||||
print("Starting my custom services...")
|
||||
await initialize_my_database()
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
print("Shutting down my custom services...")
|
||||
await cleanup_my_database()
|
||||
|
||||
app = create_openhands_app(
|
||||
context_factory=MyContext,
|
||||
custom_lifespan=my_lifespan
|
||||
)
|
||||
```
|
||||
|
||||
## Testing Your Extension
|
||||
|
||||
```python
|
||||
# tests/test_my_extension.py
|
||||
from fastapi.testclient import TestClient
|
||||
from my_extension.app import create_my_app
|
||||
|
||||
def test_my_extension():
|
||||
app = create_my_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test your custom routes
|
||||
response = client.get('/enterprise/status')
|
||||
assert response.status_code == 200
|
||||
assert response.json()['mode'] == 'enterprise'
|
||||
|
||||
# Test OpenHands routes still work
|
||||
response = client.get('/api/health')
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
## Benefits of the New Approach
|
||||
|
||||
1. **No Environment Variables**: Configuration is done through code, not environment variables
|
||||
2. **Clean Separation**: External repos don't modify OpenHands globals
|
||||
3. **Dependency Injection**: Proper FastAPI dependency injection patterns
|
||||
4. **Testability**: Easy to mock contexts for testing
|
||||
5. **Flexibility**: Can create multiple apps with different configurations
|
||||
6. **No Import-Time Side Effects**: Safe to import OpenHands modules
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The old `openhands.server.shared` module still works but is deprecated. It will show deprecation warnings and should be migrated to the new context system.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Don't set environment variables**: Use the factory pattern instead
|
||||
2. **Don't import `openhands.server.app` directly**: Use the factory to create your own app
|
||||
3. **Don't modify global state**: Use dependency injection through contexts
|
||||
4. **Don't forget to override dependencies**: Use `app.dependency_overrides` if needed
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you need help migrating your extension, please:
|
||||
1. Check the examples in `examples/external_repo_extension.py`
|
||||
2. Look at the test cases for patterns
|
||||
3. Open an issue with your specific use case
|
||||
|
||||
The new system is designed to be more flexible and maintainable while enabling clean extensibility for all types of OpenHands deployments.
|
||||
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -122,7 +122,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -46,14 +46,7 @@ When running on Linux, you might run into the error `ERROR:root:<class 'httpx.Co
|
||||
|
||||
**Resolution**
|
||||
|
||||
If you installed Docker from your distribution’s package repository (e.g., docker.io on Debian/Ubuntu), be aware that
|
||||
these packages can sometimes be outdated or include changes that cause compatibility issues. try reinstalling Docker
|
||||
[using the official instructions](https://docs.docker.com/engine/install/) to ensure you are running a compatible version.
|
||||
|
||||
If that does not solve the issue, try incrementally adding the following parameters to the docker run command:
|
||||
* `--network host`
|
||||
* `-e SANDBOX_USE_HOST_NETWORK=true`
|
||||
* `-e DOCKER_HOST_ADDR=127.0.0.1`
|
||||
* Add the `--network host` to the docker run command.
|
||||
|
||||
### Internal Server Error. Ports are not available
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# PolyForm Free Trial License 1.0.0
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the software
|
||||
to do everything you might do with the software that would
|
||||
otherwise infringe the licensor's copyright in it for any
|
||||
permitted purpose. However, you may only make changes or
|
||||
new works based on the software according to [Changes and New
|
||||
Works License](#changes-and-new-works-license), and you may
|
||||
not distribute copies of the software.
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to
|
||||
make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## Free Trial
|
||||
|
||||
Use of the software for more than 30 days per calendar year is not allowed without a commercial license.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
If you violate any of these terms, or do anything with the
|
||||
software not covered by your licenses, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
@@ -1,217 +0,0 @@
|
||||
"""Example of how an external repository can extend OpenHands.
|
||||
|
||||
This demonstrates the proper way for external repositories to build upon OpenHands
|
||||
without relying on environment variables or global state. The external repo can:
|
||||
|
||||
1. Create its own FastAPI app with custom context
|
||||
2. Add its own routes and middleware
|
||||
3. Include OpenHands routes as needed
|
||||
4. Override specific behaviors through dependency injection
|
||||
|
||||
This approach eliminates the need for environment variable configuration
|
||||
and allows clean separation between OpenHands core and extensions.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.server.context.server_context import ServerContext
|
||||
from openhands.server.factory import create_openhands_app
|
||||
|
||||
|
||||
# Step 1: Create your custom ServerContext
|
||||
class ExternalRepoContext(ServerContext):
|
||||
"""Custom context for external repository with enterprise features."""
|
||||
|
||||
def __init__(self, tenant_id: str = 'default', user_id: Optional[str] = None):
|
||||
super().__init__()
|
||||
self.tenant_id = tenant_id
|
||||
self.user_id = user_id
|
||||
self._custom_config = None
|
||||
|
||||
def get_config(self):
|
||||
"""Override config with tenant-specific settings."""
|
||||
config = super().get_config()
|
||||
|
||||
# Add tenant-specific configuration
|
||||
config.update({
|
||||
'tenant_id': self.tenant_id,
|
||||
'custom_storage_path': f'/data/tenants/{self.tenant_id}',
|
||||
'custom_feature_flags': {
|
||||
'enterprise_features': True,
|
||||
'advanced_analytics': True,
|
||||
}
|
||||
})
|
||||
|
||||
return config
|
||||
|
||||
def get_server_config(self):
|
||||
"""Override server config for enterprise deployment."""
|
||||
server_config = super().get_server_config()
|
||||
|
||||
# Customize for enterprise
|
||||
server_config.app_mode = 'ENTERPRISE' # Custom app mode
|
||||
server_config.enable_billing = True
|
||||
server_config.hide_llm_settings = False
|
||||
|
||||
return server_config
|
||||
|
||||
def get_file_store(self):
|
||||
"""Use tenant-isolated file storage."""
|
||||
# In a real implementation, this would return a tenant-aware file store
|
||||
file_store = super().get_file_store()
|
||||
# Customize file store for tenant isolation
|
||||
return file_store
|
||||
|
||||
|
||||
# Step 2: Create your custom lifespan (optional)
|
||||
@asynccontextmanager
|
||||
async def external_repo_lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Custom lifespan for external repo initialization."""
|
||||
print("🚀 Starting external repo services...")
|
||||
|
||||
# Initialize your custom services here
|
||||
# e.g., database connections, external API clients, etc.
|
||||
|
||||
yield
|
||||
|
||||
print("🛑 Shutting down external repo services...")
|
||||
# Cleanup your custom services here
|
||||
|
||||
|
||||
# Step 3: Create context factory for your needs
|
||||
def create_external_context(tenant_id: str = 'default') -> ExternalRepoContext:
|
||||
"""Factory function to create context instances."""
|
||||
return ExternalRepoContext(tenant_id=tenant_id)
|
||||
|
||||
|
||||
# Step 4: Create your FastAPI app with OpenHands integration
|
||||
def create_external_app() -> FastAPI:
|
||||
"""Create the external repository's FastAPI application."""
|
||||
|
||||
# Option A: Create OpenHands app with your custom context
|
||||
openhands_app = create_openhands_app(
|
||||
context_factory=lambda: create_external_context(),
|
||||
include_oss_routes=False, # Skip OSS routes for enterprise
|
||||
custom_lifespan=external_repo_lifespan,
|
||||
title='My Enterprise Platform',
|
||||
description='Enterprise platform built on OpenHands'
|
||||
)
|
||||
|
||||
# Option B: Create your own app and mount OpenHands
|
||||
main_app = FastAPI(
|
||||
title='My Enterprise Platform',
|
||||
description='Enterprise platform with OpenHands integration',
|
||||
version='1.0.0'
|
||||
)
|
||||
|
||||
# Add your custom routes
|
||||
@main_app.get('/enterprise/status')
|
||||
async def enterprise_status():
|
||||
return {'status': 'running', 'mode': 'enterprise'}
|
||||
|
||||
@main_app.get('/enterprise/tenant/{tenant_id}/info')
|
||||
async def tenant_info(
|
||||
tenant_id: str,
|
||||
request: Request,
|
||||
# Use dependency injection to get context
|
||||
context: ServerContext = Depends(lambda r: create_external_context(tenant_id))
|
||||
):
|
||||
config = context.get_config()
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'storage_path': config.get('custom_storage_path'),
|
||||
'features': config.get('custom_feature_flags', {})
|
||||
}
|
||||
|
||||
# Add custom middleware
|
||||
@main_app.middleware('http')
|
||||
async def tenant_middleware(request: Request, call_next):
|
||||
# Extract tenant from header or path
|
||||
tenant_id = request.headers.get('X-Tenant-ID', 'default')
|
||||
request.state.tenant_id = tenant_id
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers['X-Tenant-ID'] = tenant_id
|
||||
return response
|
||||
|
||||
# Mount OpenHands app at a subpath
|
||||
main_app.mount('/openhands', openhands_app)
|
||||
|
||||
return main_app
|
||||
|
||||
|
||||
# Step 5: Alternative approach - extend OpenHands app directly
|
||||
def create_extended_openhands_app() -> FastAPI:
|
||||
"""Alternative: extend OpenHands app directly with custom routes."""
|
||||
|
||||
app = create_openhands_app(
|
||||
context_factory=lambda: create_external_context(),
|
||||
custom_lifespan=external_repo_lifespan
|
||||
)
|
||||
|
||||
# Add your routes to the OpenHands app
|
||||
@app.get('/api/enterprise/dashboard')
|
||||
async def enterprise_dashboard(
|
||||
request: Request,
|
||||
context: ServerContext = Depends(lambda r: create_external_context())
|
||||
):
|
||||
config = context.get_config()
|
||||
return {
|
||||
'dashboard_data': 'enterprise_metrics',
|
||||
'tenant_features': config.get('custom_feature_flags', {})
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Example usage in external repo's main.py
|
||||
if __name__ == '__main__':
|
||||
import uvicorn
|
||||
|
||||
# Choose your approach
|
||||
app = create_external_app() # Full custom app with OpenHands mounted
|
||||
# app = create_extended_openhands_app() # Extended OpenHands app
|
||||
|
||||
# Run the server
|
||||
uvicorn.run(
|
||||
app,
|
||||
host='0.0.0.0',
|
||||
port=8000,
|
||||
reload=True
|
||||
)
|
||||
|
||||
|
||||
# Example of how to test the integration
|
||||
def test_external_integration():
|
||||
"""Test that the external integration works correctly."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app = create_external_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test custom routes
|
||||
response = client.get('/enterprise/status')
|
||||
assert response.status_code == 200
|
||||
assert response.json()['mode'] == 'enterprise'
|
||||
|
||||
# Test tenant-specific routes
|
||||
response = client.get('/enterprise/tenant/acme-corp/info')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['tenant_id'] == 'acme-corp'
|
||||
assert 'enterprise_features' in data['features']
|
||||
|
||||
# Test OpenHands routes still work
|
||||
response = client.get('/openhands/api/health')
|
||||
assert response.status_code == 200
|
||||
|
||||
print("✅ All integration tests passed!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Run tests
|
||||
test_external_integration()
|
||||
+79
-45
@@ -18,7 +18,6 @@ const mockUseGitRepositories = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
const mockUseRepositoryMicroagents = vi.fn();
|
||||
const mockUseMicroagentManagementConversations = vi.fn();
|
||||
const mockUseSearchRepositories = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
@@ -41,10 +40,6 @@ vi.mock("#/hooks/query/use-microagent-management-conversations", () => ({
|
||||
mockUseMicroagentManagementConversations(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-search-repositories", () => ({
|
||||
useSearchRepositories: () => mockUseSearchRepositories(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@@ -224,12 +219,6 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
data: [...mockRepositories],
|
||||
@@ -754,24 +743,17 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
await user.type(searchInput, "nonexistent");
|
||||
|
||||
// Wait for debounced search to complete (300ms debounce + buffer)
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
// Wait for the search to complete and check that no repositories are visible
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/repo2/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/TestRepository"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
// No repositories should be visible
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/repo2/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle special characters in search", async () => {
|
||||
@@ -1290,14 +1272,11 @@ describe("MicroagentManagement", () => {
|
||||
// Add microagent integration tests
|
||||
describe("Add microagent functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: [{ name: "main", commit_sha: "abc123", protected: false }]
|
||||
.length,
|
||||
});
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue(({ branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
].length }) );
|
||||
});
|
||||
|
||||
it("should render add microagent button", async () => {
|
||||
@@ -1983,14 +1962,11 @@ describe("MicroagentManagement", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: [{ name: "main", commit_sha: "abc123", protected: false }]
|
||||
.length,
|
||||
});
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue(({ branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
].length }) );
|
||||
});
|
||||
|
||||
it("should render update microagent modal when updateMicroagentModalVisible is true", async () => {
|
||||
@@ -2545,6 +2521,64 @@ describe("MicroagentManagement", () => {
|
||||
screen.queryByTestId("learn-this-repo-trigger"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle API call for branches when learn this repo modal opens", async () => {
|
||||
// Mock branch API
|
||||
const branchesSpy = vi
|
||||
.spyOn(OpenHands, "getRepositoryBranches")
|
||||
.mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
{ name: "develop", commit_sha: "def456", protected: false },
|
||||
],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: 2,
|
||||
});
|
||||
|
||||
// Mock other APIs
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"getRepositoryMicroagents",
|
||||
);
|
||||
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
|
||||
getRepositoryMicroagentsSpy.mockResolvedValue([]);
|
||||
searchConversationsSpy.mockResolvedValue([]);
|
||||
|
||||
// Test with direct Redux state that has modal visible
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: null,
|
||||
addMicroagentModalVisible: false,
|
||||
updateMicroagentModalVisible: false,
|
||||
learnThisRepoModalVisible: true, // Modal should be visible
|
||||
selectedRepository: {
|
||||
id: "1",
|
||||
full_name: "test-org/test-repo",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-01T12:00:00Z",
|
||||
},
|
||||
personalRepositories: [],
|
||||
organizationRepositories: [],
|
||||
repositories: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The branches API should be called when the modal is visible
|
||||
await waitFor(() => {
|
||||
expect(branchesSpy).toHaveBeenCalledWith("test-org/test-repo");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Learn something new button functionality tests
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.55.0",
|
||||
"version": "0.54.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.55.0",
|
||||
"version": "0.54.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.2",
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.55.0",
|
||||
"version": "0.54.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -31,7 +31,6 @@ import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
|
||||
import { SubscriptionAccess } from "#/types/billing";
|
||||
|
||||
class OpenHands {
|
||||
private static currentConversation: Conversation | null = null;
|
||||
@@ -434,13 +433,6 @@ class OpenHands {
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
|
||||
const { data } = await openHands.get<SubscriptionAccess | null>(
|
||||
"/api/billing/subscription-access",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getGitUser(): Promise<GitUser> {
|
||||
const response = await openHands.get<GitUser>("/api/user/info");
|
||||
|
||||
|
||||
@@ -49,11 +49,13 @@ export interface GetConfigResponse {
|
||||
APP_SLUG?: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
STRIPE_PUBLISHABLE_KEY?: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
HIDE_MICROAGENT_MANAGEMENT?: boolean;
|
||||
ENABLE_JIRA: boolean;
|
||||
ENABLE_JIRA_DC: boolean;
|
||||
ENABLE_LINEAR: boolean;
|
||||
|
||||
+1
@@ -288,6 +288,7 @@ export function MicroagentManagementContent() {
|
||||
conversationInstructions: formData.query,
|
||||
repository: {
|
||||
name: repositoryName,
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
|
||||
+114
-3
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
@@ -10,6 +10,13 @@ import { RootState } from "#/store";
|
||||
import XIcon from "#/icons/x.svg?react";
|
||||
import { cn, getRepoMdCreatePrompt } from "#/utils/utils";
|
||||
import { LearnThisRepoFormData } from "#/types/microagent-management";
|
||||
import { Branch } from "#/types/git";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import {
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "../home/repository-selection";
|
||||
|
||||
interface MicroagentManagementLearnThisRepoModalProps {
|
||||
onConfirm: (formData: LearnThisRepoFormData) => void;
|
||||
@@ -25,11 +32,47 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = useRef<boolean>(false);
|
||||
|
||||
const {
|
||||
data: branches,
|
||||
isLoading: isLoadingBranches,
|
||||
isError: isBranchesError,
|
||||
} = useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
|
||||
const branchesItems = branches?.map((branch) => ({
|
||||
key: branch.name,
|
||||
label: branch.name,
|
||||
}));
|
||||
|
||||
// Auto-select main or master branch if it exists.
|
||||
useEffect(() => {
|
||||
if (
|
||||
branches &&
|
||||
branches.length > 0 &&
|
||||
!selectedBranch &&
|
||||
!isLoadingBranches
|
||||
) {
|
||||
// Look for main or master branch
|
||||
const mainBranch = branches.find((branch) => branch.name === "main");
|
||||
const masterBranch = branches.find((branch) => branch.name === "master");
|
||||
|
||||
// Select main if it exists, otherwise select master if it exists
|
||||
if (mainBranch) {
|
||||
setSelectedBranch(mainBranch);
|
||||
} else if (masterBranch) {
|
||||
setSelectedBranch(masterBranch);
|
||||
}
|
||||
}
|
||||
}, [branches, isLoadingBranches, selectedBranch]);
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -40,6 +83,7 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
|
||||
onConfirm({
|
||||
query: finalQuery,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -51,9 +95,66 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
|
||||
onConfirm({
|
||||
query: finalQuery,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleBranchSelection = (key: React.Key | null) => {
|
||||
const selectedBranchObj = branches?.find((branch) => branch.name === key);
|
||||
setSelectedBranch(selectedBranchObj || null);
|
||||
// Reset the manually cleared flag when a branch is explicitly selected
|
||||
branchManuallyClearedRef.current = false;
|
||||
};
|
||||
|
||||
const handleBranchInputChange = (value: string) => {
|
||||
// Clear the selected branch if the input is empty or contains only whitespace
|
||||
// This fixes the issue where users can't delete the entire default branch name
|
||||
if (value === "" || value.trim() === "") {
|
||||
setSelectedBranch(null);
|
||||
// Set the flag to indicate that the branch was manually cleared
|
||||
branchManuallyClearedRef.current = true;
|
||||
} else {
|
||||
// Reset the flag when the user starts typing again
|
||||
branchManuallyClearedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Render the appropriate UI for branch selector based on the loading/error state
|
||||
const renderBranchSelector = () => {
|
||||
if (!selectedRepository) {
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={[]}
|
||||
onSelectionChange={() => {}}
|
||||
onInputChange={() => {}}
|
||||
isDisabled
|
||||
wrapperClassName="max-w-full w-full"
|
||||
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingBranches) {
|
||||
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
|
||||
}
|
||||
|
||||
if (isBranchesError) {
|
||||
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={branchesItems || []}
|
||||
onSelectionChange={handleBranchSelection}
|
||||
onInputChange={handleBranchInputChange}
|
||||
isDisabled={false}
|
||||
selectedKey={selectedBranch?.name}
|
||||
wrapperClassName="max-w-full w-full"
|
||||
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onCancel}>
|
||||
<ModalBody
|
||||
@@ -99,6 +200,9 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<div data-testid="branch-selector-container">
|
||||
{renderBranchSelector()}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2 w-full text-sm font-normal"
|
||||
@@ -141,9 +245,16 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={isLoading}
|
||||
isDisabled={
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isBranchesError
|
||||
}
|
||||
>
|
||||
{isLoading ? t(I18nKey.HOME$LOADING) : t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
{isLoading || isLoadingBranches
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
+1
-15
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Accordion, AccordionItem, Spinner } from "@heroui/react";
|
||||
import { Accordion, AccordionItem } from "@heroui/react";
|
||||
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { TabType } from "#/types/microagent-management";
|
||||
@@ -11,30 +11,16 @@ import { MicroagentManagementAccordionTitle } from "./microagent-management-acco
|
||||
type MicroagentManagementRepositoriesProps = {
|
||||
repositories: GitRepository[];
|
||||
tabType: TabType;
|
||||
isSearchLoading?: boolean;
|
||||
};
|
||||
|
||||
export function MicroagentManagementRepositories({
|
||||
repositories,
|
||||
tabType,
|
||||
isSearchLoading = false,
|
||||
}: MicroagentManagementRepositoriesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const numberOfRepoMicroagents = repositories.length;
|
||||
|
||||
// Show spinner when search is in progress, regardless of repository count
|
||||
if (isSearchLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-white">
|
||||
{t("HOME$SEARCHING_REPOSITORIES")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (numberOfRepoMicroagents === 0) {
|
||||
if (tabType === "personal") {
|
||||
return (
|
||||
|
||||
+1
-10
@@ -5,13 +5,7 @@ import { MicroagentManagementRepositories } from "./microagent-management-reposi
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
interface MicroagentManagementSidebarTabsProps {
|
||||
isSearchLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MicroagentManagementSidebarTabs({
|
||||
isSearchLoading = false,
|
||||
}: MicroagentManagementSidebarTabsProps) {
|
||||
export function MicroagentManagementSidebarTabs() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { repositories, personalRepositories, organizationRepositories } =
|
||||
@@ -35,21 +29,18 @@ export function MicroagentManagementSidebarTabs({
|
||||
<MicroagentManagementRepositories
|
||||
repositories={personalRepositories}
|
||||
tabType="personal"
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
|
||||
<MicroagentManagementRepositories
|
||||
repositories={repositories}
|
||||
tabType="repositories"
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
|
||||
<MicroagentManagementRepositories
|
||||
repositories={organizationRepositories}
|
||||
tabType="organizations"
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
+19
-77
@@ -5,7 +5,6 @@ import { Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
|
||||
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
|
||||
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
@@ -17,7 +16,6 @@ import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
|
||||
interface MicroagentManagementSidebarProps {
|
||||
isSmallerScreen?: boolean;
|
||||
@@ -33,29 +31,17 @@ export function MicroagentManagementSidebar({
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Use Git repositories hook with pagination for infinite scrolling
|
||||
const {
|
||||
data: repositories,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
} = useGitRepositories({
|
||||
const { data: repositories, isLoading } = useGitRepositories({
|
||||
provider: selectedProvider,
|
||||
pageSize: 30, // Load 30 repositories per page
|
||||
pageSize: 200,
|
||||
enabled: !!selectedProvider,
|
||||
});
|
||||
|
||||
// Server-side search functionality
|
||||
const { data: searchResults, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(debouncedSearchQuery, selectedProvider, 500); // Increase page size to 500 to to retrieve all search results. This should be optimized in the future.
|
||||
|
||||
// Auto-select provider if there's only one
|
||||
useEffect(() => {
|
||||
if (providers.length > 0 && !selectedProvider) {
|
||||
@@ -68,31 +54,23 @@ export function MicroagentManagementSidebar({
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
// Filter repositories based on search query and available data
|
||||
// Filter repositories based on search query
|
||||
const filteredRepositories = useMemo(() => {
|
||||
// If we have search results, use them directly (no filtering needed)
|
||||
if (debouncedSearchQuery && searchResults && searchResults.length > 0) {
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
// If no search query or no search results, use paginated repositories
|
||||
if (!repositories?.pages) return [];
|
||||
if (!repositories?.pages) return null;
|
||||
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
// If no search query, return all repositories
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
if (!searchQuery.trim()) {
|
||||
return allRepositories;
|
||||
}
|
||||
|
||||
// Fallback to client-side filtering if search didn't return results
|
||||
const sanitizedQuery = sanitizeQuery(debouncedSearchQuery);
|
||||
const sanitizedQuery = sanitizeQuery(searchQuery);
|
||||
return allRepositories.filter((repository: GitRepository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
}, [repositories, debouncedSearchQuery, searchResults]);
|
||||
}, [repositories, searchQuery, selectedProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filteredRepositories?.length) {
|
||||
@@ -126,28 +104,12 @@ export function MicroagentManagementSidebar({
|
||||
dispatch(setRepositories(otherRepos));
|
||||
}, [filteredRepositories, selectedProvider, dispatch]);
|
||||
|
||||
// Handle scroll to bottom for pagination
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
// Only enable pagination when not searching
|
||||
if (debouncedSearchQuery && searchResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
|
||||
|
||||
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
|
||||
isSmallerScreen && "w-full border-none",
|
||||
)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<MicroagentManagementSidebarHeader />
|
||||
|
||||
@@ -169,26 +131,18 @@ export function MicroagentManagementSidebar({
|
||||
<label htmlFor="repository-search" className="sr-only">
|
||||
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="repository-search"
|
||||
name="repository-search"
|
||||
type="text"
|
||||
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
|
||||
"pr-10", // Space for spinner
|
||||
)}
|
||||
/>
|
||||
{isSearchLoading && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
<input
|
||||
id="repository-search"
|
||||
name="repository-search"
|
||||
type="text"
|
||||
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -199,19 +153,7 @@ export function MicroagentManagementSidebar({
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MicroagentManagementSidebarTabs isSearchLoading={isSearchLoading} />
|
||||
|
||||
{/* Show loading indicator for pagination (only when not searching) */}
|
||||
{isFetchingNextPage && !debouncedSearchQuery && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-white ml-2">
|
||||
{t("HOME$LOADING_MORE_REPOSITORIES")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<MicroagentManagementSidebarTabs />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,9 @@ export function Sidebar() {
|
||||
const shouldHideLlmSettings =
|
||||
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && config?.APP_MODE === "saas";
|
||||
|
||||
const shouldHideMicroagentManagement =
|
||||
config?.FEATURE_FLAGS.HIDE_MICROAGENT_MANAGEMENT;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldHideLlmSettings) return;
|
||||
|
||||
@@ -80,9 +83,11 @@ export function Sidebar() {
|
||||
}
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
{!shouldHideMicroagentManagement && (
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
import RobotIcon from "#/icons/robot.svg?react";
|
||||
import UnionIcon from "#/icons/union.svg?react";
|
||||
|
||||
interface MicroagentManagementButtonProps {
|
||||
disabled?: boolean;
|
||||
@@ -22,7 +22,7 @@ export function MicroagentManagementButton({
|
||||
testId="microagent-management-button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RobotIcon width={28} height={28} />
|
||||
<UnionIcon />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,11 @@ import { Provider } from "#/types/settings";
|
||||
export function useSearchRepositories(
|
||||
query: string,
|
||||
selectedProvider?: Provider | null,
|
||||
pageSize: number = 3,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
|
||||
queryKey: ["repositories", "search", query, selectedProvider],
|
||||
queryFn: () =>
|
||||
OpenHands.searchGitRepositories(
|
||||
query,
|
||||
pageSize,
|
||||
selectedProvider || undefined,
|
||||
),
|
||||
OpenHands.searchGitRepositories(query, 3, selectedProvider || undefined),
|
||||
enabled: !!query && !!selectedProvider,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useSubscriptionAccess = () => {
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "subscription_access"],
|
||||
queryFn: OpenHands.getSubscriptionAccess,
|
||||
enabled:
|
||||
!isOnTosPage &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
config?.FEATURE_FLAGS?.ENABLE_BILLING,
|
||||
});
|
||||
};
|
||||
@@ -85,8 +85,6 @@ export enum I18nKey {
|
||||
HOME$CONNECT_TO_REPOSITORY_TOOLTIP = "HOME$CONNECT_TO_REPOSITORY_TOOLTIP",
|
||||
HOME$LOADING = "HOME$LOADING",
|
||||
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
|
||||
HOME$SEARCHING_REPOSITORIES = "HOME$SEARCHING_REPOSITORIES",
|
||||
HOME$LOADING_MORE_REPOSITORIES = "HOME$LOADING_MORE_REPOSITORIES",
|
||||
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
|
||||
HOME$LOADING_BRANCHES = "HOME$LOADING_BRANCHES",
|
||||
HOME$FAILED_TO_LOAD_BRANCHES = "HOME$FAILED_TO_LOAD_BRANCHES",
|
||||
|
||||
@@ -1359,38 +1359,6 @@
|
||||
"de": "Repositories werden geladen...",
|
||||
"uk": "Завантаження репозиторіїв..."
|
||||
},
|
||||
"HOME$SEARCHING_REPOSITORIES": {
|
||||
"en": "Searching repositories...",
|
||||
"ja": "リポジトリを検索中...",
|
||||
"zh-CN": "搜索仓库中...",
|
||||
"zh-TW": "搜尋儲存庫中...",
|
||||
"ko-KR": "저장소 검색 중...",
|
||||
"no": "Søker i repositories...",
|
||||
"it": "Ricerca repository in corso...",
|
||||
"pt": "Pesquisando repositórios...",
|
||||
"es": "Buscando repositorios...",
|
||||
"ar": "جار البحث في المستودعات...",
|
||||
"fr": "Recherche de dépôts...",
|
||||
"tr": "Depolar aranıyor...",
|
||||
"de": "Repositories werden durchsucht...",
|
||||
"uk": "Пошук репозиторіїв..."
|
||||
},
|
||||
"HOME$LOADING_MORE_REPOSITORIES": {
|
||||
"en": "Loading more repositories...",
|
||||
"ja": "さらに多くのリポジトリを読み込み中...",
|
||||
"zh-CN": "加载更多仓库中...",
|
||||
"zh-TW": "載入更多儲存庫中...",
|
||||
"ko-KR": "더 많은 저장소 로딩 중...",
|
||||
"no": "Laster flere repositories...",
|
||||
"it": "Caricamento di altri repository...",
|
||||
"pt": "Carregando mais repositórios...",
|
||||
"es": "Cargando más repositorios...",
|
||||
"ar": "جار تحميل المزيد من المستودعات...",
|
||||
"fr": "Chargement de plus de dépôts...",
|
||||
"tr": "Daha fazla depolar yükleniyor...",
|
||||
"de": "Weitere Repositories werden geladen...",
|
||||
"uk": "Завантаження більше репозиторіїв..."
|
||||
},
|
||||
"HOME$FAILED_TO_LOAD_REPOSITORIES": {
|
||||
"en": "Failed to load repositories",
|
||||
"ja": "リポジトリの読み込みに失敗しました",
|
||||
@@ -11984,20 +11952,20 @@
|
||||
"uk": "Бажаєте, щоб OpenHands розпочав нову розмову, щоб допомогти вам зрозуміти цей репозиторій?"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"en": "What would you like to know about this repository? (optional)",
|
||||
"ja": "このリポジトリについて知りたいことは何ですか?(任意)",
|
||||
"zh-CN": "您想了解此存储库的哪些内容?(可选)",
|
||||
"zh-TW": "您想了解此存儲庫的哪些內容?(選填)",
|
||||
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요? (선택 사항)",
|
||||
"no": "Hva vil du vite om dette depotet? (valgfritt)",
|
||||
"it": "Cosa vorresti sapere su questo repository? (opzionale)",
|
||||
"pt": "O que você gostaria de saber sobre este repositório? (opcional)",
|
||||
"es": "¿Qué te gustaría saber sobre este repositorio? (opcional)",
|
||||
"ar": "ماذا ترغب في معرفته عن هذا المستودع؟ (اختياري)",
|
||||
"fr": "Que souhaitez-vous savoir sur ce dépôt ? (facultatif)",
|
||||
"tr": "Bu depo hakkında ne bilmek istersiniz? (isteğe bağlı)",
|
||||
"de": "Was möchten Sie über dieses Repository wissen? (optional)",
|
||||
"uk": "Що ви хотіли б дізнатися про цей репозиторій? (необов'язково)"
|
||||
"en": "What would you like to know about this repository?",
|
||||
"ja": "このリポジトリについて何を知りたいですか?",
|
||||
"zh-CN": "您想了解此存储库的哪些内容?",
|
||||
"zh-TW": "您想了解此存儲庫的哪些內容?",
|
||||
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요?",
|
||||
"no": "Hva vil du vite om dette depotet?",
|
||||
"it": "Cosa vorresti sapere su questo repository?",
|
||||
"pt": "O que você gostaria de saber sobre este repositório?",
|
||||
"es": "¿Qué te gustaría saber sobre este repositorio?",
|
||||
"ar": "ماذا تريد أن تعرف عن هذا المستودع؟",
|
||||
"fr": "Que souhaitez-vous savoir sur ce dépôt ?",
|
||||
"tr": "Bu depo hakkında ne bilmek istersiniz?",
|
||||
"de": "Was möchten Sie über dieses Repository wissen?",
|
||||
"uk": "Що ви хотіли б дізнатися про цей репозиторій?"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"en": "Describe what you would like to know about this repository.",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="47" height="42" viewBox="0 0 47 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.58409 21.782C2.01747 21.782 1.48418 22.0153 1.11755 22.3819C0.750916 22.7486 0.517578 23.3152 0.517578 23.8485V28.0148C0.517578 28.5814 0.750916 29.1147 1.11755 29.4813C1.51751 29.8813 2.05081 30.0813 2.58409 30.0813C3.11737 30.0813 3.684 29.8479 4.05063 29.4813C4.45059 29.0814 4.65055 28.5481 4.65055 28.0148V23.8485C4.65055 23.2819 4.41726 22.7486 4.05063 22.3819C3.684 22.0153 3.11737 21.782 2.58409 21.782ZM44.2802 21.782C43.7136 21.782 43.1803 22.0153 42.8137 22.3819C42.4471 22.7486 42.2138 23.3152 42.2138 23.8485V28.0148C42.2138 28.5814 42.4471 29.1147 42.8137 29.4813C43.2137 29.8813 43.747 30.0813 44.2802 30.0813C44.8135 30.0813 45.3801 29.8479 45.7468 29.4813C46.1467 29.0814 46.3467 28.5481 46.3467 28.0148V23.8485C46.3467 23.2819 46.1134 22.7486 45.7468 22.3819C45.3801 22.0153 44.8135 21.782 44.2802 21.782ZM21.349 10.3164H13.0164C11.3499 10.3164 9.75011 10.983 8.58355 12.1496C7.41699 13.3161 6.75037 14.916 6.75037 16.5825V35.3474C6.75037 37.0139 7.41699 38.6138 8.58355 39.7804C9.75011 40.9469 11.3499 41.6135 13.0164 41.6135H33.8812C35.5477 41.6135 37.1476 40.9469 38.3141 39.7804C39.4807 38.6138 40.1473 37.0139 40.1473 35.3474V16.5825C40.1473 14.916 39.4807 13.3161 38.3141 12.1496C37.1476 10.983 35.5477 10.3164 33.8812 10.3164H25.5486M19.8491 14.4827H27.0152ZM31.3148 14.4827H33.8478C34.4145 14.4827 34.9478 14.716 35.3144 15.0826C35.7144 15.4826 35.9144 16.0159 35.9144 16.5492V35.3141C35.9144 35.8807 35.681 36.3807 35.3144 36.7806C34.9144 37.1806 34.3811 37.3806 33.8478 37.3806H12.9831C12.4165 37.3806 11.8832 37.1473 11.5166 36.7806C11.1166 36.3807 10.9167 35.8474 10.9167 35.3141V16.5492C10.9167 15.9825 11.1499 15.4493 11.5166 15.0826C11.9165 14.6827 12.4498 14.4827 12.9831 14.4827H15.5162M17.7494 21.782C17.1828 21.782 16.6495 22.0153 16.2828 22.3819C15.9162 22.7486 15.6829 23.3152 15.6829 23.8485V28.0148C15.6829 28.5814 15.9162 29.1147 16.2828 29.4813C16.6828 29.8813 17.2161 30.0813 17.7494 30.0813C18.2827 30.0813 18.8492 29.8479 19.2159 29.4813C19.6158 29.0814 19.8158 28.5481 19.8158 28.0148V23.8485C19.8158 23.2819 19.5825 22.7486 19.2159 22.3819C18.8492 22.0153 18.2827 21.782 17.7494 21.782ZM29.0816 21.782C28.515 21.782 27.9817 22.0153 27.6151 22.3819C27.2485 22.7486 27.0152 23.3152 27.0152 23.8485V28.0148C27.0152 28.5814 27.2485 29.1147 27.6151 29.4813C28.0151 29.8813 28.5484 30.0813 29.0816 30.0813C29.6149 30.0813 30.1816 29.8479 30.5482 29.4813C30.9481 29.0814 31.1481 28.5481 31.1481 28.0148V23.8485C31.1481 23.2819 30.9148 22.7486 30.5482 22.3819C30.1816 22.0153 29.6149 21.782 29.0816 21.782Z" fill="currentColor"/>
|
||||
<path d="M23.4122 0.851806C22.7122 0.851806 22.0123 1.05179 21.4123 1.45175C20.8124 1.85171 20.3791 2.41834 20.0791 3.05162C19.8125 3.71822 19.7458 4.41814 19.8792 5.11808C20.0125 5.81801 20.3458 6.4513 20.8457 6.95125C21.3457 7.45121 21.979 7.78451 22.6789 7.91783C23.3788 8.05115 24.1121 7.98448 24.7454 7.71783C25.412 7.45119 25.9452 6.98459 26.3452 6.38464C26.7452 5.7847 26.9452 5.11807 26.9452 4.38481C26.9452 3.41823 26.5785 2.51833 25.8786 1.85172C25.1786 1.18512 24.2787 0.785156 23.3455 0.785156L23.4122 0.851806Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -169,6 +169,7 @@ export const handlers = [
|
||||
APP_MODE: mockSaas ? "saas" : "oss",
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
STRIPE_PUBLISHABLE_KEY: "",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: mockSaas,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from "react";
|
||||
import { NavLink, Outlet, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
@@ -9,7 +8,6 @@ import { Route } from "./+types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
|
||||
const SAAS_ONLY_PATHS = [
|
||||
"/settings/user",
|
||||
@@ -64,22 +62,10 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
// this is used to determine which settings are available in the UI
|
||||
const navItems = useMemo(() => {
|
||||
const items = [];
|
||||
if (isSaas) {
|
||||
if (subscriptionAccess) {
|
||||
items.push({ to: "/settings", text: "SETTINGS$NAV_LLM" });
|
||||
}
|
||||
items.push(...SAAS_NAV_ITEMS);
|
||||
} else {
|
||||
items.push(...OSS_NAV_ITEMS);
|
||||
}
|
||||
return items;
|
||||
}, [isSaas, !!subscriptionAccess]);
|
||||
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
|
||||
|
||||
return (
|
||||
<main
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export type SubscriptionAccess = {
|
||||
status: "ACTIVE" | "DISABLED";
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -22,4 +22,5 @@ export interface MicroagentFormData {
|
||||
|
||||
export interface LearnThisRepoFormData {
|
||||
query: string;
|
||||
selectedBranch: string;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,7 @@ from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.event_store_abc import EventStoreABC
|
||||
from openhands.events.serialization.event import event_from_dict
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import (
|
||||
get_conversation_dir,
|
||||
get_conversation_event_filename,
|
||||
get_conversation_events_dir,
|
||||
)
|
||||
from openhands.storage.paths import ConversationPaths
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
|
||||
@@ -44,12 +40,20 @@ _DUMMY_PAGE = _CachePage(None, 1, -1)
|
||||
class EventStore(EventStoreABC):
|
||||
"""A stored list of events backing a conversation"""
|
||||
|
||||
sid: str
|
||||
paths: ConversationPaths
|
||||
file_store: FileStore
|
||||
user_id: str | None
|
||||
cache_size: int = 25
|
||||
_cur_id: int | None = None # Private field to cache the calculated value
|
||||
|
||||
# Back-compat for code that reads these attributes directly
|
||||
@property
|
||||
def sid(self) -> str: # type: ignore[override]
|
||||
return self.paths.sid
|
||||
|
||||
@property
|
||||
def user_id(self) -> str | None: # type: ignore[override]
|
||||
return self.paths.user_id
|
||||
|
||||
@property
|
||||
def cur_id(self) -> int:
|
||||
"""Lazy calculated property for the current event ID."""
|
||||
@@ -66,10 +70,12 @@ class EventStore(EventStoreABC):
|
||||
"""Calculate the current event ID based on file system content."""
|
||||
events = []
|
||||
try:
|
||||
events_dir = get_conversation_events_dir(self.sid, self.user_id)
|
||||
events_dir = self.paths.events_dir()
|
||||
events = self.file_store.list(events_dir)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'No events found for session {self.sid} at {events_dir}')
|
||||
logger.debug(
|
||||
f'No events found for session {self.paths.sid} at {events_dir}'
|
||||
)
|
||||
|
||||
if not events:
|
||||
return 0
|
||||
@@ -136,7 +142,7 @@ class EventStore(EventStoreABC):
|
||||
return
|
||||
|
||||
def get_event(self, id: int) -> Event:
|
||||
filename = self._get_filename_for_id(id, self.user_id)
|
||||
filename = self.paths.event_filename(id)
|
||||
content = self.file_store.read(filename)
|
||||
data = json.loads(content)
|
||||
return event_from_dict(data)
|
||||
@@ -153,10 +159,10 @@ class EventStore(EventStoreABC):
|
||||
yield event
|
||||
|
||||
def _get_filename_for_id(self, id: int, user_id: str | None) -> str:
|
||||
return get_conversation_event_filename(self.sid, id, user_id)
|
||||
return self.paths.event_filename(id)
|
||||
|
||||
def _get_filename_for_cache(self, start: int, end: int) -> str:
|
||||
return f'{get_conversation_dir(self.sid, self.user_id)}event_cache/{start}-{end}.json'
|
||||
return self.paths.event_cache_filename(start, end)
|
||||
|
||||
def _load_cache_page(self, start: int, end: int) -> _CachePage:
|
||||
"""Read a page from the cache. Reading individual events is slow when there are a lot of them, so we use pages."""
|
||||
|
||||
@@ -13,9 +13,7 @@ from openhands.events.event_store import EventStore
|
||||
from openhands.events.serialization.event import event_from_dict, event_to_dict
|
||||
from openhands.io import json
|
||||
from openhands.storage import FileStore
|
||||
from openhands.storage.locations import (
|
||||
get_conversation_dir,
|
||||
)
|
||||
from openhands.storage.paths import ConversationPaths
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
@@ -34,7 +32,8 @@ async def session_exists(
|
||||
sid: str, file_store: FileStore, user_id: str | None = None
|
||||
) -> bool:
|
||||
try:
|
||||
await call_sync_from_async(file_store.list, get_conversation_dir(sid, user_id))
|
||||
paths = ConversationPaths(sid=sid, user_id=user_id)
|
||||
await call_sync_from_async(file_store.list, paths.conversation_dir())
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
@@ -54,7 +53,7 @@ class EventStream(EventStore):
|
||||
_write_page_cache: list[dict]
|
||||
|
||||
def __init__(self, sid: str, file_store: FileStore, user_id: str | None = None):
|
||||
super().__init__(sid, file_store, user_id)
|
||||
super().__init__(ConversationPaths(sid=sid, user_id=user_id), file_store)
|
||||
self._stop_flag = threading.Event()
|
||||
self._queue: queue.Queue[Event] = queue.Queue()
|
||||
self._thread_pools = {}
|
||||
|
||||
@@ -2,7 +2,7 @@ import io
|
||||
import re
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Union
|
||||
from typing import Union
|
||||
|
||||
import frontmatter
|
||||
from pydantic import BaseModel
|
||||
@@ -23,31 +23,6 @@ class BaseMicroagent(BaseModel):
|
||||
source: str # path to the file
|
||||
type: MicroagentType
|
||||
|
||||
PATH_TO_THIRD_PARTY_MICROAGENT_NAME: ClassVar[dict[str, str]] = {
|
||||
'.cursorrules': 'cursorrules',
|
||||
'agents.md': 'agents',
|
||||
'agent.md': 'agents',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _handle_third_party(
|
||||
cls, path: Path, file_content: str
|
||||
) -> Union['RepoMicroagent', None]:
|
||||
# Determine the agent name based on file type
|
||||
microagent_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(path.name.lower())
|
||||
|
||||
# Create RepoMicroagent if we recognized the file type
|
||||
if microagent_name is not None:
|
||||
return RepoMicroagent(
|
||||
name=microagent_name,
|
||||
content=file_content,
|
||||
metadata=MicroagentMetadata(name=microagent_name),
|
||||
source=str(path),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
@@ -65,10 +40,11 @@ class BaseMicroagent(BaseModel):
|
||||
# Otherwise, we will rely on the name from metadata later
|
||||
derived_name = None
|
||||
if microagent_dir is not None:
|
||||
# Special handling for files which are not in microagent_dir
|
||||
derived_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(
|
||||
path.name.lower()
|
||||
) or str(path.relative_to(microagent_dir).with_suffix(''))
|
||||
# Special handling for .cursorrules files which are not in microagent_dir
|
||||
if path.name == '.cursorrules':
|
||||
derived_name = 'cursorrules'
|
||||
else:
|
||||
derived_name = str(path.relative_to(microagent_dir).with_suffix(''))
|
||||
|
||||
# Only load directly from path if file_content is not provided
|
||||
if file_content is None:
|
||||
@@ -85,10 +61,15 @@ class BaseMicroagent(BaseModel):
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
# Handle third-party agent instruction files
|
||||
third_party_agent = cls._handle_third_party(path, file_content)
|
||||
if third_party_agent is not None:
|
||||
return third_party_agent
|
||||
# Handle .cursorrules files
|
||||
if path.name == '.cursorrules':
|
||||
return RepoMicroagent(
|
||||
name='cursorrules',
|
||||
content=file_content,
|
||||
metadata=MicroagentMetadata(name='cursorrules'),
|
||||
source=str(path),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
file_io = io.StringIO(file_content)
|
||||
loaded = frontmatter.load(file_io)
|
||||
@@ -295,44 +276,31 @@ def load_microagents_from_dir(
|
||||
|
||||
# Load all agents from microagents directory
|
||||
logger.debug(f'Loading agents from {microagent_dir}')
|
||||
|
||||
# Always check for .cursorrules and AGENTS.md files in repo root, regardless of whether microagents_dir exists
|
||||
special_files = []
|
||||
repo_root = microagent_dir.parent.parent
|
||||
|
||||
# Check for .cursorrules
|
||||
if (repo_root / '.cursorrules').exists():
|
||||
special_files.append(repo_root / '.cursorrules')
|
||||
|
||||
# Check for AGENTS.md (case-insensitive)
|
||||
for agents_filename in ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md']:
|
||||
agents_path = repo_root / agents_filename
|
||||
if agents_path.exists():
|
||||
special_files.append(agents_path)
|
||||
break # Only add the first one found to avoid duplicates
|
||||
|
||||
# Collect .md files from microagents directory if it exists
|
||||
md_files = []
|
||||
if microagent_dir.exists():
|
||||
# Collect .cursorrules file from repo root and .md files from microagents dir
|
||||
cursorrules_files = []
|
||||
if (microagent_dir.parent.parent / '.cursorrules').exists():
|
||||
cursorrules_files = [microagent_dir.parent.parent / '.cursorrules']
|
||||
|
||||
md_files = [f for f in microagent_dir.rglob('*.md') if f.name != 'README.md']
|
||||
|
||||
# Process all files in one loop
|
||||
for file in chain(special_files, md_files):
|
||||
try:
|
||||
agent = BaseMicroagent.load(file, microagent_dir)
|
||||
if isinstance(agent, RepoMicroagent):
|
||||
repo_agents[agent.name] = agent
|
||||
elif isinstance(agent, KnowledgeMicroagent):
|
||||
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
|
||||
knowledge_agents[agent.name] = agent
|
||||
except MicroagentValidationError as e:
|
||||
# For validation errors, include the original exception
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise MicroagentValidationError(error_msg) from e
|
||||
except Exception as e:
|
||||
# For other errors, wrap in a ValueError with detailed message
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise ValueError(error_msg) from e
|
||||
# Process all files in one loop
|
||||
for file in chain(cursorrules_files, md_files):
|
||||
try:
|
||||
agent = BaseMicroagent.load(file, microagent_dir)
|
||||
if isinstance(agent, RepoMicroagent):
|
||||
repo_agents[agent.name] = agent
|
||||
elif isinstance(agent, KnowledgeMicroagent):
|
||||
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
|
||||
knowledge_agents[agent.name] = agent
|
||||
except MicroagentValidationError as e:
|
||||
# For validation errors, include the original exception
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise MicroagentValidationError(error_msg) from e
|
||||
except Exception as e:
|
||||
# For other errors, wrap in a ValueError with detailed message
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise ValueError(error_msg) from e
|
||||
|
||||
logger.debug(
|
||||
f'Loaded {len(repo_agents) + len(knowledge_agents)} microagents: '
|
||||
|
||||
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
|
||||
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
|
||||
```toml
|
||||
[sandbox]
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik"
|
||||
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
+83
-8
@@ -1,11 +1,86 @@
|
||||
"""OpenHands FastAPI application.
|
||||
import contextlib
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
This module provides the main FastAPI application for OpenHands.
|
||||
For extensibility and custom configurations, use the factory pattern
|
||||
from openhands.server.factory instead of importing this app directly.
|
||||
"""
|
||||
from fastapi.routing import Mount
|
||||
|
||||
from openhands.server.factory import create_default_app
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
|
||||
# Create the default OpenHands app using the factory
|
||||
app = create_default_app()
|
||||
from fastapi import (
|
||||
FastAPI,
|
||||
Request,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
|
||||
from openhands import __version__
|
||||
from openhands.integrations.service_types import AuthenticationError
|
||||
from openhands.server.routes.conversation import app as conversation_api_router
|
||||
from openhands.server.routes.feedback import app as feedback_api_router
|
||||
from openhands.server.routes.files import app as files_api_router
|
||||
from openhands.server.routes.git import app as git_api_router
|
||||
from openhands.server.routes.health import add_health_endpoints
|
||||
from openhands.server.routes.manage_conversations import (
|
||||
app as manage_conversation_api_router,
|
||||
)
|
||||
from openhands.server.routes.mcp import mcp_server
|
||||
from openhands.server.routes.public import app as public_api_router
|
||||
from openhands.server.routes.secrets import app as secrets_router
|
||||
from openhands.server.routes.security import app as security_api_router
|
||||
from openhands.server.routes.settings import app as settings_router
|
||||
from openhands.server.routes.trajectory import app as trajectory_router
|
||||
from openhands.server.shared import conversation_manager, server_config
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
mcp_app = mcp_server.http_app(path='/mcp')
|
||||
|
||||
|
||||
def combine_lifespans(*lifespans):
|
||||
# Create a combined lifespan to manage multiple session managers
|
||||
@contextlib.asynccontextmanager
|
||||
async def combined_lifespan(app):
|
||||
async with contextlib.AsyncExitStack() as stack:
|
||||
for lifespan in lifespans:
|
||||
await stack.enter_async_context(lifespan(app))
|
||||
yield
|
||||
|
||||
return combined_lifespan
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
async with conversation_manager:
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title='OpenHands',
|
||||
description='OpenHands: Code Less, Make More',
|
||||
version=__version__,
|
||||
lifespan=combine_lifespans(_lifespan, mcp_app.lifespan),
|
||||
routes=[Mount(path='/mcp', app=mcp_app)],
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(AuthenticationError)
|
||||
async def authentication_error_handler(request: Request, exc: AuthenticationError):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content=str(exc),
|
||||
)
|
||||
|
||||
|
||||
app.include_router(public_api_router)
|
||||
app.include_router(files_api_router)
|
||||
app.include_router(security_api_router)
|
||||
app.include_router(feedback_api_router)
|
||||
app.include_router(conversation_api_router)
|
||||
app.include_router(manage_conversation_api_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(secrets_router)
|
||||
if server_config.app_mode == AppMode.OSS:
|
||||
app.include_router(git_api_router)
|
||||
app.include_router(trajectory_router)
|
||||
add_health_endpoints(app)
|
||||
|
||||
@@ -13,6 +13,7 @@ class ServerConfig(ServerConfigInterface):
|
||||
enable_billing = os.environ.get('ENABLE_BILLING', 'false') == 'true'
|
||||
hide_llm_settings = os.environ.get('HIDE_LLM_SETTINGS', 'false') == 'true'
|
||||
# This config is used to hide the microagent management page from the users for now. We will remove this once we release the new microagent management page.
|
||||
hide_microagent_management = True
|
||||
settings_store_class: str = (
|
||||
'openhands.storage.settings.file_settings_store.FileSettingsStore'
|
||||
)
|
||||
@@ -43,6 +44,7 @@ class ServerConfig(ServerConfigInterface):
|
||||
'FEATURE_FLAGS': {
|
||||
'ENABLE_BILLING': self.enable_billing,
|
||||
'HIDE_LLM_SETTINGS': self.hide_llm_settings,
|
||||
'HIDE_MICROAGENT_MANAGEMENT': self.hide_microagent_management,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Server context system for dependency injection and extensibility.
|
||||
|
||||
This module provides a context-based approach to managing server dependencies,
|
||||
replacing the global variables in shared.py. This enables:
|
||||
|
||||
- Dependency injection for better testability
|
||||
- Easy extensibility for custom implementations
|
||||
- Per-request contexts for multi-user scenarios
|
||||
- No import-time dependencies on environment variables
|
||||
|
||||
Usage:
|
||||
# In route handlers
|
||||
from openhands.server.context import get_server_context
|
||||
|
||||
@app.get('/example')
|
||||
async def example_route(
|
||||
request: Request,
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
config = context.get_config()
|
||||
# ... use context instead of importing from shared
|
||||
|
||||
# For custom extensions
|
||||
from openhands.server.factory import create_openhands_app
|
||||
app = create_openhands_app(context_factory=MyServerContext)
|
||||
"""
|
||||
|
||||
from .context_provider import (
|
||||
create_server_context,
|
||||
get_server_context,
|
||||
)
|
||||
from .server_context import ServerContext
|
||||
|
||||
__all__ = [
|
||||
'ServerContext',
|
||||
'get_server_context',
|
||||
'create_server_context',
|
||||
]
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Context provider system for dependency injection.
|
||||
|
||||
This module provides the default context provider for OpenHands routes.
|
||||
For custom context implementations, use the factory pattern from
|
||||
openhands.server.factory instead of modifying global state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import Request
|
||||
|
||||
from .server_context import ServerContext
|
||||
|
||||
|
||||
async def get_server_context(request: Request) -> ServerContext:
|
||||
"""Get server context from request, with caching.
|
||||
|
||||
This function provides dependency injection for ServerContext. It:
|
||||
1. Checks if a context is already cached on the request
|
||||
2. If not, creates a new context using the configured context class
|
||||
3. Caches the context on the request for subsequent use
|
||||
|
||||
This enables:
|
||||
- Per-request context instances for multi-user scenarios
|
||||
- Lazy initialization of dependencies
|
||||
- Easy testing with mock contexts
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
ServerContext: The server context instance for this request
|
||||
|
||||
Usage:
|
||||
from fastapi import Depends, Request
|
||||
from openhands.server.context import get_server_context, ServerContext
|
||||
|
||||
@app.get('/example')
|
||||
async def example_route(
|
||||
request: Request,
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
config = context.get_config()
|
||||
# ... use context
|
||||
"""
|
||||
# Check if context is already cached on the request
|
||||
context = getattr(request.state, 'server_context', None)
|
||||
if context:
|
||||
return context
|
||||
|
||||
# Create default context instance
|
||||
from .default_server_context import DefaultServerContext
|
||||
context = DefaultServerContext()
|
||||
|
||||
# Cache on request for subsequent use
|
||||
request.state.server_context = context
|
||||
return context
|
||||
|
||||
|
||||
def create_server_context(context_class: str | None = None) -> ServerContext:
|
||||
"""Create a server context instance directly.
|
||||
|
||||
This is useful for testing, CLI applications, or other scenarios where
|
||||
you need a context outside of a FastAPI request.
|
||||
|
||||
Args:
|
||||
context_class: Optional context class name. If None, uses DefaultServerContext.
|
||||
|
||||
Returns:
|
||||
ServerContext: New context instance
|
||||
|
||||
Example:
|
||||
# For testing with custom context
|
||||
from openhands.utils.import_utils import get_impl
|
||||
context_cls = get_impl(ServerContext, 'tests.mocks.MockServerContext')
|
||||
context = context_cls()
|
||||
|
||||
# Use default context
|
||||
context = create_server_context()
|
||||
"""
|
||||
if context_class:
|
||||
from openhands.utils.import_utils import get_impl
|
||||
context_cls = get_impl(ServerContext, context_class)
|
||||
return context_cls()
|
||||
else:
|
||||
from .default_server_context import DefaultServerContext
|
||||
return DefaultServerContext()
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Default ServerContext implementation that maintains current behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openhands.server.context.server_context import ServerContext
|
||||
|
||||
# Lazy imports to avoid import-time dependencies
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
ConversationManager,
|
||||
)
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
class DefaultServerContext(ServerContext):
|
||||
"""Default implementation that maintains current behavior.
|
||||
|
||||
This implementation replicates the exact behavior of the original shared.py
|
||||
globals, ensuring backward compatibility while providing the extensibility
|
||||
framework for SaaS implementations.
|
||||
|
||||
All dependencies are lazily initialized to avoid import-time side effects
|
||||
and allow for proper testing and mocking.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Lazy initialization - only create instances when requested
|
||||
self._config: OpenHandsConfig | None = None
|
||||
self._server_config: ServerConfig | None = None
|
||||
self._file_store: FileStore | None = None
|
||||
self._socketio_server = None
|
||||
self._conversation_manager: ConversationManager | None = None
|
||||
self._monitoring_listener: MonitoringListener | None = None
|
||||
self._settings_store_class: type[SettingsStore] | None = None
|
||||
self._secrets_store_class: type[SecretsStore] | None = None
|
||||
self._conversation_store_class: type[ConversationStore] | None = None
|
||||
|
||||
def get_config(self) -> OpenHandsConfig:
|
||||
"""Get the OpenHands configuration."""
|
||||
if self._config is None:
|
||||
from openhands.core.config import load_openhands_config
|
||||
|
||||
self._config = load_openhands_config()
|
||||
return self._config
|
||||
|
||||
def get_server_config(self) -> ServerConfig:
|
||||
"""Get the server configuration."""
|
||||
if self._server_config is None:
|
||||
from openhands.server.config.server_config import load_server_config
|
||||
|
||||
self._server_config = load_server_config()
|
||||
return self._server_config
|
||||
|
||||
def get_file_store(self) -> FileStore:
|
||||
"""Get the file store implementation."""
|
||||
if self._file_store is None:
|
||||
from openhands.storage import get_file_store
|
||||
|
||||
config = self.get_config()
|
||||
self._file_store = get_file_store(
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
return self._file_store
|
||||
|
||||
def get_socketio_server(self):
|
||||
"""Get the Socket.IO server instance."""
|
||||
if self._socketio_server is None:
|
||||
import socketio
|
||||
|
||||
# Replicate the original Redis client manager logic
|
||||
client_manager = None
|
||||
redis_host = os.environ.get('REDIS_HOST')
|
||||
if redis_host:
|
||||
client_manager = socketio.AsyncRedisManager(
|
||||
f'redis://{redis_host}',
|
||||
redis_options={'password': os.environ.get('REDIS_PASSWORD')},
|
||||
)
|
||||
|
||||
self._socketio_server = socketio.AsyncServer(
|
||||
async_mode='asgi',
|
||||
cors_allowed_origins='*',
|
||||
client_manager=client_manager,
|
||||
# Increase buffer size to 4MB (to handle 3MB files with base64 overhead)
|
||||
max_http_buffer_size=4 * 1024 * 1024,
|
||||
)
|
||||
return self._socketio_server
|
||||
|
||||
def get_conversation_manager(self) -> ConversationManager:
|
||||
"""Get the conversation manager implementation."""
|
||||
if self._conversation_manager is None:
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
ConversationManager,
|
||||
)
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
server_config = self.get_server_config()
|
||||
config = self.get_config()
|
||||
file_store = self.get_file_store()
|
||||
sio = self.get_socketio_server()
|
||||
monitoring_listener = self.get_monitoring_listener()
|
||||
|
||||
ConversationManagerImpl = get_impl(
|
||||
ConversationManager,
|
||||
server_config.conversation_manager_class,
|
||||
)
|
||||
|
||||
self._conversation_manager = ConversationManagerImpl.get_instance(
|
||||
sio, config, file_store, server_config, monitoring_listener
|
||||
)
|
||||
return self._conversation_manager
|
||||
|
||||
def get_monitoring_listener(self) -> MonitoringListener:
|
||||
"""Get the monitoring listener implementation."""
|
||||
if self._monitoring_listener is None:
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
server_config = self.get_server_config()
|
||||
config = self.get_config()
|
||||
|
||||
MonitoringListenerImpl = get_impl(
|
||||
MonitoringListener,
|
||||
server_config.monitoring_listener_class,
|
||||
)
|
||||
|
||||
self._monitoring_listener = MonitoringListenerImpl.get_instance(config)
|
||||
return self._monitoring_listener
|
||||
|
||||
def get_settings_store_class(self) -> type[SettingsStore]:
|
||||
"""Get the settings store class."""
|
||||
if self._settings_store_class is None:
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
server_config = self.get_server_config()
|
||||
self._settings_store_class = get_impl(
|
||||
SettingsStore, server_config.settings_store_class
|
||||
)
|
||||
return self._settings_store_class
|
||||
|
||||
def get_secrets_store_class(self) -> type[SecretsStore]:
|
||||
"""Get the secrets store class."""
|
||||
if self._secrets_store_class is None:
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
server_config = self.get_server_config()
|
||||
self._secrets_store_class = get_impl(
|
||||
SecretsStore, server_config.secret_store_class
|
||||
)
|
||||
return self._secrets_store_class
|
||||
|
||||
def get_conversation_store_class(self) -> type[ConversationStore]:
|
||||
"""Get the conversation store class."""
|
||||
if self._conversation_store_class is None:
|
||||
from openhands.storage.conversation.conversation_store import (
|
||||
ConversationStore,
|
||||
)
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
server_config = self.get_server_config()
|
||||
self._conversation_store_class = get_impl(
|
||||
ConversationStore,
|
||||
server_config.conversation_store_class,
|
||||
)
|
||||
return self._conversation_store_class
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Base ServerContext class for dependency injection and extensibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import socketio
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
ConversationManager,
|
||||
)
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
class ServerContext(ABC):
|
||||
"""Base class for server context that holds all server dependencies.
|
||||
|
||||
This replaces the global variables in shared.py and allows for:
|
||||
- Dependency injection for better testability
|
||||
- Easy extensibility for SaaS and enterprise features
|
||||
- Per-request contexts for multi-user scenarios
|
||||
- No import-time dependencies on environment variables
|
||||
|
||||
SaaS implementations can extend this class to provide:
|
||||
- Custom server configurations with enterprise features
|
||||
- Multi-tenant storage implementations
|
||||
- Per-user/per-organization contexts
|
||||
- Custom conversation managers and monitoring
|
||||
|
||||
Example SaaS extension:
|
||||
class SaaSServerContext(ServerContext):
|
||||
def __init__(self, user_id: str, org_id: str):
|
||||
super().__init__()
|
||||
self.user_id = user_id
|
||||
self.org_id = org_id
|
||||
|
||||
def get_server_config(self) -> ServerConfig:
|
||||
return SaaSServerConfig(org_id=self.org_id)
|
||||
|
||||
def get_file_store(self) -> FileStore:
|
||||
return MultiTenantFileStore(self.user_id, self.org_id)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_config(self) -> OpenHandsConfig:
|
||||
"""Get the OpenHands configuration.
|
||||
|
||||
Returns:
|
||||
OpenHandsConfig: The core application configuration
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_server_config(self) -> ServerConfig:
|
||||
"""Get the server configuration.
|
||||
|
||||
Returns:
|
||||
ServerConfig: Server-specific configuration including feature flags,
|
||||
authentication settings, and component class names
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_file_store(self) -> FileStore:
|
||||
"""Get the file store implementation.
|
||||
|
||||
Returns:
|
||||
FileStore: File storage implementation for handling uploads,
|
||||
downloads, and file management
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_socketio_server(self) -> socketio.AsyncServer:
|
||||
"""Get the Socket.IO server instance.
|
||||
|
||||
Returns:
|
||||
socketio.AsyncServer: The Socket.IO server for real-time communication
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_conversation_manager(self) -> ConversationManager:
|
||||
"""Get the conversation manager implementation.
|
||||
|
||||
Returns:
|
||||
ConversationManager: Manager for handling conversation lifecycle,
|
||||
agent sessions, and conversation state
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_monitoring_listener(self) -> MonitoringListener:
|
||||
"""Get the monitoring listener implementation.
|
||||
|
||||
Returns:
|
||||
MonitoringListener: Listener for monitoring events and metrics
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_settings_store_class(self) -> type[SettingsStore]:
|
||||
"""Get the settings store class.
|
||||
|
||||
Returns:
|
||||
type[SettingsStore]: Class for storing and retrieving user settings
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_secrets_store_class(self) -> type[SecretsStore]:
|
||||
"""Get the secrets store class.
|
||||
|
||||
Returns:
|
||||
type[SecretsStore]: Class for storing and retrieving user secrets
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_conversation_store_class(self) -> type[ConversationStore]:
|
||||
"""Get the conversation store class.
|
||||
|
||||
Returns:
|
||||
type[ConversationStore]: Class for storing and retrieving conversations
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -1,227 +0,0 @@
|
||||
"""FastAPI app factory for OpenHands server.
|
||||
|
||||
This module provides a factory function to create OpenHands FastAPI applications
|
||||
with configurable dependencies, enabling external repositories to extend OpenHands
|
||||
without relying on global variables or environment variable configuration.
|
||||
|
||||
Example usage for external repositories:
|
||||
|
||||
# In your external repo
|
||||
from openhands.server.factory import create_openhands_app
|
||||
from my_custom_context import MyServerContext
|
||||
|
||||
# Create OpenHands app with your custom context
|
||||
openhands_app = create_openhands_app(
|
||||
context_factory=lambda: MyServerContext(),
|
||||
include_oss_routes=False, # Skip OSS-specific routes
|
||||
custom_lifespan=my_custom_lifespan
|
||||
)
|
||||
|
||||
# Add your own routes
|
||||
@openhands_app.get('/my-custom-route')
|
||||
async def my_route():
|
||||
return {'message': 'Hello from my extension!'}
|
||||
|
||||
# Or create your own app and include OpenHands routes
|
||||
from fastapi import FastAPI
|
||||
my_app = FastAPI()
|
||||
my_app.mount('/openhands', openhands_app)
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Callable, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import Mount
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
|
||||
from openhands import __version__
|
||||
from openhands.integrations.service_types import AuthenticationError
|
||||
from openhands.server.context.server_context import ServerContext
|
||||
from openhands.server.routes.conversation import app as conversation_api_router
|
||||
from openhands.server.routes.feedback import app as feedback_api_router
|
||||
from openhands.server.routes.files import app as files_api_router
|
||||
from openhands.server.routes.git import app as git_api_router
|
||||
from openhands.server.routes.health import add_health_endpoints
|
||||
from openhands.server.routes.manage_conversations import (
|
||||
app as manage_conversation_api_router,
|
||||
)
|
||||
from openhands.server.routes.mcp import mcp_server
|
||||
from openhands.server.routes.public import app as public_api_router
|
||||
from openhands.server.routes.secrets import app as secrets_router
|
||||
from openhands.server.routes.security import app as security_api_router
|
||||
from openhands.server.routes.settings import app as settings_router
|
||||
from openhands.server.routes.trajectory import app as trajectory_router
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
def combine_lifespans(*lifespans):
|
||||
"""Combine multiple FastAPI lifespans into one."""
|
||||
@contextlib.asynccontextmanager
|
||||
async def combined_lifespan(app):
|
||||
async with contextlib.AsyncExitStack() as stack:
|
||||
for lifespan in lifespans:
|
||||
await stack.enter_async_context(lifespan(app))
|
||||
yield
|
||||
|
||||
return combined_lifespan
|
||||
|
||||
|
||||
def create_openhands_app(
|
||||
context_factory: Optional[Callable[[], ServerContext]] = None,
|
||||
include_oss_routes: bool = True,
|
||||
include_mcp: bool = True,
|
||||
custom_lifespan: Optional[Callable] = None,
|
||||
title: str = 'OpenHands',
|
||||
description: str = 'OpenHands: Code Less, Make More',
|
||||
) -> FastAPI:
|
||||
"""Create a FastAPI application with OpenHands routes and configurable dependencies.
|
||||
|
||||
This factory function allows external repositories to create OpenHands applications
|
||||
with their own context implementations and configuration, without relying on
|
||||
global variables or environment variable configuration.
|
||||
|
||||
Args:
|
||||
context_factory: Factory function to create ServerContext instances.
|
||||
If None, uses DefaultServerContext.
|
||||
include_oss_routes: Whether to include OSS-specific routes (like git).
|
||||
include_mcp: Whether to include MCP (Model Context Protocol) routes.
|
||||
custom_lifespan: Custom lifespan function for the FastAPI app.
|
||||
title: Title for the FastAPI app.
|
||||
description: Description for the FastAPI app.
|
||||
|
||||
Returns:
|
||||
FastAPI: Configured FastAPI application with OpenHands routes.
|
||||
|
||||
Example:
|
||||
# Basic usage with default context
|
||||
app = create_openhands_app()
|
||||
|
||||
# Custom context for multi-tenant SaaS
|
||||
def create_saas_context():
|
||||
return SaaSServerContext(tenant_id='default')
|
||||
|
||||
app = create_openhands_app(
|
||||
context_factory=create_saas_context,
|
||||
include_oss_routes=False
|
||||
)
|
||||
|
||||
# External repo extending OpenHands
|
||||
from my_extension import MyServerContext, my_lifespan
|
||||
|
||||
app = create_openhands_app(
|
||||
context_factory=lambda: MyServerContext(),
|
||||
custom_lifespan=my_lifespan
|
||||
)
|
||||
"""
|
||||
# Import default context here to avoid import-time dependencies
|
||||
from openhands.server.context.default_server_context import DefaultServerContext
|
||||
|
||||
# Use provided context factory or default
|
||||
if context_factory is None:
|
||||
context_factory = DefaultServerContext
|
||||
|
||||
# Create a context instance to get configuration
|
||||
context = context_factory()
|
||||
server_config = context.get_server_config()
|
||||
conversation_manager = context.get_conversation_manager()
|
||||
|
||||
# Build lifespan functions
|
||||
lifespans = []
|
||||
|
||||
# Add conversation manager lifespan
|
||||
@asynccontextmanager
|
||||
async def conversation_lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
async with conversation_manager:
|
||||
yield
|
||||
|
||||
lifespans.append(conversation_lifespan)
|
||||
|
||||
# Add MCP lifespan if requested
|
||||
if include_mcp:
|
||||
mcp_app = mcp_server.http_app(path='/mcp')
|
||||
lifespans.append(mcp_app.lifespan)
|
||||
|
||||
# Add custom lifespan if provided
|
||||
if custom_lifespan:
|
||||
lifespans.append(custom_lifespan)
|
||||
|
||||
# Create routes list
|
||||
routes = []
|
||||
if include_mcp:
|
||||
routes.append(Mount(path='/mcp', app=mcp_app))
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title=title,
|
||||
description=description,
|
||||
version=__version__,
|
||||
lifespan=combine_lifespans(*lifespans) if lifespans else None,
|
||||
routes=routes,
|
||||
)
|
||||
|
||||
# Add exception handlers
|
||||
@app.exception_handler(AuthenticationError)
|
||||
async def authentication_error_handler(request: Request, exc: AuthenticationError):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content=str(exc),
|
||||
)
|
||||
|
||||
# Override the context dependency for all routes
|
||||
# This is the key: we inject our context factory into the dependency system
|
||||
from openhands.server.context.context_provider import get_server_context
|
||||
|
||||
async def custom_get_server_context(request: Request) -> ServerContext:
|
||||
"""Custom context provider that uses our factory."""
|
||||
# Check if context is already cached on the request
|
||||
context = getattr(request.state, 'server_context', None)
|
||||
if context:
|
||||
return context
|
||||
|
||||
# Create new context instance using our factory
|
||||
context = context_factory()
|
||||
|
||||
# Cache on request for subsequent use
|
||||
request.state.server_context = context
|
||||
return context
|
||||
|
||||
# Override the dependency
|
||||
app.dependency_overrides[get_server_context] = custom_get_server_context
|
||||
|
||||
# Include all the standard OpenHands routes
|
||||
app.include_router(public_api_router)
|
||||
app.include_router(files_api_router)
|
||||
app.include_router(security_api_router)
|
||||
app.include_router(feedback_api_router)
|
||||
app.include_router(conversation_api_router)
|
||||
app.include_router(manage_conversation_api_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(secrets_router)
|
||||
|
||||
# Conditionally include OSS routes based on server config
|
||||
if include_oss_routes and server_config.app_mode == AppMode.OSS:
|
||||
app.include_router(git_api_router)
|
||||
|
||||
app.include_router(trajectory_router)
|
||||
add_health_endpoints(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# For backward compatibility, create the default app
|
||||
def create_default_app() -> FastAPI:
|
||||
"""Create the default OpenHands FastAPI app.
|
||||
|
||||
This is equivalent to the old app.py behavior but using the factory pattern.
|
||||
Used for backward compatibility.
|
||||
"""
|
||||
return create_openhands_app()
|
||||
@@ -30,6 +30,7 @@ from openhands.server.shared import (
|
||||
from openhands.storage.conversation.conversation_validator import (
|
||||
create_conversation_validator,
|
||||
)
|
||||
from openhands.storage.paths import ConversationPaths
|
||||
|
||||
|
||||
@sio.event
|
||||
@@ -77,7 +78,8 @@ async def connect(connection_id: str, environ: dict) -> None:
|
||||
|
||||
try:
|
||||
event_store = EventStore(
|
||||
conversation_id, conversation_manager.file_store, user_id
|
||||
ConversationPaths(conversation_id, user_id),
|
||||
conversation_manager.file_store,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
logger.error(
|
||||
|
||||
@@ -15,6 +15,7 @@ from openhands.server.shared import conversation_manager, file_store
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.utils import get_conversation, get_conversation_metadata
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.paths import ConversationPaths
|
||||
|
||||
app = APIRouter(
|
||||
prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies()
|
||||
@@ -140,9 +141,8 @@ async def search_events(
|
||||
|
||||
# Create an event store to access the events directly
|
||||
event_store = EventStore(
|
||||
sid=conversation_id,
|
||||
ConversationPaths(conversation_id, user_id),
|
||||
file_store=file_store,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Get matching events from the store
|
||||
|
||||
@@ -72,6 +72,7 @@ from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.data_models.user_secrets import UserSecrets
|
||||
from openhands.storage.locations import get_experiment_config_filename
|
||||
from openhands.storage.paths import ConversationPaths
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.async_utils import wait_all
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
@@ -378,7 +379,7 @@ async def get_prompt(
|
||||
):
|
||||
# get event store for the conversation
|
||||
event_store = EventStore(
|
||||
sid=conversation_id, file_store=file_store, user_id=metadata.user_id
|
||||
ConversationPaths(conversation_id, metadata.user_id), file_store
|
||||
)
|
||||
|
||||
# retrieve the relevant events
|
||||
|
||||
+70
-69
@@ -1,76 +1,77 @@
|
||||
"""Shared server dependencies - DEPRECATED.
|
||||
import os
|
||||
|
||||
This module is deprecated and maintained only for backward compatibility.
|
||||
New code should use the context system from openhands.server.context instead.
|
||||
import socketio
|
||||
from dotenv import load_dotenv
|
||||
|
||||
The context system provides:
|
||||
- Better dependency injection
|
||||
- Easier testing and mocking
|
||||
- SaaS extensibility
|
||||
- Per-request contexts
|
||||
- No import-time side effects
|
||||
from openhands.core.config import load_openhands_config
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.config.server_config import ServerConfig, load_server_config
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
ConversationManager,
|
||||
)
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
from openhands.server.types import ServerConfigInterface
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
Migration guide:
|
||||
# Old way (deprecated)
|
||||
from openhands.server.shared import config, server_config
|
||||
load_dotenv()
|
||||
|
||||
# New way (recommended)
|
||||
from openhands.server.context import get_server_context
|
||||
|
||||
@app.get('/example')
|
||||
async def example_route(
|
||||
request: Request,
|
||||
context: ServerContext = Depends(get_server_context)
|
||||
):
|
||||
config = context.get_config()
|
||||
server_config = context.get_server_config()
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
from openhands.server.context.default_server_context import DefaultServerContext
|
||||
|
||||
# Load environment variables for backward compatibility
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
# dotenv is optional
|
||||
pass
|
||||
|
||||
# Create default context for backward compatibility
|
||||
_default_context = DefaultServerContext()
|
||||
|
||||
# Issue deprecation warning when this module is imported
|
||||
warnings.warn(
|
||||
'openhands.server.shared is deprecated. Use openhands.server.context instead. '
|
||||
'See the module docstring for migration guidance.',
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
config: OpenHandsConfig = load_openhands_config()
|
||||
server_config_interface: ServerConfigInterface = load_server_config()
|
||||
assert isinstance(server_config_interface, ServerConfig), (
|
||||
'Loaded server config interface is not a ServerConfig, despite this being assumed'
|
||||
)
|
||||
server_config: ServerConfig = server_config_interface
|
||||
file_store: FileStore = get_file_store(
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
file_store_web_hook_url=config.file_store_web_hook_url,
|
||||
file_store_web_hook_headers=config.file_store_web_hook_headers,
|
||||
file_store_web_hook_batch=config.file_store_web_hook_batch,
|
||||
)
|
||||
|
||||
client_manager = None
|
||||
redis_host = os.environ.get('REDIS_HOST')
|
||||
if redis_host:
|
||||
client_manager = socketio.AsyncRedisManager(
|
||||
f'redis://{redis_host}',
|
||||
redis_options={'password': os.environ.get('REDIS_PASSWORD')},
|
||||
)
|
||||
|
||||
# Module-level lazy loading using __getattr__
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading for backward compatibility globals."""
|
||||
if name == 'config':
|
||||
return _default_context.get_config()
|
||||
elif name == 'server_config':
|
||||
return _default_context.get_server_config()
|
||||
elif name == 'file_store':
|
||||
return _default_context.get_file_store()
|
||||
elif name == 'sio':
|
||||
return _default_context.get_socketio_server()
|
||||
elif name == 'conversation_manager':
|
||||
return _default_context.get_conversation_manager()
|
||||
elif name == 'monitoring_listener':
|
||||
return _default_context.get_monitoring_listener()
|
||||
elif name == 'SettingsStoreImpl':
|
||||
return _default_context.get_settings_store_class()
|
||||
elif name == 'SecretsStoreImpl':
|
||||
return _default_context.get_secrets_store_class()
|
||||
elif name == 'ConversationStoreImpl':
|
||||
return _default_context.get_conversation_store_class()
|
||||
else:
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
sio = socketio.AsyncServer(
|
||||
async_mode='asgi',
|
||||
cors_allowed_origins='*',
|
||||
client_manager=client_manager,
|
||||
# Increase buffer size to 4MB (to handle 3MB files with base64 overhead)
|
||||
max_http_buffer_size=4 * 1024 * 1024,
|
||||
)
|
||||
|
||||
MonitoringListenerImpl = get_impl(
|
||||
MonitoringListener,
|
||||
server_config.monitoring_listener_class,
|
||||
)
|
||||
|
||||
monitoring_listener = MonitoringListenerImpl.get_instance(config)
|
||||
|
||||
ConversationManagerImpl = get_impl(
|
||||
ConversationManager,
|
||||
server_config.conversation_manager_class,
|
||||
)
|
||||
|
||||
conversation_manager = ConversationManagerImpl.get_instance(
|
||||
sio, config, file_store, server_config, monitoring_listener
|
||||
)
|
||||
|
||||
SettingsStoreImpl = get_impl(SettingsStore, server_config.settings_store_class)
|
||||
|
||||
SecretsStoreImpl = get_impl(SecretsStore, server_config.secret_store_class)
|
||||
|
||||
ConversationStoreImpl = get_impl(
|
||||
ConversationStore,
|
||||
server_config.conversation_store_class,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openhands.storage.locations import (
|
||||
get_conversation_agent_state_filename,
|
||||
get_conversation_dir,
|
||||
get_conversation_event_filename,
|
||||
get_conversation_events_dir,
|
||||
get_conversation_init_data_filename,
|
||||
get_conversation_llm_registry_filename,
|
||||
get_conversation_metadata_filename,
|
||||
get_conversation_stats_filename,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConversationPaths:
|
||||
sid: str
|
||||
user_id: str | None = None
|
||||
|
||||
def conversation_dir(self) -> str:
|
||||
return get_conversation_dir(self.sid, self.user_id)
|
||||
|
||||
def events_dir(self) -> str:
|
||||
return get_conversation_events_dir(self.sid, self.user_id)
|
||||
|
||||
def event_filename(self, id: int) -> str:
|
||||
return get_conversation_event_filename(self.sid, id, self.user_id)
|
||||
|
||||
def metadata_filename(self) -> str:
|
||||
return get_conversation_metadata_filename(self.sid, self.user_id)
|
||||
|
||||
def init_data_filename(self) -> str:
|
||||
return get_conversation_init_data_filename(self.sid, self.user_id)
|
||||
|
||||
def agent_state_filename(self) -> str:
|
||||
return get_conversation_agent_state_filename(self.sid, self.user_id)
|
||||
|
||||
def llm_registry_filename(self) -> str:
|
||||
return get_conversation_llm_registry_filename(self.sid, self.user_id)
|
||||
|
||||
def stats_filename(self) -> str:
|
||||
return get_conversation_stats_filename(self.sid, self.user_id)
|
||||
|
||||
def event_cache_filename(self, start: int, end: int) -> str:
|
||||
return f'{self.conversation_dir()}event_cache/{start}-{end}.json'
|
||||
@@ -10,6 +10,7 @@ from openhands.events.event_store import EventStore
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.paths import ConversationPaths
|
||||
|
||||
|
||||
async def generate_conversation_title(
|
||||
@@ -95,7 +96,9 @@ async def auto_generate_title(
|
||||
"""
|
||||
try:
|
||||
# Create an event store for the conversation
|
||||
event_store = EventStore(conversation_id, file_store, user_id)
|
||||
event_store = EventStore(
|
||||
ConversationPaths(conversation_id, user_id), file_store
|
||||
)
|
||||
|
||||
# Find the first user message
|
||||
first_user_message = None
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.55.0"
|
||||
version = "0.54.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
|
||||
@@ -364,184 +364,3 @@ def test_load_microagents_with_cursorrules(temp_microagents_dir_with_cursorrules
|
||||
assert cursorrules_agent.name == 'cursorrules'
|
||||
assert 'Always use TypeScript for new files' in cursorrules_agent.content
|
||||
assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_cursorrules_only():
|
||||
"""Create a temporary directory with only .cursorrules file (no .openhands/microagents directory)."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
# Create .cursorrules file in repository root
|
||||
cursorrules_content = """Always use Python for new files.
|
||||
Follow PEP 8 style guidelines."""
|
||||
(root / '.cursorrules').write_text(cursorrules_content)
|
||||
|
||||
# Note: We intentionally do NOT create .openhands/microagents directory
|
||||
yield root
|
||||
|
||||
|
||||
def test_load_cursorrules_without_microagents_dir(temp_dir_with_cursorrules_only):
|
||||
"""Test loading .cursorrules file when .openhands/microagents directory doesn't exist.
|
||||
|
||||
This test reproduces the bug where .cursorrules is only loaded when
|
||||
.openhands/microagents directory exists.
|
||||
"""
|
||||
# Try to load from non-existent microagents directory
|
||||
microagents_dir = temp_dir_with_cursorrules_only / '.openhands' / 'microagents'
|
||||
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# This should find the .cursorrules file even though microagents_dir doesn't exist
|
||||
assert len(repo_agents) == 1 # Only .cursorrules
|
||||
assert 'cursorrules' in repo_agents
|
||||
assert len(knowledge_agents) == 0
|
||||
|
||||
# Check .cursorrules agent
|
||||
cursorrules_agent = repo_agents['cursorrules']
|
||||
assert isinstance(cursorrules_agent, RepoMicroagent)
|
||||
assert cursorrules_agent.name == 'cursorrules'
|
||||
assert 'Always use Python for new files' in cursorrules_agent.content
|
||||
assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
def test_agents_md_file_load():
|
||||
"""Test loading AGENTS.md file as a RepoMicroagent."""
|
||||
agents_content = """# Project Setup
|
||||
|
||||
## Setup commands
|
||||
|
||||
- Install deps: `npm install`
|
||||
- Start dev server: `npm run dev`
|
||||
- Run tests: `npm test`
|
||||
|
||||
## Code style
|
||||
|
||||
- TypeScript strict mode
|
||||
- Single quotes, no semicolons
|
||||
- Use functional patterns where possible"""
|
||||
|
||||
agents_path = Path('AGENTS.md')
|
||||
|
||||
# Test loading AGENTS.md file directly
|
||||
agent = BaseMicroagent.load(agents_path, file_content=agents_content)
|
||||
|
||||
# Verify it's loaded as a RepoMicroagent
|
||||
assert isinstance(agent, RepoMicroagent)
|
||||
assert agent.name == 'agents'
|
||||
assert agent.content == agents_content
|
||||
assert agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
assert agent.metadata.name == 'agents'
|
||||
assert agent.source == str(agents_path)
|
||||
|
||||
|
||||
def test_agents_md_case_insensitive():
|
||||
"""Test that AGENTS.md loading is case-insensitive."""
|
||||
agents_content = """# Development Guide
|
||||
|
||||
Use TypeScript for all new files."""
|
||||
|
||||
test_cases = ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md']
|
||||
|
||||
for filename in test_cases:
|
||||
agents_path = Path(filename)
|
||||
agent = BaseMicroagent.load(agents_path, file_content=agents_content)
|
||||
|
||||
assert isinstance(agent, RepoMicroagent)
|
||||
assert agent.name == 'agents'
|
||||
assert agent.content == agents_content
|
||||
assert agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_agents_md_only():
|
||||
"""Create a temporary directory with only AGENTS.md file (no .openhands/microagents directory)."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
# Create AGENTS.md file in repository root
|
||||
agents_content = """# Development Guide
|
||||
|
||||
## Setup commands
|
||||
|
||||
- Install deps: `poetry install`
|
||||
- Start dev server: `poetry run python app.py`
|
||||
- Run tests: `poetry run pytest`
|
||||
|
||||
## Code style
|
||||
|
||||
- Python 3.12+
|
||||
- Follow PEP 8 guidelines
|
||||
- Use type hints everywhere"""
|
||||
(root / 'AGENTS.md').write_text(agents_content)
|
||||
|
||||
# Note: We intentionally do NOT create .openhands/microagents directory
|
||||
yield root
|
||||
|
||||
|
||||
def test_load_agents_md_without_microagents_dir(temp_dir_with_agents_md_only):
|
||||
"""Test loading AGENTS.md file when .openhands/microagents directory doesn't exist."""
|
||||
# Try to load from non-existent microagents directory
|
||||
microagents_dir = temp_dir_with_agents_md_only / '.openhands' / 'microagents'
|
||||
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# This should find the AGENTS.md file even though microagents_dir doesn't exist
|
||||
assert len(repo_agents) == 1 # Only AGENTS.md
|
||||
assert 'agents' in repo_agents
|
||||
assert len(knowledge_agents) == 0
|
||||
|
||||
# Check AGENTS.md agent
|
||||
agents_agent = repo_agents['agents']
|
||||
assert isinstance(agents_agent, RepoMicroagent)
|
||||
assert agents_agent.name == 'agents'
|
||||
assert 'Install deps: `poetry install`' in agents_agent.content
|
||||
assert agents_agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_both_cursorrules_and_agents():
|
||||
"""Create a temporary directory with both .cursorrules and AGENTS.md files."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
# Create .cursorrules file
|
||||
cursorrules_content = """Always use Python for new files.
|
||||
Follow PEP 8 style guidelines."""
|
||||
(root / '.cursorrules').write_text(cursorrules_content)
|
||||
|
||||
# Create AGENTS.md file
|
||||
agents_content = """# Development Guide
|
||||
|
||||
## Setup commands
|
||||
|
||||
- Install deps: `poetry install`
|
||||
- Run tests: `poetry run pytest`"""
|
||||
(root / 'AGENTS.md').write_text(agents_content)
|
||||
|
||||
yield root
|
||||
|
||||
|
||||
def test_load_both_cursorrules_and_agents_md(temp_dir_with_both_cursorrules_and_agents):
|
||||
"""Test loading both .cursorrules and AGENTS.md files when .openhands/microagents doesn't exist."""
|
||||
# Try to load from non-existent microagents directory
|
||||
microagents_dir = (
|
||||
temp_dir_with_both_cursorrules_and_agents / '.openhands' / 'microagents'
|
||||
)
|
||||
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# This should find both files
|
||||
assert len(repo_agents) == 2 # .cursorrules + AGENTS.md
|
||||
assert 'cursorrules' in repo_agents
|
||||
assert 'agents' in repo_agents
|
||||
assert len(knowledge_agents) == 0
|
||||
|
||||
# Check both agents
|
||||
cursorrules_agent = repo_agents['cursorrules']
|
||||
assert isinstance(cursorrules_agent, RepoMicroagent)
|
||||
assert 'Always use Python for new files' in cursorrules_agent.content
|
||||
|
||||
agents_agent = repo_agents['agents']
|
||||
assert isinstance(agents_agent, RepoMicroagent)
|
||||
assert 'Install deps: `poetry install`' in agents_agent.content
|
||||
|
||||
@@ -16,6 +16,7 @@ from openhands.server.conversation_manager.standalone_conversation_manager impor
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
from openhands.storage.paths import ConversationPaths
|
||||
from openhands.utils.conversation_summary import auto_generate_title
|
||||
|
||||
|
||||
@@ -67,10 +68,13 @@ async def test_auto_generate_title_with_llm():
|
||||
# Verify the result
|
||||
assert title == 'Python Data Analysis Script'
|
||||
|
||||
# Verify EventStore was created with the correct parameters
|
||||
mock_event_store_cls.assert_called_once_with(
|
||||
conversation_id, file_store, user_id
|
||||
)
|
||||
# Verify EventStore was created with the correct parameters (paths + file_store)
|
||||
mock_event_store_cls.assert_called_once()
|
||||
call_args, _ = mock_event_store_cls.call_args
|
||||
assert isinstance(call_args[0], ConversationPaths)
|
||||
assert call_args[0].sid == conversation_id
|
||||
assert call_args[0].user_id == user_id
|
||||
assert call_args[1] is file_store
|
||||
|
||||
# Verify LLM registry was called with appropriate parameters
|
||||
llm_registry.request_extraneous_completion.assert_called_once()
|
||||
@@ -122,10 +126,13 @@ async def test_auto_generate_title_fallback():
|
||||
assert title == 'This is a very long message th...'
|
||||
assert len(title) <= 35
|
||||
|
||||
# Verify EventStore was created with the correct parameters
|
||||
mock_event_store_cls.assert_called_once_with(
|
||||
conversation_id, file_store, user_id
|
||||
)
|
||||
# Verify EventStore was created with the correct parameters (paths + file_store)
|
||||
mock_event_store_cls.assert_called_once()
|
||||
call_args, _ = mock_event_store_cls.call_args
|
||||
assert isinstance(call_args[0], ConversationPaths)
|
||||
assert call_args[0].sid == conversation_id
|
||||
assert call_args[0].user_id == user_id
|
||||
assert call_args[1] is file_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -163,10 +170,13 @@ async def test_auto_generate_title_no_messages():
|
||||
# Verify the result is empty
|
||||
assert title == ''
|
||||
|
||||
# Verify EventStore was created with the correct parameters
|
||||
mock_event_store_cls.assert_called_once_with(
|
||||
conversation_id, file_store, user_id
|
||||
)
|
||||
# Verify EventStore was created with the correct parameters (paths + file_store)
|
||||
mock_event_store_cls.assert_called_once()
|
||||
call_args, _ = mock_event_store_cls.call_args
|
||||
assert isinstance(call_args[0], ConversationPaths)
|
||||
assert call_args[0].sid == conversation_id
|
||||
assert call_args[0].user_id == user_id
|
||||
assert call_args[1] is file_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user