Skip to content
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
14 changes: 14 additions & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 客户端配置 |

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 使用的外部服务。
Expand Down
9 changes: 8 additions & 1 deletion src/kimi_cli/approval_runtime/runtime.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import math
import uuid
from contextvars import ContextVar, Token
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions src/kimi_cli/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import math
from pathlib import Path
from typing import Literal, Self

Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions src/kimi_cli/soul/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion src/kimi_cli/soul/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions tests/core/test_approval_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
52 changes: 52 additions & 0 deletions tests/core/test_approval_timeout_config.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions tests/core/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down Expand Up @@ -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}}')
Loading