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
@@ -0,0 +1,445 @@
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 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
+ ApprovalRequestResolved,
25
+ CompactionBegin,
26
+ CompactionEnd,
27
+ ContentPart,
28
+ StatusUpdate,
29
+ StepBegin,
30
+ StepInterrupted,
31
+ SubagentEvent,
32
+ TextPart,
33
+ ThinkPart,
34
+ TodoDisplayBlock,
35
+ ToolCall,
36
+ ToolCallPart,
37
+ ToolResult,
38
+ TurnBegin,
39
+ )
40
+
41
+ _current_turn_id = ContextVar[str | None]("current_turn_id", default=None)
42
+ _terminal_tool_call_ids = ContextVar[set[str] | None]("terminal_tool_call_ids", default=None)
43
+
44
+
45
+ def get_current_acp_tool_call_id_or_none() -> str | None:
46
+ """See `_ToolCallState.acp_tool_call_id`."""
47
+ from kimi_cli.soul.toolset import get_current_tool_call_or_none
48
+
49
+ turn_id = _current_turn_id.get()
50
+ if turn_id is None:
51
+ return None
52
+ tool_call = get_current_tool_call_or_none()
53
+ if tool_call is None:
54
+ return None
55
+ return f"{turn_id}/{tool_call.id}"
56
+
57
+
58
+ def register_terminal_tool_call_id(tool_call_id: str) -> None:
59
+ calls = _terminal_tool_call_ids.get()
60
+ if calls is not None:
61
+ calls.add(tool_call_id)
62
+
63
+
64
+ def should_hide_terminal_output(tool_call_id: str) -> bool:
65
+ calls = _terminal_tool_call_ids.get()
66
+ return calls is not None and tool_call_id in calls
67
+
68
+
69
+ class _ToolCallState:
70
+ """Manages the state of a single tool call for streaming updates."""
71
+
72
+ def __init__(self, tool_call: ToolCall):
73
+ self.tool_call = tool_call
74
+ self.args = tool_call.function.arguments or ""
75
+ self.lexer = streamingjson.Lexer()
76
+ if tool_call.function.arguments is not None:
77
+ self.lexer.append_string(tool_call.function.arguments)
78
+
79
+ @property
80
+ def acp_tool_call_id(self) -> str:
81
+ # When the user rejected or cancelled a tool call, the step result may not
82
+ # be appended to the context. In this case, future step may emit tool call
83
+ # with the same tool call ID (on the LLM side). To avoid confusion of the
84
+ # ACP client, we ensure the uniqueness by prefixing with the turn ID.
85
+ turn_id = _current_turn_id.get()
86
+ assert turn_id is not None
87
+ return f"{turn_id}/{self.tool_call.id}"
88
+
89
+ def append_args_part(self, args_part: str) -> None:
90
+ """Append a new arguments part to the accumulated args and lexer."""
91
+ self.args += args_part
92
+ self.lexer.append_string(args_part)
93
+
94
+ def get_title(self) -> str:
95
+ """Get the current title with subtitle if available."""
96
+ tool_name = self.tool_call.function.name
97
+ subtitle = extract_key_argument(self.lexer, tool_name)
98
+ if subtitle:
99
+ return f"{tool_name}: {subtitle}"
100
+ return tool_name
101
+
102
+
103
+ class _TurnState:
104
+ def __init__(self):
105
+ self.id = str(uuid.uuid4())
106
+ """Unique ID for the turn."""
107
+ self.tool_calls: dict[str, _ToolCallState] = {}
108
+ """Map of tool call ID (LLM-side ID) to tool call state."""
109
+ self.last_tool_call: _ToolCallState | None = None
110
+ self.cancel_event = asyncio.Event()
111
+
112
+
113
+ class ACPSession:
114
+ def __init__(
115
+ self,
116
+ id: str,
117
+ cli: KimiCLI,
118
+ acp_conn: acp.Client,
119
+ kaos: Kaos | None = None,
120
+ ) -> None:
121
+ self._id = id
122
+ self._cli = cli
123
+ self._conn = acp_conn
124
+ self._kaos = kaos
125
+ self._turn_state: _TurnState | None = None
126
+
127
+ @property
128
+ def id(self) -> str:
129
+ """The ID of the ACP session."""
130
+ return self._id
131
+
132
+ @property
133
+ def cli(self) -> KimiCLI:
134
+ """The Kimi CLI instance bound to this ACP session."""
135
+ return self._cli
136
+
137
+ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse:
138
+ user_input = acp_blocks_to_content_parts(prompt)
139
+ self._turn_state = _TurnState()
140
+ token = _current_turn_id.set(self._turn_state.id)
141
+ kaos_token = set_current_kaos(self._kaos) if self._kaos is not None else None
142
+ terminal_tool_calls_token = _terminal_tool_call_ids.set(set())
143
+ try:
144
+ async for msg in self._cli.run(user_input, self._turn_state.cancel_event):
145
+ match msg:
146
+ case TurnBegin():
147
+ pass
148
+ case StepBegin():
149
+ pass
150
+ case StepInterrupted():
151
+ break
152
+ case CompactionBegin():
153
+ pass
154
+ case CompactionEnd():
155
+ pass
156
+ case StatusUpdate():
157
+ pass
158
+ case ThinkPart(think=think):
159
+ await self._send_thinking(think)
160
+ case TextPart(text=text):
161
+ await self._send_text(text)
162
+ case ContentPart():
163
+ logger.warning("Unsupported content part: {part}", part=msg)
164
+ await self._send_text(f"[{msg.__class__.__name__}]")
165
+ case ToolCall():
166
+ await self._send_tool_call(msg)
167
+ case ToolCallPart():
168
+ await self._send_tool_call_part(msg)
169
+ case ToolResult():
170
+ await self._send_tool_result(msg)
171
+ case SubagentEvent():
172
+ pass
173
+ case ApprovalRequestResolved():
174
+ pass
175
+ case ApprovalRequest():
176
+ await self._handle_approval_request(msg)
177
+ except LLMNotSet as e:
178
+ logger.exception("LLM not set:")
179
+ raise acp.RequestError.auth_required() from e
180
+ except LLMNotSupported as e:
181
+ logger.exception("LLM not supported:")
182
+ raise acp.RequestError.internal_error({"error": str(e)}) from e
183
+ except ChatProviderError as e:
184
+ logger.exception("LLM provider error:")
185
+ raise acp.RequestError.internal_error({"error": str(e)}) from e
186
+ except MaxStepsReached as e:
187
+ logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps)
188
+ return acp.PromptResponse(stop_reason="max_turn_requests")
189
+ except RunCancelled:
190
+ logger.info("Prompt cancelled by user")
191
+ return acp.PromptResponse(stop_reason="cancelled")
192
+ except Exception as e:
193
+ logger.exception("Unexpected error during prompt:")
194
+ raise acp.RequestError.internal_error({"error": str(e)}) from e
195
+ finally:
196
+ self._turn_state = None
197
+ if kaos_token is not None:
198
+ reset_current_kaos(kaos_token)
199
+ _terminal_tool_call_ids.reset(terminal_tool_calls_token)
200
+ _current_turn_id.reset(token)
201
+ return acp.PromptResponse(stop_reason="end_turn")
202
+
203
+ async def cancel(self) -> None:
204
+ if self._turn_state is None:
205
+ logger.warning("Cancel requested but no prompt is running")
206
+ return
207
+
208
+ self._turn_state.cancel_event.set()
209
+
210
+ async def _send_thinking(self, think: str):
211
+ """Send thinking content to client."""
212
+ if not self._id or not self._conn:
213
+ return
214
+
215
+ await self._conn.session_update(
216
+ self._id,
217
+ acp.schema.AgentThoughtChunk(
218
+ content=acp.schema.TextContentBlock(type="text", text=think),
219
+ session_update="agent_thought_chunk",
220
+ ),
221
+ )
222
+
223
+ async def _send_text(self, text: str):
224
+ """Send text chunk to client."""
225
+ if not self._id or not self._conn:
226
+ return
227
+
228
+ await self._conn.session_update(
229
+ session_id=self._id,
230
+ update=acp.schema.AgentMessageChunk(
231
+ content=acp.schema.TextContentBlock(type="text", text=text),
232
+ session_update="agent_message_chunk",
233
+ ),
234
+ )
235
+
236
+ async def _send_tool_call(self, tool_call: ToolCall):
237
+ """Send tool call to client."""
238
+ assert self._turn_state is not None
239
+ if not self._id or not self._conn:
240
+ return
241
+
242
+ # Create and store tool call state
243
+ state = _ToolCallState(tool_call)
244
+ self._turn_state.tool_calls[tool_call.id] = state
245
+ self._turn_state.last_tool_call = state
246
+
247
+ await self._conn.session_update(
248
+ session_id=self._id,
249
+ update=acp.schema.ToolCallStart(
250
+ session_update="tool_call",
251
+ tool_call_id=state.acp_tool_call_id,
252
+ title=state.get_title(),
253
+ status="in_progress",
254
+ content=[
255
+ acp.schema.ContentToolCallContent(
256
+ type="content",
257
+ content=acp.schema.TextContentBlock(type="text", text=state.args),
258
+ )
259
+ ],
260
+ ),
261
+ )
262
+ logger.debug("Sent tool call: {name}", name=tool_call.function.name)
263
+
264
+ async def _send_tool_call_part(self, part: ToolCallPart):
265
+ """Send tool call part (streaming arguments)."""
266
+ assert self._turn_state is not None
267
+ if (
268
+ not self._id
269
+ or not self._conn
270
+ or not part.arguments_part
271
+ or self._turn_state.last_tool_call is None
272
+ ):
273
+ return
274
+
275
+ # Append new arguments part to the last tool call
276
+ self._turn_state.last_tool_call.append_args_part(part.arguments_part)
277
+
278
+ # Update the tool call with new content and title
279
+ update = acp.schema.ToolCallProgress(
280
+ session_update="tool_call_update",
281
+ tool_call_id=self._turn_state.last_tool_call.acp_tool_call_id,
282
+ title=self._turn_state.last_tool_call.get_title(),
283
+ status="in_progress",
284
+ content=[
285
+ acp.schema.ContentToolCallContent(
286
+ type="content",
287
+ content=acp.schema.TextContentBlock(
288
+ type="text", text=self._turn_state.last_tool_call.args
289
+ ),
290
+ )
291
+ ],
292
+ )
293
+
294
+ await self._conn.session_update(session_id=self._id, update=update)
295
+ logger.debug("Sent tool call update: {delta}", delta=part.arguments_part[:50])
296
+
297
+ async def _send_tool_result(self, result: ToolResult):
298
+ """Send tool result to client."""
299
+ assert self._turn_state is not None
300
+ if not self._id or not self._conn:
301
+ return
302
+
303
+ tool_ret = result.return_value
304
+
305
+ state = self._turn_state.tool_calls.pop(result.tool_call_id, None)
306
+ if state is None:
307
+ logger.warning("Tool call not found: {id}", id=result.tool_call_id)
308
+ return
309
+
310
+ update = acp.schema.ToolCallProgress(
311
+ session_update="tool_call_update",
312
+ tool_call_id=state.acp_tool_call_id,
313
+ status="failed" if tool_ret.is_error else "completed",
314
+ )
315
+
316
+ contents = (
317
+ []
318
+ if should_hide_terminal_output(state.acp_tool_call_id)
319
+ else tool_result_to_acp_content(tool_ret)
320
+ )
321
+ if contents:
322
+ update.content = contents
323
+
324
+ await self._conn.session_update(session_id=self._id, update=update)
325
+ logger.debug("Sent tool result: {id}", id=result.tool_call_id)
326
+
327
+ for block in tool_ret.display:
328
+ if isinstance(block, TodoDisplayBlock):
329
+ await self._send_plan_update(block)
330
+
331
+ async def _handle_approval_request(self, request: ApprovalRequest):
332
+ """Handle approval request by sending permission request to client."""
333
+ assert self._turn_state is not None
334
+ if not self._id or not self._conn:
335
+ logger.warning("No session ID, auto-rejecting approval request")
336
+ request.resolve("reject")
337
+ return
338
+
339
+ state = self._turn_state.tool_calls.get(request.tool_call_id, None)
340
+ if state is None:
341
+ logger.warning("Tool call not found: {id}", id=request.tool_call_id)
342
+ request.resolve("reject")
343
+ return
344
+
345
+ try:
346
+ content: list[
347
+ acp.schema.ContentToolCallContent
348
+ | acp.schema.FileEditToolCallContent
349
+ | acp.schema.TerminalToolCallContent
350
+ ] = []
351
+ if request.display:
352
+ for block in request.display:
353
+ diff_content = display_block_to_acp_content(block)
354
+ if diff_content is not None:
355
+ content.append(diff_content)
356
+ if not content:
357
+ content.append(
358
+ acp.schema.ContentToolCallContent(
359
+ type="content",
360
+ content=acp.schema.TextContentBlock(
361
+ type="text",
362
+ text=f"Requesting approval to perform: {request.description}",
363
+ ),
364
+ )
365
+ )
366
+
367
+ # Send permission request and wait for response
368
+ logger.debug("Requesting permission for action: {action}", action=request.action)
369
+ response = await self._conn.request_permission(
370
+ [
371
+ acp.schema.PermissionOption(
372
+ option_id="approve",
373
+ name="Approve once",
374
+ kind="allow_once",
375
+ ),
376
+ acp.schema.PermissionOption(
377
+ option_id="approve_for_session",
378
+ name="Approve for this session",
379
+ kind="allow_always",
380
+ ),
381
+ acp.schema.PermissionOption(
382
+ option_id="reject",
383
+ name="Reject",
384
+ kind="reject_once",
385
+ ),
386
+ ],
387
+ self._id,
388
+ acp.schema.ToolCallUpdate(
389
+ tool_call_id=state.acp_tool_call_id,
390
+ title=state.get_title(),
391
+ content=content,
392
+ ),
393
+ )
394
+ logger.debug("Received permission response: {response}", response=response)
395
+
396
+ # Process the outcome
397
+ if isinstance(response.outcome, acp.schema.AllowedOutcome):
398
+ # selected
399
+ option_id = response.outcome.option_id
400
+ if option_id == "approve":
401
+ logger.debug("Permission granted for: {action}", action=request.action)
402
+ request.resolve("approve")
403
+ elif option_id == "approve_for_session":
404
+ logger.debug("Permission granted for session: {action}", action=request.action)
405
+ request.resolve("approve_for_session")
406
+ else:
407
+ logger.debug("Permission denied for: {action}", action=request.action)
408
+ request.resolve("reject")
409
+ else:
410
+ # cancelled
411
+ logger.debug("Permission request cancelled for: {action}", action=request.action)
412
+ request.resolve("reject")
413
+ except Exception:
414
+ logger.exception("Error handling approval request:")
415
+ # On error, reject the request
416
+ request.resolve("reject")
417
+
418
+ async def _send_plan_update(self, block: TodoDisplayBlock) -> None:
419
+ """Send todo list updates as ACP agent plan updates."""
420
+
421
+ status_map: dict[str, acp.schema.PlanEntryStatus] = {
422
+ "pending": "pending",
423
+ "in progress": "in_progress",
424
+ "in_progress": "in_progress",
425
+ "done": "completed",
426
+ "completed": "completed",
427
+ }
428
+ entries: list[acp.schema.PlanEntry] = [
429
+ acp.schema.PlanEntry(
430
+ content=todo.title,
431
+ priority="medium",
432
+ status=status_map.get(todo.status.lower(), "pending"),
433
+ )
434
+ for todo in block.items
435
+ if todo.title
436
+ ]
437
+
438
+ if not entries:
439
+ logger.warning("No valid todo items to send in plan update: {todos}", todos=block.items)
440
+ return
441
+
442
+ await self._conn.session_update(
443
+ session_id=self._id,
444
+ update=acp.schema.AgentPlanUpdate(session_update="plan", entries=entries),
445
+ )
kimi_cli/acp/tools.py ADDED
@@ -0,0 +1,158 @@
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 ToolRejectedError, 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
+ if not await self._approval.request(
75
+ self.name,
76
+ "run shell command",
77
+ f"Run command `{params.command}`",
78
+ ):
79
+ return ToolRejectedError()
80
+
81
+ timeout_seconds = float(params.timeout)
82
+ timeout_label = f"{timeout_seconds:g}s"
83
+ terminal: acp.TerminalHandle | None = None
84
+ exit_status: (
85
+ acp.schema.WaitForTerminalExitResponse | acp.schema.TerminalExitStatus | None
86
+ ) = None
87
+ timed_out = False
88
+
89
+ try:
90
+ term = await self._acp_conn.create_terminal(
91
+ command=params.command,
92
+ session_id=self._acp_session_id,
93
+ output_byte_limit=builder.max_chars,
94
+ )
95
+ # FIXME: update ACP sdk for the fix
96
+ assert isinstance(term, acp.TerminalHandle), (
97
+ "Expected TerminalHandle from create_terminal"
98
+ )
99
+ terminal = term
100
+
101
+ acp_tool_call_id = get_current_acp_tool_call_id_or_none()
102
+ assert acp_tool_call_id, "Expected to have an ACP tool call ID in context"
103
+ await self._acp_conn.session_update(
104
+ session_id=self._acp_session_id,
105
+ update=acp.schema.ToolCallProgress(
106
+ session_update="tool_call_update",
107
+ tool_call_id=acp_tool_call_id,
108
+ status="in_progress",
109
+ content=[
110
+ acp.schema.TerminalToolCallContent(
111
+ type="terminal",
112
+ terminal_id=terminal.id,
113
+ )
114
+ ],
115
+ ),
116
+ )
117
+
118
+ try:
119
+ async with asyncio.timeout(timeout_seconds):
120
+ exit_status = await terminal.wait_for_exit()
121
+ except TimeoutError:
122
+ timed_out = True
123
+ await terminal.kill()
124
+
125
+ output_response = await terminal.current_output()
126
+ builder.write(output_response.output)
127
+ if output_response.exit_status:
128
+ exit_status = output_response.exit_status
129
+
130
+ exit_code = exit_status.exit_code if exit_status else None
131
+ exit_signal = exit_status.signal if exit_status else None
132
+
133
+ truncated_note = (
134
+ " Output was truncated by the client output limit."
135
+ if output_response.truncated
136
+ else ""
137
+ )
138
+
139
+ if timed_out:
140
+ return builder.error(
141
+ f"Command killed by timeout ({timeout_label}){truncated_note}",
142
+ brief=f"Killed by timeout ({timeout_label})",
143
+ )
144
+ if exit_signal:
145
+ return builder.error(
146
+ f"Command terminated by signal: {exit_signal}.{truncated_note}",
147
+ brief=f"Signal: {exit_signal}",
148
+ )
149
+ if exit_code not in (None, 0):
150
+ return builder.error(
151
+ f"Command failed with exit code: {exit_code}.{truncated_note}",
152
+ brief=f"Failed with exit code: {exit_code}",
153
+ )
154
+ return builder.ok(f"Command executed successfully.{truncated_note}")
155
+ finally:
156
+ if terminal is not None:
157
+ with suppress(Exception):
158
+ await terminal.release()
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
+ )
@@ -5,17 +5,17 @@ agent:
5
5
  system_prompt_args:
6
6
  ROLE_ADDITIONAL: ""
7
7
  tools:
8
- - "kimi_cli.tools.task:Task"
8
+ - "kimi_cli.tools.multiagent:Task"
9
+ # - "kimi_cli.tools.multiagent:CreateSubagent"
9
10
  # - "kimi_cli.tools.dmail:SendDMail"
10
- - "kimi_cli.tools.think:Think"
11
+ # - "kimi_cli.tools.think:Think"
11
12
  - "kimi_cli.tools.todo:SetTodoList"
12
- - "kimi_cli.tools.bash:Bash"
13
+ - "kimi_cli.tools.shell:Shell"
13
14
  - "kimi_cli.tools.file:ReadFile"
14
15
  - "kimi_cli.tools.file:Glob"
15
16
  - "kimi_cli.tools.file:Grep"
16
17
  - "kimi_cli.tools.file:WriteFile"
17
18
  - "kimi_cli.tools.file:StrReplaceFile"
18
- # - "kimi_cli.tools.file:PatchFile"
19
19
  - "kimi_cli.tools.web:SearchWeb"
20
20
  - "kimi_cli.tools.web:FetchURL"
21
21
  subagents:
@@ -5,7 +5,8 @@ agent:
5
5
  ROLE_ADDITIONAL: |
6
6
  You are now running as a subagent. All the `user` messages are sent by the main agent. The main agent cannot see your context, it can only see your last message when you finish the task. You need to provide a comprehensive summary on what you have done and learned in your final message. If you wrote or modified any files, you must mention them in the summary.
7
7
  exclude_tools:
8
- - "kimi_cli.tools.task:Task"
8
+ - "kimi_cli.tools.multiagent:Task"
9
+ - "kimi_cli.tools.multiagent:CreateSubagent"
9
10
  - "kimi_cli.tools.dmail:SendDMail"
10
11
  - "kimi_cli.tools.todo:SetTodoList"
11
12
  subagents: # make sure no subagents are provided