mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(cli): surface sandbox startup errors in server crash messages (#3306)
When a sandbox provider is missing or misconfigured, the server subprocess exits before the health endpoint comes up. The parent CLI process raises a `RuntimeError` that includes the last few thousand characters of log output, but the actual error message is often buried under a Python traceback and Uvicorn shutdown noise. This makes it hard for users to understand why `deepagents up` failed.
This commit is contained in:
@@ -34,6 +34,8 @@ _LOG_TAIL_CHARS = 3000
|
||||
"""Max chars of subprocess log appended to the early-exit `RuntimeError`
|
||||
message. Enough to carry a Python traceback without flooding the TUI banner
|
||||
when it surfaces via `ServerStartFailed`."""
|
||||
_STARTUP_ERROR_MARKER = "DEEPAGENTS_STARTUP_ERROR:"
|
||||
"""Machine-readable prefix emitted by the server subprocess for known startup errors."""
|
||||
|
||||
|
||||
def _port_in_use(host: str, port: int) -> bool:
|
||||
@@ -86,6 +88,22 @@ def get_server_url(host: str = _DEFAULT_HOST, port: int = _DEFAULT_PORT) -> str:
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
|
||||
def _extract_startup_error_marker(output: str) -> str | None:
|
||||
"""Extract a marked startup error from subprocess output.
|
||||
|
||||
Args:
|
||||
output: Combined stdout/stderr captured from the server subprocess.
|
||||
|
||||
Returns:
|
||||
The marked startup error message, or `None` if no marker was emitted.
|
||||
"""
|
||||
for line in reversed(output.splitlines()):
|
||||
if _STARTUP_ERROR_MARKER in line:
|
||||
_, summary = line.rsplit(_STARTUP_ERROR_MARKER, 1)
|
||||
return summary.strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def generate_langgraph_json(
|
||||
output_dir: str | Path,
|
||||
*,
|
||||
@@ -204,6 +222,9 @@ async def wait_for_server_healthy(
|
||||
output = read_log() if read_log else ""
|
||||
msg = f"Server process exited with code {process.returncode}"
|
||||
if output:
|
||||
summary = _extract_startup_error_marker(output)
|
||||
if summary:
|
||||
msg += f": {summary}"
|
||||
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
@@ -26,6 +26,20 @@ logger = logging.getLogger(__name__)
|
||||
_sandbox_cm: Any = None
|
||||
_sandbox_backend: Any = None
|
||||
_mcp_session_manager: Any = None
|
||||
_STARTUP_ERROR_MARKER = "DEEPAGENTS_STARTUP_ERROR:"
|
||||
|
||||
|
||||
def _print_startup_error(message: str) -> None:
|
||||
"""Print a startup error for both humans and the parent CLI process.
|
||||
|
||||
Args:
|
||||
message: Concise startup failure to surface in the parent process.
|
||||
"""
|
||||
print(message, file=sys.stderr) # noqa: T201 # stderr fallback for logs
|
||||
print( # noqa: T201 # machine-readable marker consumed by server.py
|
||||
f"{_STARTUP_ERROR_MARKER}{message}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def _get_mcp_session_manager() -> Any: # noqa: ANN401
|
||||
@@ -163,23 +177,20 @@ def make_graph() -> Any: # noqa: ANN401
|
||||
logger.exception(
|
||||
"Sandbox provider '%s' is not installed", config.sandbox_type
|
||||
)
|
||||
print( # noqa: T201 # stderr fallback — logger may not reach parent process
|
||||
f"Sandbox provider '{config.sandbox_type}' is not installed",
|
||||
file=sys.stderr,
|
||||
_print_startup_error(
|
||||
f"Sandbox provider '{config.sandbox_type}' is not installed"
|
||||
)
|
||||
sys.exit(1)
|
||||
except NotImplementedError:
|
||||
logger.exception("Sandbox type '%s' is not supported", config.sandbox_type)
|
||||
print( # noqa: T201 # stderr fallback — logger may not reach parent process
|
||||
f"Sandbox type '{config.sandbox_type}' is not supported",
|
||||
file=sys.stderr,
|
||||
_print_startup_error(
|
||||
f"Sandbox type '{config.sandbox_type}' is not supported"
|
||||
)
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
logger.exception("Sandbox creation failed for '%s'", config.sandbox_type)
|
||||
print( # noqa: T201 # stderr fallback — logger may not reach parent process
|
||||
f"Sandbox creation failed for '{config.sandbox_type}': {exc}",
|
||||
file=sys.stderr,
|
||||
_print_startup_error(
|
||||
f"Sandbox creation failed for '{config.sandbox_type}': {exc}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -134,6 +134,39 @@ class TestWaitForServerHealthy:
|
||||
read_log=lambda: "some log output",
|
||||
)
|
||||
|
||||
async def test_early_exit_promotes_marked_startup_error(self) -> None:
|
||||
"""Marked server startup errors should survive app error trimming."""
|
||||
process = MagicMock()
|
||||
process.poll.return_value = 1
|
||||
process.returncode = 3
|
||||
|
||||
log = (
|
||||
"Traceback (most recent call last):\n"
|
||||
"ValueError: No Runloop API key found\n"
|
||||
"Sandbox creation failed for 'runloop': No Runloop API key found. "
|
||||
"Set RUNLOOP_API_KEY or DEEPAGENTS_CLI_RUNLOOP_API_KEY.\n"
|
||||
"DEEPAGENTS_STARTUP_ERROR:Sandbox creation failed for 'runloop': "
|
||||
"No Runloop API key found. Set RUNLOOP_API_KEY or "
|
||||
"DEEPAGENTS_CLI_RUNLOOP_API_KEY.\n"
|
||||
"2026-05-11T03:37:44.911664Z [error ] "
|
||||
"Application startup failed. Exiting. [uvicorn.error]"
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await wait_for_server_healthy(
|
||||
"http://localhost:2024",
|
||||
timeout=5,
|
||||
process=process,
|
||||
read_log=lambda: log,
|
||||
)
|
||||
|
||||
first_line = str(exc_info.value).splitlines()[0]
|
||||
assert first_line == (
|
||||
"Server process exited with code 3: Sandbox creation failed for "
|
||||
"'runloop': No Runloop API key found. Set RUNLOOP_API_KEY or "
|
||||
"DEEPAGENTS_CLI_RUNLOOP_API_KEY."
|
||||
)
|
||||
|
||||
async def test_raises_on_timeout(self) -> None:
|
||||
"""Timeout exhaustion raises RuntimeError."""
|
||||
import httpx
|
||||
|
||||
Reference in New Issue
Block a user