Skip to content

Commit 72bfd4a

Browse files
fix: report evicted handles as "evicted", not "not found" (#532)
Track evicted ids in bounded tombstone deques in both handle stores and route not-found sites through a shared eviction-aware message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 185250d commit 72bfd4a

12 files changed

Lines changed: 151 additions & 24 deletions

File tree

src/sktime_mcp/runtime/executor.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import inspect
1010
import logging
1111
import uuid
12+
from collections import deque
1213
from typing import Any
1314

1415
import pandas as pd
@@ -196,6 +197,8 @@ def __init__(self):
196197
self._handle_manager = get_handle_manager()
197198
self._job_manager = get_job_manager()
198199
self._data_handles: dict[str, Any] = {}
200+
# Tombstones for data handles evicted under the cap (see _cleanup_oldest_data).
201+
self._evicted_data: deque[str] = deque(maxlen=1024)
199202
from sktime_mcp.config import settings
200203

201204
self._max_data_handles = settings.max_data_handles
@@ -205,7 +208,23 @@ def _cleanup_oldest_data(self, count: int = 10) -> None:
205208
to_remove = list(self._data_handles.keys())[:count]
206209
for handle_id in to_remove:
207210
del self._data_handles[handle_id]
208-
logger.debug("Evicted data handle %s (limit=%d)", handle_id, self._max_data_handles)
211+
self._evicted_data.append(handle_id)
212+
logger.info("Evicted data handle %s (limit %d reached)", handle_id, self._max_data_handles)
213+
214+
def data_handle_missing(self, handle_id: str) -> dict[str, Any]:
215+
"""Error body for a missing data handle — distinguishes evicted from unknown.
216+
217+
Returns the ``error`` string plus the capped available-handles summary,
218+
so callers can splat it into a not-found response.
219+
"""
220+
if handle_id in self._evicted_data:
221+
error = (
222+
f"Data handle '{handle_id}' was evicted (handle limit "
223+
f"{self._max_data_handles} reached); reload the source."
224+
)
225+
else:
226+
error = f"Data handle '{handle_id}' not found"
227+
return {"error": error, **self.summarize_available_handles()}
209228

210229
def _register_data_handle(self, handle_id: str, data: dict[str, Any]) -> None:
211230
if len(self._data_handles) >= self._max_data_handles:
@@ -380,7 +399,7 @@ def fit(
380399
handle_info = self._handle_manager.get_info(handle_id)
381400
instance = handle_info.instance
382401
except KeyError:
383-
return {"success": False, "error": f"Handle not found: {handle_id}"}
402+
return {"success": False, "error": self._handle_manager.describe_missing(handle_id)}
384403

385404
obj_type = getattr(instance, "get_class_tag", lambda x, y: "")("object_type", "")
386405
if not hasattr(instance, "fit"):
@@ -448,7 +467,7 @@ def predict(
448467
try:
449468
instance = self._handle_manager.get_instance(handle_id)
450469
except KeyError:
451-
return {"success": False, "error": f"Handle not found: {handle_id}"}
470+
return {"success": False, "error": self._handle_manager.describe_missing(handle_id)}
452471

453472
obj_type = getattr(instance, "get_class_tag", lambda x, y: "")("object_type", "")
454473
if (
@@ -667,7 +686,7 @@ def call_method(
667686
try:
668687
instance = self._handle_manager.get_instance(handle_id)
669688
except KeyError:
670-
return {"success": False, "error": f"Handle not found: {handle_id}"}
689+
return {"success": False, "error": self._handle_manager.describe_missing(handle_id)}
671690

672691
if not hasattr(instance, method_name):
673692
obj_type = getattr(instance, "get_class_tag", lambda x, y: "")("object_type", "")
@@ -748,7 +767,7 @@ def update(
748767
try:
749768
instance = self._handle_manager.get_instance(handle_id)
750769
except KeyError:
751-
return {"success": False, "error": f"Handle not found: {handle_id}"}
770+
return {"success": False, "error": self._handle_manager.describe_missing(handle_id)}
752771

753772
if not self._handle_manager.is_fitted(handle_id):
754773
return {"success": False, "error": "Estimator not fitted"}
@@ -788,7 +807,7 @@ def get_fitted_params(self, handle_id: str) -> dict[str, Any]:
788807
try:
789808
instance = self._handle_manager.get_instance(handle_id)
790809
except KeyError:
791-
return {"success": False, "error": f"Handle not found: {handle_id}"}
810+
return {"success": False, "error": self._handle_manager.describe_missing(handle_id)}
792811

793812
if not self._handle_manager.is_fitted(handle_id):
794813
return {"success": False, "error": "Estimator not fitted"}
@@ -932,7 +951,7 @@ async def evaluate_async(
932951
try:
933952
instance = self._handle_manager.get_instance(handle_id)
934953
except KeyError as err:
935-
raise ValueError(f"Handle not found: {handle_id}") from err
954+
raise ValueError(self._handle_manager.describe_missing(handle_id)) from err
936955

937956
y_res = self._resolve_source(y)
938957
if not y_res["success"]:
@@ -1259,7 +1278,7 @@ def format_data_handle(
12591278
was never exposed to the caller.
12601279
"""
12611280
if data_handle not in self._data_handles:
1262-
return {"success": False, "error": f"Data handle '{data_handle}' not found"}
1281+
return {"success": False, **self.data_handle_missing(data_handle)}
12631282

12641283
data_info = self._data_handles[data_handle]
12651284
y = data_info["y"].copy()
@@ -1424,7 +1443,7 @@ def release_data_handle(self, data_handle: str) -> dict[str, Any]:
14241443
else:
14251444
return {
14261445
"success": False,
1427-
"error": f"Data handle '{data_handle}' not found",
1446+
"error": self.data_handle_missing(data_handle)["error"],
14281447
}
14291448

14301449

src/sktime_mcp/runtime/handles.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import logging
88
import uuid
9+
from collections import deque
910
from dataclasses import dataclass, field
1011
from datetime import datetime
1112
from typing import Any
@@ -42,6 +43,21 @@ class HandleManager:
4243
def __init__(self, max_handles: int = 100):
4344
self._handles: dict[str, HandleInfo] = {}
4445
self._max_handles = max_handles
46+
# Tombstones: ids evicted to stay under the cap, so a later lookup can
47+
# say "evicted" instead of an indistinguishable "not found".
48+
self._evicted: deque[str] = deque(maxlen=1024)
49+
50+
def describe_missing(self, handle_id: str) -> str:
51+
"""Message for a handle that isn't present — distinguishes evicted from unknown."""
52+
if handle_id in self._evicted:
53+
return (
54+
f"Estimator handle '{handle_id}' was evicted (handle limit "
55+
f"{self._max_handles} reached); re-create it with instantiate."
56+
)
57+
return f"Handle not found: {handle_id}"
58+
59+
def was_evicted(self, handle_id: str) -> bool:
60+
return handle_id in self._evicted
4561

4662
def create_handle(
4763
self,
@@ -67,12 +83,12 @@ def create_handle(
6783

6884
def get_instance(self, handle_id: str) -> Any:
6985
if handle_id not in self._handles:
70-
raise KeyError(f"Handle not found: {handle_id}")
86+
raise KeyError(self.describe_missing(handle_id))
7187
return self._handles[handle_id].instance
7288

7389
def get_info(self, handle_id: str) -> HandleInfo:
7490
if handle_id not in self._handles:
75-
raise KeyError(f"Handle not found: {handle_id}")
91+
raise KeyError(self.describe_missing(handle_id))
7692
return self._handles[handle_id]
7793

7894
def exists(self, handle_id: str) -> bool:
@@ -113,6 +129,10 @@ def _cleanup_oldest(self, count: int = 10) -> None:
113129
)
114130
for handle_id, _ in sorted_handles[:count]:
115131
del self._handles[handle_id]
132+
self._evicted.append(handle_id)
133+
logger.info(
134+
"Evicted estimator handle %s (limit %d reached)", handle_id, self._max_handles
135+
)
116136

117137

118138
_handle_manager_instance: HandleManager | None = None

src/sktime_mcp/tools/codegen.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def export_code_tool(
8686
try:
8787
handle_info = handle_manager.get_info(handle)
8888
except KeyError:
89-
return {"success": False, "error": f"Handle not found: {handle}"}
89+
return {"success": False, "error": handle_manager.describe_missing(handle)}
9090

9191
if not _is_valid_var_name(var_name):
9292
return {

src/sktime_mcp/tools/evaluate.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ def evaluate_tool(
7373
try:
7474
instance = executor._handle_manager.get_instance(estimator_handle)
7575
except KeyError:
76-
return {"success": False, "error": f"Handle not found: {estimator_handle}"}
76+
return {
77+
"success": False,
78+
"error": executor._handle_manager.describe_missing(estimator_handle),
79+
}
7780

7881
y_res = executor._resolve_source(y)
7982
if not y_res["success"]:

src/sktime_mcp/tools/inspect_data.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,7 @@ def inspect_data_tool(data_handle: str) -> dict[str, Any]:
5555
if data_handle not in executor._data_handles:
5656
return {
5757
"success": False,
58-
"error": f"Data handle '{data_handle}' not found",
59-
**executor.summarize_available_handles(),
58+
**executor.data_handle_missing(data_handle),
6059
}
6160

6261
data_info = executor._data_handles[data_handle]

src/sktime_mcp/tools/instantiate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def release_handle_tool(handle: str) -> dict[str, Any]:
5959
return {
6060
"success": released,
6161
"handle": handle,
62-
"message": "Handle released" if released else "Handle not found",
62+
"message": "Handle released" if released else handle_manager.describe_missing(handle),
6363
}
6464

6565

src/sktime_mcp/tools/plotting.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,13 @@ def plot_series_tool(
179179
missing.append(handle)
180180

181181
if missing:
182+
evicted = [h for h in missing if h in executor._evicted_data]
183+
detail = f"Data handle(s) not found: {missing}"
184+
if evicted:
185+
detail += f" (evicted under the handle limit: {evicted}; reload the source)"
182186
return {
183187
"success": False,
184-
"error": f"Data handle(s) not found: {missing}",
188+
"error": detail,
185189
**executor.summarize_available_handles(),
186190
}
187191

src/sktime_mcp/tools/save_data.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ def save_data_tool(
6060
if data_handle not in executor._data_handles:
6161
return {
6262
"success": False,
63-
"error": f"Data handle '{data_handle}' not found",
64-
**executor.summarize_available_handles(),
63+
**executor.data_handle_missing(data_handle),
6564
}
6665

6766
fmt = format.lower()

src/sktime_mcp/tools/save_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def save_model_tool(
6060
try:
6161
estimator = handle_manager.get_instance(estimator_handle)
6262
except KeyError:
63-
return {"success": False, "error": f"Handle not found: {estimator_handle}"}
63+
return {"success": False, "error": handle_manager.describe_missing(estimator_handle)}
6464

6565
if not handle_manager.is_fitted(estimator_handle):
6666
handle_info = handle_manager.get_info(estimator_handle)

src/sktime_mcp/tools/split_data.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,7 @@ def split_data_tool(
5656
if data_handle not in executor._data_handles:
5757
return {
5858
"success": False,
59-
"error": f"Data handle '{data_handle}' not found",
60-
**executor.summarize_available_handles(),
59+
**executor.data_handle_missing(data_handle),
6160
}
6261

6362
if test_size is not None and fh is not None:

0 commit comments

Comments
 (0)