Skip to content
This repository was archived by the owner on Sep 23, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
fix(shell): adapt timeouts for long commands
  • Loading branch information
he-yufeng committed May 11, 2026
commit bbadafd975d6075acea60e0a7295adc6df283841
35 changes: 30 additions & 5 deletions src/kimi_cli/tools/shell/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import re
from collections.abc import Callable
from pathlib import Path
from typing import Self, override
Expand All @@ -21,6 +22,24 @@

MAX_FOREGROUND_TIMEOUT = 5 * 60
MAX_BACKGROUND_TIMEOUT = 24 * 60 * 60
DEFAULT_TIMEOUT = 60

LONG_RUNNING_COMMAND_TIMEOUTS: tuple[tuple[re.Pattern[str], int], ...] = (
(re.compile(r"\bgit\s+submodule\s+(?:deinit|update|sync)\b", re.I), 300),
(re.compile(r"\bgit\s+(?:clone|fetch)\b", re.I), 300),
(re.compile(r"\bgit\s+show\b.*\s--\s", re.I), 120),
(re.compile(r"\b(?:npm|yarn|pnpm)\s+(?:install|ci|run\s+build|build)\b", re.I), 180),
(re.compile(r"\b(?:docker|cargo)\s+build\b", re.I), 300),
(re.compile(r"\bmake(?:\s+-j\d*)?(?:\s|$)", re.I), 300),
)


def _effective_timeout(command: str, timeout: int, *, max_timeout: int) -> int:
normalized = " ".join(command.split())
for pattern, suggested_timeout in LONG_RUNNING_COMMAND_TIMEOUTS:
if pattern.search(normalized):
return min(max(timeout, suggested_timeout), max_timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect caller-provided shorter timeout values

Do not unconditionally raise the timeout for matched commands: _effective_timeout currently returns max(timeout, suggested_timeout), so Shell(timeout=5) on commands like npm install will actually run for 180s. This changes the semantics of the timeout parameter from an explicit limit to a minimum and can cause callers that rely on fast-fail behavior to block for minutes unexpectedly; consider only applying adaptive extension when the caller kept the default timeout.

Useful? React with 👍 / 👎.

return timeout


class Params(BaseModel):
Expand All @@ -30,7 +49,7 @@ class Params(BaseModel):
"The timeout in seconds for the command to execute. "
"If the command takes longer than this, it will be killed."
),
default=60,
default=DEFAULT_TIMEOUT,
ge=1,
le=MAX_BACKGROUND_TIMEOUT,
)
Expand Down Expand Up @@ -88,6 +107,9 @@ async def __call__(self, params: Params) -> ToolReturnValue:
return await self._run_in_background(params)

command = self._preprocess_command(params.command)
timeout = _effective_timeout(
command, params.timeout, max_timeout=MAX_FOREGROUND_TIMEOUT
)

result = await self._approval.request(
self.name,
Expand All @@ -112,7 +134,7 @@ def stderr_cb(line: bytes):
builder.write(line_str)

try:
exitcode = await self._run_shell_command(command, stdout_cb, stderr_cb, params.timeout)
exitcode = await self._run_shell_command(command, stdout_cb, stderr_cb, timeout)

if exitcode == 0:
return builder.ok("Command executed successfully.")
Expand All @@ -123,8 +145,8 @@ def stderr_cb(line: bytes):
)
except TimeoutError:
return builder.error(
f"Command killed by timeout ({params.timeout}s)",
brief=f"Killed by timeout ({params.timeout}s)",
f"Command killed by timeout ({timeout}s)",
brief=f"Killed by timeout ({timeout}s)",
)
except Exception as e:
logger.error(
Expand Down Expand Up @@ -162,10 +184,13 @@ async def _run_in_background(self, params: Params) -> ToolReturnValue:
return result.rejection_error()

try:
timeout = _effective_timeout(
command, params.timeout, max_timeout=MAX_BACKGROUND_TIMEOUT
)
view = self._runtime.background_tasks.create_bash_task(
command=command,
description=params.description.strip(),
timeout_s=params.timeout,
timeout_s=timeout,
tool_call_id=tool_call.id,
shell_name="bash",
shell_path=str(self._shell_path),
Expand Down
66 changes: 66 additions & 0 deletions tests/tools/test_shell_timeout_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

from types import SimpleNamespace

from kimi_cli.tools.shell import DEFAULT_TIMEOUT, Params, Shell, _effective_timeout


def test_effective_timeout_keeps_normal_commands_at_default() -> None:
assert _effective_timeout("echo ok", DEFAULT_TIMEOUT, max_timeout=300) == DEFAULT_TIMEOUT


def test_effective_timeout_extends_known_long_commands() -> None:
assert _effective_timeout("git submodule deinit -f deps/hal", 60, max_timeout=300) == 300
assert _effective_timeout("git show HEAD -- vendor/sdk", 60, max_timeout=300) == 120
assert _effective_timeout("npm run build", 60, max_timeout=300) == 180


def test_effective_timeout_keeps_larger_explicit_timeout() -> None:
assert _effective_timeout("npm install", 240, max_timeout=300) == 240


async def test_shell_uses_adaptive_timeout(shell_tool: Shell, monkeypatch) -> None:
seen: dict[str, int] = {}

async def fake_run_shell_command(command, stdout_cb, stderr_cb, timeout):
seen["timeout"] = timeout
return 0

monkeypatch.setattr(shell_tool, "_run_shell_command", fake_run_shell_command)

result = await shell_tool(Params(command="git submodule deinit -f deps/hal"))

assert not result.is_error
assert seen["timeout"] == 300


async def test_background_shell_uses_adaptive_timeout(shell_tool: Shell, monkeypatch) -> None:
seen: dict[str, int] = {}

def fake_create_bash_task(**kwargs):
seen["timeout"] = kwargs["timeout_s"]
return SimpleNamespace(
spec=SimpleNamespace(
id="bash-test",
kind="shell",
kind_payload=None,
description=kwargs["description"],
command=kwargs["command"],
),
runtime=SimpleNamespace(status="running", exit_code=None, failure_reason=None),
)

monkeypatch.setattr(
shell_tool._runtime.background_tasks, "create_bash_task", fake_create_bash_task
)

result = await shell_tool(
Params(
command="npm install",
run_in_background=True,
description="install dependencies",
)
)

assert not result.is_error
assert seen["timeout"] == 180