Repository: All-Hands-AI/OpenHands
Pull request #7340: All-1465 Move user conversations
Author: login: chuckbutkus
Created: 2025-03-19T06:51:37Z | Merged: 2025-03-19T20:03:09Z

If there is a user ID, store agent state and events in a conversation store under the users folder (user/{user_id}/conversations/{conversation_id}).  If not then store under "sesssions" as they are currently.

---

To run this PR locally, use the following command:
```
docker run -it --rm   -p 3000:3000   -v /var/run/docker.sock:/var/run/docker.sock   --add-host host.docker.internal:host-gateway   -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:9e14b65-nikolaik   --name openhands-app-9e14b65   docker.all-hands.dev/all-hands-ai/openhands:9e14b65
```


Diff:
diff --git a/openhands/controller/state/state.py b/openhands/controller/state/state.py
index 58187d1231fc..549124fc0e0e 100644
--- a/openhands/controller/state/state.py
+++ b/openhands/controller/state/state.py
@@ -102,22 +102,42 @@ class State:
     extra_data: dict[str, Any] = field(default_factory=dict)
     last_error: str = ''
 
-    def save_to_session(self, sid: str, file_store: FileStore):
+    def save_to_session(self, sid: str, file_store: FileStore, user_id: str | None):
         pickled = pickle.dumps(self)
         logger.debug(f'Saving state to session {sid}:{self.agent_state}')
         encoded = base64.b64encode(pickled).decode('utf-8')
         try:
-            file_store.write(get_conversation_agent_state_filename(sid), encoded)
+            file_store.write(
+                get_conversation_agent_state_filename(sid, user_id), encoded
+            )
+
+            # see if state is in old directory. If yes, delete it.
+            filename = get_conversation_agent_state_filename(sid)
+            try:
+                file_store.delete(filename)
+            except Exception:
+                pass
         except Exception as e:
             logger.error(f'Failed to save state to session: {e}')
             raise e
 
     @staticmethod
-    def restore_from_session(sid: str, file_store: FileStore) -> 'State':
+    def restore_from_session(
+        sid: str, file_store: FileStore, user_id: str | None = None
+    ) -> 'State':
         try:
-            encoded = file_store.read(get_conversation_agent_state_filename(sid))
+            encoded = file_store.read(
+                get_conversation_agent_state_filename(sid, user_id)
+            )
             pickled = base64.b64decode(encoded)
             state = pickle.loads(pickled)
+        except FileNotFoundError:
+            if user_id:
+                # see if state is in old directory. If yes, load it.
+                filename = get_conversation_agent_state_filename(sid)
+                encoded = file_store.read(filename)
+                pickled = base64.b64decode(encoded)
+                state = pickle.loads(pickled)
         except Exception as e:
             logger.debug(f'Could not restore state from session: {e}')
             raise e
diff --git a/openhands/core/main.py b/openhands/core/main.py
index 01c381976622..fcd8a5468e54 100644
--- a/openhands/core/main.py
+++ b/openhands/core/main.py
@@ -194,7 +194,9 @@ def on_event(event: Event):
     if config.file_store is not None and config.file_store != 'memory':
         end_state = controller.get_state()
         # NOTE: the saved state does not include delegates events
-        end_state.save_to_session(event_stream.sid, event_stream.file_store)
+        end_state.save_to_session(
+            event_stream.sid, event_stream.file_store, event_stream.user_id
+        )
 
     await controller.close(set_stop_state=False)
 
diff --git a/openhands/events/stream.py b/openhands/events/stream.py
index e1ecb7adbe96..9cdaf60b48eb 100644
--- a/openhands/events/stream.py
+++ b/openhands/events/stream.py
@@ -32,9 +32,11 @@ class EventStreamSubscriber(str, Enum):
     TEST = 'test'
 
 
-async def session_exists(sid: str, file_store: FileStore) -> bool:
+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))
+        await call_sync_from_async(file_store.list, get_conversation_dir(sid, user_id))
         return True
     except FileNotFoundError:
         return False
@@ -57,6 +59,7 @@ async def __aiter__(self):
 
 class EventStream:
     sid: str
+    user_id: str | None
     file_store: FileStore
     secrets: dict[str, str]
     # For each subscriber ID, there is a map of callback functions - useful
@@ -70,9 +73,10 @@ class EventStream:
     _thread_pools: dict[str, dict[str, ThreadPoolExecutor]]
     _thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]]
 
-    def __init__(self, sid: str, file_store: FileStore):
+    def __init__(self, sid: str, file_store: FileStore, user_id: str | None = None):
         self.sid = sid
         self.file_store = file_store
+        self.user_id = user_id
         self._stop_flag = threading.Event()
         self._queue: queue.Queue[Event] = queue.Queue()
         self._thread_pools = {}
@@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
+            events += self.file_store.list(events_dir)
         except FileNotFoundError:
-            logger.debug(f'No events found for session {self.sid}')
+            logger.debug(f'No events found for session {self.sid} at {events_dir}')
+
+        if self.user_id:
+            # During transition to new location, try old location if user_id is set
+            # TODO: remove this code after 5/1/2025
+            try:
+                events_dir = get_conversation_events_dir(self.sid)
+                events += self.file_store.list(events_dir)
+            except FileNotFoundError:
+                logger.debug(f'No events found for session {self.sid} at {events_dir}')
+
+        if not events:
             self._cur_id = 0
             return
 
@@ -156,8 +174,8 @@ def _clean_up_subscriber(self, subscriber_id: str, callback_id: str):
 
         del self._subscribers[subscriber_id][callback_id]
 
-    def _get_filename_for_id(self, id: int) -> str:
-        return get_conversation_event_filename(self.sid, id)
+    def _get_filename_for_id(self, id: int, user_id: str | None) -> str:
+        return get_conversation_event_filename(self.sid, id, user_id)
 
     @staticmethod
     def _get_id_from_filename(filename: str) -> int:
@@ -223,10 +241,20 @@ def should_filter(event: Event):
                 event_id += 1
 
     def get_event(self, id: int) -> Event:
-        filename = self._get_filename_for_id(id)
-        content = self.file_store.read(filename)
-        data = json.loads(content)
-        return event_from_dict(data)
+        filename = self._get_filename_for_id(id, self.user_id)
+        try:
+            content = self.file_store.read(filename)
+            data = json.loads(content)
+            return event_from_dict(data)
+        except FileNotFoundError:
+            logger.debug(f'File {filename} not found')
+            # TODO remove this block after 5/1/2025
+            if self.user_id:
+                filename = self._get_filename_for_id(id, None)
+                content = self.file_store.read(filename)
+                data = json.loads(content)
+                return event_from_dict(data)
+            raise
 
     def get_latest_event(self) -> Event:
         return self.get_event(self._cur_id - 1)
@@ -277,7 +305,9 @@ def add_event(self, event: Event, source: EventSource):
         data = self._replace_secrets(data)
         event = event_from_dict(data)
         if event.id is not None:
-            self.file_store.write(self._get_filename_for_id(event.id), json.dumps(data))
+            self.file_store.write(
+                self._get_filename_for_id(event.id, self.user_id), json.dumps(data)
+            )
         self._queue.put(event)
 
     def set_secrets(self, secrets: dict[str, str]):
diff --git a/openhands/server/conversation_manager/conversation_manager.py b/openhands/server/conversation_manager/conversation_manager.py
index d152a936f66a..86bae399706c 100644
--- a/openhands/server/conversation_manager/conversation_manager.py
+++ b/openhands/server/conversation_manager/conversation_manager.py
@@ -37,7 +37,9 @@ async def __aexit__(self, exc_type, exc_value, traceback):
         """Clean up the conversation manager."""
 
     @abstractmethod
-    async def attach_to_conversation(self, sid: str) -> Conversation | None:
+    async def attach_to_conversation(
+        self, sid: str, user_id: str | None = None
+    ) -> Conversation | None:
         """Attach to an existing conversation or create a new one."""
 
     @abstractmethod
diff --git a/openhands/server/conversation_manager/standalone_conversation_manager.py b/openhands/server/conversation_manager/standalone_conversation_manager.py
index b36f4025f979..540228a5186b 100644
--- a/openhands/server/conversation_manager/standalone_conversation_manager.py
+++ b/openhands/server/conversation_manager/standalone_conversation_manager.py
@@ -63,9 +63,11 @@ async def __aexit__(self, exc_type, exc_value, traceback):
             self._cleanup_task.cancel()
             self._cleanup_task = None
 
-    async def attach_to_conversation(self, sid: str) -> Conversation | None:
+    async def attach_to_conversation(
+        self, sid: str, user_id: str | None = None
+    ) -> Conversation | None:
         start_time = time.time()
-        if not await session_exists(sid, self.file_store):
+        if not await session_exists(sid, self.file_store, user_id=user_id):
             return None
 
         async with self._conversations_lock:
@@ -88,7 +90,9 @@ async def attach_to_conversation(self, sid: str) -> Conversation | None:
                 return conversation
 
             # Create new conversation if none exists
-            c = Conversation(sid, file_store=self.file_store, config=self.config)
+            c = Conversation(
+                sid, file_store=self.file_store, config=self.config, user_id=user_id
+            )
             try:
                 await c.connect()
             except AgentRuntimeUnavailableError as e:
@@ -119,7 +123,7 @@ async def join_conversation(
         )
         await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
         self._local_connection_id_to_session_id[connection_id] = sid
-        event_stream = await self._get_event_stream(sid)
+        event_stream = await self._get_event_stream(sid, user_id)
         if not event_stream:
             return await self.maybe_start_agent_loop(
                 sid, settings, user_id, github_user_id=github_user_id
@@ -299,7 +303,7 @@ async def maybe_start_agent_loop(
             except ValueError:
                 pass  # Already subscribed - take no action
 
-        event_stream = await self._get_event_stream(sid)
+        event_stream = await self._get_event_stream(sid, user_id)
         if not event_stream:
             logger.error(
                 f'No event stream after starting agent loop: {sid}',
@@ -308,7 +312,9 @@ async def maybe_start_agent_loop(
             raise RuntimeError(f'no_event_stream:{sid}')
         return event_stream
 
-    async def _get_event_stream(self, sid: str) -> EventStream | None:
+    async def _get_event_stream(
+        self, sid: str, user_id: str | None
+    ) -> EventStream | None:
         logger.info(f'_get_event_stream:{sid}', extra={'session_id': sid})
         session = self._local_agent_loops_by_sid.get(sid)
         if session:
diff --git a/openhands/server/middleware.py b/openhands/server/middleware.py
index 3d690306f72f..acb09c287bb8 100644
--- a/openhands/server/middleware.py
+++ b/openhands/server/middleware.py
@@ -148,7 +148,9 @@ async def _attach_conversation(self, request: Request) -> JSONResponse | None:
         Attach the user's session based on the provided authentication token.
         """
         request.state.conversation = (
-            await shared.conversation_manager.attach_to_conversation(request.state.sid)
+            await shared.conversation_manager.attach_to_conversation(
+                request.state.sid, get_user_id(request)
+            )
         )
         if not request.state.conversation:
             return JSONResponse(
diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py
index 58518ab7a306..5c9c1ca498cc 100644
--- a/openhands/server/session/agent_session.py
+++ b/openhands/server/session/agent_session.py
@@ -37,6 +37,7 @@ class AgentSession:
     """
 
     sid: str
+    user_id: str | None
     event_stream: EventStream
     file_store: FileStore
     controller: AgentController | None = None
@@ -63,7 +64,7 @@ def __init__(
         """
 
         self.sid = sid
-        self.event_stream = EventStream(sid, file_store)
+        self.event_stream = EventStream(sid, file_store, user_id)
         self.file_store = file_store
         self._status_callback = status_callback
         self.user_id = user_id
@@ -186,7 +187,7 @@ async def close(self):
             self.event_stream.close()
         if self.controller is not None:
             end_state = self.controller.get_state()
-            end_state.save_to_session(self.sid, self.file_store)
+            end_state.save_to_session(self.sid, self.file_store, self.user_id)
             await self.controller.close()
         if self.runtime is not None:
             self.runtime.close()
@@ -371,7 +372,9 @@ def _maybe_restore_state(self) -> State | None:
         # Use a heuristic to figure out if we should have a state:
         # if we have events in the stream.
         try:
-            restored_state = State.restore_from_session(self.sid, self.file_store)
+            restored_state = State.restore_from_session(
+                self.sid, self.file_store, self.user_id
+            )
             self.logger.debug(f'Restored state from session, sid: {self.sid}')
         except Exception as e:
             if self.event_stream.get_latest_event_id() > 0:
diff --git a/openhands/server/session/conversation.py b/openhands/server/session/conversation.py
index 14aa1363e63b..b827996e4cdf 100644
--- a/openhands/server/session/conversation.py
+++ b/openhands/server/session/conversation.py
@@ -14,17 +14,16 @@ class Conversation:
     file_store: FileStore
     event_stream: EventStream
     runtime: Runtime
+    user_id: str | None
 
     def __init__(
-        self,
-        sid: str,
-        file_store: FileStore,
-        config: AppConfig,
+        self, sid: str, file_store: FileStore, config: AppConfig, user_id: str | None
     ):
         self.sid = sid
         self.config = config
         self.file_store = file_store
-        self.event_stream = EventStream(sid, file_store)
+        self.user_id = user_id
+        self.event_stream = EventStream(sid, file_store, user_id)
         if config.security.security_analyzer:
             self.security_analyzer = options.SecurityAnalyzers.get(
                 config.security.security_analyzer, SecurityAnalyzer
diff --git a/openhands/storage/locations.py b/openhands/storage/locations.py
index 450ebe4a6f51..43e1661b9175 100644
--- a/openhands/storage/locations.py
+++ b/openhands/storage/locations.py
@@ -1,25 +1,30 @@
 CONVERSATION_BASE_DIR = 'sessions'
 
 
-def get_conversation_dir(sid: str) -> str:
-    return f'{CONVERSATION_BASE_DIR}/{sid}/'
+def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
+    if user_id:
+        return f'users/{user_id}/conversations/{sid}/'
+    else:
+        return f'{CONVERSATION_BASE_DIR}/{sid}/'
 
 
-def get_conversation_events_dir(sid: str) -> str:
-    return f'{get_conversation_dir(sid)}events/'
+def get_conversation_events_dir(sid: str, user_id: str | None = None) -> str:
+    return f'{get_conversation_dir(sid, user_id)}events/'
 
 
-def get_conversation_event_filename(sid: str, id: int) -> str:
-    return f'{get_conversation_events_dir(sid)}{id}.json'
+def get_conversation_event_filename(
+    sid: str, id: int, user_id: str | None = None
+) -> str:
+    return f'{get_conversation_events_dir(sid, user_id)}{id}.json'
 
 
-def get_conversation_metadata_filename(sid: str) -> str:
-    return f'{get_conversation_dir(sid)}metadata.json'
+def get_conversation_metadata_filename(sid: str, user_id: str | None = None) -> str:
+    return f'{get_conversation_dir(sid, user_id)}metadata.json'
 
 
-def get_conversation_init_data_filename(sid: str) -> str:
-    return f'{get_conversation_dir(sid)}init.json'
+def get_conversation_init_data_filename(sid: str, user_id: str | None = None) -> str:
+    return f'{get_conversation_dir(sid, user_id)}init.json'
 
 
-def get_conversation_agent_state_filename(sid: str) -> str:
-    return f'{get_conversation_dir(sid)}agent_state.pkl'
+def get_conversation_agent_state_filename(sid: str, user_id: str | None = None) -> str:
+    return f'{get_conversation_dir(sid, user_id)}agent_state.pkl'


Reviews:
user: login: rbren
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T13:30:48Z
user: login: rbren
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T13:32:20Z
user: login: rbren
body: Just a couple comments. Only blocker is not deleting--if you think we should delete let's chat
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-19T13:32:57Z
user: login: enyst
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T14:08:36Z
user: login: enyst
body: This seems to go in a great direction!

It depends on user_id though. Could you please help me understand what is user_id set to? There seem to have been some changes in time. 😅 

There's a related bit I don't fully understand: to my mind, the current location of conversations is already a user-specific location with settings and conversations, as opposed to _project root_ or configured location, where there are most of the settings.

(So the new location is location option... four? 😂 It seems like maybe we can simplify or at least figure out how it should be like)

This PR moves conversations and searches for them in two locations. I wonder what happens with `settings.json` that the app saves in the same ("old") directory.

Could we look at it like, they are about the same thing, a user-specific set of things to save, thus this isn't a "conversations" dir alone, maybe it's a user-specific dir?
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-03-19T14:35:35Z
user: login: chuckbutkus
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T16:46:23Z
user: login: enyst
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T18:59:51Z
user: login: rbren
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T19:09:16Z
user: login: chuckbutkus
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T19:10:11Z
user: login: enyst
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T19:12:29Z
user: login: rbren
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T19:29:56Z
user: login: enyst
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T19:36:42Z
user: login: enyst
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T19:52:54Z
user: login: rbren
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T19:54:06Z
user: login: enyst
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T20:03:44Z

Review comments:
diff_hunk: @@ -102,22 +102,36 @@ class State:
     extra_data: dict[str, Any] = field(default_factory=dict)
     last_error: str = ''
 
-    def save_to_session(self, sid: str, file_store: FileStore):
+    def save_to_session(self, sid: str, file_store: FileStore, user_id: str | None):
         pickled = pickle.dumps(self)
         logger.debug(f'Saving state to session {sid}:{self.agent_state}')
         encoded = base64.b64encode(pickled).decode('utf-8')
         try:
-            file_store.write(get_conversation_agent_state_filename(sid), encoded)
+            file_store.write(
+                get_conversation_agent_state_filename(sid, user_id), encoded
+            )
         except Exception as e:
             logger.error(f'Failed to save state to session: {e}')
             raise e
 
     @staticmethod
-    def restore_from_session(sid: str, file_store: FileStore) -> 'State':
+    def restore_from_session(
+        sid: str, file_store: FileStore, user_id: str | None = None
+    ) -> 'State':
         try:
-            encoded = file_store.read(get_conversation_agent_state_filename(sid))
+            encoded = file_store.read(
+                get_conversation_agent_state_filename(sid, user_id)
+            )
             pickled = base64.b64decode(encoded)
             state = pickle.loads(pickled)
+        except FileNotFoundError:
+            if user_id:
+                # see if state is in old directory. If yes, load and delete.
+                filename = get_conversation_agent_state_filename(sid)
+                encoded = file_store.read(filename)
+                pickled = base64.b64decode(encoded)
+                state = pickle.loads(pickled)
+                file_store.delete(filename)
path: openhands/controller/state/state.py
user: login: rbren
body: ```suggestion
```
let's leave them for safety
created_at: 2025-03-19T13:30:47Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,23 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
+        # first try old location
path: openhands/events/stream.py
user: login: rbren
body: Should we try new first?
```suggestion
        # first try old location. TODO: remove this code after 5/1/2025
```
created_at: 2025-03-19T13:32:20Z
author_association: COLLABORATOR
diff_hunk: @@ -223,7 +240,15 @@ def should_filter(event: Event):
                 event_id += 1
 
     def get_event(self, id: int) -> Event:
-        filename = self._get_filename_for_id(id)
+        filename = self._get_filename_for_id(id, self.user_id)
+        try:
+            content = self.file_store.read(filename)
+            data = json.loads(content)
+            return event_from_dict(data)
+        except FileNotFoundError:
+            logger.info(f'File {filename} not found')
path: openhands/events/stream.py
user: login: enyst
body: This seems useful, but if I see this right, this will have a lot of messages if one doesn't have this?
created_at: 2025-03-19T14:08:35Z
author_association: COLLABORATOR
diff_hunk: @@ -223,7 +240,15 @@ def should_filter(event: Event):
                 event_id += 1
 
     def get_event(self, id: int) -> Event:
-        filename = self._get_filename_for_id(id)
+        filename = self._get_filename_for_id(id, self.user_id)
+        try:
+            content = self.file_store.read(filename)
+            data = json.loads(content)
+            return event_from_dict(data)
+        except FileNotFoundError:
+            logger.info(f'File {filename} not found')
path: openhands/events/stream.py
user: login: chuckbutkus
body: Made it debug level now.
created_at: 2025-03-19T16:46:23Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
path: openhands/events/stream.py
user: login: enyst
body: Would it be possible for `get_conversation_events_dir` to be set with `user_id` if any, _always_, so that it returns the right thing, and we wouldn't maybe have to write user_id in the caller?
created_at: 2025-03-19T18:59:51Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
path: openhands/events/stream.py
user: login: rbren
body: I don't think so no--user_id a dynamic variable that's different in different parts of the app

We'd have to construct a `FileNameManager` and pass user_id to that, but I think that just makes the code more complicated
created_at: 2025-03-19T19:09:16Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
path: openhands/events/stream.py
user: login: chuckbutkus
body: get_conversation_events_dir is just a function, so there is no state associated with it.  We could move the location.py functions to a class, but that would require some refactoring.
created_at: 2025-03-19T19:10:11Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
path: openhands/events/stream.py
user: login: enyst
body: A dynamic variable? May I ask why would user_id be ever changeable or different as opposed to identifying the user?
created_at: 2025-03-19T19:12:29Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
path: openhands/events/stream.py
user: login: rbren
body: Sorry maybe that's the wrong word. But every time this function gets called user_id is set differently (same for the same user, but in a multi-user instance you've got many different users all hitting the same server)
created_at: 2025-03-19T19:29:56Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
path: openhands/events/stream.py
user: login: enyst
body: Does that mean that the event stream is per user or is it per group, that is, all users that can access a particular server?

Wait, not group. Err. You mean randomly get assigned a runtime server?
created_at: 2025-03-19T19:36:42Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
path: openhands/events/stream.py
user: login: enyst
body: I still don't get it, sorry for being dense. The event stream is _per_ _sid_, which cannot possibly be shared with random users. Location on disk, okay... but why known inside the event stream... it's not obvious to me, sorry.
created_at: 2025-03-19T19:52:54Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
path: openhands/events/stream.py
user: login: rbren
body: I guess we could make an async call from inside this utility function to retrieve the user ID from the SID...but that seems like way too much
created_at: 2025-03-19T19:54:06Z
author_association: COLLABORATOR
diff_hunk: @@ -90,10 +94,24 @@ def __init__(self, sid: str, file_store: FileStore):
         self.__post_init__()
 
     def __post_init__(self) -> None:
+        events = []
+
         try:
-            events = self.file_store.list(get_conversation_events_dir(self.sid))
+            events_dir = get_conversation_events_dir(self.sid, self.user_id)
path: openhands/events/stream.py
user: login: enyst
body: Idk, the reverse is that it seems to make the event stream complex in a way that is foreign to it, conceptually, doesn't it?... And it's the event stream, it's a core class that system components depend on, this seems to make it dependent on an implementation detail of one of them
created_at: 2025-03-19T20:03:44Z
author_association: COLLABORATOR

PR comments:
user: login: enyst
created_at: 2025-03-19T16:12:48Z
body: Just to elaborate on the 4 locations, we have:
- project root, default
- user-configurable [location](https://github.com/All-Hands-AI/OpenHands/blob/09aa62f1c34a46a5bffc141811168879a1694fa2/openhands/core/config/utils.py#L548) of settings
- user-specific [location](https://github.com/All-Hands-AI/OpenHands/blob/13bb4746238ef627862e2b3e0c880d04ee6ef60a/openhands/server/shared.py#L52) of some settings
- user-specific location with user_id (in this PR)

Would it be possible to rethink and restate the case for only 2, like a global/default/system one, and a user-specific one? Or could you perhaps explain if the cloud use needs more than 2?

author_association: COLLABORATOR
user: login: chuckbutkus
created_at: 2025-03-19T16:52:26Z
body: > Just to elaborate on the 4 locations, we have:
> 
> * project root, default
> * user-configurable [location](https://github.com/All-Hands-AI/OpenHands/blob/09aa62f1c34a46a5bffc141811168879a1694fa2/openhands/core/config/utils.py#L548) of settings
> * user-specific [location](https://github.com/All-Hands-AI/OpenHands/blob/13bb4746238ef627862e2b3e0c880d04ee6ef60a/openhands/server/shared.py#L52) of some settings
> * user-specific location with user_id (in this PR)
> 
> Would it be possible to rethink and restate the case for only 2, like a global/default/system one, and a user-specific one? Or could you perhaps explain if the cloud use needs more than 2?

This change should have no impact if you are just running the OSS version as user_id is not set (at least it should not be set and is not in all my testing).

This is so on the SAAS product we can store the conversations in user specific locations.
author_association: COLLABORATOR
user: login: enyst
created_at: 2025-03-19T17:16:29Z
body: > This change should have no impact if you are just running the OSS version as user_id is not set (at least it should not be set and is not in all my testing).
> 
> This is so on the SAAS product we can store the conversations in user specific locations.

Thank you, Chuck. This is relevant, I just don't know if it's entirely the case:
- the project has user-specific configurations or locations (locations 2 and 3 above)
- locally, there is a user, strictly speaking, since there is a user token
(also, I never worked on the cloud use case, nor I remember messing around with github auth locally in openhands, and yet my settings files contain a user_id, so things happen 🤷)

To clarify, my concern is that we're adding a 4th location, when it's error-prone to work with those we have.

What if we go with this user_id overall, let the user_id be known locally when the user has set some token, and go with it, like, the configurable location in alternative 2 would include it, and/or settings.json would continue to be where conversations are, in a location with user_id if set? It already is a user-specific location, just let it have user_id and use the same.

This might be a bad idea, I'm just looking for ways to reduce the cognitive load / variations.
author_association: COLLABORATOR
user: login: enyst
created_at: 2025-03-19T17:36:40Z
body: Sort of related: [slack discussion](https://openhands-ai.slack.com/archives/C078L0FUGUX/p1742399146041919)

The discussion above relates to the main problem, which, to be clear, is not in this PR. This PR is just adding more on top of the disjoint design of duplicate configuration hierarchies, which we really may want to reconsider how we're doing it.

So I'm just brainstorming here, can we, on this one, go the opposite way maybe, accept user_id locally when we know it, and by that, reduce a tiny bit the differences? Of course, that would be optional and doesn't imply actual relevance for anything else than what it already has: the github connection/repos/selection. Though I could be wrong, maybe it wouldn't help.
author_association: COLLABORATOR
user: login: chuckbutkus
created_at: 2025-03-19T18:03:59Z
body: > Sort of related: [slack discussion](https://openhands-ai.slack.com/archives/C078L0FUGUX/p1742399146041919)
> 
> The discussion above relates to the main problem, which, to be clear, is not in this PR. This PR is just adding more on top of the disjoint design of duplicate configuration hierarchies, which we really may want to reconsider how we're doing it.
> 
> So I'm just brainstorming here, can we, on this one, go the opposite way maybe, accept user_id locally when we know it, and by that, reduce a tiny bit the differences? Of course, that would be optional and doesn't imply actual relevance for anything else than what it already has: the github connection/repos/selection. Though I could be wrong, maybe it wouldn't help.

This seems like a larger issue that will need to be discussed with many people.

I guess we need to decide how that impacts this PR.  @rbren 
author_association: COLLABORATOR
user: login: enyst
created_at: 2025-03-19T18:08:52Z
body: Just a quick question, about locations 3 and 4 above:
- does the cloud use case need 2 different vars/locations for the user-specific settings, and the user's conversations?
author_association: COLLABORATOR
user: login: chuckbutkus
created_at: 2025-03-19T18:18:29Z
body: > Just a quick question, about locations 3 and 4 above:
> 
> * does the cloud use case need 2 different vars/locations for the user-specific settings, and the user's conversations?

In the cloud we store all user info in the database now and just the conversation events (which we are moving here) are stored on the file system.  So we just need to make sure that for the OSS version we have a good layout.
author_association: COLLABORATOR
user: login: rbren
created_at: 2025-03-19T18:27:54Z
body: @enyst yeah on cloud we store the settings in a database, and store the conversation events in a filestore

We need a user_id in the store for cloud (obviously :)), even though it's always `None` in OSS

I agree we need to figure out the relationship between TOML and settings.json, but that's probably for the future 😅 
author_association: COLLABORATOR
user: login: enyst
created_at: 2025-03-19T19:08:42Z
body: > In the cloud we store all user info in the database now and just the conversation events (which we are moving here) are stored on the file system. So we just need to make sure that for the OSS version we have a good layout.

This sounds very good to me actually. 🤔

If so, maybe we can reduce these to 3... ish, lets try 2:
- project root
- user-specific location. On local, this should = user-configurable = optional. On remote use cases, what differs? User-specific configurations differs from event stream / sessions / conversations which has to be user-specific. OK, so 3... 🥹

Maybe instead of setting user_id everywhere, we could merge the conversation_dir to the file_store/{user_id}/conversations? I wonder if we could set it with user_id, just in hopefully one/few places in the code, or is there some reason why we can't?
author_association: COLLABORATOR
user: login: rbren
created_at: 2025-03-19T19:12:31Z
body: @enyst I'm not sure I understand your end goal here. I don't think there's a way around passing user_id around unfortunately

> we could merge the conversation_dir to the file_store/{user_id}/conversations

I think this would end up being `file_store/None/conversations` in OSS, which would be silly 😄 that's the whole reason the code forks--we can keep the file structure simpler in the single-user scenario
author_association: COLLABORATOR
user: login: enyst
created_at: 2025-03-19T19:32:20Z
body: > @enyst I'm not sure I understand your end goal here. I don't think there's a way around passing user_id around unfortunately

In the specific case of user_id, I wonder if there is some solution that doesn't require a lot of code affected, because that seems to mean
- on local, error prone (you know sometimes it's required when it shouldn't)
- on remote use cases, missing it somewhere could have security impact
- in general in the open source project, harder to understand/account for, for potential contributors or to work with.

If you're sure there is no solution, then there is no solution. Maybe I don't understand fully the cases here. 😥
author_association: COLLABORATOR
user: login: rbren
created_at: 2025-03-19T19:55:14Z
body: Thanks @enyst I do think there's a wider discussion here for how we might be able to minimize the existence of `user_id` in the OSS repo. But for now I don't think there's a more elegant solution
author_association: COLLABORATOR
user: login: enyst
created_at: 2025-03-19T19:59:01Z
body: > I think this would end up being file_store/None/conversations in OSS, which would be silly 😄 that's the whole reason the code forks--we can keep the file structure simpler in the single-user scenario

Or system user, or the usual `sandbox_user_id`, or, when available, user from github/other tokens. (plenty of bad ideas?)
author_association: COLLABORATOR
user: login: enyst
created_at: 2025-03-19T23:39:42Z
body: Sorry for nagging, I gave it some thought and design-wise, seems like maybe we can sometime discuss these:
- a particular path component ("{user_id}/") becomes the job of an agent state (`State`) and of the session's `event stream`
- it feels like it could be the responsibility of a `FileStore` instead
- StandaloneConversationManager also has and uses `user_id`, even though it's the local one.

Idk, maybe we can give it some thought on 5/1/2025 or when the transition is complete?
author_association: COLLABORATOR