mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
5 Commits
debug-logging
...
0.41.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d37c3ac57 | |||
| 51c92676b3 | |||
| 15c5e8da26 | |||
| 0dc9d635ec | |||
| 2cea1461fb |
+1
-1
@@ -136,7 +136,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.40-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.41-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -51,17 +51,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.40
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.41
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.40
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.41
|
||||
```
|
||||
|
||||
您将在[http://localhost:3000](http://localhost:3000)找到运行中的OpenHands!
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.40-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.41-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.40-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -31,7 +31,7 @@ This command opens an interactive prompt where you can type tasks or commands an
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -40,7 +40,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--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.40 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.41 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ To run OpenHands in Headless mode with Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -41,7 +41,7 @@ docker run -it \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--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.40 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.41 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -88,17 +88,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.40
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.41
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
@@ -54,25 +54,25 @@ Check [the installation guide](https://docs.all-hands.dev/modules/usage/installa
|
||||
export LMSTUDIO_MODEL_NAME="imported-models/uncategorized/devstralq4_k_m.gguf" # <- Replace this with the model name you copied from LMStudio
|
||||
export LMSTUDIO_URL="http://host.docker.internal:1234" # <- Replace this with the port from LMStudio
|
||||
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik
|
||||
|
||||
mkdir -p ~/.openhands-state && echo '{"language":"en","agent":"CodeActAgent","max_iterations":null,"security_analyzer":null,"confirmation_mode":false,"llm_model":"lm_studio/'$LMSTUDIO_MODEL_NAME'","llm_api_key":"dummy","llm_base_url":"'$LMSTUDIO_URL/v1'","remote_runtime_resource_factor":null,"github_token":null,"enable_default_condenser":true,"user_consents_to_analytics":true}' > ~/.openhands-state/settings.json
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.40-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.41-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.40
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.41
|
||||
```
|
||||
|
||||
Once your server is running -- you can visit `http://localhost:3000` in your browser to use OpenHands with local Devstral model:
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.40
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.41
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.40.0",
|
||||
"version": "0.41.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.40.0",
|
||||
"version": "0.41.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.7.8",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.40.0",
|
||||
"version": "0.41.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -236,6 +236,26 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async startConversation(
|
||||
conversationId: string,
|
||||
): Promise<Conversation | null> {
|
||||
const { data } = await openHands.post<Conversation | null>(
|
||||
`/api/conversations/${conversationId}/start`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async stopConversation(
|
||||
conversationId: string,
|
||||
): Promise<Conversation | null> {
|
||||
const { data } = await openHands.post<Conversation | null>(
|
||||
`/api/conversations/${conversationId}/stop`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the settings from the server or use the default settings if not found
|
||||
*/
|
||||
|
||||
@@ -84,7 +84,7 @@ export function AgentStatusBar() {
|
||||
setStatusMessage(t(I18nKey.STATUS$STARTING_RUNTIME));
|
||||
setIndicatorColor(IndicatorColor.RED);
|
||||
} else if (status === WsClientProviderStatus.DISCONNECTED) {
|
||||
setStatusMessage(t(I18nKey.STATUS$CONNECTED)); // Using STATUS$CONNECTED instead of STATUS$CONNECTING
|
||||
setStatusMessage(t(I18nKey.STATUS$WEBSOCKET_CLOSED));
|
||||
setIndicatorColor(IndicatorColor.RED);
|
||||
} else {
|
||||
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
|
||||
|
||||
@@ -150,7 +150,8 @@ export function WsClientProvider({
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const messageRateHandler = useRate({ threshold: 250 });
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { data: conversation, refetch: refetchConversation } =
|
||||
useActiveConversation();
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
if (!sioRef.current) {
|
||||
@@ -269,14 +270,11 @@ export function WsClientProvider({
|
||||
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
|
||||
setErrorMessage(
|
||||
hasValidMessageProperty(data)
|
||||
? data.message
|
||||
: "The WebSocket connection was closed.",
|
||||
);
|
||||
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
|
||||
}
|
||||
|
||||
function handleError(data: unknown) {
|
||||
// set status
|
||||
setStatus(WsClientProviderStatus.DISCONNECTED);
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
|
||||
@@ -285,6 +283,9 @@ export function WsClientProvider({
|
||||
? data.message
|
||||
: "An unknown error occurred on the WebSocket connection.",
|
||||
);
|
||||
|
||||
// check if something went wrong with the conversation.
|
||||
refetchConversation();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -300,12 +301,19 @@ export function WsClientProvider({
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
}
|
||||
if (!conversation || conversation.status === "STARTING") {
|
||||
if (
|
||||
!conversation ||
|
||||
["STOPPED", "STARTING"].includes(conversation.status)
|
||||
) {
|
||||
return () => undefined; // conversation not yet loaded
|
||||
}
|
||||
|
||||
let sio = sioRef.current;
|
||||
|
||||
if (sio?.connected) {
|
||||
sio.disconnect();
|
||||
}
|
||||
|
||||
const lastEvent = lastEventRef.current;
|
||||
const query = {
|
||||
latest_event_id: lastEvent?.id ?? -1,
|
||||
|
||||
@@ -7,7 +7,7 @@ export const useActiveConversation = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
return useUserConversation(conversationId, (query) => {
|
||||
if (query.state.data?.status === "STARTING") {
|
||||
return 2000; // 2 seconds
|
||||
return 3000; // 3 seconds
|
||||
}
|
||||
return FIVE_MINUTES;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
|
||||
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
|
||||
HOME$READ_THIS = "HOME$READ_THIS",
|
||||
AUTH$LOGGING_BACK_IN = "AUTH$LOGGING_BACK_IN",
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
{
|
||||
"STATUS$WEBSOCKET_CLOSED": {
|
||||
"en": "The WebSocket connection was closed.",
|
||||
"ja": "WebSocket接続が閉じられました。",
|
||||
"zh-CN": "WebSocket连接已关闭。",
|
||||
"zh-TW": "WebSocket連接已關閉。",
|
||||
"ko-KR": "WebSocket 연결이 닫혔습니다.",
|
||||
"no": "WebSocket-tilkoblingen ble lukket.",
|
||||
"it": "La connessione WebSocket è stata chiusa.",
|
||||
"pt": "A conexão WebSocket foi fechada.",
|
||||
"es": "La conexión WebSocket se ha cerrado.",
|
||||
"ar": "تم إغلاق اتصال WebSocket.",
|
||||
"fr": "La connexion WebSocket a été fermée.",
|
||||
"tr": "WebSocket bağlantısı kapatıldı.",
|
||||
"de": "Die WebSocket-Verbindung wurde geschlossen.",
|
||||
"uk": "З'єднання WebSocket було закрито."
|
||||
},
|
||||
"HOME$LAUNCH_FROM_SCRATCH": {
|
||||
"en": "Launch from Scratch",
|
||||
"ja": "ゼロから始める",
|
||||
|
||||
@@ -43,7 +43,7 @@ function AppContent() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = useSettings();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation, isFetched } = useActiveConversation();
|
||||
const { data: conversation, isFetched, refetch } = useActiveConversation();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
@@ -61,8 +61,13 @@ function AppContent() {
|
||||
"This conversation does not exist, or you do not have permission to access it.",
|
||||
);
|
||||
navigate("/");
|
||||
} else if (conversation?.status === "STOPPED") {
|
||||
// start the conversation if the state is stopped on initial load
|
||||
OpenHands.startConversation(conversation.conversation_id).then(() =>
|
||||
refetch(),
|
||||
);
|
||||
}
|
||||
}, [conversation, isFetched, isAuthed]);
|
||||
}, [conversation?.conversation_id, isFetched, isAuthed]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(clearTerminal());
|
||||
|
||||
@@ -273,7 +273,23 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
raise ValueError('unsupported_operation')
|
||||
|
||||
async def close_session(self, sid: str):
|
||||
stop_all_containers(f'openhands-runtime-{sid}')
|
||||
# First try to graceful stop server.
|
||||
try:
|
||||
container = self.docker_client.containers.get(f'openhands-runtime-{sid}')
|
||||
except docker.errors.NotFound as e:
|
||||
return
|
||||
try:
|
||||
nested_url = self.get_nested_url_for_container(container)
|
||||
async with httpx.AsyncClient(
|
||||
headers={
|
||||
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
|
||||
}
|
||||
) as client:
|
||||
response = await client.post(f'{nested_url}/api/conversations/{sid}/stop')
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
logger.exception("error_stopping_container")
|
||||
container.stop()
|
||||
|
||||
async def get_agent_loop_info(self, user_id=None, filter_to_sids=None):
|
||||
results = []
|
||||
|
||||
@@ -111,7 +111,8 @@ class StandaloneConversationManager(ConversationManager):
|
||||
return None
|
||||
end_time = time.time()
|
||||
logger.info(
|
||||
f'ServerConversation {c.sid} connected in {end_time - start_time} seconds'
|
||||
f'ServerConversation {c.sid} connected in {end_time - start_time} seconds',
|
||||
extra={'session_id': sid}
|
||||
)
|
||||
self._active_conversations[sid] = (c, 1)
|
||||
return c
|
||||
@@ -364,7 +365,9 @@ class StandaloneConversationManager(ConversationManager):
|
||||
f'removing connections: {connection_ids_to_remove}',
|
||||
extra={'session_id': sid},
|
||||
)
|
||||
# Perform a graceful shutdown of each connection
|
||||
for connection_id in connection_ids_to_remove:
|
||||
await self.sio.disconnect(connection_id)
|
||||
self._local_connection_id_to_session_id.pop(connection_id, None)
|
||||
|
||||
session = self._local_agent_loops_by_sid.pop(sid, None)
|
||||
|
||||
@@ -145,5 +145,5 @@ async def search_events(
|
||||
@app.post('/events')
|
||||
async def add_event(request: Request, conversation: ServerConversation = Depends(get_conversation)):
|
||||
data = request.json()
|
||||
conversation_manager.send_to_event_stream(conversation.sid, data)
|
||||
await conversation_manager.send_to_event_stream(conversation.sid, data)
|
||||
return JSONResponse({'success': True})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -36,6 +35,7 @@ from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
get_user_secrets,
|
||||
get_user_settings,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
from openhands.server.utils import get_conversation_store
|
||||
@@ -45,6 +45,7 @@ from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationTrigger,
|
||||
)
|
||||
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.utils.async_utils import wait_all
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
@@ -68,10 +69,11 @@ class InitSessionRequest(BaseModel):
|
||||
model_config = {'extra': 'forbid'}
|
||||
|
||||
|
||||
class InitSessionResponse(BaseModel):
|
||||
class ConversationResponse(BaseModel):
|
||||
status: str
|
||||
conversation_id: str
|
||||
message: str | None = None
|
||||
conversation_status: ConversationStatus | None = None
|
||||
|
||||
|
||||
@app.post('/conversations')
|
||||
@@ -81,7 +83,7 @@ async def new_conversation(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
user_secrets: UserSecrets = Depends(get_user_secrets),
|
||||
auth_type: AuthType | None = Depends(get_auth_type),
|
||||
) -> InitSessionResponse:
|
||||
) -> ConversationResponse:
|
||||
"""Initialize a new session or join an existing one.
|
||||
|
||||
After successful initialization, the client should connect to the WebSocket
|
||||
@@ -126,7 +128,7 @@ async def new_conversation(
|
||||
await provider_handler.verify_repo_provider(repository, git_provider)
|
||||
|
||||
conversation_id = getattr(data, 'conversation_id', None) or uuid.uuid4().hex
|
||||
await create_new_conversation(
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
@@ -141,9 +143,10 @@ async def new_conversation(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
return InitSessionResponse(
|
||||
return ConversationResponse(
|
||||
status='ok',
|
||||
conversation_id=conversation_id,
|
||||
conversation_status=agent_loop_info.status,
|
||||
)
|
||||
except MissingSettingsError as e:
|
||||
return JSONResponse(
|
||||
@@ -303,3 +306,108 @@ async def _get_conversation_info(
|
||||
extra={'session_id': conversation.conversation_id},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@app.post('/conversations/{conversation_id}/start')
|
||||
async def start_conversation(
|
||||
conversation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
settings: Settings = Depends(get_user_settings),
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
) -> ConversationResponse:
|
||||
"""Start an agent loop for a conversation.
|
||||
|
||||
This endpoint calls the conversation_manager's maybe_start_agent_loop method
|
||||
to start a conversation. If the conversation is already running, it will
|
||||
return the existing agent loop info.
|
||||
"""
|
||||
logger.info(f'Starting conversation: {conversation_id}')
|
||||
|
||||
try:
|
||||
|
||||
# Check that the conversation exists
|
||||
try:
|
||||
await conversation_store.get_metadata(conversation_id)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={
|
||||
'status': 'error',
|
||||
'conversation_id': conversation_id,
|
||||
},
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Start the agent loop
|
||||
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
|
||||
sid=conversation_id,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return ConversationResponse(
|
||||
status='ok',
|
||||
conversation_id=conversation_id,
|
||||
conversation_status=agent_loop_info.status,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error starting conversation {conversation_id}: {str(e)}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
'status': 'error',
|
||||
'conversation_id': conversation_id,
|
||||
'message': f'Failed to start conversation: {str(e)}',
|
||||
},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@app.post('/conversations/{conversation_id}/stop')
|
||||
async def stop_conversation(
|
||||
conversation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> ConversationResponse:
|
||||
"""Stop an agent loop for a conversation.
|
||||
|
||||
This endpoint calls the conversation_manager's close_session method
|
||||
to stop a conversation.
|
||||
"""
|
||||
logger.info(f'Stopping conversation: {conversation_id}')
|
||||
|
||||
try:
|
||||
# Check if the conversation is running
|
||||
agent_loop_info = await conversation_manager.get_agent_loop_info(user_id=user_id, filter_to_sids={conversation_id})
|
||||
conversation_status = agent_loop_info[0].status if agent_loop_info else ConversationStatus.STOPPED
|
||||
|
||||
if conversation_status not in (ConversationStatus.STARTING, ConversationStatus.RUNNING):
|
||||
return ConversationResponse(
|
||||
status='ok',
|
||||
conversation_id=conversation_id,
|
||||
message='Conversation was not running',
|
||||
conversation_status=conversation_status,
|
||||
)
|
||||
|
||||
# Stop the conversation
|
||||
await conversation_manager.close_session(conversation_id)
|
||||
|
||||
return ConversationResponse(
|
||||
status='ok',
|
||||
conversation_id=conversation_id,
|
||||
message='Conversation stopped successfully',
|
||||
conversation_status=conversation_status,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error stopping conversation {conversation_id}: {str(e)}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
'status': 'error',
|
||||
'conversation_id': conversation_id,
|
||||
'message': f'Failed to stop conversation: {str(e)}',
|
||||
},
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import Depends, Request
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
@@ -25,6 +26,15 @@ async def get_conversation(
|
||||
conversation = await conversation_manager.attach_to_conversation(
|
||||
conversation_id, user_id
|
||||
)
|
||||
if not conversation:
|
||||
logger.warn(
|
||||
f'get_conversation: conversation {conversation_id} not found, attach_to_conversation returned None',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Conversation {conversation_id} not found',
|
||||
)
|
||||
try:
|
||||
yield conversation
|
||||
finally:
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.40.0"
|
||||
version = "0.41.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
|
||||
@@ -20,8 +20,8 @@ from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
)
|
||||
from openhands.server.routes.manage_conversations import (
|
||||
ConversationResponse,
|
||||
InitSessionRequest,
|
||||
InitSessionResponse,
|
||||
delete_conversation,
|
||||
get_conversation,
|
||||
new_conversation,
|
||||
@@ -250,6 +250,7 @@ async def test_new_conversation_success(provider_handler_mock):
|
||||
conversation_id='test_conversation_id',
|
||||
url='https://my-conversation.com',
|
||||
session_api_key=None,
|
||||
status=ConversationStatus.RUNNING,
|
||||
)
|
||||
|
||||
test_request = InitSessionRequest(
|
||||
@@ -263,7 +264,7 @@ async def test_new_conversation_success(provider_handler_mock):
|
||||
response = await create_new_test_conversation(test_request)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, InitSessionResponse)
|
||||
assert isinstance(response, ConversationResponse)
|
||||
assert response.status == 'ok'
|
||||
# Don't check the exact conversation_id as it's now generated dynamically
|
||||
assert response.conversation_id is not None
|
||||
@@ -293,6 +294,7 @@ async def test_new_conversation_with_suggested_task(provider_handler_mock):
|
||||
conversation_id='test_conversation_id',
|
||||
url='https://my-conversation.com',
|
||||
session_api_key=None,
|
||||
status=ConversationStatus.RUNNING,
|
||||
)
|
||||
|
||||
# Mock SuggestedTask.get_prompt_for_task
|
||||
@@ -321,7 +323,7 @@ async def test_new_conversation_with_suggested_task(provider_handler_mock):
|
||||
response = await create_new_test_conversation(test_request)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, InitSessionResponse)
|
||||
assert isinstance(response, ConversationResponse)
|
||||
assert response.status == 'ok'
|
||||
# Don't check the exact conversation_id as it's now generated dynamically
|
||||
assert response.conversation_id is not None
|
||||
@@ -479,6 +481,7 @@ async def test_new_conversation_with_bearer_auth(provider_handler_mock):
|
||||
conversation_id='test_conversation_id',
|
||||
url='https://my-conversation.com',
|
||||
session_api_key=None,
|
||||
status=ConversationStatus.RUNNING,
|
||||
)
|
||||
|
||||
# Create the request object
|
||||
@@ -492,7 +495,7 @@ async def test_new_conversation_with_bearer_auth(provider_handler_mock):
|
||||
response = await create_new_test_conversation(test_request, AuthType.BEARER)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, InitSessionResponse)
|
||||
assert isinstance(response, ConversationResponse)
|
||||
assert response.status == 'ok'
|
||||
|
||||
# Verify that create_new_conversation was called with REMOTE_API_KEY trigger
|
||||
@@ -516,6 +519,7 @@ async def test_new_conversation_with_null_repository():
|
||||
conversation_id='test_conversation_id',
|
||||
url='https://my-conversation.com',
|
||||
session_api_key=None,
|
||||
status=ConversationStatus.RUNNING,
|
||||
)
|
||||
|
||||
# Create the request object with null repository
|
||||
@@ -529,7 +533,7 @@ async def test_new_conversation_with_null_repository():
|
||||
response = await create_new_test_conversation(test_request)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, InitSessionResponse)
|
||||
assert isinstance(response, ConversationResponse)
|
||||
assert response.status == 'ok'
|
||||
|
||||
# Verify that create_new_conversation was called with None repository
|
||||
|
||||
@@ -167,6 +167,7 @@ async def test_add_to_local_event_stream():
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_session_connections():
|
||||
sio = get_mock_sio()
|
||||
sio.disconnect = AsyncMock() # Mock the disconnect method
|
||||
async with StandaloneConversationManager(
|
||||
sio, OpenHandsConfig(), InMemoryFileStore(), MonitoringListener()
|
||||
) as conversation_manager:
|
||||
@@ -181,6 +182,7 @@ async def test_cleanup_session_connections():
|
||||
|
||||
await conversation_manager._close_session('session1')
|
||||
|
||||
# Check that connections were removed from the dictionary
|
||||
remaining_connections = conversation_manager._local_connection_id_to_session_id
|
||||
assert 'conn1' not in remaining_connections
|
||||
assert 'conn2' not in remaining_connections
|
||||
@@ -188,3 +190,8 @@ async def test_cleanup_session_connections():
|
||||
assert 'conn4' in remaining_connections
|
||||
assert remaining_connections['conn3'] == 'session2'
|
||||
assert remaining_connections['conn4'] == 'session2'
|
||||
|
||||
# Check that disconnect was called for each connection
|
||||
assert sio.disconnect.await_count == 2
|
||||
sio.disconnect.assert_any_call('conn1')
|
||||
sio.disconnect.assert_any_call('conn2')
|
||||
|
||||
Reference in New Issue
Block a user