fix(sdk): make sync glob timeout bound wall-clock time (#3866)

Fixes #3867

---

The sync `glob` tool ran the backend glob in a per-call
`ThreadPoolExecutor` inside a `with` block, so on timeout the `return`
triggered `shutdown(wait=True)` and blocked until the runaway glob
finished anyway — `GLOB_TIMEOUT` never actually bounded latency. This
submits to a shared executor created at middleware init instead (no
`with` block, >1 worker so a stuck glob can't stall the next call),
making timeouts return immediately and removing per-call executor
construction.

No breaking changes.

Verified by: a wall-clock assertion added to the existing timeout test
(fails on `main` with "glob tool blocked for 2.01s past its 0.5s
timeout", passes with the fix), a new test that a timed-out glob doesn't
delay subsequent calls, and `make format` / `make lint` / `make test`
all passing from `libs/deepagents` (1758 passed).

---------

Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
Phạm Gia Linh
2026-06-11 13:27:34 +07:00
committed by GitHub
parent 194b638a14
commit cba6caf8f7
4 changed files with 284 additions and 20 deletions
@@ -3,8 +3,10 @@
import asyncio
import concurrent.futures
import contextlib
import contextvars
import mimetypes
import threading
import uuid
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
@@ -42,6 +44,7 @@ from deepagents.backends.protocol import (
EditResult,
FileData as FileData, # Re-export for backwards compatibility
FileInfo,
GlobResult,
GrepMatch,
GrepResult,
ReadResult,
@@ -69,6 +72,7 @@ from deepagents.middleware._message_eviction import (
from deepagents.middleware._utils import append_to_system_message
_FS_WCMATCH_FLAGS = wcglob.BRACE | wcglob.GLOBSTAR
_SYNC_GLOB_WORKERS = 4
FilesystemOperation = Literal["read", "write"]
@@ -232,6 +236,23 @@ def _apply_permissions_to_glob_results(
EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"
GLOB_TIMEOUT = 20.0 # seconds
LINE_NUMBER_WIDTH = 6
def _glob_timeout_message() -> str:
"""Build the glob-timeout error string.
Reads `GLOB_TIMEOUT` at call time so tests and overrides keep the message
in sync with the active deadline.
"""
return f"Error: glob timed out after {GLOB_TIMEOUT}s. Try a more specific pattern or a narrower path."
def _discard_task_result(task: asyncio.Future[Any]) -> None:
"""Consume a cancelled background task result to avoid event-loop warnings."""
with contextlib.suppress(asyncio.CancelledError, Exception):
task.result()
DEFAULT_READ_OFFSET = 0
DEFAULT_READ_LIMIT = 100
# Template for truncation message in read_file
@@ -761,6 +782,15 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
self._max_execute_timeout = max_execute_timeout
self._permissions = list(_permissions or [])
# Shared executor for enforcing GLOB_TIMEOUT on the sync glob tool.
# Timed-out worker threads keep running until the backend call returns,
# so the semaphore rejects overload instead of queueing behind them.
self._glob_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=_SYNC_GLOB_WORKERS,
thread_name_prefix="deepagents-glob",
)
self._glob_slots = threading.BoundedSemaphore(_SYNC_GLOB_WORKERS)
self.tools = [
self._create_ls_tool(),
self._create_read_file_tool(),
@@ -1240,11 +1270,11 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
args_schema=EditFileSchema,
)
def _create_glob_tool(self) -> BaseTool: # noqa: C901 # Tool wiring + permission/result shaping
def _create_glob_tool(self) -> BaseTool: # noqa: C901, PLR0915 # Tool wiring + permission/result shaping + timeout handling
"""Create the glob tool."""
tool_description = self._custom_tool_descriptions.get("glob") or GLOB_TOOL_DESCRIPTION
def sync_glob(
def sync_glob( # noqa: PLR0911 - early returns for distinct error conditions
pattern: Annotated[str, "Glob pattern to match files (e.g., '**/*.py', '*.txt', '/subdir/**/*.md')."],
runtime: ToolRuntime[None, FilesystemState],
path: Annotated[str | None, "Base directory to search from. Defaults to the backend's default root."] = None,
@@ -1269,17 +1299,61 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
)
backend_path = permission_path if path is not None else None
ctx = contextvars.copy_context()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(lambda: ctx.run(resolved_backend.glob, pattern, path=backend_path))
# Submit to the shared executor rather than a per-call
# ThreadPoolExecutor: a `with` block here would call
# shutdown(wait=True) on timeout and block until the runaway glob
# finished anyway, defeating the timeout.
if not self._glob_slots.acquire(blocking=False):
return ToolMessage(
content=("Error: too many glob calls are already running. Try again later with a more specific pattern or a narrower path."),
name="glob",
tool_call_id=runtime.tool_call_id,
status="error",
)
def run_glob() -> GlobResult:
try:
glob_result = future.result(timeout=GLOB_TIMEOUT)
except concurrent.futures.TimeoutError:
return ToolMessage(
content=f"Error: glob timed out after {GLOB_TIMEOUT}s. Try a more specific pattern or a narrower path.",
name="glob",
tool_call_id=runtime.tool_call_id,
status="error",
)
return ctx.run(resolved_backend.glob, pattern, path=backend_path)
finally:
self._glob_slots.release()
try:
future = self._glob_executor.submit(run_glob)
except Exception:
self._glob_slots.release()
raise
# Separate the wait deadline from result retrieval. On Python 3.11+
# `concurrent.futures.TimeoutError is TimeoutError`, so catching the
# future's wait-timeout would also swallow a builtin TimeoutError
# raised *inside* the backend glob (e.g. a sandbox RPC timeout) and
# misreport it as a glob-pattern timeout. `wait()` reports only
# whether the deadline elapsed, leaving real backend exceptions to
# surface through `future.result()` below.
done, _ = concurrent.futures.wait([future], timeout=GLOB_TIMEOUT)
if not done:
# Deadline elapsed while the worker is still running; it cannot
# be cancelled, so abandon it (run_glob's finally releases the
# slot when it eventually returns). cancel() only succeeds if
# the task never started, in which case release the slot here.
if future.cancel():
self._glob_slots.release()
return ToolMessage(
content=_glob_timeout_message(),
name="glob",
tool_call_id=runtime.tool_call_id,
status="error",
)
try:
glob_result = future.result()
except Exception as e: # noqa: BLE001 # tool boundary: surface backend errors, never let them escape
# run_glob's finally already released the slot before the
# exception propagated, so do not release again here.
return ToolMessage(
content=f"Error: glob failed: {e}",
name="glob",
tool_call_id=runtime.tool_call_id,
status="error",
)
if glob_result.error:
return ToolMessage(
content=f"Error: {glob_result.error}",
@@ -1320,14 +1394,26 @@ class FilesystemMiddleware(AgentMiddleware[FilesystemState, ContextT, ResponseT]
status="error",
)
backend_path = permission_path if path is not None else None
try:
glob_result = await asyncio.wait_for(
resolved_backend.aglob(pattern, path=backend_path),
timeout=GLOB_TIMEOUT,
)
except TimeoutError:
# Run the backend glob as a task and wait on the deadline separately
# so a `TimeoutError` raised *inside* the backend (rather than by the
# deadline) is not misreported as a glob-pattern timeout, mirroring
# the sync path. Other backend exceptions surface via `task.result()`.
task = asyncio.ensure_future(resolved_backend.aglob(pattern, path=backend_path))
done, _ = await asyncio.wait({task}, timeout=GLOB_TIMEOUT)
if not done:
task.add_done_callback(_discard_task_result)
task.cancel()
return ToolMessage(
content=f"Error: glob timed out after {GLOB_TIMEOUT}s. Try a more specific pattern or a narrower path.",
content=_glob_timeout_message(),
name="glob",
tool_call_id=runtime.tool_call_id,
status="error",
)
try:
glob_result = task.result()
except Exception as e: # noqa: BLE001 # tool boundary: surface backend errors, never let them escape
return ToolMessage(
content=f"Error: glob failed: {e}",
name="glob",
tool_call_id=runtime.tool_call_id,
status="error",
@@ -426,14 +426,103 @@ class TestFilesystemMiddleware:
patch.object(middleware, "_get_backend", return_value=backend_obj),
patch.object(backend_obj, "glob", side_effect=slow_glob),
):
start = time.monotonic()
result = glob_search_tool.invoke(
{
"pattern": "**/*",
"runtime": _runtime(),
}
)
elapsed = time.monotonic() - start
assert result.content == "Error: glob timed out after 0.5s. Try a more specific pattern or a narrower path."
# The tool must return as soon as the timeout fires, not block until
# the runaway glob (2s) finishes in its worker thread.
assert elapsed < 1.5, f"glob tool blocked for {elapsed:.2f}s past its 0.5s timeout"
def test_glob_timeout_does_not_stall_subsequent_calls(self):
"""A timed-out glob still running in a worker must not block the next glob."""
backend, _ = _make_backend()
middleware = FilesystemMiddleware(backend=backend)
glob_search_tool = next(tool for tool in middleware.tools if tool.name == "glob")
backend_obj = middleware._get_backend(_runtime())
call_count = 0
def stuck_then_fast_glob(*args: object, **kwargs: object) -> object:
nonlocal call_count
call_count += 1
if call_count == 1:
time.sleep(2)
return backend_obj.__class__.glob(backend_obj, *args, **kwargs)
with (
patch.object(filesystem_middleware, "GLOB_TIMEOUT", 0.5),
patch.object(middleware, "_get_backend", return_value=backend_obj),
patch.object(backend_obj, "glob", side_effect=stuck_then_fast_glob),
):
first_start = time.monotonic()
first = glob_search_tool.invoke({"pattern": "**/*", "runtime": _runtime()})
first_elapsed = time.monotonic() - first_start
start = time.monotonic()
second = glob_search_tool.invoke({"pattern": "*.py", "runtime": _runtime()})
elapsed = time.monotonic() - start
assert "timed out" in first.content
# The first (timing-out) call must itself return at the 0.5s timeout
# rather than block until its 2s worker finishes - an independent guard
# against the original `with`-block regression, where the timeout did
# not bound wall-clock latency.
assert first_elapsed < 1.5, f"first glob blocked for {first_elapsed:.2f}s past its 0.5s timeout"
assert second.status == "success"
assert elapsed < 1.0, f"second glob waited {elapsed:.2f}s behind a stuck one"
def test_glob_surfaces_backend_exception_as_error(self):
"""A non-timeout exception from the backend glob is returned as a tool error, not propagated."""
backend, _ = _make_backend()
middleware = FilesystemMiddleware(backend=backend)
glob_search_tool = next(tool for tool in middleware.tools if tool.name == "glob")
backend_obj = middleware._get_backend(_runtime())
def boom(*_args: object, **_kwargs: object) -> object:
msg = "path traversal not allowed"
raise ValueError(msg)
with (
patch.object(middleware, "_get_backend", return_value=backend_obj),
patch.object(backend_obj, "glob", side_effect=boom),
):
result = glob_search_tool.invoke({"pattern": "**/*", "runtime": _runtime()})
assert result.status == "error"
assert result.content == "Error: glob failed: path traversal not allowed"
def test_glob_backend_timeouterror_not_misreported_as_glob_timeout(self):
"""A `TimeoutError` raised inside the backend must not be reported as a glob-pattern timeout.
`concurrent.futures.TimeoutError is TimeoutError` on Python 3.11+, so a
single `except concurrent.futures.TimeoutError` around the future's wait
would also swallow a backend-raised builtin `TimeoutError` and misreport
it as the glob pattern timing out.
"""
backend, _ = _make_backend()
middleware = FilesystemMiddleware(backend=backend)
glob_search_tool = next(tool for tool in middleware.tools if tool.name == "glob")
backend_obj = middleware._get_backend(_runtime())
def raise_timeout(*_args: object, **_kwargs: object) -> object:
msg = "backend RPC timed out"
raise TimeoutError(msg)
with (
patch.object(middleware, "_get_backend", return_value=backend_obj),
patch.object(backend_obj, "glob", side_effect=raise_timeout),
):
result = glob_search_tool.invoke({"pattern": "**/*", "runtime": _runtime()})
assert result.status == "error"
assert "timed out after" not in result.content
assert result.content == "Error: glob failed: backend RPC timed out"
def test_glob_search_truncates_large_results(self):
"""Test that glob results are truncated when they exceed token limit."""
@@ -316,6 +316,47 @@ class TestFilesystemMiddlewareAsync:
assert result.content == "Error: glob timed out after 0.5s. Try a more specific pattern or a narrower path."
async def test_glob_surfaces_backend_exception_as_error_async(self):
"""A non-timeout exception from the backend aglob is returned as a tool error, not propagated."""
backend, _ = _make_backend()
middleware = FilesystemMiddleware(backend=backend)
glob_search_tool = next(tool for tool in middleware.tools if tool.name == "glob")
backend_obj = middleware._get_backend(_runtime())
async def boom(*_args: object, **_kwargs: object) -> object:
msg = "path traversal not allowed"
raise ValueError(msg)
with (
patch.object(middleware, "_get_backend", return_value=backend_obj),
patch.object(backend_obj, "aglob", side_effect=boom),
):
result = await glob_search_tool.ainvoke({"pattern": "**/*", "runtime": _runtime()})
assert result.status == "error"
assert result.content == "Error: glob failed: path traversal not allowed"
async def test_glob_backend_timeouterror_not_misreported_as_glob_timeout_async(self):
"""A `TimeoutError` raised inside the backend aglob must not be reported as a glob-pattern timeout."""
backend, _ = _make_backend()
middleware = FilesystemMiddleware(backend=backend)
glob_search_tool = next(tool for tool in middleware.tools if tool.name == "glob")
backend_obj = middleware._get_backend(_runtime())
async def raise_timeout(*_args: object, **_kwargs: object) -> object:
msg = "backend RPC timed out"
raise TimeoutError(msg)
with (
patch.object(middleware, "_get_backend", return_value=backend_obj),
patch.object(backend_obj, "aglob", side_effect=raise_timeout),
):
result = await glob_search_tool.ainvoke({"pattern": "**/*", "runtime": _runtime()})
assert result.status == "error"
assert "timed out after" not in result.content
assert result.content == "Error: glob failed: backend RPC timed out"
async def test_agrep_search_shortterm_files_with_matches(self):
"""Test async grep with files_with_matches mode."""
files = {
@@ -1,5 +1,7 @@
"""Unit tests for filesystem permission enforcement in `FilesystemMiddleware`."""
import threading
import pytest
from langchain.tools import ToolRuntime
from langchain.tools.tool_node import ToolCallRequest
@@ -9,9 +11,10 @@ from langgraph.store.memory import InMemoryStore
from deepagents.backends import StateBackend, StoreBackend
from deepagents.backends.composite import CompositeBackend
from deepagents.backends.protocol import EditResult, ExecuteResponse, ReadResult, SandboxBackendProtocol, WriteResult
from deepagents.backends.protocol import EditResult, ExecuteResponse, GlobResult, ReadResult, SandboxBackendProtocol, WriteResult
from deepagents.backends.utils import _glob_anchor, _paths_overlap
from deepagents.graph import create_deep_agent
from deepagents.middleware import filesystem as filesystem_module
from deepagents.middleware._fs_interrupt import _build_interrupt_on_from_permissions, _make_fs_when_predicate
from deepagents.middleware.filesystem import (
FilesystemMiddleware,
@@ -815,6 +818,51 @@ class TestCanonicalizationBypass:
class TestGlobToolPermissions:
"""Tests for the glob tool permission checks in FilesystemMiddleware."""
def test_sync_glob_rejects_when_timed_out_workers_are_saturated(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class BlockingGlobBackend(StoreBackend):
def __init__(self, *, release: threading.Event, started: threading.Semaphore) -> None:
super().__init__(store=InMemoryStore(), namespace=lambda _ctx: ("filesystem",))
self.release = release
self.started = started
self._calls = 0
self._lock = threading.Lock()
@property
def calls(self) -> int:
with self._lock:
return self._calls
def glob(self, pattern: str, path: str | None = None) -> GlobResult:
with self._lock:
self._calls += 1
self.started.release()
self.release.wait(timeout=5)
return GlobResult(matches=[])
monkeypatch.setattr(filesystem_module, "GLOB_TIMEOUT", 0.01)
release = threading.Event()
started = threading.Semaphore(0)
backend = BlockingGlobBackend(release=release, started=started)
middleware = FilesystemMiddleware(backend=backend)
glob_tool = next(t for t in middleware.tools if t.name == "glob")
try:
for idx in range(filesystem_module._SYNC_GLOB_WORKERS):
result = glob_tool.invoke({"runtime": _runtime(f"glob-{idx}"), "pattern": "**/*", "path": "/"})
assert "glob timed out" in result.content
assert started.acquire(timeout=1)
result = glob_tool.invoke({"runtime": _runtime("glob-saturated"), "pattern": "**/*", "path": "/"})
assert "too many glob calls are already running" in result.content
assert backend.calls == filesystem_module._SYNC_GLOB_WORKERS
finally:
release.set()
middleware._glob_executor.shutdown(wait=True)
def test_glob_denied_on_restricted_base_path(self):
backend = _make_backend({"/secrets/key.txt": "top secret"})
middleware = FilesystemMiddleware(backend=backend)