mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user