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.
- kimi_cli/CHANGELOG.md +349 -40
- kimi_cli/__init__.py +6 -0
- kimi_cli/acp/AGENTS.md +91 -0
- kimi_cli/acp/__init__.py +13 -0
- kimi_cli/acp/convert.py +111 -0
- kimi_cli/acp/kaos.py +270 -0
- kimi_cli/acp/mcp.py +46 -0
- kimi_cli/acp/server.py +335 -0
- kimi_cli/acp/session.py +445 -0
- kimi_cli/acp/tools.py +158 -0
- kimi_cli/acp/types.py +13 -0
- kimi_cli/agents/default/agent.yaml +4 -4
- kimi_cli/agents/default/sub.yaml +2 -1
- kimi_cli/agents/default/system.md +79 -21
- kimi_cli/agents/okabe/agent.yaml +17 -0
- kimi_cli/agentspec.py +53 -25
- kimi_cli/app.py +180 -52
- kimi_cli/cli/__init__.py +595 -0
- kimi_cli/cli/__main__.py +8 -0
- kimi_cli/cli/info.py +63 -0
- kimi_cli/cli/mcp.py +349 -0
- kimi_cli/config.py +153 -17
- kimi_cli/constant.py +3 -0
- kimi_cli/exception.py +23 -2
- kimi_cli/flow/__init__.py +117 -0
- kimi_cli/flow/d2.py +376 -0
- kimi_cli/flow/mermaid.py +218 -0
- kimi_cli/llm.py +129 -23
- kimi_cli/metadata.py +32 -7
- kimi_cli/platforms.py +262 -0
- kimi_cli/prompts/__init__.py +2 -0
- kimi_cli/prompts/compact.md +4 -5
- kimi_cli/session.py +223 -31
- kimi_cli/share.py +2 -0
- kimi_cli/skill.py +145 -0
- kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
- kimi_cli/skills/skill-creator/SKILL.md +351 -0
- kimi_cli/soul/__init__.py +51 -20
- kimi_cli/soul/agent.py +213 -85
- kimi_cli/soul/approval.py +86 -17
- kimi_cli/soul/compaction.py +64 -53
- kimi_cli/soul/context.py +38 -5
- kimi_cli/soul/denwarenji.py +2 -0
- kimi_cli/soul/kimisoul.py +442 -60
- kimi_cli/soul/message.py +54 -54
- kimi_cli/soul/slash.py +72 -0
- kimi_cli/soul/toolset.py +387 -6
- kimi_cli/toad.py +74 -0
- kimi_cli/tools/AGENTS.md +5 -0
- kimi_cli/tools/__init__.py +42 -34
- kimi_cli/tools/display.py +25 -0
- kimi_cli/tools/dmail/__init__.py +10 -10
- kimi_cli/tools/dmail/dmail.md +11 -9
- kimi_cli/tools/file/__init__.py +1 -3
- kimi_cli/tools/file/glob.py +20 -23
- kimi_cli/tools/file/grep.md +1 -1
- kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
- kimi_cli/tools/file/read.md +24 -6
- kimi_cli/tools/file/read.py +134 -50
- kimi_cli/tools/file/replace.md +1 -1
- kimi_cli/tools/file/replace.py +36 -29
- kimi_cli/tools/file/utils.py +282 -0
- kimi_cli/tools/file/write.py +43 -22
- kimi_cli/tools/multiagent/__init__.py +7 -0
- kimi_cli/tools/multiagent/create.md +11 -0
- kimi_cli/tools/multiagent/create.py +50 -0
- kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
- kimi_cli/tools/shell/__init__.py +120 -0
- kimi_cli/tools/{bash → shell}/bash.md +1 -2
- kimi_cli/tools/shell/powershell.md +25 -0
- kimi_cli/tools/test.py +4 -4
- kimi_cli/tools/think/__init__.py +2 -2
- kimi_cli/tools/todo/__init__.py +14 -8
- kimi_cli/tools/utils.py +64 -24
- kimi_cli/tools/web/fetch.py +68 -13
- kimi_cli/tools/web/search.py +10 -12
- kimi_cli/ui/acp/__init__.py +65 -412
- kimi_cli/ui/print/__init__.py +37 -49
- kimi_cli/ui/print/visualize.py +179 -0
- kimi_cli/ui/shell/__init__.py +141 -84
- kimi_cli/ui/shell/console.py +2 -0
- kimi_cli/ui/shell/debug.py +28 -23
- kimi_cli/ui/shell/keyboard.py +5 -1
- kimi_cli/ui/shell/prompt.py +220 -194
- kimi_cli/ui/shell/replay.py +111 -46
- kimi_cli/ui/shell/setup.py +89 -82
- kimi_cli/ui/shell/slash.py +422 -0
- kimi_cli/ui/shell/update.py +4 -2
- kimi_cli/ui/shell/usage.py +271 -0
- kimi_cli/ui/shell/visualize.py +574 -72
- kimi_cli/ui/wire/__init__.py +267 -0
- kimi_cli/ui/wire/jsonrpc.py +142 -0
- kimi_cli/ui/wire/protocol.py +1 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +2 -0
- kimi_cli/utils/aioqueue.py +72 -0
- kimi_cli/utils/broadcast.py +37 -0
- kimi_cli/utils/changelog.py +12 -7
- kimi_cli/utils/clipboard.py +12 -0
- kimi_cli/utils/datetime.py +37 -0
- kimi_cli/utils/environment.py +58 -0
- kimi_cli/utils/envvar.py +12 -0
- kimi_cli/utils/frontmatter.py +44 -0
- kimi_cli/utils/logging.py +7 -6
- kimi_cli/utils/message.py +9 -14
- kimi_cli/utils/path.py +99 -9
- kimi_cli/utils/pyinstaller.py +6 -0
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/columns.py +99 -0
- kimi_cli/utils/rich/markdown.py +961 -0
- kimi_cli/utils/rich/markdown_sample.md +108 -0
- kimi_cli/utils/rich/markdown_sample_short.md +2 -0
- kimi_cli/utils/signals.py +2 -0
- kimi_cli/utils/slashcmd.py +124 -0
- kimi_cli/utils/string.py +2 -0
- kimi_cli/utils/term.py +168 -0
- kimi_cli/utils/typing.py +20 -0
- kimi_cli/wire/__init__.py +98 -29
- kimi_cli/wire/serde.py +45 -0
- kimi_cli/wire/types.py +299 -0
- kimi_cli-0.78.dist-info/METADATA +200 -0
- kimi_cli-0.78.dist-info/RECORD +135 -0
- kimi_cli-0.78.dist-info/entry_points.txt +4 -0
- kimi_cli/cli.py +0 -250
- kimi_cli/soul/runtime.py +0 -96
- kimi_cli/tools/bash/__init__.py +0 -99
- kimi_cli/tools/file/patch.md +0 -8
- kimi_cli/tools/file/patch.py +0 -143
- kimi_cli/tools/mcp.py +0 -85
- kimi_cli/ui/shell/liveview.py +0 -386
- kimi_cli/ui/shell/metacmd.py +0 -262
- kimi_cli/wire/message.py +0 -91
- kimi_cli-0.44.dist-info/METADATA +0 -188
- kimi_cli-0.44.dist-info/RECORD +0 -89
- kimi_cli-0.44.dist-info/entry_points.txt +0 -3
- /kimi_cli/tools/{task → multiagent}/task.md +0 -0
- {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
|
|
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.
|
|
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,
|
|
31
|
-
from kimi_cli.soul.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
+
step_outcome = await self._step()
|
|
155
347
|
except BackToTheFuture as e:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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]
|