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/agent.py CHANGED
@@ -1,41 +1,211 @@
1
- import importlib
2
- import inspect
1
+ from __future__ import annotations
2
+
3
+ import asyncio
3
4
  import string
5
+ from collections.abc import Mapping
6
+ from dataclasses import asdict, dataclass
7
+ from datetime import datetime
4
8
  from pathlib import Path
5
- from typing import Any, NamedTuple
9
+ from typing import TYPE_CHECKING, Any
6
10
 
7
- from kosong.tooling import CallableTool, CallableTool2, Toolset
11
+ import pydantic
12
+ from kaos.path import KaosPath
13
+ from kosong.tooling import Toolset
8
14
 
9
- from kimi_cli.agentspec import ResolvedAgentSpec, load_agent_spec
15
+ from kimi_cli.agentspec import load_agent_spec
10
16
  from kimi_cli.config import Config
17
+ from kimi_cli.exception import MCPConfigError
18
+ from kimi_cli.llm import LLM
11
19
  from kimi_cli.session import Session
20
+ from kimi_cli.skill import (
21
+ Skill,
22
+ discover_skills_from_roots,
23
+ get_builtin_skills_dir,
24
+ get_claude_skills_dir,
25
+ get_skills_dir,
26
+ index_skills,
27
+ )
12
28
  from kimi_cli.soul.approval import Approval
13
29
  from kimi_cli.soul.denwarenji import DenwaRenji
14
- from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime
15
- from kimi_cli.soul.toolset import CustomToolset
30
+ from kimi_cli.soul.toolset import KimiToolset
31
+ from kimi_cli.utils.environment import Environment
16
32
  from kimi_cli.utils.logging import logger
33
+ from kimi_cli.utils.path import list_directory
34
+
35
+ if TYPE_CHECKING:
36
+ from fastmcp.mcp_config import MCPConfig
37
+
38
+
39
+ @dataclass(frozen=True, slots=True, kw_only=True)
40
+ class BuiltinSystemPromptArgs:
41
+ """Builtin system prompt arguments."""
42
+
43
+ KIMI_NOW: str
44
+ """The current datetime."""
45
+ KIMI_WORK_DIR: KaosPath
46
+ """The absolute path of current working directory."""
47
+ KIMI_WORK_DIR_LS: str
48
+ """The directory listing of current working directory."""
49
+ KIMI_AGENTS_MD: str # TODO: move to first message from system prompt
50
+ """The content of AGENTS.md."""
51
+ KIMI_SKILLS: str
52
+ """Formatted information about available skills."""
53
+
54
+
55
+ async def load_agents_md(work_dir: KaosPath) -> str | None:
56
+ paths = [
57
+ work_dir / "AGENTS.md",
58
+ work_dir / "agents.md",
59
+ ]
60
+ for path in paths:
61
+ if await path.is_file():
62
+ logger.info("Loaded agents.md: {path}", path=path)
63
+ return (await path.read_text()).strip()
64
+ logger.info("No AGENTS.md found in {work_dir}", work_dir=work_dir)
65
+ return None
66
+
67
+
68
+ @dataclass(slots=True, kw_only=True)
69
+ class Runtime:
70
+ """Agent runtime."""
71
+
72
+ config: Config
73
+ llm: LLM | None # we do not freeze the `Runtime` dataclass because LLM can be changed
74
+ session: Session
75
+ builtin_args: BuiltinSystemPromptArgs
76
+ denwa_renji: DenwaRenji
77
+ approval: Approval
78
+ labor_market: LaborMarket
79
+ environment: Environment
80
+ skills: dict[str, Skill]
81
+
82
+ @staticmethod
83
+ async def create(
84
+ config: Config,
85
+ llm: LLM | None,
86
+ session: Session,
87
+ yolo: bool,
88
+ skills_dir: Path | None = None,
89
+ ) -> Runtime:
90
+ ls_output, agents_md, environment = await asyncio.gather(
91
+ list_directory(session.work_dir),
92
+ load_agents_md(session.work_dir),
93
+ Environment.detect(),
94
+ )
95
+
96
+ # Discover and format skills
97
+ builtin_skills_dir = get_builtin_skills_dir()
98
+ if skills_dir is None:
99
+ skills_dir = get_skills_dir()
100
+ if not skills_dir.is_dir() and (claude_skills_dir := get_claude_skills_dir()).is_dir():
101
+ skills_dir = claude_skills_dir
102
+ skills_roots = [builtin_skills_dir, skills_dir]
103
+ skills = discover_skills_from_roots(skills_roots)
104
+ skills_by_name = index_skills(skills)
105
+ logger.info("Discovered {count} skill(s)", count=len(skills))
106
+ skills_formatted = "\n".join(
107
+ (
108
+ f"- {skill.name}\n"
109
+ f" - Path: {skill.skill_md_file}\n"
110
+ f" - Description: {skill.description}"
111
+ )
112
+ for skill in skills
113
+ )
114
+
115
+ return Runtime(
116
+ config=config,
117
+ llm=llm,
118
+ session=session,
119
+ builtin_args=BuiltinSystemPromptArgs(
120
+ KIMI_NOW=datetime.now().astimezone().isoformat(),
121
+ KIMI_WORK_DIR=session.work_dir,
122
+ KIMI_WORK_DIR_LS=ls_output,
123
+ KIMI_AGENTS_MD=agents_md or "",
124
+ KIMI_SKILLS=skills_formatted or "No skills found.",
125
+ ),
126
+ denwa_renji=DenwaRenji(),
127
+ approval=Approval(yolo=yolo),
128
+ labor_market=LaborMarket(),
129
+ environment=environment,
130
+ skills=skills_by_name,
131
+ )
132
+
133
+ def copy_for_fixed_subagent(self) -> Runtime:
134
+ """Clone runtime for fixed subagent."""
135
+ return Runtime(
136
+ config=self.config,
137
+ llm=self.llm,
138
+ session=self.session,
139
+ builtin_args=self.builtin_args,
140
+ denwa_renji=DenwaRenji(), # subagent must have its own DenwaRenji
141
+ approval=self.approval,
142
+ labor_market=LaborMarket(), # fixed subagent has its own LaborMarket
143
+ environment=self.environment,
144
+ skills=self.skills,
145
+ )
17
146
 
147
+ def copy_for_dynamic_subagent(self) -> Runtime:
148
+ """Clone runtime for dynamic subagent."""
149
+ return Runtime(
150
+ config=self.config,
151
+ llm=self.llm,
152
+ session=self.session,
153
+ builtin_args=self.builtin_args,
154
+ denwa_renji=DenwaRenji(), # subagent must have its own DenwaRenji
155
+ approval=self.approval,
156
+ labor_market=self.labor_market, # dynamic subagent shares LaborMarket with main agent
157
+ environment=self.environment,
158
+ skills=self.skills,
159
+ )
18
160
 
19
- class Agent(NamedTuple):
161
+
162
+ @dataclass(frozen=True, slots=True, kw_only=True)
163
+ class Agent:
20
164
  """The loaded agent."""
21
165
 
22
166
  name: str
23
167
  system_prompt: str
24
168
  toolset: Toolset
169
+ runtime: Runtime
170
+ """Each agent has its own runtime, which should be derived from its main agent."""
171
+
172
+
173
+ class LaborMarket:
174
+ def __init__(self):
175
+ self.fixed_subagents: dict[str, Agent] = {}
176
+ self.fixed_subagent_descs: dict[str, str] = {}
177
+ self.dynamic_subagents: dict[str, Agent] = {}
178
+
179
+ @property
180
+ def subagents(self) -> Mapping[str, Agent]:
181
+ """Get all subagents in the labor market."""
182
+ return {**self.fixed_subagents, **self.dynamic_subagents}
183
+
184
+ def add_fixed_subagent(self, name: str, agent: Agent, description: str):
185
+ """Add a fixed subagent."""
186
+ self.fixed_subagents[name] = agent
187
+ self.fixed_subagent_descs[name] = description
188
+
189
+ def add_dynamic_subagent(self, name: str, agent: Agent):
190
+ """Add a dynamic subagent."""
191
+ self.dynamic_subagents[name] = agent
25
192
 
26
193
 
27
194
  async def load_agent(
28
195
  agent_file: Path,
29
196
  runtime: Runtime,
30
197
  *,
31
- mcp_configs: list[dict[str, Any]],
198
+ mcp_configs: list[MCPConfig] | list[dict[str, Any]],
32
199
  ) -> Agent:
33
200
  """
34
201
  Load agent from specification file.
35
202
 
36
203
  Raises:
37
- FileNotFoundError: If the agent spec file does not exist.
38
- AgentSpecError: If the agent spec is not valid.
204
+ FileNotFoundError: When the agent file is not found.
205
+ AgentSpecError(KimiCLIException, ValueError): When the agent specification is invalid.
206
+ InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded.
207
+ MCPConfigError(KimiCLIException, ValueError): When any MCP configuration is invalid.
208
+ MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be connected.
39
209
  """
40
210
  logger.info("Loading agent: {agent_file}", agent_file=agent_file)
41
211
  agent_spec = load_agent_spec(agent_file)
@@ -46,32 +216,56 @@ async def load_agent(
46
216
  runtime.builtin_args,
47
217
  )
48
218
 
219
+ # load subagents before loading tools because Task tool depends on LaborMarket on initialization
220
+ for subagent_name, subagent_spec in agent_spec.subagents.items():
221
+ logger.debug("Loading subagent: {subagent_name}", subagent_name=subagent_name)
222
+ subagent = await load_agent(
223
+ subagent_spec.path,
224
+ runtime.copy_for_fixed_subagent(),
225
+ mcp_configs=mcp_configs,
226
+ )
227
+ runtime.labor_market.add_fixed_subagent(subagent_name, subagent, subagent_spec.description)
228
+
229
+ toolset = KimiToolset()
49
230
  tool_deps = {
50
- ResolvedAgentSpec: agent_spec,
231
+ KimiToolset: toolset,
51
232
  Runtime: runtime,
233
+ # TODO: remove all the following dependencies and use Runtime instead
52
234
  Config: runtime.config,
53
235
  BuiltinSystemPromptArgs: runtime.builtin_args,
54
236
  Session: runtime.session,
55
237
  DenwaRenji: runtime.denwa_renji,
56
238
  Approval: runtime.approval,
239
+ LaborMarket: runtime.labor_market,
240
+ Environment: runtime.environment,
57
241
  }
58
242
  tools = agent_spec.tools
59
243
  if agent_spec.exclude_tools:
60
244
  logger.debug("Excluding tools: {tools}", tools=agent_spec.exclude_tools)
61
245
  tools = [tool for tool in tools if tool not in agent_spec.exclude_tools]
62
- toolset = CustomToolset()
63
- bad_tools = _load_tools(toolset, tools, tool_deps)
64
- if bad_tools:
65
- raise ValueError(f"Invalid tools: {bad_tools}")
246
+ toolset.load_tools(tools, tool_deps)
66
247
 
67
- assert isinstance(toolset, CustomToolset)
68
248
  if mcp_configs:
69
- await _load_mcp_tools(toolset, mcp_configs)
249
+ validated_mcp_configs: list[MCPConfig] = []
250
+ if mcp_configs:
251
+ from fastmcp.mcp_config import MCPConfig
252
+
253
+ for mcp_config in mcp_configs:
254
+ try:
255
+ validated_mcp_configs.append(
256
+ mcp_config
257
+ if isinstance(mcp_config, MCPConfig)
258
+ else MCPConfig.model_validate(mcp_config)
259
+ )
260
+ except pydantic.ValidationError as e:
261
+ raise MCPConfigError(f"Invalid MCP config: {e}") from e
262
+ await toolset.load_mcp_tools(validated_mcp_configs, runtime)
70
263
 
71
264
  return Agent(
72
265
  name=agent_spec.name,
73
266
  system_prompt=system_prompt,
74
267
  toolset=toolset,
268
+ runtime=runtime,
75
269
  )
76
270
 
77
271
 
@@ -85,70 +279,4 @@ def _load_system_prompt(
85
279
  builtin_args=builtin_args,
86
280
  spec_args=args,
87
281
  )
88
- return string.Template(system_prompt).substitute(builtin_args._asdict(), **args)
89
-
90
-
91
- type ToolType = CallableTool | CallableTool2[Any]
92
- # TODO: move this to kosong.tooling.simple
93
-
94
-
95
- def _load_tools(
96
- toolset: CustomToolset,
97
- tool_paths: list[str],
98
- dependencies: dict[type[Any], Any],
99
- ) -> list[str]:
100
- bad_tools: list[str] = []
101
- for tool_path in tool_paths:
102
- tool = _load_tool(tool_path, dependencies)
103
- if tool:
104
- toolset += tool
105
- else:
106
- bad_tools.append(tool_path)
107
- logger.info("Loaded tools: {tools}", tools=[tool.name for tool in toolset.tools])
108
- if bad_tools:
109
- logger.error("Bad tools: {bad_tools}", bad_tools=bad_tools)
110
- return bad_tools
111
-
112
-
113
- def _load_tool(tool_path: str, dependencies: dict[type[Any], Any]) -> ToolType | None:
114
- logger.debug("Loading tool: {tool_path}", tool_path=tool_path)
115
- module_name, class_name = tool_path.rsplit(":", 1)
116
- try:
117
- module = importlib.import_module(module_name)
118
- except ImportError:
119
- return None
120
- cls = getattr(module, class_name, None)
121
- if cls is None:
122
- return None
123
- args: list[type[Any]] = []
124
- for param in inspect.signature(cls).parameters.values():
125
- if param.kind == inspect.Parameter.KEYWORD_ONLY:
126
- # once we encounter a keyword-only parameter, we stop injecting dependencies
127
- break
128
- # all positional parameters should be dependencies to be injected
129
- if param.annotation not in dependencies:
130
- raise ValueError(f"Tool dependency not found: {param.annotation}")
131
- args.append(dependencies[param.annotation])
132
- return cls(*args)
133
-
134
-
135
- async def _load_mcp_tools(
136
- toolset: CustomToolset,
137
- mcp_configs: list[dict[str, Any]],
138
- ):
139
- """
140
- Raises:
141
- ValueError: If the MCP config is not valid.
142
- RuntimeError: If the MCP server cannot be connected.
143
- """
144
- import fastmcp
145
-
146
- from kimi_cli.tools.mcp import MCPTool
147
-
148
- for mcp_config in mcp_configs:
149
- logger.info("Loading MCP tools from: {mcp_config}", mcp_config=mcp_config)
150
- client = fastmcp.Client(mcp_config)
151
- async with client:
152
- for tool in await client.list_tools():
153
- toolset += MCPTool(tool, client)
154
- return toolset
282
+ return string.Template(system_prompt).substitute(asdict(builtin_args), **args)
kimi_cli/soul/approval.py CHANGED
@@ -1,13 +1,33 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
4
+ import uuid
5
+ from dataclasses import dataclass
6
+ from typing import Literal
2
7
 
3
8
  from kimi_cli.soul.toolset import get_current_tool_call_or_none
9
+ from kimi_cli.utils.aioqueue import Queue
4
10
  from kimi_cli.utils.logging import logger
5
- from kimi_cli.wire.message import ApprovalRequest, ApprovalResponse
11
+ from kimi_cli.wire.types import DisplayBlock
12
+
13
+
14
+ @dataclass(frozen=True, slots=True, kw_only=True)
15
+ class Request:
16
+ id: str
17
+ tool_call_id: str
18
+ sender: str
19
+ action: str
20
+ description: str
21
+ display: list[DisplayBlock]
22
+
23
+
24
+ type Response = Literal["approve", "approve_for_session", "reject"]
6
25
 
7
26
 
8
27
  class Approval:
9
28
  def __init__(self, yolo: bool = False):
10
- self._request_queue = asyncio.Queue[ApprovalRequest]()
29
+ self._request_queue = Queue[Request]()
30
+ self._requests: dict[str, tuple[Request, asyncio.Future[bool]]] = {}
11
31
  self._yolo = yolo
12
32
  self._auto_approve_actions: set[str] = set() # TODO: persist across sessions
13
33
  """Set of action names that should automatically be approved."""
@@ -15,7 +35,16 @@ class Approval:
15
35
  def set_yolo(self, yolo: bool) -> None:
16
36
  self._yolo = yolo
17
37
 
18
- async def request(self, sender: str, action: str, description: str) -> bool:
38
+ def is_yolo(self) -> bool:
39
+ return self._yolo
40
+
41
+ async def request(
42
+ self,
43
+ sender: str,
44
+ action: str,
45
+ description: str,
46
+ display: list[DisplayBlock] | None = None,
47
+ ) -> bool:
19
48
  """
20
49
  Request approval for the given action. Intended to be called by tools.
21
50
 
@@ -48,21 +77,61 @@ class Approval:
48
77
  if action in self._auto_approve_actions:
49
78
  return True
50
79
 
51
- request = ApprovalRequest(tool_call.id, sender, action, description)
80
+ request = Request(
81
+ id=str(uuid.uuid4()),
82
+ tool_call_id=tool_call.id,
83
+ sender=sender,
84
+ action=action,
85
+ description=description,
86
+ display=display or [],
87
+ )
88
+ approved_future = asyncio.Future[bool]()
52
89
  self._request_queue.put_nowait(request)
53
- response = await request.wait()
54
- logger.debug("Received approval response: {response}", response=response)
55
- match response:
56
- case ApprovalResponse.APPROVE:
57
- return True
58
- case ApprovalResponse.APPROVE_FOR_SESSION:
59
- self._auto_approve_actions.add(action)
60
- return True
61
- case ApprovalResponse.REJECT:
62
- return False
63
-
64
- async def fetch_request(self) -> ApprovalRequest:
90
+ self._requests[request.id] = (request, approved_future)
91
+ return await approved_future
92
+
93
+ async def fetch_request(self) -> Request:
65
94
  """
66
95
  Fetch an approval request from the queue. Intended to be called by the soul.
67
96
  """
68
- return await self._request_queue.get()
97
+ while True:
98
+ request = await self._request_queue.get()
99
+ if request.action in self._auto_approve_actions:
100
+ # the action is not auto-approved when the request was created, but now it should be
101
+ logger.debug(
102
+ "Auto-approving previously requested action: {action}", action=request.action
103
+ )
104
+ self.resolve_request(request.id, "approve")
105
+ continue
106
+
107
+ return request
108
+
109
+ def resolve_request(self, request_id: str, response: Response) -> None:
110
+ """
111
+ Resolve an approval request with the given response. Intended to be called by the soul.
112
+
113
+ Args:
114
+ request_id (str): The ID of the request to resolve.
115
+ response (Response): The response to the request.
116
+
117
+ Raises:
118
+ KeyError: If there is no pending request with the given ID.
119
+ """
120
+ request_tuple = self._requests.pop(request_id, None)
121
+ if request_tuple is None:
122
+ raise KeyError(f"No pending request with ID {request_id}")
123
+ request, future = request_tuple
124
+
125
+ logger.debug(
126
+ "Received approval response for request {request_id}: {response}",
127
+ request_id=request_id,
128
+ response=response,
129
+ )
130
+ match response:
131
+ case "approve":
132
+ future.set_result(True)
133
+ case "approve_for_session":
134
+ self._auto_approve_actions.add(request.action)
135
+ future.set_result(True)
136
+ case "reject":
137
+ future.set_result(False)
@@ -1,14 +1,17 @@
1
+ from __future__ import annotations
2
+
1
3
  from collections.abc import Sequence
2
- from string import Template
3
- from typing import TYPE_CHECKING, Protocol, runtime_checkable
4
+ from typing import TYPE_CHECKING, NamedTuple, Protocol, runtime_checkable
4
5
 
5
- from kosong.base import generate
6
- from kosong.base.message import ContentPart, Message, TextPart
6
+ import kosong
7
+ from kosong.message import Message
8
+ from kosong.tooling.empty import EmptyToolset
7
9
 
8
10
  import kimi_cli.prompts as prompts
9
11
  from kimi_cli.llm import LLM
10
12
  from kimi_cli.soul.message import system
11
13
  from kimi_cli.utils.logging import logger
14
+ from kimi_cli.wire.types import ContentPart, TextPart, ThinkPart
12
15
 
13
16
 
14
17
  @runtime_checkable
@@ -30,76 +33,84 @@ class Compaction(Protocol):
30
33
  ...
31
34
 
32
35
 
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
36
+ if TYPE_CHECKING:
40
37
 
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
38
+ def type_check(simple: SimpleCompaction):
39
+ _: Compaction = simple
49
40
 
50
- if n_preserved < self.MAX_PRESERVED_MESSAGES:
51
- return history
52
41
 
53
- to_compact = history[:preserve_start_index]
54
- to_preserve = history[preserve_start_index:]
42
+ class SimpleCompaction:
43
+ def __init__(self, max_preserved_messages: int = 2) -> None:
44
+ self.max_preserved_messages = max_preserved_messages
55
45
 
56
- if not to_compact:
57
- # Let's hope this won't exceed the context size limit
46
+ async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Message]:
47
+ compact_message, to_preserve = self.prepare(messages)
48
+ if compact_message is None:
58
49
  return to_preserve
59
50
 
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
51
+ # Call kosong.step to get the compacted context
74
52
  # TODO: set max completion tokens
75
53
  logger.debug("Compacting context...")
76
- compacted_msg, usage = await generate(
54
+ result = await kosong.step(
77
55
  chat_provider=llm.chat_provider,
78
56
  system_prompt="You are a helpful assistant that compacts conversation context.",
79
- tools=[],
57
+ toolset=EmptyToolset(),
80
58
  history=[compact_message],
81
59
  )
82
- if usage:
60
+ if result.usage:
83
61
  logger.debug(
84
62
  "Compaction used {input} input tokens and {output} output tokens",
85
- input=usage.input,
86
- output=usage.output,
63
+ input=result.usage.input,
64
+ output=result.usage.output,
87
65
  )
88
66
 
89
67
  content: list[ContentPart] = [
90
68
  system("Previous context has been compacted. Here is the compaction output:")
91
69
  ]
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)]
70
+ compacted_msg = result.message
71
+
72
+ # drop thinking parts if any
73
+ content.extend(part for part in compacted_msg.content if not isinstance(part, ThinkPart))
74
+ compacted_messages: list[Message] = [Message(role="user", content=content)]
98
75
  compacted_messages.extend(to_preserve)
99
76
  return compacted_messages
100
77
 
78
+ class PrepareResult(NamedTuple):
79
+ compact_message: Message | None
80
+ to_preserve: Sequence[Message]
101
81
 
102
- if TYPE_CHECKING:
82
+ def prepare(self, messages: Sequence[Message]) -> PrepareResult:
83
+ if not messages or self.max_preserved_messages <= 0:
84
+ return self.PrepareResult(compact_message=None, to_preserve=messages)
103
85
 
104
- def type_check(simple: SimpleCompaction):
105
- _: Compaction = simple
86
+ history = list(messages)
87
+ preserve_start_index = len(history)
88
+ n_preserved = 0
89
+ for index in range(len(history) - 1, -1, -1):
90
+ if history[index].role in {"user", "assistant"}:
91
+ n_preserved += 1
92
+ if n_preserved == self.max_preserved_messages:
93
+ preserve_start_index = index
94
+ break
95
+
96
+ if n_preserved < self.max_preserved_messages:
97
+ return self.PrepareResult(compact_message=None, to_preserve=messages)
98
+
99
+ to_compact = history[:preserve_start_index]
100
+ to_preserve = history[preserve_start_index:]
101
+
102
+ if not to_compact:
103
+ # Let's hope this won't exceed the context size limit
104
+ return self.PrepareResult(compact_message=None, to_preserve=to_preserve)
105
+
106
+ # Create input message for compaction
107
+ compact_message = Message(role="user", content=[])
108
+ for i, msg in enumerate(to_compact):
109
+ compact_message.content.append(
110
+ TextPart(text=f"## Message {i + 1}\nRole: {msg.role}\nContent:\n")
111
+ )
112
+ compact_message.content.extend(
113
+ part for part in msg.content if not isinstance(part, ThinkPart)
114
+ )
115
+ compact_message.content.append(TextPart(text="\n" + prompts.COMPACT))
116
+ return self.PrepareResult(compact_message=compact_message, to_preserve=to_preserve)