kimi-cli 0.35__py3-none-any.whl → 0.52__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.
Files changed (88) hide show
  1. kimi_cli/CHANGELOG.md +165 -0
  2. kimi_cli/__init__.py +0 -374
  3. kimi_cli/agents/{koder → default}/agent.yaml +1 -1
  4. kimi_cli/agents/{koder → default}/system.md +1 -1
  5. kimi_cli/agentspec.py +115 -0
  6. kimi_cli/app.py +208 -0
  7. kimi_cli/cli.py +321 -0
  8. kimi_cli/config.py +33 -16
  9. kimi_cli/constant.py +4 -0
  10. kimi_cli/exception.py +16 -0
  11. kimi_cli/llm.py +144 -3
  12. kimi_cli/metadata.py +6 -69
  13. kimi_cli/prompts/__init__.py +4 -0
  14. kimi_cli/session.py +103 -0
  15. kimi_cli/soul/__init__.py +130 -9
  16. kimi_cli/soul/agent.py +159 -0
  17. kimi_cli/soul/approval.py +5 -6
  18. kimi_cli/soul/compaction.py +106 -0
  19. kimi_cli/soul/context.py +1 -1
  20. kimi_cli/soul/kimisoul.py +180 -80
  21. kimi_cli/soul/message.py +6 -6
  22. kimi_cli/soul/runtime.py +96 -0
  23. kimi_cli/soul/toolset.py +3 -2
  24. kimi_cli/tools/__init__.py +35 -31
  25. kimi_cli/tools/bash/__init__.py +25 -9
  26. kimi_cli/tools/bash/cmd.md +31 -0
  27. kimi_cli/tools/dmail/__init__.py +5 -4
  28. kimi_cli/tools/file/__init__.py +8 -0
  29. kimi_cli/tools/file/glob.md +1 -1
  30. kimi_cli/tools/file/glob.py +4 -4
  31. kimi_cli/tools/file/grep.py +36 -19
  32. kimi_cli/tools/file/patch.py +52 -10
  33. kimi_cli/tools/file/read.py +6 -5
  34. kimi_cli/tools/file/replace.py +16 -4
  35. kimi_cli/tools/file/write.py +16 -4
  36. kimi_cli/tools/mcp.py +7 -4
  37. kimi_cli/tools/task/__init__.py +60 -41
  38. kimi_cli/tools/task/task.md +1 -1
  39. kimi_cli/tools/todo/__init__.py +4 -2
  40. kimi_cli/tools/utils.py +1 -1
  41. kimi_cli/tools/web/fetch.py +2 -1
  42. kimi_cli/tools/web/search.py +13 -12
  43. kimi_cli/ui/__init__.py +0 -68
  44. kimi_cli/ui/acp/__init__.py +67 -38
  45. kimi_cli/ui/print/__init__.py +46 -69
  46. kimi_cli/ui/shell/__init__.py +145 -154
  47. kimi_cli/ui/shell/console.py +27 -1
  48. kimi_cli/ui/shell/debug.py +187 -0
  49. kimi_cli/ui/shell/keyboard.py +183 -0
  50. kimi_cli/ui/shell/metacmd.py +34 -81
  51. kimi_cli/ui/shell/prompt.py +245 -28
  52. kimi_cli/ui/shell/replay.py +104 -0
  53. kimi_cli/ui/shell/setup.py +19 -19
  54. kimi_cli/ui/shell/update.py +11 -5
  55. kimi_cli/ui/shell/visualize.py +576 -0
  56. kimi_cli/ui/wire/README.md +109 -0
  57. kimi_cli/ui/wire/__init__.py +340 -0
  58. kimi_cli/ui/wire/jsonrpc.py +48 -0
  59. kimi_cli/utils/__init__.py +0 -0
  60. kimi_cli/utils/aiohttp.py +10 -0
  61. kimi_cli/utils/changelog.py +6 -2
  62. kimi_cli/utils/clipboard.py +10 -0
  63. kimi_cli/utils/message.py +15 -1
  64. kimi_cli/utils/rich/__init__.py +33 -0
  65. kimi_cli/utils/rich/markdown.py +959 -0
  66. kimi_cli/utils/rich/markdown_sample.md +108 -0
  67. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  68. kimi_cli/utils/signals.py +41 -0
  69. kimi_cli/utils/string.py +8 -0
  70. kimi_cli/utils/term.py +114 -0
  71. kimi_cli/wire/__init__.py +73 -0
  72. kimi_cli/wire/message.py +191 -0
  73. kimi_cli-0.52.dist-info/METADATA +186 -0
  74. kimi_cli-0.52.dist-info/RECORD +99 -0
  75. kimi_cli-0.52.dist-info/entry_points.txt +3 -0
  76. kimi_cli/agent.py +0 -261
  77. kimi_cli/agents/koder/README.md +0 -3
  78. kimi_cli/prompts/metacmds/__init__.py +0 -4
  79. kimi_cli/soul/wire.py +0 -101
  80. kimi_cli/ui/shell/liveview.py +0 -158
  81. kimi_cli/utils/provider.py +0 -64
  82. kimi_cli-0.35.dist-info/METADATA +0 -24
  83. kimi_cli-0.35.dist-info/RECORD +0 -76
  84. kimi_cli-0.35.dist-info/entry_points.txt +0 -3
  85. /kimi_cli/agents/{koder → default}/sub.yaml +0 -0
  86. /kimi_cli/prompts/{metacmds/compact.md → compact.md} +0 -0
  87. /kimi_cli/prompts/{metacmds/init.md → init.md} +0 -0
  88. {kimi_cli-0.35.dist-info → kimi_cli-0.52.dist-info}/WHEEL +0 -0
kimi_cli/soul/approval.py CHANGED
@@ -1,25 +1,26 @@
1
1
  import asyncio
2
2
 
3
3
  from kimi_cli.soul.toolset import get_current_tool_call_or_none
4
- from kimi_cli.soul.wire import ApprovalRequest, ApprovalResponse
5
4
  from kimi_cli.utils.logging import logger
5
+ from kimi_cli.wire.message import ApprovalRequest, ApprovalResponse
6
6
 
7
7
 
8
8
  class Approval:
9
9
  def __init__(self, yolo: bool = False):
10
10
  self._request_queue = asyncio.Queue[ApprovalRequest]()
11
11
  self._yolo = yolo
12
- self._auto_approve_actions = set() # TODO: persist across sessions
12
+ self._auto_approve_actions: set[str] = set() # TODO: persist across sessions
13
13
  """Set of action names that should automatically be approved."""
14
14
 
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)
@@ -59,8 +60,6 @@ class Approval:
59
60
  return True
60
61
  case ApprovalResponse.REJECT:
61
62
  return False
62
- case _:
63
- raise ValueError(f"Unknown approval response: {response}")
64
63
 
65
64
  async def fetch_request(self) -> ApprovalRequest:
66
65
  """
@@ -0,0 +1,106 @@
1
+ from collections.abc import Sequence
2
+ from string import Template
3
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
4
+
5
+ from kosong import generate
6
+ from kosong.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
+
14
+ @runtime_checkable
15
+ class Compaction(Protocol):
16
+ async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Message]:
17
+ """
18
+ Compact a sequence of messages into a new sequence of messages.
19
+
20
+ Args:
21
+ messages (Sequence[Message]): The messages to compact.
22
+ llm (LLM): The LLM to use for compaction.
23
+
24
+ Returns:
25
+ Sequence[Message]: The compacted messages.
26
+
27
+ Raises:
28
+ ChatProviderError: When the chat provider returns an error.
29
+ """
30
+ ...
31
+
32
+
33
+ class SimpleCompaction(Compaction):
34
+ MAX_PRESERVED_MESSAGES = 2
35
+
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 == self.MAX_PRESERVED_MESSAGES:
47
+ preserve_start_index = index
48
+ break
49
+
50
+ if n_preserved < self.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
+ result = 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 result.usage:
83
+ logger.debug(
84
+ "Compaction used {input} input tokens and {output} output tokens",
85
+ input=result.usage.input,
86
+ output=result.usage.output,
87
+ )
88
+
89
+ content: list[ContentPart] = [
90
+ system("Previous context has been compacted. Here is the compaction output:")
91
+ ]
92
+ compacted_msg = result.message
93
+ content.extend(
94
+ [TextPart(text=compacted_msg.content)]
95
+ if isinstance(compacted_msg.content, str)
96
+ else compacted_msg.content
97
+ )
98
+ compacted_messages: list[Message] = [Message(role="assistant", content=content)]
99
+ compacted_messages.extend(to_preserve)
100
+ return compacted_messages
101
+
102
+
103
+ if TYPE_CHECKING:
104
+
105
+ def type_check(simple: SimpleCompaction):
106
+ _: Compaction = simple
kimi_cli/soul/context.py CHANGED
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  import aiofiles
6
6
  import aiofiles.os
7
- from kosong.base.message import Message
7
+ from kosong.message import Message
8
8
 
9
9
  from kimi_cli.soul.message import system
10
10
  from kimi_cli.utils.logging import logger
kimi_cli/soul/kimisoul.py CHANGED
@@ -1,55 +1,86 @@
1
1
  import asyncio
2
+ from collections.abc import Sequence
3
+ from functools import partial
4
+ from typing import TYPE_CHECKING
2
5
 
3
6
  import kosong
4
7
  import tenacity
5
8
  from kosong import StepResult
6
- from kosong.base.message import Message
7
9
  from kosong.chat_provider import (
8
10
  APIConnectionError,
11
+ APIEmptyResponseError,
9
12
  APIStatusError,
10
13
  APITimeoutError,
11
14
  ChatProviderError,
15
+ ThinkingEffort,
12
16
  )
17
+ from kosong.message import ContentPart, ImageURLPart, Message
13
18
  from kosong.tooling import ToolResult
14
19
  from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter
15
20
 
16
- from kimi_cli.agent import Agent, AgentGlobals
17
- from kimi_cli.config import LoopControl
18
- from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul, StatusSnapshot
21
+ from kimi_cli.llm import ModelCapability
22
+ from kimi_cli.soul import (
23
+ LLMNotSet,
24
+ LLMNotSupported,
25
+ MaxStepsReached,
26
+ Soul,
27
+ StatusSnapshot,
28
+ wire_send,
29
+ )
30
+ from kimi_cli.soul.agent import Agent
31
+ from kimi_cli.soul.compaction import SimpleCompaction
19
32
  from kimi_cli.soul.context import Context
20
33
  from kimi_cli.soul.message import system, tool_result_to_messages
21
- from kimi_cli.soul.wire import StatusUpdate, StepBegin, StepInterrupted, Wire, current_wire
34
+ from kimi_cli.soul.runtime import Runtime
22
35
  from kimi_cli.tools.dmail import NAME as SendDMail_NAME
23
36
  from kimi_cli.tools.utils import ToolRejectedError
24
37
  from kimi_cli.utils.logging import logger
38
+ from kimi_cli.wire.message import (
39
+ CompactionBegin,
40
+ CompactionEnd,
41
+ StatusUpdate,
42
+ StepBegin,
43
+ StepInterrupted,
44
+ )
45
+
46
+ if TYPE_CHECKING:
47
+
48
+ def type_check(soul: "KimiSoul"):
49
+ _: Soul = soul
50
+
25
51
 
52
+ RESERVED_TOKENS = 50_000
26
53
 
27
- class KimiSoul:
54
+
55
+ class KimiSoul(Soul):
28
56
  """The soul of Kimi CLI."""
29
57
 
30
58
  def __init__(
31
59
  self,
32
60
  agent: Agent,
33
- agent_globals: AgentGlobals,
61
+ runtime: Runtime,
34
62
  *,
35
63
  context: Context,
36
- loop_control: LoopControl,
37
64
  ):
38
65
  """
39
66
  Initialize the soul.
40
67
 
41
68
  Args:
42
69
  agent (Agent): The agent to run.
43
- agent_globals (AgentGlobals): Global states and parameters.
70
+ runtime (Runtime): Runtime parameters and states.
44
71
  context (Context): The context of the agent.
45
- loop_control (LoopControl): The control parameters for the agent loop.
46
72
  """
47
73
  self._agent = agent
48
- self._agent_globals = agent_globals
49
- self._denwa_renji = agent_globals.denwa_renji
50
- self._approval = agent_globals.approval
74
+ self._runtime = runtime
75
+ self._denwa_renji = runtime.denwa_renji
76
+ self._approval = runtime.approval
51
77
  self._context = context
52
- self._loop_control = loop_control
78
+ self._loop_control = runtime.config.loop_control
79
+ self._compaction = SimpleCompaction() # TODO: maybe configurable and composable
80
+ self._reserved_tokens = RESERVED_TOKENS
81
+ if self._runtime.llm is not None:
82
+ assert self._reserved_tokens <= self._runtime.llm.max_context_size
83
+ self._thinking_effort: ThinkingEffort = "off"
53
84
 
54
85
  for tool in agent.toolset.tools:
55
86
  if tool.name == SendDMail_NAME:
@@ -63,62 +94,106 @@ class KimiSoul:
63
94
  return self._agent.name
64
95
 
65
96
  @property
66
- def model(self) -> str:
67
- return self._agent_globals.llm.chat_provider.model_name if self._agent_globals.llm else ""
97
+ def model_name(self) -> str:
98
+ return self._runtime.llm.chat_provider.model_name if self._runtime.llm else ""
99
+
100
+ @property
101
+ def model_capabilities(self) -> set[ModelCapability] | None:
102
+ if self._runtime.llm is None:
103
+ return None
104
+ return self._runtime.llm.capabilities
68
105
 
69
106
  @property
70
107
  def status(self) -> StatusSnapshot:
71
108
  return StatusSnapshot(context_usage=self._context_usage)
72
109
 
110
+ @property
111
+ def context(self) -> Context:
112
+ return self._context
113
+
73
114
  @property
74
115
  def _context_usage(self) -> float:
75
- if self._agent_globals.llm is not None:
76
- return self._context.token_count / self._agent_globals.llm.max_context_size
116
+ if self._runtime.llm is not None:
117
+ return self._context.token_count / self._runtime.llm.max_context_size
77
118
  return 0.0
78
119
 
120
+ @property
121
+ def thinking(self) -> bool:
122
+ """Whether thinking mode is enabled."""
123
+ return self._thinking_effort != "off"
124
+
125
+ def set_thinking(self, enabled: bool) -> None:
126
+ """
127
+ Enable/disable thinking mode for the soul.
128
+
129
+ Raises:
130
+ LLMNotSet: When the LLM is not set.
131
+ LLMNotSupported: When the LLM does not support thinking mode.
132
+ """
133
+ if self._runtime.llm is None:
134
+ raise LLMNotSet()
135
+ if enabled and "thinking" not in self._runtime.llm.capabilities:
136
+ raise LLMNotSupported(self._runtime.llm, ["thinking"])
137
+ self._thinking_effort = "high" if enabled else "off"
138
+
79
139
  async def _checkpoint(self):
80
140
  await self._context.checkpoint(self._checkpoint_with_user_message)
81
141
 
82
- async def run(self, user_input: str, wire: Wire):
83
- if self._agent_globals.llm is None:
142
+ async def run(self, user_input: str | list[ContentPart]):
143
+ if self._runtime.llm is None:
84
144
  raise LLMNotSet()
85
145
 
146
+ if (
147
+ isinstance(user_input, list)
148
+ and any(isinstance(part, ImageURLPart) for part in user_input)
149
+ and "image_in" not in self._runtime.llm.capabilities
150
+ ):
151
+ raise LLMNotSupported(self._runtime.llm, ["image_in"])
152
+
86
153
  await self._checkpoint() # this creates the checkpoint 0 on first run
87
154
  await self._context.append_message(Message(role="user", content=user_input))
88
155
  logger.debug("Appended user message to context")
89
- wire_token = current_wire.set(wire)
90
- try:
91
- await self._agent_loop(wire)
92
- finally:
93
- current_wire.reset(wire_token)
156
+ await self._agent_loop()
94
157
 
95
- async def _agent_loop(self, wire: Wire):
158
+ async def _agent_loop(self):
96
159
  """The main agent loop for one run."""
160
+ assert self._runtime.llm is not None
97
161
 
98
162
  async def _pipe_approval_to_wire():
99
163
  while True:
100
164
  request = await self._approval.fetch_request()
101
- wire.send(request)
165
+ wire_send(request)
102
166
 
103
167
  step_no = 1
104
168
  while True:
105
- wire.send(StepBegin(step_no))
169
+ wire_send(StepBegin(step_no))
106
170
  approval_task = asyncio.create_task(_pipe_approval_to_wire())
107
171
  # FIXME: It's possible that a subagent's approval task steals approval request
108
172
  # from the main agent. We must ensure that the Task tool will redirect them
109
173
  # to the main wire. See `_SubWire` for more details. Later we need to figure
110
174
  # out a better solution.
111
175
  try:
176
+ # compact the context if needed
177
+ if (
178
+ self._context.token_count + self._reserved_tokens
179
+ >= self._runtime.llm.max_context_size
180
+ ):
181
+ logger.info("Context too long, compacting...")
182
+ wire_send(CompactionBegin())
183
+ await self.compact_context()
184
+ wire_send(CompactionEnd())
185
+
186
+ logger.debug("Beginning step {step_no}", step_no=step_no)
112
187
  await self._checkpoint()
113
188
  self._denwa_renji.set_n_checkpoints(self._context.n_checkpoints)
114
- finished = await self._step(wire)
189
+ finished = await self._step()
115
190
  except BackToTheFuture as e:
116
191
  await self._context.revert_to(e.checkpoint_id)
117
192
  await self._checkpoint()
118
- await self._context.append_message(e.message)
193
+ await self._context.append_message(e.messages)
119
194
  continue
120
195
  except (ChatProviderError, asyncio.CancelledError):
121
- wire.send(StepInterrupted())
196
+ wire_send(StepInterrupted())
122
197
  # break the agent loop
123
198
  raise
124
199
  finally:
@@ -131,34 +206,15 @@ class KimiSoul:
131
206
  if step_no > self._loop_control.max_steps_per_run:
132
207
  raise MaxStepsReached(self._loop_control.max_steps_per_run)
133
208
 
134
- async def _step(self, wire: Wire) -> bool:
209
+ async def _step(self) -> bool:
135
210
  """Run an single step and return whether the run should be stopped."""
136
211
  # already checked in `run`
137
- assert self._agent_globals.llm is not None
138
- chat_provider = self._agent_globals.llm.chat_provider
139
-
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
- )
212
+ assert self._runtime.llm is not None
213
+ chat_provider = self._runtime.llm.chat_provider
158
214
 
159
215
  @tenacity.retry(
160
- retry=retry_if_exception(_is_retryable_error),
161
- before_sleep=_retry_log,
216
+ retry=retry_if_exception(self._is_retryable_error),
217
+ before_sleep=partial(self._retry_log, "step"),
162
218
  wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5),
163
219
  stop=stop_after_attempt(self._loop_control.max_retries_per_step),
164
220
  reraise=True,
@@ -166,12 +222,12 @@ class KimiSoul:
166
222
  async def _kosong_step_with_retry() -> StepResult:
167
223
  # run an LLM step (may be interrupted)
168
224
  return await kosong.step(
169
- chat_provider,
225
+ chat_provider.with_thinking(self._thinking_effort),
170
226
  self._agent.system_prompt,
171
227
  self._agent.toolset,
172
228
  self._context.history,
173
- on_message_part=wire.send,
174
- on_tool_result=wire.send,
229
+ on_message_part=wire_send,
230
+ on_tool_result=wire_send,
175
231
  )
176
232
 
177
233
  result = await _kosong_step_with_retry()
@@ -179,7 +235,7 @@ class KimiSoul:
179
235
  if result.usage is not None:
180
236
  # mark the token count for the context before the step
181
237
  await self._context.update_token_count(result.usage.input)
182
- wire.send(StatusUpdate(status=self.status))
238
+ wire_send(StatusUpdate(status=self.status))
183
239
 
184
240
  # wait for all tool results (may be interrupted)
185
241
  results = await result.tool_results()
@@ -202,19 +258,21 @@ class KimiSoul:
202
258
  # raise to let the main loop take us back to the future
203
259
  raise BackToTheFuture(
204
260
  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
- ),
261
+ [
262
+ Message(
263
+ role="user",
264
+ content=[
265
+ system(
266
+ "You just got a D-Mail from your future self. "
267
+ "It is likely that your future self has already done "
268
+ "something in the current working directory. Please read "
269
+ "the D-Mail and decide what to do next. You MUST NEVER "
270
+ "mention to the user about this information. "
271
+ f"D-Mail content:\n\n{dmail.message.strip()}"
272
+ )
273
+ ],
274
+ )
275
+ ],
218
276
  )
219
277
 
220
278
  return not result.tool_calls
@@ -230,6 +288,54 @@ class KimiSoul:
230
288
  logger.debug("Appending tool result to context: {tool_result}", tool_result=tool_result)
231
289
  await self._context.append_message(tool_result_to_messages(tool_result))
232
290
 
291
+ async def compact_context(self) -> None:
292
+ """
293
+ Compact the context.
294
+
295
+ Raises:
296
+ LLMNotSet: When the LLM is not set.
297
+ ChatProviderError: When the chat provider returns an error.
298
+ """
299
+
300
+ @tenacity.retry(
301
+ retry=retry_if_exception(self._is_retryable_error),
302
+ before_sleep=partial(self._retry_log, "compaction"),
303
+ wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5),
304
+ stop=stop_after_attempt(self._loop_control.max_retries_per_step),
305
+ reraise=True,
306
+ )
307
+ async def _compact_with_retry() -> Sequence[Message]:
308
+ if self._runtime.llm is None:
309
+ raise LLMNotSet()
310
+ return await self._compaction.compact(self._context.history, self._runtime.llm)
311
+
312
+ compacted_messages = await _compact_with_retry()
313
+ await self._context.revert_to(0)
314
+ await self._checkpoint()
315
+ await self._context.append_message(compacted_messages)
316
+
317
+ @staticmethod
318
+ def _is_retryable_error(exception: BaseException) -> bool:
319
+ if isinstance(exception, (APIConnectionError, APITimeoutError, APIEmptyResponseError)):
320
+ return True
321
+ return isinstance(exception, APIStatusError) and exception.status_code in (
322
+ 429, # Too Many Requests
323
+ 500, # Internal Server Error
324
+ 502, # Bad Gateway
325
+ 503, # Service Unavailable
326
+ )
327
+
328
+ @staticmethod
329
+ def _retry_log(name: str, retry_state: RetryCallState):
330
+ logger.info(
331
+ "Retrying {name} for the {n} time. Waiting {sleep} seconds.",
332
+ name=name,
333
+ n=retry_state.attempt_number,
334
+ sleep=retry_state.next_action.sleep
335
+ if retry_state.next_action is not None
336
+ else "unknown",
337
+ )
338
+
233
339
 
234
340
  class BackToTheFuture(Exception):
235
341
  """
@@ -237,12 +343,6 @@ class BackToTheFuture(Exception):
237
343
  The main agent loop should catch this exception and handle it.
238
344
  """
239
345
 
240
- def __init__(self, checkpoint_id: int, message: Message):
346
+ def __init__(self, checkpoint_id: int, messages: Sequence[Message]):
241
347
  self.checkpoint_id = checkpoint_id
242
- self.message = message
243
-
244
-
245
- def __static_type_check(
246
- kimi_soul: KimiSoul,
247
- ):
248
- _: Soul = kimi_soul
348
+ self.messages = messages
kimi_cli/soul/message.py CHANGED
@@ -1,4 +1,4 @@
1
- from kosong.base.message import ContentPart, Message, TextPart
1
+ from kosong.message import ContentPart, Message, TextPart
2
2
  from kosong.tooling import ToolError, ToolOk, ToolResult
3
3
  from kosong.tooling.error import ToolRuntimeError
4
4
 
@@ -14,7 +14,7 @@ def tool_result_to_messages(tool_result: ToolResult) -> list[Message]:
14
14
  message = tool_result.result.message
15
15
  if isinstance(tool_result.result, ToolRuntimeError):
16
16
  message += "\nThis is an unexpected error and the tool is probably not working."
17
- content = [system(message)]
17
+ content: list[ContentPart] = [system(f"ERROR: {message}")]
18
18
  if tool_result.result.output:
19
19
  content.append(TextPart(text=tool_result.result.output))
20
20
  return [
@@ -26,8 +26,8 @@ def tool_result_to_messages(tool_result: ToolResult) -> list[Message]:
26
26
  ]
27
27
 
28
28
  content = tool_ok_to_message_content(tool_result.result)
29
- text_parts = []
30
- non_text_parts = []
29
+ text_parts: list[ContentPart] = []
30
+ non_text_parts: list[ContentPart] = []
31
31
  for part in content:
32
32
  if isinstance(part, TextPart):
33
33
  text_parts.append(part)
@@ -60,7 +60,7 @@ def tool_result_to_messages(tool_result: ToolResult) -> list[Message]:
60
60
 
61
61
  def tool_ok_to_message_content(result: ToolOk) -> list[ContentPart]:
62
62
  """Convert a tool return value to a list of message content parts."""
63
- content = []
63
+ content: list[ContentPart] = []
64
64
  if result.message:
65
65
  content.append(system(result.message))
66
66
  match output := result.output:
@@ -70,7 +70,7 @@ def tool_ok_to_message_content(result: ToolOk) -> list[ContentPart]:
70
70
  case ContentPart():
71
71
  content.append(output)
72
72
  case _:
73
- content.extend(list(output))
73
+ content.extend(output)
74
74
  if not content:
75
75
  content.append(system("Tool output is empty."))
76
76
  return content