kimi-cli 0.35__py3-none-any.whl → 0.37__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kimi-cli might be problematic. Click here for more details.

kimi_cli/CHANGELOG.md CHANGED
@@ -9,6 +9,26 @@ Internal builds may append content to the Unreleased section.
9
9
  Only write entries that are worth mentioning to users.
10
10
  -->
11
11
 
12
+ ## [0.37] - 2025-10-24
13
+
14
+ ### Fixed
15
+
16
+ - Fix update checking
17
+
18
+ ## [0.36] - 2025-10-24
19
+
20
+ ### Added
21
+
22
+ - Add `/debug` meta command to debug the context
23
+ - Add auto context compaction
24
+ - Add approval request mechanism
25
+ - Add `--yolo` option to automatically approve all actions
26
+ - Render markdown content for better readability
27
+
28
+ ### Fixed
29
+
30
+ - Fix "unknown error" message when interrupting a meta command
31
+
12
32
  ## [0.35] - 2025-10-22
13
33
 
14
34
  ### Changed
kimi_cli/__init__.py CHANGED
@@ -152,6 +152,16 @@ UIMode = Literal["shell", "print", "acp"]
152
152
  "Default: none."
153
153
  ),
154
154
  )
155
+ @click.option(
156
+ "--yolo",
157
+ "--yes",
158
+ "-y",
159
+ "--auto-approve",
160
+ "yolo",
161
+ is_flag=True,
162
+ default=False,
163
+ help="Automatically approve all actions. Default: no.",
164
+ )
155
165
  def kimi(
156
166
  verbose: bool,
157
167
  debug: bool,
@@ -165,6 +175,7 @@ def kimi(
165
175
  output_format: OutputFormat | None,
166
176
  mcp_config_file: list[Path],
167
177
  mcp_config: list[str],
178
+ yolo: bool,
168
179
  ):
169
180
  """Kimi, your next CLI agent."""
170
181
  echo = click.echo if verbose else lambda *args, **kwargs: None
@@ -232,6 +243,7 @@ def kimi(
232
243
  input_format=input_format,
233
244
  output_format=output_format,
234
245
  mcp_configs=mcp_configs,
246
+ yolo=yolo,
235
247
  )
236
248
  )
237
249
  if not succeeded:
@@ -254,6 +266,7 @@ async def kimi_run(
254
266
  input_format: InputFormat | None = None,
255
267
  output_format: OutputFormat | None = None,
256
268
  mcp_configs: list[dict[str, Any]] | None = None,
269
+ yolo: bool = False,
257
270
  ) -> bool:
258
271
  """Run Kimi CLI."""
259
272
  echo = click.echo if verbose else lambda *args, **kwargs: None
@@ -286,7 +299,7 @@ async def kimi_run(
286
299
  echo(f"✓ Using LLM provider: {provider}")
287
300
  echo(f"✓ Using LLM model: {model}")
288
301
  stream = ui != "print" # use non-streaming mode only for print UI
289
- llm = create_llm(provider, model, stream=stream)
302
+ llm = create_llm(provider, model, stream=stream, session_id=session.id)
290
303
 
291
304
  # TODO: support Windows
292
305
  ls = subprocess.run(["ls", "-la"], capture_output=True, text=True)
@@ -305,7 +318,7 @@ async def kimi_run(
305
318
  ),
306
319
  denwa_renji=DenwaRenji(),
307
320
  session=session,
308
- approval=Approval(),
321
+ approval=Approval(yolo=yolo),
309
322
  )
310
323
  try:
311
324
  agent = await load_agent_with_mcp(agent_file, agent_globals, mcp_configs or [])
kimi_cli/soul/__init__.py CHANGED
@@ -51,7 +51,7 @@ class Soul(Protocol):
51
51
  wire (Wire): The wire to send events and requests to the UI loop.
52
52
 
53
53
  Raises:
54
- ChatProviderNotSet: When the chat provider is not set.
54
+ LLMNotSet: When the LLM is not set.
55
55
  ChatProviderError: When the LLM provider returns an error.
56
56
  MaxStepsReached: When the maximum number of steps is reached.
57
57
  asyncio.CancelledError: When the run is cancelled by user.
kimi_cli/soul/approval.py CHANGED
@@ -15,11 +15,12 @@ class Approval:
15
15
  def set_yolo(self, yolo: bool) -> None:
16
16
  self._yolo = yolo
17
17
 
18
- async def request(self, action: str, description: str) -> bool:
18
+ async def request(self, sender: str, action: str, description: str) -> bool:
19
19
  """
20
20
  Request approval for the given action. Intended to be called by tools.
21
21
 
22
22
  Args:
23
+ sender (str): The name of the sender.
23
24
  action (str): The action to request approval for.
24
25
  This is used to identify the action for auto-approval.
25
26
  description (str): The description of the action. This is used to display to the user.
@@ -47,7 +48,7 @@ class Approval:
47
48
  if action in self._auto_approve_actions:
48
49
  return True
49
50
 
50
- request = ApprovalRequest(tool_call.id, action, description)
51
+ request = ApprovalRequest(tool_call.id, sender, action, description)
51
52
  self._request_queue.put_nowait(request)
52
53
  response = await request.wait()
53
54
  logger.debug("Received approval response: {response}", response=response)
@@ -0,0 +1,105 @@
1
+ from collections.abc import Sequence
2
+ from string import Template
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from kosong.base import generate
6
+ from kosong.base.message import ContentPart, Message, TextPart
7
+
8
+ import kimi_cli.prompts as prompts
9
+ from kimi_cli.llm import LLM
10
+ from kimi_cli.soul.message import system
11
+ from kimi_cli.utils.logging import logger
12
+
13
+ MAX_PRESERVED_MESSAGES = 2
14
+
15
+
16
+ @runtime_checkable
17
+ class Compaction(Protocol):
18
+ async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Message]:
19
+ """
20
+ Compact a sequence of messages into a new sequence of messages.
21
+
22
+ Args:
23
+ messages (Sequence[Message]): The messages to compact.
24
+ llm (LLM): The LLM to use for compaction.
25
+
26
+ Returns:
27
+ Sequence[Message]: The compacted messages.
28
+
29
+ Raises:
30
+ ChatProviderError: When the chat provider returns an error.
31
+ """
32
+ ...
33
+
34
+
35
+ class SimpleCompaction:
36
+ async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Message]:
37
+ history = list(messages)
38
+ if not history:
39
+ return history
40
+
41
+ preserve_start_index = len(history)
42
+ n_preserved = 0
43
+ for index in range(len(history) - 1, -1, -1):
44
+ if history[index].role in {"user", "assistant"}:
45
+ n_preserved += 1
46
+ if n_preserved == MAX_PRESERVED_MESSAGES:
47
+ preserve_start_index = index
48
+ break
49
+
50
+ if n_preserved < MAX_PRESERVED_MESSAGES:
51
+ return history
52
+
53
+ to_compact = history[:preserve_start_index]
54
+ to_preserve = history[preserve_start_index:]
55
+
56
+ if not to_compact:
57
+ # Let's hope this won't exceed the context size limit
58
+ return to_preserve
59
+
60
+ # Convert history to string for the compact prompt
61
+ history_text = "\n\n".join(
62
+ f"## Message {i + 1}\nRole: {msg.role}\nContent: {msg.content}"
63
+ for i, msg in enumerate(to_compact)
64
+ )
65
+
66
+ # Build the compact prompt using string template
67
+ compact_template = Template(prompts.COMPACT)
68
+ compact_prompt = compact_template.substitute(CONTEXT=history_text)
69
+
70
+ # Create input message for compaction
71
+ compact_message = Message(role="user", content=compact_prompt)
72
+
73
+ # Call generate to get the compacted context
74
+ # TODO: set max completion tokens
75
+ logger.debug("Compacting context...")
76
+ compacted_msg, usage = await generate(
77
+ chat_provider=llm.chat_provider,
78
+ system_prompt="You are a helpful assistant that compacts conversation context.",
79
+ tools=[],
80
+ history=[compact_message],
81
+ )
82
+ if usage:
83
+ logger.debug(
84
+ "Compaction used {input} input tokens and {output} output tokens",
85
+ input=usage.input,
86
+ output=usage.output,
87
+ )
88
+
89
+ content: list[ContentPart] = [
90
+ system("Previous context has been compacted. Here is the compaction output:")
91
+ ]
92
+ content.extend(
93
+ [TextPart(text=compacted_msg.content)]
94
+ if isinstance(compacted_msg.content, str)
95
+ else compacted_msg.content
96
+ )
97
+ compacted_messages: list[Message] = [Message(role="assistant", content=content)]
98
+ compacted_messages.extend(to_preserve)
99
+ return compacted_messages
100
+
101
+
102
+ def __static_type_check(
103
+ simple: SimpleCompaction,
104
+ ):
105
+ _: Compaction = simple
kimi_cli/soul/kimisoul.py CHANGED
@@ -1,4 +1,6 @@
1
1
  import asyncio
2
+ from collections.abc import Sequence
3
+ from functools import partial
2
4
 
3
5
  import kosong
4
6
  import tenacity
@@ -16,13 +18,24 @@ from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wai
16
18
  from kimi_cli.agent import Agent, AgentGlobals
17
19
  from kimi_cli.config import LoopControl
18
20
  from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul, StatusSnapshot
21
+ from kimi_cli.soul.compaction import SimpleCompaction
19
22
  from kimi_cli.soul.context import Context
20
23
  from kimi_cli.soul.message import system, tool_result_to_messages
21
- from kimi_cli.soul.wire import StatusUpdate, StepBegin, StepInterrupted, Wire, current_wire
24
+ from kimi_cli.soul.wire import (
25
+ CompactionBegin,
26
+ CompactionEnd,
27
+ StatusUpdate,
28
+ StepBegin,
29
+ StepInterrupted,
30
+ Wire,
31
+ current_wire,
32
+ )
22
33
  from kimi_cli.tools.dmail import NAME as SendDMail_NAME
23
34
  from kimi_cli.tools.utils import ToolRejectedError
24
35
  from kimi_cli.utils.logging import logger
25
36
 
37
+ RESERVED_TOKENS = 50_000
38
+
26
39
 
27
40
  class KimiSoul:
28
41
  """The soul of Kimi CLI."""
@@ -50,6 +63,10 @@ class KimiSoul:
50
63
  self._approval = agent_globals.approval
51
64
  self._context = context
52
65
  self._loop_control = loop_control
66
+ self._compaction = SimpleCompaction() # TODO: maybe configurable and composable
67
+ self._reserved_tokens = RESERVED_TOKENS
68
+ if self._agent_globals.llm is not None:
69
+ assert self._reserved_tokens <= self._agent_globals.llm.max_context_size
53
70
 
54
71
  for tool in agent.toolset.tools:
55
72
  if tool.name == SendDMail_NAME:
@@ -109,13 +126,21 @@ class KimiSoul:
109
126
  # to the main wire. See `_SubWire` for more details. Later we need to figure
110
127
  # out a better solution.
111
128
  try:
129
+ # compact the context if needed
130
+ if self._context.token_count >= self._reserved_tokens:
131
+ logger.info("Context too long, compacting...")
132
+ wire.send(CompactionBegin())
133
+ await self.compact_context()
134
+ wire.send(CompactionEnd())
135
+
136
+ logger.debug("Beginning step {step_no}", step_no=step_no)
112
137
  await self._checkpoint()
113
138
  self._denwa_renji.set_n_checkpoints(self._context.n_checkpoints)
114
139
  finished = await self._step(wire)
115
140
  except BackToTheFuture as e:
116
141
  await self._context.revert_to(e.checkpoint_id)
117
142
  await self._checkpoint()
118
- await self._context.append_message(e.message)
143
+ await self._context.append_message(e.messages)
119
144
  continue
120
145
  except (ChatProviderError, asyncio.CancelledError):
121
146
  wire.send(StepInterrupted())
@@ -137,28 +162,9 @@ class KimiSoul:
137
162
  assert self._agent_globals.llm is not None
138
163
  chat_provider = self._agent_globals.llm.chat_provider
139
164
 
140
- def _is_retryable_error(exception: BaseException) -> bool:
141
- if isinstance(exception, (APIConnectionError, APITimeoutError)):
142
- return True
143
- return isinstance(exception, APIStatusError) and exception.status_code in (
144
- 429, # Too Many Requests
145
- 500, # Internal Server Error
146
- 502, # Bad Gateway
147
- 503, # Service Unavailable
148
- )
149
-
150
- def _retry_log(retry_state: RetryCallState):
151
- logger.info(
152
- "Retrying step for the {n} time. Waiting {sleep} seconds.",
153
- n=retry_state.attempt_number,
154
- sleep=retry_state.next_action.sleep
155
- if retry_state.next_action is not None
156
- else "unknown",
157
- )
158
-
159
165
  @tenacity.retry(
160
- retry=retry_if_exception(_is_retryable_error),
161
- before_sleep=_retry_log,
166
+ retry=retry_if_exception(self._is_retryable_error),
167
+ before_sleep=partial(self._retry_log, "step"),
162
168
  wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5),
163
169
  stop=stop_after_attempt(self._loop_control.max_retries_per_step),
164
170
  reraise=True,
@@ -202,19 +208,21 @@ class KimiSoul:
202
208
  # raise to let the main loop take us back to the future
203
209
  raise BackToTheFuture(
204
210
  dmail.checkpoint_id,
205
- Message(
206
- role="user",
207
- content=[
208
- system(
209
- "You just got a D-Mail from your future self. "
210
- "It is likely that your future self has already done "
211
- "something in the current working directory. Please read "
212
- "the D-Mail and decide what to do next. You MUST NEVER "
213
- "mention to the user about this information. "
214
- f"D-Mail content:\n\n{dmail.message.strip()}"
215
- )
216
- ],
217
- ),
211
+ [
212
+ Message(
213
+ role="user",
214
+ content=[
215
+ system(
216
+ "You just got a D-Mail from your future self. "
217
+ "It is likely that your future self has already done "
218
+ "something in the current working directory. Please read "
219
+ "the D-Mail and decide what to do next. You MUST NEVER "
220
+ "mention to the user about this information. "
221
+ f"D-Mail content:\n\n{dmail.message.strip()}"
222
+ )
223
+ ],
224
+ )
225
+ ],
218
226
  )
219
227
 
220
228
  return not result.tool_calls
@@ -230,6 +238,54 @@ class KimiSoul:
230
238
  logger.debug("Appending tool result to context: {tool_result}", tool_result=tool_result)
231
239
  await self._context.append_message(tool_result_to_messages(tool_result))
232
240
 
241
+ async def compact_context(self) -> None:
242
+ """
243
+ Compact the context.
244
+
245
+ Raises:
246
+ LLMNotSet: When the LLM is not set.
247
+ ChatProviderError: When the chat provider returns an error.
248
+ """
249
+
250
+ @tenacity.retry(
251
+ retry=retry_if_exception(self._is_retryable_error),
252
+ before_sleep=partial(self._retry_log, "compaction"),
253
+ wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5),
254
+ stop=stop_after_attempt(self._loop_control.max_retries_per_step),
255
+ reraise=True,
256
+ )
257
+ async def _compact_with_retry() -> Sequence[Message]:
258
+ if self._agent_globals.llm is None:
259
+ raise LLMNotSet()
260
+ return await self._compaction.compact(self._context.history, self._agent_globals.llm)
261
+
262
+ compacted_messages = await _compact_with_retry()
263
+ await self._context.revert_to(0)
264
+ await self._checkpoint()
265
+ await self._context.append_message(compacted_messages)
266
+
267
+ @staticmethod
268
+ def _is_retryable_error(exception: BaseException) -> bool:
269
+ if isinstance(exception, (APIConnectionError, APITimeoutError)):
270
+ return True
271
+ return isinstance(exception, APIStatusError) and exception.status_code in (
272
+ 429, # Too Many Requests
273
+ 500, # Internal Server Error
274
+ 502, # Bad Gateway
275
+ 503, # Service Unavailable
276
+ )
277
+
278
+ @staticmethod
279
+ def _retry_log(name: str, retry_state: RetryCallState):
280
+ logger.info(
281
+ "Retrying {name} for the {n} time. Waiting {sleep} seconds.",
282
+ name=name,
283
+ n=retry_state.attempt_number,
284
+ sleep=retry_state.next_action.sleep
285
+ if retry_state.next_action is not None
286
+ else "unknown",
287
+ )
288
+
233
289
 
234
290
  class BackToTheFuture(Exception):
235
291
  """
@@ -237,9 +293,9 @@ class BackToTheFuture(Exception):
237
293
  The main agent loop should catch this exception and handle it.
238
294
  """
239
295
 
240
- def __init__(self, checkpoint_id: int, message: Message):
296
+ def __init__(self, checkpoint_id: int, messages: Sequence[Message]):
241
297
  self.checkpoint_id = checkpoint_id
242
- self.message = message
298
+ self.messages = messages
243
299
 
244
300
 
245
301
  def __static_type_check(
kimi_cli/soul/wire.py CHANGED
@@ -15,7 +15,26 @@ class StepBegin(NamedTuple):
15
15
  n: int
16
16
 
17
17
 
18
- class StepInterrupted(NamedTuple):
18
+ class StepInterrupted:
19
+ pass
20
+
21
+
22
+ class CompactionBegin:
23
+ """
24
+ Indicates that a compaction just began.
25
+ This event must be sent during a step, which means, between `StepBegin` and `StepInterrupted`.
26
+ And, there must be a `CompactionEnd` directly following this event.
27
+ """
28
+
29
+ pass
30
+
31
+
32
+ class CompactionEnd:
33
+ """
34
+ Indicates that a compaction just ended.
35
+ This event must be sent directly after a `CompactionBegin` event.
36
+ """
37
+
19
38
  pass
20
39
 
21
40
 
@@ -23,7 +42,7 @@ class StatusUpdate(NamedTuple):
23
42
  status: StatusSnapshot
24
43
 
25
44
 
26
- type ControlFlowEvent = StepBegin | StepInterrupted | StatusUpdate
45
+ type ControlFlowEvent = StepBegin | StepInterrupted | CompactionBegin | CompactionEnd | StatusUpdate
27
46
  type Event = ControlFlowEvent | ContentPart | ToolCall | ToolCallPart | ToolResult
28
47
 
29
48
 
@@ -34,9 +53,10 @@ class ApprovalResponse(Enum):
34
53
 
35
54
 
36
55
  class ApprovalRequest:
37
- def __init__(self, tool_call_id: str, action: str, description: str):
56
+ def __init__(self, tool_call_id: str, sender: str, action: str, description: str):
38
57
  self.id = str(uuid.uuid4())
39
58
  self.tool_call_id = tool_call_id
59
+ self.sender = sender
40
60
  self.action = action
41
61
  self.description = description
42
62
  self._future = asyncio.Future[ApprovalResponse]()
@@ -44,7 +64,7 @@ class ApprovalRequest:
44
64
  def __repr__(self) -> str:
45
65
  return (
46
66
  f"ApprovalRequest(id={self.id}, tool_call_id={self.tool_call_id}, "
47
- f"action={self.action}, description={self.description})"
67
+ f"sender={self.sender}, action={self.action}, description={self.description})"
48
68
  )
49
69
 
50
70
  async def wait(self) -> ApprovalResponse:
@@ -63,6 +83,11 @@ class ApprovalRequest:
63
83
  """
64
84
  self._future.set_result(response)
65
85
 
86
+ @property
87
+ def resolved(self) -> bool:
88
+ """Whether the request is resolved."""
89
+ return self._future.done()
90
+
66
91
 
67
92
  type WireMessage = Event | ApprovalRequest
68
93
 
@@ -38,7 +38,9 @@ class Bash(CallableTool2[Params]):
38
38
  builder = ToolResultBuilder()
39
39
 
40
40
  if not await self._approval.request(
41
- f"run command {params.command}", f"Run command `{params.command}`"
41
+ self.name,
42
+ "run shell command",
43
+ f"Run command `{params.command}`",
42
44
  ):
43
45
  return ToolRejectedError()
44
46
 
@@ -1,9 +1,17 @@
1
+ from enum import Enum
2
+
3
+
1
4
  class FileOpsWindow:
2
5
  """Maintains a window of file operations."""
3
6
 
4
7
  pass
5
8
 
6
9
 
10
+ class FileActions(str, Enum):
11
+ READ = "read file"
12
+ EDIT = "edit file"
13
+
14
+
7
15
  from .glob import Glob # noqa: E402
8
16
  from .grep import Grep # noqa: E402
9
17
  from .patch import PatchFile # noqa: E402
@@ -7,6 +7,9 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
7
7
  from pydantic import BaseModel, Field
8
8
 
9
9
  from kimi_cli.agent import BuiltinSystemPromptArgs
10
+ from kimi_cli.soul.approval import Approval
11
+ from kimi_cli.tools.file import FileActions
12
+ from kimi_cli.tools.utils import ToolRejectedError
10
13
 
11
14
 
12
15
  class Params(BaseModel):
@@ -19,9 +22,10 @@ class PatchFile(CallableTool2[Params]):
19
22
  description: str = (Path(__file__).parent / "patch.md").read_text()
20
23
  params: type[Params] = Params
21
24
 
22
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
25
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
23
26
  super().__init__(**kwargs)
24
27
  self._work_dir = builtin_args.KIMI_WORK_DIR
28
+ self._approval = approval
25
29
 
26
30
  def _validate_path(self, path: Path) -> ToolError | None:
27
31
  """Validate that the path is safe to patch."""
@@ -70,6 +74,14 @@ class PatchFile(CallableTool2[Params]):
70
74
  brief="Invalid path",
71
75
  )
72
76
 
77
+ # Request approval
78
+ if not await self._approval.request(
79
+ self.name,
80
+ FileActions.EDIT,
81
+ f"Patch file `{params.path}`",
82
+ ):
83
+ return ToolRejectedError()
84
+
73
85
  # Read the file content
74
86
  async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
75
87
  original_content = await f.read()
@@ -6,6 +6,9 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
  from kimi_cli.agent import BuiltinSystemPromptArgs
9
+ from kimi_cli.soul.approval import Approval
10
+ from kimi_cli.tools.file import FileActions
11
+ from kimi_cli.tools.utils import ToolRejectedError
9
12
 
10
13
 
11
14
  class Edit(BaseModel):
@@ -29,9 +32,10 @@ class StrReplaceFile(CallableTool2[Params]):
29
32
  description: str = (Path(__file__).parent / "replace.md").read_text()
30
33
  params: type[Params] = Params
31
34
 
32
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
35
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
33
36
  super().__init__(**kwargs)
34
37
  self._work_dir = builtin_args.KIMI_WORK_DIR
38
+ self._approval = approval
35
39
 
36
40
  def _validate_path(self, path: Path) -> ToolError | None:
37
41
  """Validate that the path is safe to edit."""
@@ -87,6 +91,14 @@ class StrReplaceFile(CallableTool2[Params]):
87
91
  brief="Invalid path",
88
92
  )
89
93
 
94
+ # Request approval
95
+ if not await self._approval.request(
96
+ self.name,
97
+ FileActions.EDIT,
98
+ f"Edit file `{params.path}`",
99
+ ):
100
+ return ToolRejectedError()
101
+
90
102
  # Read the file content
91
103
  async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
92
104
  content = await f.read()
@@ -6,6 +6,9 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
  from kimi_cli.agent import BuiltinSystemPromptArgs
9
+ from kimi_cli.soul.approval import Approval
10
+ from kimi_cli.tools.file import FileActions
11
+ from kimi_cli.tools.utils import ToolRejectedError
9
12
 
10
13
 
11
14
  class Params(BaseModel):
@@ -26,9 +29,10 @@ class WriteFile(CallableTool2[Params]):
26
29
  description: str = (Path(__file__).parent / "write.md").read_text()
27
30
  params: type[Params] = Params
28
31
 
29
- def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
32
+ def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
30
33
  super().__init__(**kwargs)
31
34
  self._work_dir = builtin_args.KIMI_WORK_DIR
35
+ self._approval = approval
32
36
 
33
37
  def _validate_path(self, path: Path) -> ToolError | None:
34
38
  """Validate that the path is safe to write."""
@@ -85,6 +89,14 @@ class WriteFile(CallableTool2[Params]):
85
89
  brief="Invalid write mode",
86
90
  )
87
91
 
92
+ # Request approval
93
+ if not await self._approval.request(
94
+ self.name,
95
+ FileActions.EDIT,
96
+ f"Write file `{params.path}`",
97
+ ):
98
+ return ToolRejectedError()
99
+
88
100
  # Determine file mode for aiofiles
89
101
  file_mode = "w" if params.mode == "overwrite" else "a"
90
102
 
kimi_cli/ui/__init__.py CHANGED
@@ -28,6 +28,7 @@ async def run_soul(
28
28
  the run will be gracefully stopped and a `RunCancelled` will be raised.
29
29
 
30
30
  Raises:
31
+ LLMNotSet: When the LLM is not set.
31
32
  ChatProviderError: When the LLM provider returns an error.
32
33
  MaxStepsReached: When the maximum number of steps is reached.
33
34
  RunCancelled: When the run is cancelled by the cancel event.
@@ -155,6 +155,7 @@ class PrintApp:
155
155
  logger.debug("Visualization loop shutting down")
156
156
 
157
157
  async def _visualize_stream_json(self, wire: Wire, start_position: int):
158
+ # TODO: be aware of context compaction
158
159
  try:
159
160
  async with aiofiles.open(self.soul._context._file_backend, encoding="utf-8") as f:
160
161
  await f.seek(start_position)