Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat conversations CRUDS API #5775

Merged
merged 75 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from 65 commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
74682ef
Conversation endpoint
tofarr Dec 23, 2024
fbae222
Merge branch 'main' into feat-search-conversations
tofarr Dec 23, 2024
dc82094
Added endpoint for conversation search
tofarr Dec 23, 2024
e3f9b7e
WIP
tofarr Dec 23, 2024
3ac818b
WIP
tofarr Dec 23, 2024
76f359c
Added specific types
tofarr Dec 23, 2024
251b47f
Merge branch 'main' into feat-search-conversations
tofarr Dec 26, 2024
413e1b7
Merge branch 'main' into feat-search-conversations
tofarr Dec 27, 2024
fe0acad
Metadata
tofarr Dec 27, 2024
5edc93d
Search conversations endpoint
tofarr Dec 27, 2024
8781657
Add unit tests for search_utils and implement page_id_to_offset
openhands-agent Dec 27, 2024
bdbce31
Made tests more generic
tofarr Dec 27, 2024
b182ba0
Removed something I don't know how it got in there
tofarr Dec 27, 2024
ae9ab6d
Now returning selected repository
tofarr Dec 27, 2024
74a2068
Added get conversation endpoint
tofarr Dec 27, 2024
9d43f1f
Merge branch 'main' into feat-search-conversations
tofarr Dec 27, 2024
8d0896b
Lint fix
tofarr Dec 27, 2024
ede28d5
dded tests
tofarr Dec 27, 2024
6b30f2a
Lint fixes
tofarr Dec 27, 2024
4aeeb3f
Merge branch 'main' into feat-search-conversations
tofarr Dec 27, 2024
7bd01d2
WIP
tofarr Dec 27, 2024
0220b69
Ruff
tofarr Dec 27, 2024
4172365
Lint fix
tofarr Dec 27, 2024
4305335
WIP
tofarr Dec 27, 2024
a90dd21
Fix tests
tofarr Dec 27, 2024
a8379d7
WIP
tofarr Dec 27, 2024
e9d3f4b
WIP
tofarr Dec 27, 2024
02cd435
WIP
tofarr Dec 27, 2024
c54a050
Merge branch 'main' into feat-search-conversations
tofarr Dec 27, 2024
d7ccd78
WIP
tofarr Dec 27, 2024
64d8dc8
WIP
tofarr Dec 27, 2024
392e1cc
Merge branch 'main' into feat-search-conversations
tofarr Dec 28, 2024
77f0b57
Merge branch 'main' into feat-search-conversations
tofarr Dec 30, 2024
9f6b63c
Merge branch 'feat-search-conversations' of github.com:All-Hands-AI/O…
tofarr Dec 30, 2024
858d4cb
Ruff
tofarr Dec 30, 2024
5b197b4
Merge branch 'main' into feat-search-conversations
tofarr Dec 31, 2024
4181d09
WIP
tofarr Dec 31, 2024
c204108
Ruff
tofarr Dec 31, 2024
b1e161b
Merge branch 'main' into feat-search-conversations
tofarr Dec 31, 2024
9d171b5
WIP
tofarr Dec 31, 2024
2312dba
Merge branch 'main' into feat-search-conversations
tofarr Dec 31, 2024
3707971
WIP
tofarr Dec 31, 2024
8f70910
Merge branch 'main' into feat-search-conversations
tofarr Dec 31, 2024
bdd23da
WIP
tofarr Dec 31, 2024
0e03376
Test fixes
tofarr Dec 31, 2024
d3d1b28
Ruff
tofarr Dec 31, 2024
c5f8850
Lint fix
tofarr Dec 31, 2024
56ef8e3
Merge branch 'main' into feat-search-conversations
tofarr Jan 1, 2025
1696987
Merge branch 'main' into feat-search-conversations
tofarr Jan 1, 2025
3f4e87f
Handled case where there are no conversations
tofarr Jan 2, 2025
cf1d148
Implemented default title
tofarr Jan 2, 2025
2d667c3
Moved as suggested
tofarr Jan 2, 2025
215c633
Using put as post is for create
tofarr Jan 2, 2025
d2a80d9
Fixed typo
tofarr Jan 2, 2025
5becacf
Added test for update conversation
tofarr Jan 2, 2025
ea5f583
Moved models as suggested by Enyst
tofarr Jan 2, 2025
c89f5f7
Renamed file for clarity
tofarr Jan 2, 2025
ba67047
Ruff
tofarr Jan 2, 2025
0315069
Ruff
tofarr Jan 2, 2025
2b80060
Ruff
tofarr Jan 2, 2025
46260e6
Fixed broken tests
tofarr Jan 2, 2025
92d7726
Now updating conversation metadata as events come in
tofarr Jan 2, 2025
d3b02e0
Lint fixes
tofarr Jan 2, 2025
e012059
Merge branch 'main' into feat-search-conversations
tofarr Jan 2, 2025
792b2f9
Lint fix
tofarr Jan 2, 2025
9cedca1
Test fixes
tofarr Jan 2, 2025
0290a07
Shorter conversation name
tofarr Jan 2, 2025
502f02a
Less utils
tofarr Jan 2, 2025
ca99db3
Using first 5 chars
tofarr Jan 2, 2025
f4a0645
Unified API as suggested by Stephan
tofarr Jan 2, 2025
fb634f0
Patch is most appropriate here
tofarr Jan 2, 2025
78ac6aa
Fix body params
tofarr Jan 2, 2025
881dc21
Merge branch 'main' into feat-search-conversations
tofarr Jan 2, 2025
d67380a
Merge branch 'main' into feat-search-conversations
tofarr Jan 2, 2025
5b56f8c
Merge branch 'main' into feat-search-conversations
tofarr Jan 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions openhands/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
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.github import app as github_api_router
from openhands.server.routes.new_conversation import app as new_conversation_api_router
from openhands.server.routes.manage_conversations import (
app as manage_conversation_api_router,
)
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
Expand Down Expand Up @@ -58,7 +60,7 @@ async def health():
app.include_router(security_api_router)
app.include_router(feedback_api_router)
app.include_router(conversation_api_router)
app.include_router(new_conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(github_api_router)

Expand Down
2 changes: 1 addition & 1 deletion openhands/server/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def _should_attach(self, request) -> bool:
if request.url.path.startswith('/api/conversation'):
# FIXME: we should be able to use path_params
path_parts = request.url.path.split('/')
if len(path_parts) > 3:
if len(path_parts) > 4:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did this change? /api/conversations/foo should have 4 parts

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see--we don't need to attach the runtime for these.

Right now I think that's our main auth mechanism, so probably better to leave this as 3? Worth refactoring in the future though

conversation_id = request.url.path.split('/')[3]
if not conversation_id:
return False
Expand Down
173 changes: 173 additions & 0 deletions openhands/server/routes/manage_conversations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import uuid

from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from github import Github
from pydantic import BaseModel

from openhands.core.logger import openhands_logger as logger
from openhands.events.stream import EventStreamSubscriber
from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.shared import config, session_manager
from openhands.storage.data_models.conversation_info import ConversationInfo
from openhands.storage.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
)
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
from openhands.utils.async_utils import call_sync_from_async, wait_all
from openhands.utils.conversation_utils import (
create_conversation_update_callback,
get_conversation_info,
)

app = APIRouter(prefix='/api')
UPDATED_AT_CALLBACK_ID = 'updated_at_callback_id'


class InitSessionRequest(BaseModel):
github_token: str | None = None
latest_event_id: int = -1
selected_repository: str | None = None
args: dict | None = None


@app.post('/conversations')
async def new_conversation(request: Request, data: InitSessionRequest):
"""Initialize a new session or join an existing one.
After successful initialization, the client should connect to the WebSocket
using the returned conversation ID
"""
logger.info('Initializing new conversation')
github_token = data.github_token or ''

logger.info('Loading settings')
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
logger.info('Settings loaded')

session_init_args: dict = {}
if settings:
session_init_args = {**settings.__dict__, **session_init_args}

session_init_args['github_token'] = github_token
session_init_args['selected_repository'] = data.selected_repository
conversation_init_data = ConversationInitData(**session_init_args)
logger.info('Loading conversation store')
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
logger.info('Conversation store loaded')

conversation_id = uuid.uuid4().hex
while await conversation_store.exists(conversation_id):
logger.warning(f'Collision on conversation ID: {conversation_id}. Retrying...')
conversation_id = uuid.uuid4().hex
logger.info(f'New conversation ID: {conversation_id}')

user_id = ''
if data.github_token:
logger.info('Fetching Github user ID')
with Github(data.github_token) as g:
gh_user = await call_sync_from_async(g.get_user)
user_id = gh_user.id

logger.info(f'Saving metadata for conversation {conversation_id}')
await conversation_store.save_metadata(
ConversationMetadata(
conversation_id=conversation_id,
github_user_id=user_id,
selected_repository=data.selected_repository,
)
)

logger.info(f'Starting agent loop for conversation {conversation_id}')
event_stream = await session_manager.maybe_start_agent_loop(
conversation_id, conversation_init_data
)
try:
event_stream.subscribe(
EventStreamSubscriber.SERVER,
create_conversation_update_callback(
data.github_token or '', conversation_id
),
UPDATED_AT_CALLBACK_ID,
)
except ValueError:
pass # Already subscribed - take no action
logger.info(f'Finished initializing conversation {conversation_id}')
return JSONResponse(content={'status': 'ok', 'conversation_id': conversation_id})


@app.get('/conversations')
async def search_conversations(
request: Request,
page_id: str | None = None,
limit: int = 20,
) -> ConversationInfoResultSet:
github_token = getattr(request.state, 'github_token', '') or ''
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
conversation_ids = set(
conversation.conversation_id
for conversation in conversation_metadata_result_set.results
)
running_conversations = await session_manager.get_agent_loop_running(
set(conversation_ids)
)
result = ConversationInfoResultSet(
results=await wait_all(
get_conversation_info(
conversation=conversation,
is_running=conversation.conversation_id in running_conversations,
)
for conversation in conversation_metadata_result_set.results
),
next_page_id=conversation_metadata_result_set.next_page_id,
)
return result


@app.get('/conversations/{conversation_id}')
async def get_conversation(
conversation_id: str, request: Request
) -> ConversationInfo | None:
github_token = getattr(request.state, 'github_token', '') or ''
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
try:
metadata = await conversation_store.get_metadata(conversation_id)
is_running = await session_manager.is_agent_loop_running(conversation_id)
conversation_info = await get_conversation_info(metadata, is_running)
return conversation_info
except FileNotFoundError:
return None


@app.put('/conversations/{conversation_id}')
async def update_conversation(
conversation_id: str, title: str, request: Request
) -> bool:
github_token = getattr(request.state, 'github_token', '') or ''
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
metadata = await conversation_store.get_metadata(conversation_id)
if not metadata:
return False
metadata.title = title
await conversation_store.save_metadata(metadata)
return True


@app.delete('/conversations/{conversation_id}')
async def delete_conversation(
conversation_id: str,
request: Request,
) -> bool:
github_token = getattr(request.state, 'github_token', '') or ''
conversation_store = await ConversationStoreImpl.get_instance(config, github_token)
try:
await conversation_store.get_metadata(conversation_id)
except FileNotFoundError:
return False
is_running = await session_manager.is_agent_loop_running(conversation_id)
if is_running:
return False
await conversation_store.delete_metadata(conversation_id)
return True
80 changes: 0 additions & 80 deletions openhands/server/routes/new_conversation.py

This file was deleted.

8 changes: 2 additions & 6 deletions openhands/server/routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,8 @@


@app.get('/settings')
async def load_settings(
request: Request,
) -> Settings | None:
github_token = ''
if hasattr(request.state, 'github_token'):
github_token = request.state.github_token
async def load_settings(request: Request) -> Settings | None:
github_token = getattr(request.state, 'github_token', '') or ''
try:
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
settings = await settings_store.load()
Expand Down
17 changes: 16 additions & 1 deletion openhands/storage/conversation/conversation_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
from abc import ABC, abstractmethod

from openhands.core.config.app_config import AppConfig
from openhands.server.data_models.conversation_metadata import ConversationMetadata
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
from openhands.storage.data_models.conversation_metadata_result_set import (
ConversationMetadataResultSet,
)


class ConversationStore(ABC):
Expand All @@ -19,10 +22,22 @@ async def save_metadata(self, metadata: ConversationMetadata):
async def get_metadata(self, conversation_id: str) -> ConversationMetadata:
"""Load conversation metadata"""

@abstractmethod
async def delete_metadata(self, conversation_id: str) -> None:
"""delete conversation metadata"""

@abstractmethod
async def exists(self, conversation_id: str) -> bool:
"""Check if conversation exists"""

@abstractmethod
async def search(
self,
page_id: str | None = None,
limit: int = 20,
) -> ConversationMetadataResultSet:
"""Search conversations"""

@classmethod
@abstractmethod
async def get_instance(
Expand Down
Loading
Loading