bhu-cli 1.46.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. bhu_cli-1.46.0.dist-info/METADATA +210 -0
  2. bhu_cli-1.46.0.dist-info/RECORD +258 -0
  3. bhu_cli-1.46.0.dist-info/WHEEL +4 -0
  4. bhu_cli-1.46.0.dist-info/entry_points.txt +4 -0
  5. kimi_cli/CHANGELOG.md +1281 -0
  6. kimi_cli/__init__.py +29 -0
  7. kimi_cli/__main__.py +43 -0
  8. kimi_cli/_build_info.py +1 -0
  9. kimi_cli/acp/AGENTS.md +92 -0
  10. kimi_cli/acp/__init__.py +13 -0
  11. kimi_cli/acp/convert.py +128 -0
  12. kimi_cli/acp/kaos.py +291 -0
  13. kimi_cli/acp/mcp.py +46 -0
  14. kimi_cli/acp/server.py +468 -0
  15. kimi_cli/acp/session.py +499 -0
  16. kimi_cli/acp/tools.py +167 -0
  17. kimi_cli/acp/types.py +13 -0
  18. kimi_cli/acp/version.py +45 -0
  19. kimi_cli/agents/default/agent.yaml +36 -0
  20. kimi_cli/agents/default/coder.yaml +25 -0
  21. kimi_cli/agents/default/explore.yaml +46 -0
  22. kimi_cli/agents/default/plan.yaml +30 -0
  23. kimi_cli/agents/default/system.md +160 -0
  24. kimi_cli/agents/okabe/agent.yaml +22 -0
  25. kimi_cli/agentspec.py +160 -0
  26. kimi_cli/app.py +807 -0
  27. kimi_cli/approval_runtime/__init__.py +29 -0
  28. kimi_cli/approval_runtime/models.py +42 -0
  29. kimi_cli/approval_runtime/runtime.py +235 -0
  30. kimi_cli/auth/__init__.py +5 -0
  31. kimi_cli/auth/oauth.py +1092 -0
  32. kimi_cli/auth/platforms.py +374 -0
  33. kimi_cli/background/__init__.py +36 -0
  34. kimi_cli/background/agent_runner.py +231 -0
  35. kimi_cli/background/ids.py +19 -0
  36. kimi_cli/background/manager.py +725 -0
  37. kimi_cli/background/models.py +105 -0
  38. kimi_cli/background/store.py +237 -0
  39. kimi_cli/background/summary.py +66 -0
  40. kimi_cli/background/worker.py +209 -0
  41. kimi_cli/cli/__init__.py +1081 -0
  42. kimi_cli/cli/__main__.py +34 -0
  43. kimi_cli/cli/_lazy_group.py +238 -0
  44. kimi_cli/cli/export.py +322 -0
  45. kimi_cli/cli/info.py +62 -0
  46. kimi_cli/cli/mcp.py +353 -0
  47. kimi_cli/cli/plugin.py +347 -0
  48. kimi_cli/cli/toad.py +73 -0
  49. kimi_cli/cli/vis.py +38 -0
  50. kimi_cli/cli/web.py +80 -0
  51. kimi_cli/config.py +429 -0
  52. kimi_cli/constant.py +116 -0
  53. kimi_cli/exception.py +43 -0
  54. kimi_cli/hooks/__init__.py +24 -0
  55. kimi_cli/hooks/config.py +34 -0
  56. kimi_cli/hooks/defaults/rtk.py +48 -0
  57. kimi_cli/hooks/engine.py +394 -0
  58. kimi_cli/hooks/events.py +190 -0
  59. kimi_cli/hooks/runner.py +103 -0
  60. kimi_cli/llm.py +333 -0
  61. kimi_cli/mcp_oauth.py +72 -0
  62. kimi_cli/metadata.py +79 -0
  63. kimi_cli/notifications/__init__.py +33 -0
  64. kimi_cli/notifications/llm.py +77 -0
  65. kimi_cli/notifications/manager.py +145 -0
  66. kimi_cli/notifications/models.py +50 -0
  67. kimi_cli/notifications/notifier.py +41 -0
  68. kimi_cli/notifications/store.py +118 -0
  69. kimi_cli/notifications/wire.py +21 -0
  70. kimi_cli/plugin/__init__.py +124 -0
  71. kimi_cli/plugin/manager.py +153 -0
  72. kimi_cli/plugin/tool.py +173 -0
  73. kimi_cli/prompts/__init__.py +6 -0
  74. kimi_cli/prompts/compact.md +73 -0
  75. kimi_cli/prompts/init.md +21 -0
  76. kimi_cli/py.typed +0 -0
  77. kimi_cli/session.py +319 -0
  78. kimi_cli/session_fork.py +325 -0
  79. kimi_cli/session_state.py +132 -0
  80. kimi_cli/share.py +14 -0
  81. kimi_cli/skill/__init__.py +727 -0
  82. kimi_cli/skill/flow/__init__.py +99 -0
  83. kimi_cli/skill/flow/d2.py +482 -0
  84. kimi_cli/skill/flow/mermaid.py +266 -0
  85. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  86. kimi_cli/skills/skill-creator/SKILL.md +367 -0
  87. kimi_cli/soul/__init__.py +304 -0
  88. kimi_cli/soul/agent.py +519 -0
  89. kimi_cli/soul/approval.py +267 -0
  90. kimi_cli/soul/btw.py +237 -0
  91. kimi_cli/soul/compaction.py +189 -0
  92. kimi_cli/soul/context.py +339 -0
  93. kimi_cli/soul/denwarenji.py +39 -0
  94. kimi_cli/soul/dynamic_injection.py +84 -0
  95. kimi_cli/soul/dynamic_injections/__init__.py +0 -0
  96. kimi_cli/soul/dynamic_injections/afk_mode.py +74 -0
  97. kimi_cli/soul/dynamic_injections/plan_mode.py +245 -0
  98. kimi_cli/soul/kimisoul.py +1710 -0
  99. kimi_cli/soul/message.py +92 -0
  100. kimi_cli/soul/slash.py +341 -0
  101. kimi_cli/soul/toolset.py +891 -0
  102. kimi_cli/subagents/__init__.py +21 -0
  103. kimi_cli/subagents/builder.py +42 -0
  104. kimi_cli/subagents/core.py +86 -0
  105. kimi_cli/subagents/git_context.py +170 -0
  106. kimi_cli/subagents/models.py +54 -0
  107. kimi_cli/subagents/output.py +71 -0
  108. kimi_cli/subagents/registry.py +28 -0
  109. kimi_cli/subagents/runner.py +428 -0
  110. kimi_cli/subagents/store.py +196 -0
  111. kimi_cli/telemetry/__init__.py +197 -0
  112. kimi_cli/telemetry/crash.py +149 -0
  113. kimi_cli/telemetry/sink.py +143 -0
  114. kimi_cli/telemetry/transport.py +318 -0
  115. kimi_cli/tools/AGENTS.md +5 -0
  116. kimi_cli/tools/__init__.py +105 -0
  117. kimi_cli/tools/agent/__init__.py +277 -0
  118. kimi_cli/tools/agent/description.md +41 -0
  119. kimi_cli/tools/ask_user/__init__.py +154 -0
  120. kimi_cli/tools/ask_user/description.md +19 -0
  121. kimi_cli/tools/background/__init__.py +318 -0
  122. kimi_cli/tools/background/list.md +10 -0
  123. kimi_cli/tools/background/output.md +11 -0
  124. kimi_cli/tools/background/stop.md +8 -0
  125. kimi_cli/tools/display.py +46 -0
  126. kimi_cli/tools/dmail/__init__.py +38 -0
  127. kimi_cli/tools/dmail/dmail.md +17 -0
  128. kimi_cli/tools/file/__init__.py +30 -0
  129. kimi_cli/tools/file/glob.md +21 -0
  130. kimi_cli/tools/file/glob.py +178 -0
  131. kimi_cli/tools/file/grep.md +6 -0
  132. kimi_cli/tools/file/grep_local.py +590 -0
  133. kimi_cli/tools/file/plan_mode.py +45 -0
  134. kimi_cli/tools/file/read.md +16 -0
  135. kimi_cli/tools/file/read.py +300 -0
  136. kimi_cli/tools/file/read_media.md +24 -0
  137. kimi_cli/tools/file/read_media.py +217 -0
  138. kimi_cli/tools/file/replace.md +7 -0
  139. kimi_cli/tools/file/replace.py +195 -0
  140. kimi_cli/tools/file/utils.py +257 -0
  141. kimi_cli/tools/file/write.md +5 -0
  142. kimi_cli/tools/file/write.py +177 -0
  143. kimi_cli/tools/plan/__init__.py +339 -0
  144. kimi_cli/tools/plan/description.md +29 -0
  145. kimi_cli/tools/plan/enter.py +197 -0
  146. kimi_cli/tools/plan/enter_description.md +35 -0
  147. kimi_cli/tools/plan/heroes.py +277 -0
  148. kimi_cli/tools/shell/__init__.py +260 -0
  149. kimi_cli/tools/shell/bash.md +35 -0
  150. kimi_cli/tools/test.py +55 -0
  151. kimi_cli/tools/think/__init__.py +21 -0
  152. kimi_cli/tools/think/think.md +1 -0
  153. kimi_cli/tools/todo/__init__.py +168 -0
  154. kimi_cli/tools/todo/set_todo_list.md +23 -0
  155. kimi_cli/tools/utils.py +199 -0
  156. kimi_cli/tools/web/__init__.py +4 -0
  157. kimi_cli/tools/web/fetch.md +1 -0
  158. kimi_cli/tools/web/fetch.py +189 -0
  159. kimi_cli/tools/web/search.md +1 -0
  160. kimi_cli/tools/web/search.py +163 -0
  161. kimi_cli/ui/__init__.py +0 -0
  162. kimi_cli/ui/acp/__init__.py +99 -0
  163. kimi_cli/ui/print/__init__.py +474 -0
  164. kimi_cli/ui/print/visualize.py +194 -0
  165. kimi_cli/ui/shell/__init__.py +1540 -0
  166. kimi_cli/ui/shell/console.py +109 -0
  167. kimi_cli/ui/shell/debug.py +190 -0
  168. kimi_cli/ui/shell/echo.py +17 -0
  169. kimi_cli/ui/shell/export_import.py +117 -0
  170. kimi_cli/ui/shell/keyboard.py +300 -0
  171. kimi_cli/ui/shell/mcp_status.py +111 -0
  172. kimi_cli/ui/shell/oauth.py +149 -0
  173. kimi_cli/ui/shell/placeholders.py +531 -0
  174. kimi_cli/ui/shell/prompt.py +2259 -0
  175. kimi_cli/ui/shell/replay.py +215 -0
  176. kimi_cli/ui/shell/session_picker.py +227 -0
  177. kimi_cli/ui/shell/setup.py +212 -0
  178. kimi_cli/ui/shell/slash.py +893 -0
  179. kimi_cli/ui/shell/startup.py +32 -0
  180. kimi_cli/ui/shell/task_browser.py +486 -0
  181. kimi_cli/ui/shell/update.py +349 -0
  182. kimi_cli/ui/shell/usage.py +295 -0
  183. kimi_cli/ui/shell/visualize/__init__.py +165 -0
  184. kimi_cli/ui/shell/visualize/_approval_panel.py +505 -0
  185. kimi_cli/ui/shell/visualize/_blocks.py +640 -0
  186. kimi_cli/ui/shell/visualize/_btw_panel.py +224 -0
  187. kimi_cli/ui/shell/visualize/_input_router.py +48 -0
  188. kimi_cli/ui/shell/visualize/_interactive.py +524 -0
  189. kimi_cli/ui/shell/visualize/_live_view.py +902 -0
  190. kimi_cli/ui/shell/visualize/_question_panel.py +586 -0
  191. kimi_cli/ui/theme.py +241 -0
  192. kimi_cli/utils/__init__.py +0 -0
  193. kimi_cli/utils/aiohttp.py +24 -0
  194. kimi_cli/utils/aioqueue.py +72 -0
  195. kimi_cli/utils/broadcast.py +37 -0
  196. kimi_cli/utils/changelog.py +108 -0
  197. kimi_cli/utils/clipboard.py +246 -0
  198. kimi_cli/utils/datetime.py +64 -0
  199. kimi_cli/utils/diff.py +135 -0
  200. kimi_cli/utils/editor.py +91 -0
  201. kimi_cli/utils/environment.py +225 -0
  202. kimi_cli/utils/envvar.py +22 -0
  203. kimi_cli/utils/export.py +696 -0
  204. kimi_cli/utils/file_filter.py +367 -0
  205. kimi_cli/utils/frontmatter.py +70 -0
  206. kimi_cli/utils/io.py +27 -0
  207. kimi_cli/utils/logging.py +124 -0
  208. kimi_cli/utils/media_tags.py +29 -0
  209. kimi_cli/utils/message.py +24 -0
  210. kimi_cli/utils/path.py +245 -0
  211. kimi_cli/utils/proctitle.py +33 -0
  212. kimi_cli/utils/proxy.py +31 -0
  213. kimi_cli/utils/pyinstaller.py +43 -0
  214. kimi_cli/utils/rich/__init__.py +33 -0
  215. kimi_cli/utils/rich/columns.py +99 -0
  216. kimi_cli/utils/rich/diff_render.py +481 -0
  217. kimi_cli/utils/rich/markdown.py +898 -0
  218. kimi_cli/utils/rich/markdown_sample.md +108 -0
  219. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  220. kimi_cli/utils/rich/syntax.py +114 -0
  221. kimi_cli/utils/sensitive.py +54 -0
  222. kimi_cli/utils/server.py +121 -0
  223. kimi_cli/utils/shell_quoting.py +38 -0
  224. kimi_cli/utils/signals.py +43 -0
  225. kimi_cli/utils/slashcmd.py +145 -0
  226. kimi_cli/utils/string.py +41 -0
  227. kimi_cli/utils/subprocess_env.py +73 -0
  228. kimi_cli/utils/term.py +168 -0
  229. kimi_cli/utils/typing.py +20 -0
  230. kimi_cli/utils/windows_paths.py +48 -0
  231. kimi_cli/vis/__init__.py +0 -0
  232. kimi_cli/vis/api/__init__.py +5 -0
  233. kimi_cli/vis/api/sessions.py +687 -0
  234. kimi_cli/vis/api/statistics.py +209 -0
  235. kimi_cli/vis/api/system.py +19 -0
  236. kimi_cli/vis/app.py +175 -0
  237. kimi_cli/web/__init__.py +5 -0
  238. kimi_cli/web/api/__init__.py +15 -0
  239. kimi_cli/web/api/config.py +208 -0
  240. kimi_cli/web/api/open_in.py +197 -0
  241. kimi_cli/web/api/sessions.py +1223 -0
  242. kimi_cli/web/app.py +451 -0
  243. kimi_cli/web/auth.py +191 -0
  244. kimi_cli/web/models.py +98 -0
  245. kimi_cli/web/runner/__init__.py +5 -0
  246. kimi_cli/web/runner/messages.py +57 -0
  247. kimi_cli/web/runner/process.py +754 -0
  248. kimi_cli/web/runner/worker.py +95 -0
  249. kimi_cli/web/store/__init__.py +1 -0
  250. kimi_cli/web/store/sessions.py +432 -0
  251. kimi_cli/wire/__init__.py +148 -0
  252. kimi_cli/wire/file.py +151 -0
  253. kimi_cli/wire/jsonrpc.py +263 -0
  254. kimi_cli/wire/protocol.py +2 -0
  255. kimi_cli/wire/root_hub.py +27 -0
  256. kimi_cli/wire/serde.py +26 -0
  257. kimi_cli/wire/server.py +1060 -0
  258. kimi_cli/wire/types.py +717 -0
@@ -0,0 +1,499 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import uuid
5
+ from contextvars import ContextVar
6
+
7
+ import acp
8
+ import streamingjson # type: ignore[reportMissingTypeStubs]
9
+ from kaos import Kaos, reset_current_kaos, set_current_kaos
10
+ from kosong.chat_provider import APIStatusError, ChatProviderError
11
+
12
+ from kimi_cli.acp.convert import (
13
+ acp_blocks_to_content_parts,
14
+ display_block_to_acp_content,
15
+ tool_result_to_acp_content,
16
+ )
17
+ from kimi_cli.acp.types import ACPContentBlock
18
+ from kimi_cli.app import KimiCLI
19
+ from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled
20
+ from kimi_cli.tools import extract_key_argument
21
+ from kimi_cli.utils.logging import logger
22
+ from kimi_cli.wire.types import (
23
+ ApprovalRequest,
24
+ ApprovalResponse,
25
+ CompactionBegin,
26
+ CompactionEnd,
27
+ ContentPart,
28
+ MCPLoadingBegin,
29
+ MCPLoadingEnd,
30
+ Notification,
31
+ PlanDisplay,
32
+ QuestionRequest,
33
+ StatusUpdate,
34
+ SteerInput,
35
+ StepBegin,
36
+ StepInterrupted,
37
+ StepRetry,
38
+ SubagentEvent,
39
+ TextPart,
40
+ ThinkPart,
41
+ TodoDisplayBlock,
42
+ ToolCall,
43
+ ToolCallPart,
44
+ ToolCallRequest,
45
+ ToolResult,
46
+ TurnBegin,
47
+ TurnEnd,
48
+ )
49
+
50
+ _current_turn_id = ContextVar[str | None]("current_turn_id", default=None)
51
+ _terminal_tool_call_ids = ContextVar[set[str] | None]("terminal_tool_call_ids", default=None)
52
+
53
+
54
+ def get_current_acp_tool_call_id_or_none() -> str | None:
55
+ """See `_ToolCallState.acp_tool_call_id`."""
56
+ from kimi_cli.soul.toolset import get_current_tool_call_or_none
57
+
58
+ turn_id = _current_turn_id.get()
59
+ if turn_id is None:
60
+ return None
61
+ tool_call = get_current_tool_call_or_none()
62
+ if tool_call is None:
63
+ return None
64
+ return f"{turn_id}/{tool_call.id}"
65
+
66
+
67
+ def register_terminal_tool_call_id(tool_call_id: str) -> None:
68
+ calls = _terminal_tool_call_ids.get()
69
+ if calls is not None:
70
+ calls.add(tool_call_id)
71
+
72
+
73
+ def should_hide_terminal_output(tool_call_id: str) -> bool:
74
+ calls = _terminal_tool_call_ids.get()
75
+ return calls is not None and tool_call_id in calls
76
+
77
+
78
+ class _ToolCallState:
79
+ """Manages the state of a single tool call for streaming updates."""
80
+
81
+ def __init__(self, tool_call: ToolCall):
82
+ self.tool_call = tool_call
83
+ self.args = tool_call.function.arguments or ""
84
+ self.lexer = streamingjson.Lexer()
85
+ if tool_call.function.arguments is not None:
86
+ self.lexer.append_string(tool_call.function.arguments)
87
+
88
+ @property
89
+ def acp_tool_call_id(self) -> str:
90
+ # When the user rejected or cancelled a tool call, the step result may not
91
+ # be appended to the context. In this case, future step may emit tool call
92
+ # with the same tool call ID (on the LLM side). To avoid confusion of the
93
+ # ACP client, we ensure the uniqueness by prefixing with the turn ID.
94
+ turn_id = _current_turn_id.get()
95
+ assert turn_id is not None
96
+ return f"{turn_id}/{self.tool_call.id}"
97
+
98
+ def append_args_part(self, args_part: str) -> None:
99
+ """Append a new arguments part to the accumulated args and lexer."""
100
+ self.args += args_part
101
+ self.lexer.append_string(args_part)
102
+
103
+ def get_title(self) -> str:
104
+ """Get the current title with subtitle if available."""
105
+ tool_name = self.tool_call.function.name
106
+ subtitle = extract_key_argument(self.lexer, tool_name)
107
+ if subtitle:
108
+ return f"{tool_name}: {subtitle}"
109
+ return tool_name
110
+
111
+
112
+ class _TurnState:
113
+ def __init__(self):
114
+ self.id = str(uuid.uuid4())
115
+ """Unique ID for the turn."""
116
+ self.tool_calls: dict[str, _ToolCallState] = {}
117
+ """Map of tool call ID (LLM-side ID) to tool call state."""
118
+ self.last_tool_call: _ToolCallState | None = None
119
+ self.cancel_event = asyncio.Event()
120
+
121
+
122
+ class ACPSession:
123
+ def __init__(
124
+ self,
125
+ id: str,
126
+ cli: KimiCLI,
127
+ acp_conn: acp.Client,
128
+ kaos: Kaos | None = None,
129
+ ) -> None:
130
+ self._id = id
131
+ self._cli = cli
132
+ self._conn = acp_conn
133
+ self._kaos = kaos
134
+ self._turn_state: _TurnState | None = None
135
+
136
+ @property
137
+ def id(self) -> str:
138
+ """The ID of the ACP session."""
139
+ return self._id
140
+
141
+ @property
142
+ def cli(self) -> KimiCLI:
143
+ """The Kimi Code CLI instance bound to this ACP session."""
144
+ return self._cli
145
+
146
+ def _is_oauth_session(self) -> bool:
147
+ """Return True if the current session uses OAuth-based authentication."""
148
+ try:
149
+ llm = self._cli.soul.runtime.llm
150
+ return llm is not None and getattr(llm.provider_config, "oauth", None) is not None
151
+ except AttributeError:
152
+ return False
153
+
154
+ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse:
155
+ user_input = acp_blocks_to_content_parts(prompt)
156
+ self._turn_state = _TurnState()
157
+ token = _current_turn_id.set(self._turn_state.id)
158
+ kaos_token = set_current_kaos(self._kaos) if self._kaos is not None else None
159
+ terminal_tool_calls_token = _terminal_tool_call_ids.set(set())
160
+ try:
161
+ async for msg in self._cli.run(user_input, self._turn_state.cancel_event):
162
+ match msg:
163
+ case TurnBegin():
164
+ pass
165
+ case SteerInput():
166
+ pass
167
+ case TurnEnd():
168
+ pass
169
+ case StepBegin():
170
+ pass
171
+ case StepInterrupted():
172
+ break
173
+ case StepRetry():
174
+ pass
175
+ case CompactionBegin():
176
+ pass
177
+ case CompactionEnd():
178
+ pass
179
+ case MCPLoadingBegin():
180
+ pass
181
+ case MCPLoadingEnd():
182
+ pass
183
+ case StatusUpdate():
184
+ pass
185
+ case Notification():
186
+ await self._send_notification(msg)
187
+ case ThinkPart(think=think):
188
+ await self._send_thinking(think)
189
+ case TextPart(text=text):
190
+ await self._send_text(text)
191
+ case ContentPart():
192
+ logger.warning("Unsupported content part: {part}", part=msg)
193
+ await self._send_text(f"[{msg.__class__.__name__}]")
194
+ case ToolCall():
195
+ await self._send_tool_call(msg)
196
+ case ToolCallPart():
197
+ await self._send_tool_call_part(msg)
198
+ case ToolResult():
199
+ await self._send_tool_result(msg)
200
+ case ApprovalResponse():
201
+ pass
202
+ case SubagentEvent():
203
+ pass
204
+ case PlanDisplay():
205
+ pass
206
+ case ApprovalRequest():
207
+ await self._handle_approval_request(msg)
208
+ case ToolCallRequest():
209
+ logger.warning("Unexpected ToolCallRequest in ACP session: {msg}", msg=msg)
210
+ case QuestionRequest():
211
+ logger.warning(
212
+ "QuestionRequest is unsupported in ACP session; resolving empty answer."
213
+ )
214
+ msg.resolve({})
215
+ case _:
216
+ pass
217
+ except LLMNotSet as e:
218
+ logger.exception("LLM not set:")
219
+ raise acp.RequestError.auth_required() from e
220
+ except LLMNotSupported as e:
221
+ logger.exception("LLM not supported:")
222
+ raise acp.RequestError.internal_error({"error": str(e)}) from e
223
+ except APIStatusError as e:
224
+ if e.status_code == 401 and self._is_oauth_session():
225
+ logger.warning("Authentication failed (401), prompting re-login")
226
+ raise acp.RequestError.auth_required() from e
227
+ logger.exception("LLM API status error:")
228
+ raise acp.RequestError.internal_error({"error": str(e)}) from e
229
+ except ChatProviderError as e:
230
+ logger.exception("LLM provider error:")
231
+ raise acp.RequestError.internal_error({"error": str(e)}) from e
232
+ except MaxStepsReached as e:
233
+ logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps)
234
+ return acp.PromptResponse(stop_reason="max_turn_requests")
235
+ except RunCancelled:
236
+ logger.info("Prompt cancelled by user")
237
+ return acp.PromptResponse(stop_reason="cancelled")
238
+ except Exception as e:
239
+ logger.exception("Unexpected error during prompt:")
240
+ raise acp.RequestError.internal_error({"error": str(e)}) from e
241
+ finally:
242
+ self._turn_state = None
243
+ if kaos_token is not None:
244
+ reset_current_kaos(kaos_token)
245
+ _terminal_tool_call_ids.reset(terminal_tool_calls_token)
246
+ _current_turn_id.reset(token)
247
+ return acp.PromptResponse(stop_reason="end_turn")
248
+
249
+ async def cancel(self) -> None:
250
+ if self._turn_state is None:
251
+ logger.warning("Cancel requested but no prompt is running")
252
+ return
253
+
254
+ self._turn_state.cancel_event.set()
255
+
256
+ async def _send_thinking(self, think: str):
257
+ """Send thinking content to client."""
258
+ if not self._id or not self._conn:
259
+ return
260
+
261
+ await self._conn.session_update(
262
+ self._id,
263
+ acp.schema.AgentThoughtChunk(
264
+ content=acp.schema.TextContentBlock(type="text", text=think),
265
+ session_update="agent_thought_chunk",
266
+ ),
267
+ )
268
+
269
+ async def _send_text(self, text: str):
270
+ """Send text chunk to client."""
271
+ if not self._id or not self._conn:
272
+ return
273
+
274
+ await self._conn.session_update(
275
+ session_id=self._id,
276
+ update=acp.schema.AgentMessageChunk(
277
+ content=acp.schema.TextContentBlock(type="text", text=text),
278
+ session_update="agent_message_chunk",
279
+ ),
280
+ )
281
+
282
+ async def _send_notification(self, notification: Notification):
283
+ """Send a system notification to the client as a text chunk."""
284
+ body = notification.body.strip()
285
+ text = f"[Notification] {notification.title}"
286
+ if body:
287
+ text = f"{text}\n{body}"
288
+ await self._send_text(text)
289
+
290
+ async def _send_tool_call(self, tool_call: ToolCall):
291
+ """Send tool call to client."""
292
+ assert self._turn_state is not None
293
+ if not self._id or not self._conn:
294
+ return
295
+
296
+ # Create and store tool call state
297
+ state = _ToolCallState(tool_call)
298
+ self._turn_state.tool_calls[tool_call.id] = state
299
+ self._turn_state.last_tool_call = state
300
+
301
+ await self._conn.session_update(
302
+ session_id=self._id,
303
+ update=acp.schema.ToolCallStart(
304
+ session_update="tool_call",
305
+ tool_call_id=state.acp_tool_call_id,
306
+ title=state.get_title(),
307
+ status="in_progress",
308
+ content=[
309
+ acp.schema.ContentToolCallContent(
310
+ type="content",
311
+ content=acp.schema.TextContentBlock(type="text", text=state.args),
312
+ )
313
+ ],
314
+ ),
315
+ )
316
+ logger.debug("Sent tool call: {name}", name=tool_call.function.name)
317
+
318
+ async def _send_tool_call_part(self, part: ToolCallPart):
319
+ """Send tool call part (streaming arguments)."""
320
+ assert self._turn_state is not None
321
+ if (
322
+ not self._id
323
+ or not self._conn
324
+ or not part.arguments_part
325
+ or self._turn_state.last_tool_call is None
326
+ ):
327
+ return
328
+
329
+ # Append new arguments part to the last tool call
330
+ self._turn_state.last_tool_call.append_args_part(part.arguments_part)
331
+
332
+ # Update the tool call with new content and title
333
+ update = acp.schema.ToolCallProgress(
334
+ session_update="tool_call_update",
335
+ tool_call_id=self._turn_state.last_tool_call.acp_tool_call_id,
336
+ title=self._turn_state.last_tool_call.get_title(),
337
+ status="in_progress",
338
+ content=[
339
+ acp.schema.ContentToolCallContent(
340
+ type="content",
341
+ content=acp.schema.TextContentBlock(
342
+ type="text", text=self._turn_state.last_tool_call.args
343
+ ),
344
+ )
345
+ ],
346
+ )
347
+
348
+ await self._conn.session_update(session_id=self._id, update=update)
349
+ logger.debug("Sent tool call update: {delta}", delta=part.arguments_part[:50])
350
+
351
+ async def _send_tool_result(self, result: ToolResult):
352
+ """Send tool result to client."""
353
+ assert self._turn_state is not None
354
+ if not self._id or not self._conn:
355
+ return
356
+
357
+ tool_ret = result.return_value
358
+
359
+ state = self._turn_state.tool_calls.pop(result.tool_call_id, None)
360
+ if state is None:
361
+ logger.warning("Tool call not found: {id}", id=result.tool_call_id)
362
+ return
363
+
364
+ update = acp.schema.ToolCallProgress(
365
+ session_update="tool_call_update",
366
+ tool_call_id=state.acp_tool_call_id,
367
+ status="failed" if tool_ret.is_error else "completed",
368
+ )
369
+
370
+ contents = (
371
+ []
372
+ if should_hide_terminal_output(state.acp_tool_call_id)
373
+ else tool_result_to_acp_content(tool_ret)
374
+ )
375
+ if contents:
376
+ update.content = contents
377
+
378
+ await self._conn.session_update(session_id=self._id, update=update)
379
+ logger.debug("Sent tool result: {id}", id=result.tool_call_id)
380
+
381
+ for block in tool_ret.display:
382
+ if isinstance(block, TodoDisplayBlock):
383
+ await self._send_plan_update(block)
384
+
385
+ async def _handle_approval_request(self, request: ApprovalRequest):
386
+ """Handle approval request by sending permission request to client."""
387
+ assert self._turn_state is not None
388
+ if not self._id or not self._conn:
389
+ logger.warning("No session ID, auto-rejecting approval request")
390
+ request.resolve("reject")
391
+ return
392
+
393
+ state = self._turn_state.tool_calls.get(request.tool_call_id, None)
394
+ if state is None:
395
+ logger.warning("Tool call not found: {id}", id=request.tool_call_id)
396
+ request.resolve("reject")
397
+ return
398
+
399
+ try:
400
+ content: list[
401
+ acp.schema.ContentToolCallContent
402
+ | acp.schema.FileEditToolCallContent
403
+ | acp.schema.TerminalToolCallContent
404
+ ] = []
405
+ if request.display:
406
+ for block in request.display:
407
+ diff_content = display_block_to_acp_content(block)
408
+ if diff_content is not None:
409
+ content.append(diff_content)
410
+ if not content:
411
+ content.append(
412
+ acp.schema.ContentToolCallContent(
413
+ type="content",
414
+ content=acp.schema.TextContentBlock(
415
+ type="text",
416
+ text=f"Requesting approval to perform: {request.description}",
417
+ ),
418
+ )
419
+ )
420
+
421
+ # Send permission request and wait for response
422
+ logger.debug("Requesting permission for action: {action}", action=request.action)
423
+ response = await self._conn.request_permission(
424
+ [
425
+ acp.schema.PermissionOption(
426
+ option_id="approve",
427
+ name="Approve once",
428
+ kind="allow_once",
429
+ ),
430
+ acp.schema.PermissionOption(
431
+ option_id="approve_for_session",
432
+ name="Approve for this session",
433
+ kind="allow_always",
434
+ ),
435
+ acp.schema.PermissionOption(
436
+ option_id="reject",
437
+ name="Reject",
438
+ kind="reject_once",
439
+ ),
440
+ ],
441
+ self._id,
442
+ acp.schema.ToolCallUpdate(
443
+ tool_call_id=state.acp_tool_call_id,
444
+ title=state.get_title(),
445
+ content=content,
446
+ ),
447
+ )
448
+ logger.debug("Received permission response: {response}", response=response)
449
+
450
+ # Process the outcome
451
+ if isinstance(response.outcome, acp.schema.AllowedOutcome):
452
+ # selected
453
+ option_id = response.outcome.option_id
454
+ if option_id == "approve":
455
+ logger.debug("Permission granted for: {action}", action=request.action)
456
+ request.resolve("approve")
457
+ elif option_id == "approve_for_session":
458
+ logger.debug("Permission granted for session: {action}", action=request.action)
459
+ request.resolve("approve_for_session")
460
+ else:
461
+ logger.debug("Permission denied for: {action}", action=request.action)
462
+ request.resolve("reject")
463
+ else:
464
+ # cancelled
465
+ logger.debug("Permission request cancelled for: {action}", action=request.action)
466
+ request.resolve("reject")
467
+ except Exception:
468
+ logger.exception("Error handling approval request:")
469
+ # On error, reject the request
470
+ request.resolve("reject")
471
+
472
+ async def _send_plan_update(self, block: TodoDisplayBlock) -> None:
473
+ """Send todo list updates as ACP agent plan updates."""
474
+
475
+ status_map: dict[str, acp.schema.PlanEntryStatus] = {
476
+ "pending": "pending",
477
+ "in progress": "in_progress",
478
+ "in_progress": "in_progress",
479
+ "done": "completed",
480
+ "completed": "completed",
481
+ }
482
+ entries: list[acp.schema.PlanEntry] = [
483
+ acp.schema.PlanEntry(
484
+ content=todo.title,
485
+ priority="medium",
486
+ status=status_map.get(todo.status.lower(), "pending"),
487
+ )
488
+ for todo in block.items
489
+ if todo.title
490
+ ]
491
+
492
+ if not entries:
493
+ logger.warning("No valid todo items to send in plan update: {todos}", todos=block.items)
494
+ return
495
+
496
+ await self._conn.session_update(
497
+ session_id=self._id,
498
+ update=acp.schema.AgentPlanUpdate(session_update="plan", entries=entries),
499
+ )
kimi_cli/acp/tools.py ADDED
@@ -0,0 +1,167 @@
1
+ import asyncio
2
+ from contextlib import suppress
3
+
4
+ import acp
5
+ from kaos import get_current_kaos
6
+ from kaos.local import local_kaos
7
+ from kosong.tooling import CallableTool2, ToolReturnValue
8
+
9
+ from kimi_cli.soul.agent import Runtime
10
+ from kimi_cli.soul.approval import Approval
11
+ from kimi_cli.soul.toolset import KimiToolset
12
+ from kimi_cli.tools.shell import Params as ShellParams
13
+ from kimi_cli.tools.shell import Shell
14
+ from kimi_cli.tools.utils import ToolResultBuilder
15
+ from kimi_cli.wire.types import DisplayBlock
16
+
17
+
18
+ def replace_tools(
19
+ client_capabilities: acp.schema.ClientCapabilities,
20
+ acp_conn: acp.Client,
21
+ acp_session_id: str,
22
+ toolset: KimiToolset,
23
+ runtime: Runtime,
24
+ ) -> None:
25
+ current_kaos = get_current_kaos().name
26
+ if current_kaos not in (local_kaos.name, "acp"):
27
+ # Only replace tools when running locally or under ACPKaos.
28
+ return
29
+
30
+ if client_capabilities.terminal and (shell_tool := toolset.find(Shell)):
31
+ # Replace the Shell tool with the ACP Terminal tool if supported.
32
+ toolset.add(
33
+ Terminal(
34
+ shell_tool,
35
+ acp_conn,
36
+ acp_session_id,
37
+ runtime.approval,
38
+ )
39
+ )
40
+
41
+
42
+ class HideOutputDisplayBlock(DisplayBlock):
43
+ """A special DisplayBlock that indicates output should be hidden in ACP clients."""
44
+
45
+ type: str = "acp/hide_output"
46
+
47
+
48
+ class Terminal(CallableTool2[ShellParams]):
49
+ def __init__(
50
+ self,
51
+ shell_tool: Shell,
52
+ acp_conn: acp.Client,
53
+ acp_session_id: str,
54
+ approval: Approval,
55
+ ) -> None:
56
+ # Use the `name`, `description`, and `params` from the existing Shell tool,
57
+ # so that when this is added to the toolset, it replaces the original Shell tool.
58
+ super().__init__(shell_tool.name, shell_tool.description, shell_tool.params)
59
+ self._acp_conn = acp_conn
60
+ self._acp_session_id = acp_session_id
61
+ self._approval = approval
62
+
63
+ async def __call__(self, params: ShellParams) -> ToolReturnValue:
64
+ from kimi_cli.acp.session import get_current_acp_tool_call_id_or_none
65
+
66
+ builder = ToolResultBuilder()
67
+ # Hide tool output because we use `TerminalToolCallContent` which already streams output
68
+ # directly to the user.
69
+ builder.display(HideOutputDisplayBlock())
70
+
71
+ if not params.command:
72
+ return builder.error("Command cannot be empty.", brief="Empty command")
73
+
74
+ approval_result = await self._approval.request(
75
+ self.name,
76
+ "run shell command",
77
+ f"Run command `{params.command}`",
78
+ )
79
+ if not approval_result:
80
+ return approval_result.rejection_error()
81
+
82
+ timeout_seconds = float(params.timeout)
83
+ timeout_label = f"{timeout_seconds:g}s"
84
+ terminal_id: str | None = None
85
+ exit_status: (
86
+ acp.schema.WaitForTerminalExitResponse | acp.schema.TerminalExitStatus | None
87
+ ) = None
88
+ timed_out = False
89
+
90
+ try:
91
+ resp = await self._acp_conn.create_terminal(
92
+ command=params.command,
93
+ session_id=self._acp_session_id,
94
+ output_byte_limit=builder.max_chars,
95
+ )
96
+ terminal_id = resp.terminal_id
97
+
98
+ acp_tool_call_id = get_current_acp_tool_call_id_or_none()
99
+ assert acp_tool_call_id, "Expected to have an ACP tool call ID in context"
100
+ await self._acp_conn.session_update(
101
+ session_id=self._acp_session_id,
102
+ update=acp.schema.ToolCallProgress(
103
+ session_update="tool_call_update",
104
+ tool_call_id=acp_tool_call_id,
105
+ status="in_progress",
106
+ content=[
107
+ acp.schema.TerminalToolCallContent(
108
+ type="terminal",
109
+ terminal_id=terminal_id,
110
+ )
111
+ ],
112
+ ),
113
+ )
114
+
115
+ try:
116
+ async with asyncio.timeout(timeout_seconds):
117
+ exit_status = await self._acp_conn.wait_for_terminal_exit(
118
+ session_id=self._acp_session_id,
119
+ terminal_id=terminal_id,
120
+ )
121
+ except TimeoutError:
122
+ timed_out = True
123
+ await self._acp_conn.kill_terminal(
124
+ session_id=self._acp_session_id,
125
+ terminal_id=terminal_id,
126
+ )
127
+
128
+ output_response = await self._acp_conn.terminal_output(
129
+ session_id=self._acp_session_id,
130
+ terminal_id=terminal_id,
131
+ )
132
+ builder.write(output_response.output)
133
+ if output_response.exit_status:
134
+ exit_status = output_response.exit_status
135
+
136
+ exit_code = exit_status.exit_code if exit_status else None
137
+ exit_signal = exit_status.signal if exit_status else None
138
+
139
+ truncated_note = (
140
+ " Output was truncated by the client output limit."
141
+ if output_response.truncated
142
+ else ""
143
+ )
144
+
145
+ if timed_out:
146
+ return builder.error(
147
+ f"Command killed by timeout ({timeout_label}){truncated_note}",
148
+ brief=f"Killed by timeout ({timeout_label})",
149
+ )
150
+ if exit_signal:
151
+ return builder.error(
152
+ f"Command terminated by signal: {exit_signal}.{truncated_note}",
153
+ brief=f"Signal: {exit_signal}",
154
+ )
155
+ if exit_code not in (None, 0):
156
+ return builder.error(
157
+ f"Command failed with exit code: {exit_code}.{truncated_note}",
158
+ brief=f"Failed with exit code: {exit_code}",
159
+ )
160
+ return builder.ok(f"Command executed successfully.{truncated_note}")
161
+ finally:
162
+ if terminal_id is not None:
163
+ with suppress(Exception):
164
+ await self._acp_conn.release_terminal(
165
+ session_id=self._acp_session_id,
166
+ terminal_id=terminal_id,
167
+ )
kimi_cli/acp/types.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ import acp
4
+
5
+ MCPServer = acp.schema.HttpMcpServer | acp.schema.SseMcpServer | acp.schema.McpServerStdio
6
+
7
+ ACPContentBlock = (
8
+ acp.schema.TextContentBlock
9
+ | acp.schema.ImageContentBlock
10
+ | acp.schema.AudioContentBlock
11
+ | acp.schema.ResourceContentBlock
12
+ | acp.schema.EmbeddedResourceContentBlock
13
+ )