kimi-cli 0.44__py3-none-any.whl → 0.78__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.

Files changed (137) hide show
  1. kimi_cli/CHANGELOG.md +349 -40
  2. kimi_cli/__init__.py +6 -0
  3. kimi_cli/acp/AGENTS.md +91 -0
  4. kimi_cli/acp/__init__.py +13 -0
  5. kimi_cli/acp/convert.py +111 -0
  6. kimi_cli/acp/kaos.py +270 -0
  7. kimi_cli/acp/mcp.py +46 -0
  8. kimi_cli/acp/server.py +335 -0
  9. kimi_cli/acp/session.py +445 -0
  10. kimi_cli/acp/tools.py +158 -0
  11. kimi_cli/acp/types.py +13 -0
  12. kimi_cli/agents/default/agent.yaml +4 -4
  13. kimi_cli/agents/default/sub.yaml +2 -1
  14. kimi_cli/agents/default/system.md +79 -21
  15. kimi_cli/agents/okabe/agent.yaml +17 -0
  16. kimi_cli/agentspec.py +53 -25
  17. kimi_cli/app.py +180 -52
  18. kimi_cli/cli/__init__.py +595 -0
  19. kimi_cli/cli/__main__.py +8 -0
  20. kimi_cli/cli/info.py +63 -0
  21. kimi_cli/cli/mcp.py +349 -0
  22. kimi_cli/config.py +153 -17
  23. kimi_cli/constant.py +3 -0
  24. kimi_cli/exception.py +23 -2
  25. kimi_cli/flow/__init__.py +117 -0
  26. kimi_cli/flow/d2.py +376 -0
  27. kimi_cli/flow/mermaid.py +218 -0
  28. kimi_cli/llm.py +129 -23
  29. kimi_cli/metadata.py +32 -7
  30. kimi_cli/platforms.py +262 -0
  31. kimi_cli/prompts/__init__.py +2 -0
  32. kimi_cli/prompts/compact.md +4 -5
  33. kimi_cli/session.py +223 -31
  34. kimi_cli/share.py +2 -0
  35. kimi_cli/skill.py +145 -0
  36. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  37. kimi_cli/skills/skill-creator/SKILL.md +351 -0
  38. kimi_cli/soul/__init__.py +51 -20
  39. kimi_cli/soul/agent.py +213 -85
  40. kimi_cli/soul/approval.py +86 -17
  41. kimi_cli/soul/compaction.py +64 -53
  42. kimi_cli/soul/context.py +38 -5
  43. kimi_cli/soul/denwarenji.py +2 -0
  44. kimi_cli/soul/kimisoul.py +442 -60
  45. kimi_cli/soul/message.py +54 -54
  46. kimi_cli/soul/slash.py +72 -0
  47. kimi_cli/soul/toolset.py +387 -6
  48. kimi_cli/toad.py +74 -0
  49. kimi_cli/tools/AGENTS.md +5 -0
  50. kimi_cli/tools/__init__.py +42 -34
  51. kimi_cli/tools/display.py +25 -0
  52. kimi_cli/tools/dmail/__init__.py +10 -10
  53. kimi_cli/tools/dmail/dmail.md +11 -9
  54. kimi_cli/tools/file/__init__.py +1 -3
  55. kimi_cli/tools/file/glob.py +20 -23
  56. kimi_cli/tools/file/grep.md +1 -1
  57. kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
  58. kimi_cli/tools/file/read.md +24 -6
  59. kimi_cli/tools/file/read.py +134 -50
  60. kimi_cli/tools/file/replace.md +1 -1
  61. kimi_cli/tools/file/replace.py +36 -29
  62. kimi_cli/tools/file/utils.py +282 -0
  63. kimi_cli/tools/file/write.py +43 -22
  64. kimi_cli/tools/multiagent/__init__.py +7 -0
  65. kimi_cli/tools/multiagent/create.md +11 -0
  66. kimi_cli/tools/multiagent/create.py +50 -0
  67. kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
  68. kimi_cli/tools/shell/__init__.py +120 -0
  69. kimi_cli/tools/{bash → shell}/bash.md +1 -2
  70. kimi_cli/tools/shell/powershell.md +25 -0
  71. kimi_cli/tools/test.py +4 -4
  72. kimi_cli/tools/think/__init__.py +2 -2
  73. kimi_cli/tools/todo/__init__.py +14 -8
  74. kimi_cli/tools/utils.py +64 -24
  75. kimi_cli/tools/web/fetch.py +68 -13
  76. kimi_cli/tools/web/search.py +10 -12
  77. kimi_cli/ui/acp/__init__.py +65 -412
  78. kimi_cli/ui/print/__init__.py +37 -49
  79. kimi_cli/ui/print/visualize.py +179 -0
  80. kimi_cli/ui/shell/__init__.py +141 -84
  81. kimi_cli/ui/shell/console.py +2 -0
  82. kimi_cli/ui/shell/debug.py +28 -23
  83. kimi_cli/ui/shell/keyboard.py +5 -1
  84. kimi_cli/ui/shell/prompt.py +220 -194
  85. kimi_cli/ui/shell/replay.py +111 -46
  86. kimi_cli/ui/shell/setup.py +89 -82
  87. kimi_cli/ui/shell/slash.py +422 -0
  88. kimi_cli/ui/shell/update.py +4 -2
  89. kimi_cli/ui/shell/usage.py +271 -0
  90. kimi_cli/ui/shell/visualize.py +574 -72
  91. kimi_cli/ui/wire/__init__.py +267 -0
  92. kimi_cli/ui/wire/jsonrpc.py +142 -0
  93. kimi_cli/ui/wire/protocol.py +1 -0
  94. kimi_cli/utils/__init__.py +0 -0
  95. kimi_cli/utils/aiohttp.py +2 -0
  96. kimi_cli/utils/aioqueue.py +72 -0
  97. kimi_cli/utils/broadcast.py +37 -0
  98. kimi_cli/utils/changelog.py +12 -7
  99. kimi_cli/utils/clipboard.py +12 -0
  100. kimi_cli/utils/datetime.py +37 -0
  101. kimi_cli/utils/environment.py +58 -0
  102. kimi_cli/utils/envvar.py +12 -0
  103. kimi_cli/utils/frontmatter.py +44 -0
  104. kimi_cli/utils/logging.py +7 -6
  105. kimi_cli/utils/message.py +9 -14
  106. kimi_cli/utils/path.py +99 -9
  107. kimi_cli/utils/pyinstaller.py +6 -0
  108. kimi_cli/utils/rich/__init__.py +33 -0
  109. kimi_cli/utils/rich/columns.py +99 -0
  110. kimi_cli/utils/rich/markdown.py +961 -0
  111. kimi_cli/utils/rich/markdown_sample.md +108 -0
  112. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  113. kimi_cli/utils/signals.py +2 -0
  114. kimi_cli/utils/slashcmd.py +124 -0
  115. kimi_cli/utils/string.py +2 -0
  116. kimi_cli/utils/term.py +168 -0
  117. kimi_cli/utils/typing.py +20 -0
  118. kimi_cli/wire/__init__.py +98 -29
  119. kimi_cli/wire/serde.py +45 -0
  120. kimi_cli/wire/types.py +299 -0
  121. kimi_cli-0.78.dist-info/METADATA +200 -0
  122. kimi_cli-0.78.dist-info/RECORD +135 -0
  123. kimi_cli-0.78.dist-info/entry_points.txt +4 -0
  124. kimi_cli/cli.py +0 -250
  125. kimi_cli/soul/runtime.py +0 -96
  126. kimi_cli/tools/bash/__init__.py +0 -99
  127. kimi_cli/tools/file/patch.md +0 -8
  128. kimi_cli/tools/file/patch.py +0 -143
  129. kimi_cli/tools/mcp.py +0 -85
  130. kimi_cli/ui/shell/liveview.py +0 -386
  131. kimi_cli/ui/shell/metacmd.py +0 -262
  132. kimi_cli/wire/message.py +0 -91
  133. kimi_cli-0.44.dist-info/METADATA +0 -188
  134. kimi_cli-0.44.dist-info/RECORD +0 -89
  135. kimi_cli-0.44.dist-info/entry_points.txt +0 -3
  136. /kimi_cli/tools/{task → multiagent}/task.md +0 -0
  137. {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
kimi_cli/soul/kimisoul.py CHANGED
@@ -1,21 +1,28 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
- from collections.abc import Sequence
4
+ from collections.abc import Awaitable, Callable, Sequence
5
+ from contextlib import suppress
6
+ from dataclasses import dataclass
3
7
  from functools import partial
4
- from typing import TYPE_CHECKING
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, Literal
5
10
 
6
11
  import kosong
7
12
  import tenacity
8
13
  from kosong import StepResult
9
- from kosong.base.message import ContentPart, ImageURLPart, Message
10
14
  from kosong.chat_provider import (
11
15
  APIConnectionError,
16
+ APIEmptyResponseError,
12
17
  APIStatusError,
13
18
  APITimeoutError,
14
- ChatProviderError,
15
19
  )
16
- from kosong.tooling import ToolResult
20
+ from kosong.message import Message
17
21
  from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter
18
22
 
23
+ from kimi_cli.flow import FlowEdge, FlowNode, PromptFlow, parse_choice
24
+ from kimi_cli.llm import ModelCapability
25
+ from kimi_cli.skill import Skill, read_skill_text
19
26
  from kimi_cli.soul import (
20
27
  LLMNotSet,
21
28
  LLMNotSupported,
@@ -24,51 +31,87 @@ from kimi_cli.soul import (
24
31
  StatusSnapshot,
25
32
  wire_send,
26
33
  )
27
- from kimi_cli.soul.agent import Agent
34
+ from kimi_cli.soul.agent import Agent, Runtime
28
35
  from kimi_cli.soul.compaction import SimpleCompaction
29
36
  from kimi_cli.soul.context import Context
30
- from kimi_cli.soul.message import system, tool_result_to_messages
31
- from kimi_cli.soul.runtime import Runtime
37
+ from kimi_cli.soul.message import check_message, system, tool_result_to_message
38
+ from kimi_cli.soul.slash import registry as soul_slash_registry
39
+ from kimi_cli.soul.toolset import KimiToolset
32
40
  from kimi_cli.tools.dmail import NAME as SendDMail_NAME
33
41
  from kimi_cli.tools.utils import ToolRejectedError
34
42
  from kimi_cli.utils.logging import logger
35
- from kimi_cli.wire.message import (
43
+ from kimi_cli.utils.slashcmd import SlashCommand, parse_slash_command_call
44
+ from kimi_cli.wire.types import (
45
+ ApprovalRequest,
46
+ ApprovalRequestResolved,
36
47
  CompactionBegin,
37
48
  CompactionEnd,
49
+ ContentPart,
38
50
  StatusUpdate,
39
51
  StepBegin,
40
52
  StepInterrupted,
53
+ TextPart,
54
+ ToolResult,
55
+ TurnBegin,
41
56
  )
42
57
 
58
+ if TYPE_CHECKING:
59
+
60
+ def type_check(soul: KimiSoul):
61
+ _: Soul = soul
62
+
63
+
43
64
  RESERVED_TOKENS = 50_000
44
65
 
66
+ SKILL_COMMAND_PREFIX = "skill:"
67
+ DEFAULT_MAX_FLOW_MOVES = 1000
68
+
69
+
70
+ type StepStopReason = Literal["no_tool_calls", "tool_rejected"]
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class StepOutcome:
75
+ stop_reason: StepStopReason
76
+ assistant_message: Message
77
+
78
+
79
+ type TurnStopReason = StepStopReason
45
80
 
46
- class KimiSoul(Soul):
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class TurnOutcome:
84
+ stop_reason: TurnStopReason
85
+ final_message: Message | None
86
+ step_count: int
87
+
88
+
89
+ class KimiSoul:
47
90
  """The soul of Kimi CLI."""
48
91
 
49
92
  def __init__(
50
93
  self,
51
94
  agent: Agent,
52
- runtime: Runtime,
53
95
  *,
54
96
  context: Context,
97
+ flow: PromptFlow | None = None,
55
98
  ):
56
99
  """
57
100
  Initialize the soul.
58
101
 
59
102
  Args:
60
103
  agent (Agent): The agent to run.
61
- runtime (Runtime): Runtime parameters and states.
62
104
  context (Context): The context of the agent.
63
105
  """
64
106
  self._agent = agent
65
- self._runtime = runtime
66
- self._denwa_renji = runtime.denwa_renji
67
- self._approval = runtime.approval
107
+ self._runtime = agent.runtime
108
+ self._denwa_renji = agent.runtime.denwa_renji
109
+ self._approval = agent.runtime.approval
68
110
  self._context = context
69
- self._loop_control = runtime.config.loop_control
111
+ self._loop_control = agent.runtime.config.loop_control
70
112
  self._compaction = SimpleCompaction() # TODO: maybe configurable and composable
71
113
  self._reserved_tokens = RESERVED_TOKENS
114
+ self._flow_runner = FlowRunner(flow) if flow is not None else None
72
115
  if self._runtime.llm is not None:
73
116
  assert self._reserved_tokens <= self._runtime.llm.max_context_size
74
117
 
@@ -79,17 +122,46 @@ class KimiSoul(Soul):
79
122
  else:
80
123
  self._checkpoint_with_user_message = False
81
124
 
125
+ self._slash_commands = self._build_slash_commands()
126
+ self._slash_command_map = self._index_slash_commands(self._slash_commands)
127
+
82
128
  @property
83
129
  def name(self) -> str:
84
130
  return self._agent.name
85
131
 
86
132
  @property
87
- def model(self) -> str:
133
+ def model_name(self) -> str:
88
134
  return self._runtime.llm.chat_provider.model_name if self._runtime.llm else ""
89
135
 
136
+ @property
137
+ def model_capabilities(self) -> set[ModelCapability] | None:
138
+ if self._runtime.llm is None:
139
+ return None
140
+ return self._runtime.llm.capabilities
141
+
142
+ @property
143
+ def thinking(self) -> bool | None:
144
+ """Whether thinking mode is enabled."""
145
+ if self._runtime.llm is None:
146
+ return None
147
+ if thinking_effort := self._runtime.llm.chat_provider.thinking_effort:
148
+ return thinking_effort != "off"
149
+ return None
150
+
90
151
  @property
91
152
  def status(self) -> StatusSnapshot:
92
- return StatusSnapshot(context_usage=self._context_usage)
153
+ return StatusSnapshot(
154
+ context_usage=self._context_usage,
155
+ yolo_enabled=self._approval.is_yolo(),
156
+ )
157
+
158
+ @property
159
+ def agent(self) -> Agent:
160
+ return self._agent
161
+
162
+ @property
163
+ def runtime(self) -> Runtime:
164
+ return self._runtime
93
165
 
94
166
  @property
95
167
  def context(self) -> Context:
@@ -101,42 +173,164 @@ class KimiSoul(Soul):
101
173
  return self._context.token_count / self._runtime.llm.max_context_size
102
174
  return 0.0
103
175
 
176
+ @property
177
+ def wire_file(self) -> Path:
178
+ return self._runtime.session.wire_file
179
+
104
180
  async def _checkpoint(self):
105
181
  await self._context.checkpoint(self._checkpoint_with_user_message)
106
182
 
183
+ @property
184
+ def available_slash_commands(self) -> list[SlashCommand[Any]]:
185
+ return self._slash_commands
186
+
107
187
  async def run(self, user_input: str | list[ContentPart]):
188
+ user_message = Message(role="user", content=user_input)
189
+ text_input = user_message.extract_text(" ").strip()
190
+
191
+ if command_call := parse_slash_command_call(text_input):
192
+ wire_send(TurnBegin(user_input=user_input))
193
+ command = self._find_slash_command(command_call.name)
194
+ if command is None:
195
+ # this should not happen actually, the shell should have filtered it out
196
+ wire_send(TextPart(text=f'Unknown slash command "/{command_call.name}".'))
197
+ return
198
+
199
+ ret = command.func(self, command_call.args)
200
+ if isinstance(ret, Awaitable):
201
+ await ret
202
+ return
203
+
204
+ if self._loop_control.max_ralph_iterations != 0 and self._flow_runner is None:
205
+ runner = FlowRunner.ralph_loop(
206
+ user_message,
207
+ self._loop_control.max_ralph_iterations,
208
+ )
209
+ await runner.run(self, "")
210
+ return
211
+
212
+ wire_send(TurnBegin(user_input=user_input))
213
+ result = await self._turn(user_message)
214
+ if result.stop_reason == "tool_rejected":
215
+ return
216
+
217
+ async def _turn(self, user_message: Message) -> TurnOutcome:
108
218
  if self._runtime.llm is None:
109
219
  raise LLMNotSet()
110
220
 
111
- if (
112
- isinstance(user_input, list)
113
- and any(isinstance(part, ImageURLPart) for part in user_input)
114
- and not self._runtime.llm.supports_image_in
115
- ):
116
- raise LLMNotSupported(self._runtime.llm, ["image_in"])
221
+ if missing_caps := check_message(user_message, self._runtime.llm.capabilities):
222
+ raise LLMNotSupported(self._runtime.llm, list(missing_caps))
117
223
 
118
224
  await self._checkpoint() # this creates the checkpoint 0 on first run
119
- await self._context.append_message(Message(role="user", content=user_input))
225
+ await self._context.append_message(user_message)
120
226
  logger.debug("Appended user message to context")
121
- await self._agent_loop()
227
+ return await self._agent_loop()
228
+
229
+ def _build_slash_commands(self) -> list[SlashCommand[Any]]:
230
+ commands: list[SlashCommand[Any]] = list(soul_slash_registry.list_commands())
231
+ seen_names = {cmd.name for cmd in commands}
232
+
233
+ for skill in self._runtime.skills.values():
234
+ name = f"{SKILL_COMMAND_PREFIX}{skill.name}"
235
+ if name in seen_names:
236
+ logger.warning(
237
+ "Skipping skill slash command /{name}: name already registered",
238
+ name=name,
239
+ )
240
+ continue
241
+ commands.append(
242
+ SlashCommand(
243
+ name=name,
244
+ func=self._make_skill_runner(skill),
245
+ description=skill.description or "",
246
+ aliases=[],
247
+ )
248
+ )
249
+ seen_names.add(name)
250
+
251
+ if self._flow_runner is not None:
252
+ commands.append(
253
+ SlashCommand(
254
+ name="begin",
255
+ func=self._flow_runner.run,
256
+ description="Start the prompt flow",
257
+ aliases=[],
258
+ )
259
+ )
260
+
261
+ return commands
262
+
263
+ @staticmethod
264
+ def _index_slash_commands(
265
+ commands: list[SlashCommand[Any]],
266
+ ) -> dict[str, SlashCommand[Any]]:
267
+ indexed: dict[str, SlashCommand[Any]] = {}
268
+ for command in commands:
269
+ indexed[command.name] = command
270
+ for alias in command.aliases:
271
+ indexed[alias] = command
272
+ return indexed
273
+
274
+ def _find_slash_command(self, name: str) -> SlashCommand[Any] | None:
275
+ return self._slash_command_map.get(name)
276
+
277
+ def _make_skill_runner(self, skill: Skill) -> Callable[[KimiSoul, str], None | Awaitable[None]]:
278
+ async def _run_skill(soul: KimiSoul, args: str, *, _skill: Skill = skill) -> None:
279
+ skill_text = read_skill_text(_skill)
280
+ if skill_text is None:
281
+ wire_send(
282
+ TextPart(text=f'Failed to load skill "/{SKILL_COMMAND_PREFIX}{_skill.name}".')
283
+ )
284
+ return
285
+ extra = args.strip()
286
+ if extra:
287
+ skill_text = f"{skill_text}\n\nUser request:\n{extra}"
288
+ await soul._turn(Message(role="user", content=skill_text))
122
289
 
123
- async def _agent_loop(self):
290
+ _run_skill.__doc__ = skill.description
291
+ return _run_skill
292
+
293
+ async def _agent_loop(self) -> TurnOutcome:
124
294
  """The main agent loop for one run."""
125
295
  assert self._runtime.llm is not None
296
+ if isinstance(self._agent.toolset, KimiToolset):
297
+ await self._agent.toolset.wait_for_mcp_tools()
126
298
 
127
299
  async def _pipe_approval_to_wire():
128
300
  while True:
129
301
  request = await self._approval.fetch_request()
130
- wire_send(request)
131
-
132
- step_no = 1
302
+ # Here we decouple the wire approval request and the soul approval request.
303
+ wire_request = ApprovalRequest(
304
+ id=request.id,
305
+ action=request.action,
306
+ description=request.description,
307
+ sender=request.sender,
308
+ tool_call_id=request.tool_call_id,
309
+ display=request.display,
310
+ )
311
+ wire_send(wire_request)
312
+ # We wait for the request to be resolved over the wire, which means that,
313
+ # for each soul, we will have only one approval request waiting on the wire
314
+ # at a time. However, be aware that subagents (which have their own souls) may
315
+ # also send approval requests to the root wire.
316
+ resp = await wire_request.wait()
317
+ self._approval.resolve_request(request.id, resp)
318
+ wire_send(ApprovalRequestResolved(request_id=request.id, response=resp))
319
+
320
+ step_no = 0
133
321
  while True:
134
- wire_send(StepBegin(step_no))
322
+ step_no += 1
323
+ if step_no > self._loop_control.max_steps_per_turn:
324
+ raise MaxStepsReached(self._loop_control.max_steps_per_turn)
325
+
326
+ wire_send(StepBegin(n=step_no))
135
327
  approval_task = asyncio.create_task(_pipe_approval_to_wire())
136
328
  # FIXME: It's possible that a subagent's approval task steals approval request
137
329
  # from the main agent. We must ensure that the Task tool will redirect them
138
330
  # to the main wire. See `_SubWire` for more details. Later we need to figure
139
331
  # out a better solution.
332
+ back_to_the_future: BackToTheFuture | None = None
333
+ step_outcome: StepOutcome | None = None
140
334
  try:
141
335
  # compact the context if needed
142
336
  if (
@@ -144,35 +338,46 @@ class KimiSoul(Soul):
144
338
  >= self._runtime.llm.max_context_size
145
339
  ):
146
340
  logger.info("Context too long, compacting...")
147
- wire_send(CompactionBegin())
148
341
  await self.compact_context()
149
- wire_send(CompactionEnd())
150
342
 
151
343
  logger.debug("Beginning step {step_no}", step_no=step_no)
152
344
  await self._checkpoint()
153
345
  self._denwa_renji.set_n_checkpoints(self._context.n_checkpoints)
154
- finished = await self._step()
346
+ step_outcome = await self._step()
155
347
  except BackToTheFuture as e:
156
- await self._context.revert_to(e.checkpoint_id)
157
- await self._checkpoint()
158
- await self._context.append_message(e.messages)
159
- continue
160
- except (ChatProviderError, asyncio.CancelledError):
348
+ back_to_the_future = e
349
+ except Exception:
350
+ # any other exception should interrupt the step
161
351
  wire_send(StepInterrupted())
162
352
  # break the agent loop
163
353
  raise
164
354
  finally:
165
355
  approval_task.cancel() # stop piping approval requests to the wire
356
+ with suppress(asyncio.CancelledError):
357
+ try:
358
+ await approval_task
359
+ except Exception:
360
+ logger.exception("Approval piping task failed")
361
+
362
+ if step_outcome is not None:
363
+ final_message = (
364
+ step_outcome.assistant_message
365
+ if step_outcome.stop_reason == "no_tool_calls"
366
+ else None
367
+ )
368
+ return TurnOutcome(
369
+ stop_reason=step_outcome.stop_reason,
370
+ final_message=final_message,
371
+ step_count=step_no,
372
+ )
373
+
374
+ if back_to_the_future is not None:
375
+ await self._context.revert_to(back_to_the_future.checkpoint_id)
376
+ await self._checkpoint()
377
+ await self._context.append_message(back_to_the_future.messages)
166
378
 
167
- if finished:
168
- return
169
-
170
- step_no += 1
171
- if step_no > self._loop_control.max_steps_per_run:
172
- raise MaxStepsReached(self._loop_control.max_steps_per_run)
173
-
174
- async def _step(self) -> bool:
175
- """Run an single step and return whether the run should be stopped."""
379
+ async def _step(self) -> StepOutcome | None:
380
+ """Run a single step and return a stop outcome, or None to continue."""
176
381
  # already checked in `run`
177
382
  assert self._runtime.llm is not None
178
383
  chat_provider = self._runtime.llm.chat_provider
@@ -197,10 +402,12 @@ class KimiSoul(Soul):
197
402
 
198
403
  result = await _kosong_step_with_retry()
199
404
  logger.debug("Got step result: {result}", result=result)
405
+ status_update = StatusUpdate(token_usage=result.usage, message_id=result.id)
200
406
  if result.usage is not None:
201
407
  # mark the token count for the context before the step
202
408
  await self._context.update_token_count(result.usage.input)
203
- wire_send(StatusUpdate(status=self.status))
409
+ status_update.context_usage = self.status.context_usage
410
+ wire_send(status_update)
204
411
 
205
412
  # wait for all tool results (may be interrupted)
206
413
  results = await result.tool_results()
@@ -209,10 +416,10 @@ class KimiSoul(Soul):
209
416
  # shield the context manipulation from interruption
210
417
  await asyncio.shield(self._grow_context(result, results))
211
418
 
212
- rejected = any(isinstance(result.result, ToolRejectedError) for result in results)
419
+ rejected = any(isinstance(result.return_value, ToolRejectedError) for result in results)
213
420
  if rejected:
214
421
  _ = self._denwa_renji.fetch_pending_dmail()
215
- return True
422
+ return StepOutcome(stop_reason="tool_rejected", assistant_message=result.message)
216
423
 
217
424
  # handle pending D-Mail
218
425
  if dmail := self._denwa_renji.fetch_pending_dmail():
@@ -240,18 +447,32 @@ class KimiSoul(Soul):
240
447
  ],
241
448
  )
242
449
 
243
- return not result.tool_calls
450
+ if result.tool_calls:
451
+ return None
452
+ return StepOutcome(stop_reason="no_tool_calls", assistant_message=result.message)
244
453
 
245
454
  async def _grow_context(self, result: StepResult, tool_results: list[ToolResult]):
246
455
  logger.debug("Growing context with result: {result}", result=result)
456
+
457
+ assert self._runtime.llm is not None
458
+ tool_messages = [tool_result_to_message(tr) for tr in tool_results]
459
+ for tm in tool_messages:
460
+ if missing_caps := check_message(tm, self._runtime.llm.capabilities):
461
+ logger.warning(
462
+ "Tool result message requires unsupported capabilities: {caps}",
463
+ caps=missing_caps,
464
+ )
465
+ raise LLMNotSupported(self._runtime.llm, list(missing_caps))
466
+
247
467
  await self._context.append_message(result.message)
248
468
  if result.usage is not None:
249
469
  await self._context.update_token_count(result.usage.total)
250
470
 
471
+ logger.debug(
472
+ "Appending tool messages to context: {tool_messages}", tool_messages=tool_messages
473
+ )
474
+ await self._context.append_message(tool_messages)
251
475
  # token count of tool results are not available yet
252
- for tool_result in tool_results:
253
- logger.debug("Appending tool result to context: {tool_result}", tool_result=tool_result)
254
- await self._context.append_message(tool_result_to_messages(tool_result))
255
476
 
256
477
  async def compact_context(self) -> None:
257
478
  """
@@ -274,14 +495,16 @@ class KimiSoul(Soul):
274
495
  raise LLMNotSet()
275
496
  return await self._compaction.compact(self._context.history, self._runtime.llm)
276
497
 
498
+ wire_send(CompactionBegin())
277
499
  compacted_messages = await _compact_with_retry()
278
- await self._context.revert_to(0)
500
+ await self._context.clear()
279
501
  await self._checkpoint()
280
502
  await self._context.append_message(compacted_messages)
503
+ wire_send(CompactionEnd())
281
504
 
282
505
  @staticmethod
283
506
  def _is_retryable_error(exception: BaseException) -> bool:
284
- if isinstance(exception, (APIConnectionError, APITimeoutError)):
507
+ if isinstance(exception, (APIConnectionError, APITimeoutError, APIEmptyResponseError)):
285
508
  return True
286
509
  return isinstance(exception, APIStatusError) and exception.status_code in (
287
510
  429, # Too Many Requests
@@ -313,7 +536,166 @@ class BackToTheFuture(Exception):
313
536
  self.messages = messages
314
537
 
315
538
 
316
- if TYPE_CHECKING:
539
+ class FlowRunner:
540
+ def __init__(self, flow: PromptFlow, *, max_moves: int = DEFAULT_MAX_FLOW_MOVES) -> None:
541
+ self._flow = flow
542
+ self._max_moves = max_moves
317
543
 
318
- def type_check(kimi_soul: KimiSoul):
319
- _: Soul = kimi_soul
544
+ @staticmethod
545
+ def ralph_loop(
546
+ user_message: Message,
547
+ max_ralph_iterations: int,
548
+ ) -> FlowRunner:
549
+ prompt_content = list(user_message.content)
550
+ prompt_text = Message(role="user", content=prompt_content).extract_text(" ").strip()
551
+ total_runs = max_ralph_iterations + 1
552
+ if max_ralph_iterations < 0:
553
+ total_runs = 1000000000000000 # effectively infinite
554
+
555
+ nodes: dict[str, FlowNode] = {
556
+ "BEGIN": FlowNode(id="BEGIN", label="BEGIN", kind="begin"),
557
+ "END": FlowNode(id="END", label="END", kind="end"),
558
+ }
559
+ outgoing: dict[str, list[FlowEdge]] = {"BEGIN": [], "END": []}
560
+
561
+ nodes["R1"] = FlowNode(id="R1", label=prompt_content, kind="task")
562
+ nodes["R2"] = FlowNode(
563
+ id="R2",
564
+ label=(
565
+ f"{prompt_text}. (You are running in an automated loop where the same "
566
+ "prompt is fed repeatedly. Only choose STOP when the task is fully complete. "
567
+ "Including it will stop further iterations. If you are not 100% sure, "
568
+ "choose CONTINUE.)"
569
+ ).strip(),
570
+ kind="decision",
571
+ )
572
+ outgoing["R1"] = []
573
+ outgoing["R2"] = []
574
+
575
+ outgoing["BEGIN"].append(FlowEdge(src="BEGIN", dst="R1", label=None))
576
+ outgoing["R1"].append(FlowEdge(src="R1", dst="R2", label=None))
577
+ outgoing["R2"].append(FlowEdge(src="R2", dst="R2", label="CONTINUE"))
578
+ outgoing["R2"].append(FlowEdge(src="R2", dst="END", label="STOP"))
579
+
580
+ flow = PromptFlow(nodes=nodes, outgoing=outgoing, begin_id="BEGIN", end_id="END")
581
+ max_moves = total_runs
582
+ return FlowRunner(flow, max_moves=max_moves)
583
+
584
+ async def run(self, soul: KimiSoul, args: str) -> None:
585
+ if args.strip():
586
+ logger.warning("Prompt flow /begin ignores args: {args}", args=args)
587
+ return
588
+
589
+ current_id = self._flow.begin_id
590
+ moves = 0
591
+ total_steps = 0
592
+ while True:
593
+ node = self._flow.nodes[current_id]
594
+ edges = self._flow.outgoing.get(current_id, [])
595
+
596
+ if node.kind == "end":
597
+ logger.info("Prompt flow reached END node {node_id}", node_id=current_id)
598
+ return
599
+
600
+ if node.kind == "begin":
601
+ if not edges:
602
+ logger.error(
603
+ 'Prompt flow BEGIN node "{node_id}" has no outgoing edges; stopping.',
604
+ node_id=node.id,
605
+ )
606
+ return
607
+ current_id = edges[0].dst
608
+ continue
609
+
610
+ if moves >= self._max_moves:
611
+ raise MaxStepsReached(total_steps)
612
+ next_id, steps_used = await self._execute_flow_node(soul, node, edges)
613
+ total_steps += steps_used
614
+ if next_id is None:
615
+ return
616
+ moves += 1
617
+ current_id = next_id
618
+
619
+ async def _execute_flow_node(
620
+ self,
621
+ soul: KimiSoul,
622
+ node: FlowNode,
623
+ edges: list[FlowEdge],
624
+ ) -> tuple[str | None, int]:
625
+ if not edges:
626
+ logger.error(
627
+ 'Prompt flow node "{node_id}" has no outgoing edges; stopping.',
628
+ node_id=node.id,
629
+ )
630
+ return None, 0
631
+
632
+ base_prompt = self._build_flow_prompt(node, edges)
633
+ prompt = base_prompt
634
+ steps_used = 0
635
+ while True:
636
+ result = await self._flow_turn(soul, prompt)
637
+ steps_used += result.step_count
638
+ if result.stop_reason == "tool_rejected":
639
+ logger.error("Prompt flow stopped after tool rejection.")
640
+ return None, steps_used
641
+
642
+ if node.kind != "decision":
643
+ return edges[0].dst, steps_used
644
+
645
+ choice = (
646
+ parse_choice(result.final_message.extract_text(" "))
647
+ if result.final_message
648
+ else None
649
+ )
650
+ next_id = self._match_flow_edge(edges, choice)
651
+ if next_id is not None:
652
+ return next_id, steps_used
653
+
654
+ options = ", ".join(edge.label or "" for edge in edges)
655
+ logger.warning(
656
+ "Prompt flow invalid choice. Got: {choice}. Available: {options}.",
657
+ choice=choice or "<missing>",
658
+ options=options,
659
+ )
660
+ prompt = (
661
+ f"{base_prompt}\n\n"
662
+ "Your last response did not include a valid choice. "
663
+ "Reply with one of the choices using <choice>...</choice>."
664
+ )
665
+
666
+ @staticmethod
667
+ def _build_flow_prompt(node: FlowNode, edges: list[FlowEdge]) -> str | list[ContentPart]:
668
+ if node.kind != "decision":
669
+ return node.label
670
+
671
+ if not isinstance(node.label, str):
672
+ label_text = Message(role="user", content=node.label).extract_text(" ")
673
+ else:
674
+ label_text = node.label
675
+ choices = [edge.label for edge in edges if edge.label]
676
+ lines = [
677
+ label_text,
678
+ "",
679
+ "Available branches:",
680
+ *(f"- {choice}" for choice in choices),
681
+ "",
682
+ "Reply with a choice using <choice>...</choice>.",
683
+ ]
684
+ return "\n".join(lines)
685
+
686
+ @staticmethod
687
+ def _match_flow_edge(edges: list[FlowEdge], choice: str | None) -> str | None:
688
+ if not choice:
689
+ return None
690
+ for edge in edges:
691
+ if edge.label == choice:
692
+ return edge.dst
693
+ return None
694
+
695
+ @staticmethod
696
+ async def _flow_turn(
697
+ soul: KimiSoul,
698
+ prompt: str | list[ContentPart],
699
+ ) -> TurnOutcome:
700
+ wire_send(TurnBegin(user_input=prompt))
701
+ return await soul._turn(Message(role="user", content=prompt)) # type: ignore[reportPrivateUsage]