From 888fdb6ebb2ca55e44d419a95759183f2e02a909 Mon Sep 17 00:00:00 2001 From: bloodycoder Date: Sat, 11 Apr 2026 22:18:38 +0800 Subject: [PATCH] feat(config): make approval timeout configurable for requests --- docs/en/configuration/config-files.md | 14 ++++ docs/zh/configuration/config-files.md | 14 ++++ src/kimi_cli/approval_runtime/runtime.py | 9 ++- src/kimi_cli/config.py | 17 +++++ src/kimi_cli/soul/agent.py | 1 + src/kimi_cli/soul/approval.py | 15 +++- tests/core/test_approval_runtime.py | 89 ++++++++++++++++++++++ tests/core/test_approval_timeout_config.py | 52 +++++++++++++ tests/core/test_config.py | 33 ++++++++ 9 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 tests/core/test_approval_timeout_config.py diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 440cbc580f..6b84f79d2d 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -35,6 +35,7 @@ The configuration file contains the following top-level configuration items: | `models` | `table` | Model configuration | | `loop_control` | `table` | Agent loop control parameters | | `background` | `table` | Background task runtime parameters | +| `approval` | `table` | Approval runtime parameters | | `services` | `table` | External service configuration (search, fetch) | | `mcp` | `table` | MCP client configuration | @@ -71,6 +72,9 @@ max_running_tasks = 4 keep_alive_on_exit = false agent_task_timeout_s = 900 +[approval] +timeout_s = 300 + [services.moonshot_search] base_url = "https://api.kimi.com/coding/v1/search" api_key = "sk-xxx" @@ -148,6 +152,16 @@ capabilities = ["thinking", "image_in"] | `keep_alive_on_exit` | `boolean` | `false` | Whether to keep background tasks running when CLI exits; default is to terminate all background tasks on exit | | `agent_task_timeout_s` | `integer` | `900` | Maximum runtime in seconds for a background agent task; timed-out tasks are marked as failed and the main agent is notified | +### `approval` + +`approval` controls approval waiting behavior. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `timeout_s` | `float` | `300` | Approval request timeout in seconds. Set `0` to wait indefinitely. | + +`approval.timeout_s` must be a finite non-negative number. + ### `services` `services` configures external services used by Kimi Code CLI. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index fcc9f07141..0af88fb64c 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -35,6 +35,7 @@ kimi --config '{"default_model": "kimi-for-coding", "providers": {...}, "models" | `models` | `table` | 模型配置 | | `loop_control` | `table` | Agent 循环控制参数 | | `background` | `table` | 后台任务运行参数 | +| `approval` | `table` | 审批运行参数 | | `services` | `table` | 外部服务配置(搜索、抓取) | | `mcp` | `table` | MCP 客户端配置 | @@ -71,6 +72,9 @@ max_running_tasks = 4 keep_alive_on_exit = false agent_task_timeout_s = 900 +[approval] +timeout_s = 300 + [services.moonshot_search] base_url = "https://api.kimi.com/coding/v1/search" api_key = "sk-xxx" @@ -148,6 +152,16 @@ capabilities = ["thinking", "image_in"] | `keep_alive_on_exit` | `boolean` | `false` | CLI 退出时是否保留后台任务运行;默认退出时终止所有后台任务 | | `agent_task_timeout_s` | `integer` | `900` | 后台 Agent 任务的最大运行时间(秒);超时后任务标记为失败并通知主 Agent | +### `approval` + +`approval` 用于控制审批等待行为。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `timeout_s` | `float` | `300` | 审批请求超时时间(秒)。设为 `0` 表示无限等待。 | + +`approval.timeout_s` 必须是有限且非负的数字。 + ### `services` `services` 配置 Kimi Code CLI 使用的外部服务。 diff --git a/src/kimi_cli/approval_runtime/runtime.py b/src/kimi_cli/approval_runtime/runtime.py index 3938001ed8..ab90449655 100644 --- a/src/kimi_cli/approval_runtime/runtime.py +++ b/src/kimi_cli/approval_runtime/runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import math import uuid from contextvars import ContextVar, Token from typing import TYPE_CHECKING @@ -83,7 +84,7 @@ def create_request( return request async def wait_for_response( - self, request_id: str, timeout: float = 300.0 + self, request_id: str, timeout: float | None = 300.0 ) -> tuple[ApprovalResponseKind, str]: waiter = self._waiters.get(request_id) request = self._requests.get(request_id) @@ -97,6 +98,12 @@ async def wait_for_response( return request.response, request.feedback waiter = asyncio.get_running_loop().create_future() self._waiters[request_id] = waiter + if timeout is not None and (timeout < 0 or not math.isfinite(timeout)): + raise ValueError("timeout must be a finite non-negative number or None") + + if timeout is None or timeout == 0: + return await asyncio.shield(waiter) + try: return await asyncio.wait_for(asyncio.shield(waiter), timeout=timeout) except TimeoutError: diff --git a/src/kimi_cli/config.py b/src/kimi_cli/config.py index 78019555af..0315661746 100644 --- a/src/kimi_cli/config.py +++ b/src/kimi_cli/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math from pathlib import Path from typing import Literal, Self @@ -114,6 +115,19 @@ class NotificationConfig(BaseModel): claim_stale_after_ms: int = Field(default=15_000, ge=1000) +class ApprovalConfig(BaseModel): + """Approval runtime configuration.""" + + timeout_s: float = Field(default=300.0, ge=0) + """Approval request timeout in seconds. Set to 0 for unlimited wait.""" + + @model_validator(mode="after") + def validate_timeout_s(self) -> Self: + if not math.isfinite(self.timeout_s): + raise ValueError("approval.timeout_s must be a finite non-negative number") + return self + + class MoonshotSearchConfig(BaseModel): """Moonshot Search configuration.""" @@ -208,6 +222,9 @@ class Config(BaseModel): notifications: NotificationConfig = Field( default_factory=NotificationConfig, description="Notification configuration" ) + approval: ApprovalConfig = Field( + default_factory=ApprovalConfig, description="Approval configuration" + ) services: Services = Field(default_factory=Services, description="Services configuration") mcp: MCPConfig = Field(default_factory=MCPConfig, description="MCP configuration") hooks: list[HookDef] = Field(default_factory=list, description="Hook definitions") # pyright: ignore[reportUnknownVariableType] diff --git a/src/kimi_cli/soul/agent.py b/src/kimi_cli/soul/agent.py index 8d47db568b..f7959686d9 100644 --- a/src/kimi_cli/soul/agent.py +++ b/src/kimi_cli/soul/agent.py @@ -218,6 +218,7 @@ def __post_init__(self) -> None: self.approval_runtime = ApprovalRuntime() self.approval_runtime.bind_root_wire_hub(self.root_wire_hub) self.approval.set_runtime(self.approval_runtime) + self.approval.set_request_timeout(self.config.approval.timeout_s) self.background_tasks.bind_runtime(self) @staticmethod diff --git a/src/kimi_cli/soul/approval.py b/src/kimi_cli/soul/approval.py index c3279264b4..1408672539 100644 --- a/src/kimi_cli/soul/approval.py +++ b/src/kimi_cli/soul/approval.py @@ -57,11 +57,15 @@ def __init__( self, yolo: bool = False, auto_approve_actions: set[str] | None = None, + request_timeout_s: float | None = 300.0, on_change: Callable[[], None] | None = None, ): self.yolo = yolo + """Whether approvals are bypassed.""" self.auto_approve_actions: set[str] = auto_approve_actions or set() """Set of action names that should automatically be approved.""" + self.request_timeout_s = request_timeout_s + """Per-request approval timeout in seconds; ``None`` means unlimited wait.""" self._on_change = on_change def notify_change(self) -> None: @@ -98,6 +102,13 @@ def set_yolo(self, yolo: bool) -> None: def is_yolo(self) -> bool: return self._state.yolo + @property + def request_timeout_s(self) -> float | None: + return self._state.request_timeout_s + + def set_request_timeout(self, timeout_s: float | None) -> None: + self._state.request_timeout_s = timeout_s + async def request( self, sender: str, @@ -154,7 +165,9 @@ async def request( source=source, ) try: - response, feedback = await self._runtime.wait_for_response(request_id) + response, feedback = await self._runtime.wait_for_response( + request_id, timeout=self._state.request_timeout_s + ) except ApprovalCancelledError: return ApprovalResult(approved=False) match response: diff --git a/tests/core/test_approval_runtime.py b/tests/core/test_approval_runtime.py index 7944bd64f2..5d8c5c5394 100644 --- a/tests/core/test_approval_runtime.py +++ b/tests/core/test_approval_runtime.py @@ -231,3 +231,92 @@ async def test_approval_runtime_wait_for_response_times_out() -> None: record = runtime.get_request(request.id) assert record is not None assert record.status == "cancelled" + + +@pytest.mark.asyncio +async def test_approval_runtime_wait_for_response_uses_default_timeout_300(monkeypatch) -> None: + runtime = ApprovalRuntime() + request = runtime.create_request( + request_id="req-default-timeout", + tool_call_id="call-default-timeout", + sender="WriteFile", + action="edit file", + description="Write file /tmp/default.txt", + display=[], + source=ApprovalSource(kind="foreground_turn", id="turn-default"), + ) + + captured_timeout: list[float] = [] + original_wait_for = asyncio.wait_for + + async def _wait_for_spy(awaitable, timeout): + captured_timeout.append(timeout) + return await original_wait_for(awaitable, timeout=timeout) + + monkeypatch.setattr(asyncio, "wait_for", _wait_for_spy) + + waiter = asyncio.create_task(runtime.wait_for_response(request.id)) + await asyncio.sleep(0) + assert runtime.resolve(request.id, "approve") + response, feedback = await waiter + + assert response == "approve" + assert feedback == "" + assert captured_timeout == [300.0] + + +@pytest.mark.asyncio +async def test_approval_runtime_timeout_zero_waits_indefinitely(monkeypatch) -> None: + runtime = ApprovalRuntime() + request = runtime.create_request( + request_id="req-timeout-zero", + tool_call_id="call-timeout-zero", + sender="WriteFile", + action="edit file", + description="Write file /tmp/zero.txt", + display=[], + source=ApprovalSource(kind="foreground_turn", id="turn-zero"), + ) + + called_wait_for = False + + async def _wait_for_should_not_run(awaitable, timeout): + nonlocal called_wait_for + called_wait_for = True + return await awaitable + + monkeypatch.setattr(asyncio, "wait_for", _wait_for_should_not_run) + + waiter = asyncio.create_task(runtime.wait_for_response(request.id, timeout=0)) + await asyncio.sleep(0) + assert runtime.resolve(request.id, "approve") + response, feedback = await waiter + + assert response == "approve" + assert feedback == "" + assert called_wait_for is False + + +@pytest.mark.asyncio +async def test_approval_runtime_unlimited_wait_can_be_cancelled_by_source() -> None: + runtime = ApprovalRuntime() + request = runtime.create_request( + request_id="req-timeout-zero-cancel", + tool_call_id="call-timeout-zero-cancel", + sender="WriteFile", + action="edit file", + description="Write file /tmp/zero-cancel.txt", + display=[], + source=ApprovalSource(kind="background_agent", id="task-timeout-zero-cancel"), + ) + + waiter = asyncio.create_task(runtime.wait_for_response(request.id, timeout=0)) + await asyncio.sleep(0) + assert runtime.cancel_by_source("background_agent", "task-timeout-zero-cancel") == 1 + + with pytest.raises(ApprovalCancelledError): + await waiter + + record = runtime.get_request(request.id) + assert record is not None + assert record.status == "cancelled" diff --git a/tests/core/test_approval_timeout_config.py b/tests/core/test_approval_timeout_config.py new file mode 100644 index 0000000000..d6f4b32524 --- /dev/null +++ b/tests/core/test_approval_timeout_config.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from kimi_cli.auth.oauth import OAuthManager +from kimi_cli.background import BackgroundTaskManager +from kimi_cli.notifications import NotificationManager +from kimi_cli.soul.agent import Runtime +from kimi_cli.soul.approval import Approval + + +def test_runtime_injects_approval_timeout_from_config( + config, + llm, + builtin_args, + denwa_renji, + session, + labor_market, + environment, +) -> None: + config.approval.timeout_s = 123 + notifications = NotificationManager( + session.context_file.parent / "notifications", + config.notifications, + ) + runtime = Runtime( + config=config, + llm=llm, + builtin_args=builtin_args, + denwa_renji=denwa_renji, + session=session, + approval=Approval(yolo=True), + labor_market=labor_market, + environment=environment, + notifications=notifications, + background_tasks=BackgroundTaskManager( + session, + config.background, + notifications=notifications, + ), + skills={}, + oauth=OAuthManager(config), + additional_dirs=[], + skills_dirs=[], + role="root", + ) + + assert runtime.approval.request_timeout_s == 123 + + subagent_runtime = runtime.copy_for_subagent(agent_id="a-test", subagent_type="coder") + assert subagent_runtime.approval.request_timeout_s == 123 + + runtime.approval.set_request_timeout(45) + assert subagent_runtime.approval.request_timeout_s == 45 diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 4f522a0be9..b8faa65bc3 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -51,6 +51,9 @@ def test_default_config_dump(): "notifications": { "claim_stale_after_ms": 15000, }, + "approval": { + "timeout_s": 300.0, + }, "services": {"moonshot_search": None, "moonshot_fetch": None}, "mcp": {"client": {"tool_call_timeout_ms": 60000}}, "hooks": [], @@ -132,3 +135,33 @@ def test_load_config_compaction_trigger_ratio_too_low(): def test_load_config_compaction_trigger_ratio_too_high(): with pytest.raises(ConfigError, match="compaction_trigger_ratio"): load_config_from_string('{"loop_control": {"compaction_trigger_ratio": 1.0}}') + + +def test_load_config_approval_timeout_s_default(): + config = load_config_from_string("{}") + assert config.approval.timeout_s == 300.0 + + +def test_load_config_approval_timeout_s_toml_finite(): + config = load_config_from_string("[approval]\ntimeout_s = 120\n") + assert config.approval.timeout_s == 120 + + +def test_load_config_approval_timeout_s_toml_unlimited_zero(): + config = load_config_from_string("[approval]\ntimeout_s = 0\n") + assert config.approval.timeout_s == 0 + + +def test_load_config_approval_timeout_s_reject_negative(): + with pytest.raises(ConfigError, match="timeout_s"): + load_config_from_string("[approval]\ntimeout_s = -1\n") + + +def test_load_config_approval_timeout_s_reject_nan(): + with pytest.raises(ConfigError, match="timeout_s"): + load_config_from_string('{"approval": {"timeout_s": NaN}}') + + +def test_load_config_approval_timeout_s_reject_inf(): + with pytest.raises(ConfigError, match="timeout_s"): + load_config_from_string('{"approval": {"timeout_s": Infinity}}')