@@ -142,6 +142,15 @@ async def _set_cached_active_project(
142142 await context .set_state ("default_project_name" , active_project .name )
143143
144144
145+ async def _clear_cached_active_project (context : Optional [Context ]) -> None :
146+ """Clear cached project metadata that may no longer match the active route."""
147+ if not context :
148+ return
149+
150+ await context .set_state ("active_project" , None )
151+ await context .set_state ("default_project_name" , None )
152+
153+
145154async def _get_cached_active_workspace (context : Optional [Context ]) -> Optional [WorkspaceInfo ]:
146155 """Return the cached active workspace from context when available."""
147156 if not context :
@@ -167,8 +176,7 @@ async def _set_cached_active_workspace(
167176 # Why: project names are only unique inside one workspace, so a cached
168177 # ProjectItem from the previous tenant can point at the wrong project
169178 # Outcome: force the next validation call to resolve within the new tenant
170- await context .set_state ("active_project" , None )
171- await context .set_state ("default_project_name" , None )
179+ await _clear_cached_active_project (context )
172180
173181 await context .set_state ("active_workspace" , active_workspace .model_dump ())
174182
@@ -525,6 +533,28 @@ def _cloud_workspace_discovery_available(config: BasicMemoryConfig) -> bool:
525533 )
526534
527535
536+ def _workspace_identifier_discovery_available (
537+ identifier : str ,
538+ config : BasicMemoryConfig ,
539+ ) -> bool :
540+ """Return True when an identifier is allowed to consult workspace discovery."""
541+ if _cloud_workspace_discovery_available (config ):
542+ return True
543+
544+ from basic_memory .mcp .async_client import (
545+ _explicit_routing ,
546+ _force_local_mode ,
547+ )
548+
549+ if _explicit_routing () and _force_local_mode ():
550+ return False
551+
552+ return (
553+ has_cloud_credentials (config )
554+ and _split_workspace_identifier_segments (identifier ) is not None
555+ )
556+
557+
528558async def resolve_workspace_qualified_memory_url (
529559 identifier : str ,
530560 context : Optional [Context ] = None ,
@@ -1326,11 +1356,23 @@ async def detect_project_from_identifier_prefix(
13261356 # Outcome: keep unqualified search/title input on the active/default project route.
13271357 return None
13281358
1329- if _cloud_workspace_discovery_available (config ):
1330- workspace_resolution = await resolve_workspace_qualified_identifier (
1331- identifier ,
1332- context = context ,
1359+ if _workspace_identifier_discovery_available (identifier , config ):
1360+ workspace_discovery_fallback_errors = (
1361+ "not found" ,
1362+ "no accessible workspaces" ,
1363+ "unable to discover" ,
13331364 )
1365+ try :
1366+ workspace_resolution = await resolve_workspace_qualified_identifier (
1367+ identifier ,
1368+ context = context ,
1369+ )
1370+ except ValueError as exc :
1371+ message = str (exc ).lower ()
1372+ if any (error in message for error in workspace_discovery_fallback_errors ):
1373+ return None
1374+ raise
1375+
13341376 if workspace_resolution is not None :
13351377 return workspace_resolution .project_identifier
13361378
@@ -1345,7 +1387,7 @@ async def detect_project_from_identifier_prefix(
13451387 )
13461388 except ValueError as exc :
13471389 message = str (exc ).lower ()
1348- if "not found" in message or "no accessible workspaces" in message :
1390+ if any ( error in message for error in workspace_discovery_fallback_errors ) :
13491391 return None
13501392 raise
13511393
@@ -1462,9 +1504,54 @@ async def get_project_client(
14621504 if project_id and not cloud_available :
14631505 project_mode = ProjectMode .LOCAL
14641506
1507+ # Trigger: project_id is a local external_id in a mixed local+cloud setup.
1508+ # Why: UUIDs are not local config keys, so get_project_mode() treats them as
1509+ # cloud projects. A local-first probe avoids making local UUIDs depend on
1510+ # healthy cloud workspace discovery.
1511+ # Outcome: resolve the effective UUID against local ASGI first; if it is not
1512+ # local, preserve the existing cloud workspace lookup path.
1513+ if (
1514+ project_id
1515+ and config .projects
1516+ and not factory_mode
1517+ and not explicit_cloud_routing
1518+ and project_mode == ProjectMode .CLOUD
1519+ ):
1520+ try :
1521+ canonical_project_id = str (UUID (resolved_project ))
1522+ except ValueError :
1523+ pass
1524+ else :
1525+ with logfire .span (
1526+ "routing.local_project_id_probe" ,
1527+ project_id = canonical_project_id ,
1528+ ):
1529+ async with get_client () as client :
1530+ try :
1531+ active_project = await get_active_project (
1532+ client ,
1533+ canonical_project_id ,
1534+ context ,
1535+ )
1536+ except ToolError as exc :
1537+ if "not found" not in str (exc ).lower ():
1538+ raise
1539+ else :
1540+ route_mode = "local_asgi"
1541+ await _clear_cached_active_workspace_for_local_route (context )
1542+ with logfire .span (
1543+ "routing.client_session" ,
1544+ project_name = active_project .name ,
1545+ route_mode = route_mode ,
1546+ ):
1547+ logger .debug ("Using local ASGI routing for project_id" )
1548+ yield client , active_project
1549+ return
1550+
14651551 if factory_mode or project_mode == ProjectMode .CLOUD or explicit_cloud_routing :
14661552 route_mode = "factory" if factory_mode else "cloud_proxy"
14671553 active_ws : WorkspaceInfo | None = None
1554+ resolved_entry : WorkspaceProjectEntry | None = None
14681555 workspace_id : str
14691556 project_for_api = _unqualified_project_identifier (resolved_project )
14701557
@@ -1487,6 +1574,13 @@ async def get_project_client(
14871574
14881575 if active_ws is not None :
14891576 await _set_cached_active_workspace (context , active_ws )
1577+ if resolved_entry is not None :
1578+ cached_project = await _get_cached_active_project (context )
1579+ if (
1580+ cached_project is not None
1581+ and cached_project .external_id != resolved_entry .project .external_id
1582+ ):
1583+ await _clear_cached_active_project (context )
14901584 with logfire .span (
14911585 "routing.client_session" ,
14921586 project_name = project_for_api ,
0 commit comments