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
@@ -1,436 +1,89 @@
1
- import asyncio
2
- import uuid
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, NoReturn
3
4
 
4
5
  import acp
5
- import streamingjson
6
- from kosong.base.message import (
7
- ContentPart,
8
- TextPart,
9
- ToolCall,
10
- ToolCallPart,
11
- )
12
- from kosong.chat_provider import ChatProviderError
13
- from kosong.tooling import ToolError, ToolOk, ToolResult
14
6
 
15
- from kimi_cli.soul import LLMNotSet, MaxStepsReached, RunCancelled, Soul, run_soul
16
- from kimi_cli.tools import extract_subtitle
7
+ from kimi_cli.acp.types import ACPContentBlock, MCPServer
8
+ from kimi_cli.soul import Soul
17
9
  from kimi_cli.utils.logging import logger
18
- from kimi_cli.wire import WireUISide
19
- from kimi_cli.wire.message import (
20
- ApprovalRequest,
21
- ApprovalResponse,
22
- StatusUpdate,
23
- StepBegin,
24
- StepInterrupted,
25
- )
26
-
27
-
28
- class _ToolCallState:
29
- """Manages the state of a single tool call for streaming updates."""
30
-
31
- def __init__(self, tool_call: ToolCall):
32
- # When the user rejected or cancelled a tool call, the step result may not
33
- # be appended to the context. In this case, future step may emit tool call
34
- # with the same tool call ID (on the LLM side). To avoid confusion of the
35
- # ACP client, we need to ensure the uniqueness in the ACP connection.
36
- self.acp_tool_call_id = str(uuid.uuid4())
37
-
38
- self.tool_call = tool_call
39
- self.args = tool_call.function.arguments or ""
40
- self.lexer = streamingjson.Lexer()
41
- if tool_call.function.arguments is not None:
42
- self.lexer.append_string(tool_call.function.arguments)
43
-
44
- def append_args_part(self, args_part: str):
45
- """Append a new arguments part to the accumulated args and lexer."""
46
- self.args += args_part
47
- self.lexer.append_string(args_part)
48
-
49
- def get_title(self) -> str:
50
- """Get the current title with subtitle if available."""
51
- tool_name = self.tool_call.function.name
52
- subtitle = extract_subtitle(self.lexer, tool_name)
53
- if subtitle:
54
- return f"{tool_name}: {subtitle}"
55
- return tool_name
56
-
57
-
58
- class _RunState:
59
- def __init__(self):
60
- self.tool_calls: dict[str, _ToolCallState] = {}
61
- """Map of tool call ID (LLM-side ID) to tool call state."""
62
- self.last_tool_call: _ToolCallState | None = None
63
- self.cancel_event = asyncio.Event()
64
10
 
11
+ _DEPRECATED_MESSAGE = (
12
+ "`kimi --acp` is deprecated. "
13
+ "Update your ACP client settings to use `kimi acp` without any flags or options."
14
+ )
65
15
 
66
- class ACPAgent:
67
- """Implementation of the ACP Agent protocol."""
68
16
 
69
- def __init__(self, soul: Soul, connection: acp.AgentSideConnection):
17
+ class ACPServerSingleSession:
18
+ def __init__(self, soul: Soul):
70
19
  self.soul = soul
71
- self.connection = connection
72
- self.session_id: str | None = None
73
- self.run_state: _RunState | None = None
74
-
75
- async def initialize(self, params: acp.InitializeRequest) -> acp.InitializeResponse:
76
- """Handle initialize request."""
77
- logger.info(
78
- "ACP server initialized with protocol version: {version}",
79
- version=params.protocolVersion,
80
- )
81
20
 
82
- return acp.InitializeResponse(
83
- protocolVersion=params.protocolVersion,
84
- agentCapabilities=acp.schema.AgentCapabilities(
85
- loadSession=False,
86
- promptCapabilities=acp.schema.PromptCapabilities(
87
- embeddedContext=False, image=False, audio=False
88
- ),
89
- ),
90
- authMethods=[],
91
- )
92
-
93
- async def authenticate(self, params: acp.AuthenticateRequest) -> None:
94
- """Handle authenticate request."""
95
- logger.info("Authenticate with method: {method}", method=params.methodId)
96
-
97
- async def newSession(self, params: acp.NewSessionRequest) -> acp.NewSessionResponse:
98
- """Handle new session request."""
99
- self.session_id = f"sess_{uuid.uuid4().hex[:16]}"
100
- logger.info("Created session {id} with cwd: {cwd}", id=self.session_id, cwd=params.cwd)
101
- return acp.NewSessionResponse(sessionId=self.session_id)
102
-
103
- async def loadSession(self, params: acp.LoadSessionRequest) -> None:
104
- """Handle load session request."""
105
- self.session_id = params.sessionId
106
- logger.info("Loaded session: {id}", id=self.session_id)
107
-
108
- async def setSessionModel(self, params: acp.SetSessionModelRequest) -> None:
109
- """Handle set session model request."""
110
- logger.warning("Set session model: {model}", model=params.modelId)
111
-
112
- async def setSessionMode(
113
- self, params: acp.SetSessionModeRequest
21
+ def on_connect(self, conn: acp.Client) -> None:
22
+ logger.info("ACP client connected")
23
+
24
+ def _raise(self) -> NoReturn:
25
+ logger.error(_DEPRECATED_MESSAGE)
26
+ raise acp.RequestError.invalid_params({"error": _DEPRECATED_MESSAGE})
27
+
28
+ async def initialize(
29
+ self,
30
+ protocol_version: int,
31
+ client_capabilities: acp.schema.ClientCapabilities | None = None,
32
+ client_info: acp.schema.Implementation | None = None,
33
+ **kwargs: Any,
34
+ ) -> acp.InitializeResponse:
35
+ self._raise()
36
+
37
+ async def new_session(
38
+ self, cwd: str, mcp_servers: list[MCPServer], **kwargs: Any
39
+ ) -> acp.NewSessionResponse:
40
+ self._raise()
41
+
42
+ async def load_session(
43
+ self, cwd: str, mcp_servers: list[MCPServer], session_id: str, **kwargs: Any
44
+ ) -> None:
45
+ self._raise()
46
+
47
+ async def list_sessions(
48
+ self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
49
+ ) -> acp.schema.ListSessionsResponse:
50
+ self._raise()
51
+
52
+ async def set_session_mode(
53
+ self, mode_id: str, session_id: str, **kwargs: Any
114
54
  ) -> acp.SetSessionModeResponse | None:
115
- """Handle set session mode request."""
116
- logger.warning("Set session mode: {mode}", mode=params.modeId)
117
- return None
118
-
119
- async def extMethod(self, method: str, params: dict) -> dict:
120
- """Handle extension method."""
121
- logger.warning("Unsupported extension method: {method}", method=method)
122
- return {}
123
-
124
- async def extNotification(self, method: str, params: dict) -> None:
125
- """Handle extension notification."""
126
- logger.warning("Unsupported extension notification: {method}", method=method)
127
-
128
- async def prompt(self, params: acp.PromptRequest) -> acp.PromptResponse:
129
- """Handle prompt request with streaming support."""
130
- # Extract text from prompt content blocks
131
- prompt_text = "\n".join(
132
- block.text for block in params.prompt if isinstance(block, acp.schema.TextContentBlock)
133
- )
134
-
135
- if not prompt_text:
136
- raise acp.RequestError.invalid_params({"reason": "No text in prompt"})
137
-
138
- logger.info("Processing prompt: {text}", text=prompt_text[:100])
139
-
140
- self.run_state = _RunState()
141
- try:
142
- await run_soul(self.soul, prompt_text, self._stream_events, self.run_state.cancel_event)
143
- return acp.PromptResponse(stopReason="end_turn")
144
- except LLMNotSet:
145
- logger.error("LLM not set")
146
- raise acp.RequestError.internal_error({"error": "LLM not set"}) from None
147
- except ChatProviderError as e:
148
- logger.exception("LLM provider error:")
149
- raise acp.RequestError.internal_error({"error": f"LLM provider error: {e}"}) from e
150
- except MaxStepsReached as e:
151
- logger.warning("Max steps reached: {n}", n=e.n_steps)
152
- return acp.PromptResponse(stopReason="max_turn_requests")
153
- except RunCancelled:
154
- logger.info("Prompt cancelled by user")
155
- return acp.PromptResponse(stopReason="cancelled")
156
- except BaseException as e:
157
- logger.exception("Unknown error:")
158
- raise acp.RequestError.internal_error({"error": f"Unknown error: {e}"}) from e
159
- finally:
160
- self.run_state = None
161
-
162
- async def cancel(self, params: acp.CancelNotification) -> None:
163
- """Handle cancel notification."""
164
- logger.info("Cancel for session: {id}", id=params.sessionId)
165
-
166
- if self.run_state is None:
167
- logger.warning("No running prompt to cancel")
168
- return
169
-
170
- if not self.run_state.cancel_event.is_set():
171
- logger.info("Cancelling running prompt")
172
- self.run_state.cancel_event.set()
55
+ self._raise()
173
56
 
174
- async def _stream_events(self, wire: WireUISide):
175
- assert isinstance(await wire.receive(), StepBegin)
57
+ async def set_session_model(
58
+ self, model_id: str, session_id: str, **kwargs: Any
59
+ ) -> acp.SetSessionModelResponse | None:
60
+ self._raise()
176
61
 
177
- while True:
178
- msg = await wire.receive()
62
+ async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateResponse | None:
63
+ self._raise()
179
64
 
180
- if isinstance(msg, TextPart):
181
- await self._send_text(msg.text)
182
- elif isinstance(msg, ContentPart):
183
- logger.warning("Unsupported content part: {part}", part=msg)
184
- await self._send_text(f"[{msg.__class__.__name__}]")
185
- elif isinstance(msg, ToolCall):
186
- await self._send_tool_call(msg)
187
- elif isinstance(msg, ToolCallPart):
188
- await self._send_tool_call_part(msg)
189
- elif isinstance(msg, ToolResult):
190
- await self._send_tool_result(msg)
191
- elif isinstance(msg, ApprovalRequest):
192
- await self._handle_approval_request(msg)
193
- elif isinstance(msg, StatusUpdate):
194
- # TODO: stream status if needed
195
- pass
196
- elif isinstance(msg, StepInterrupted):
197
- break
65
+ async def prompt(
66
+ self, prompt: list[ACPContentBlock], session_id: str, **kwargs: Any
67
+ ) -> acp.PromptResponse:
68
+ self._raise()
198
69
 
199
- async def _send_text(self, text: str):
200
- """Send text chunk to client."""
201
- if not self.session_id:
202
- return
70
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
71
+ self._raise()
203
72
 
204
- await self.connection.sessionUpdate(
205
- acp.SessionNotification(
206
- sessionId=self.session_id,
207
- update=acp.schema.AgentMessageChunk(
208
- content=acp.schema.TextContentBlock(type="text", text=text),
209
- sessionUpdate="agent_message_chunk",
210
- ),
211
- )
212
- )
73
+ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
74
+ self._raise()
213
75
 
214
- async def _send_tool_call(self, tool_call: ToolCall):
215
- """Send tool call to client."""
216
- assert self.run_state is not None
217
- if not self.session_id:
218
- return
76
+ async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
77
+ self._raise()
219
78
 
220
- # Create and store tool call state
221
- state = _ToolCallState(tool_call)
222
- self.run_state.tool_calls[tool_call.id] = state
223
- self.run_state.last_tool_call = state
224
79
 
225
- await self.connection.sessionUpdate(
226
- acp.SessionNotification(
227
- sessionId=self.session_id,
228
- update=acp.schema.ToolCallStart(
229
- sessionUpdate="tool_call",
230
- toolCallId=state.acp_tool_call_id,
231
- title=state.get_title(),
232
- status="in_progress",
233
- content=[
234
- acp.schema.ContentToolCallContent(
235
- type="content",
236
- content=acp.schema.TextContentBlock(type="text", text=state.args),
237
- )
238
- ],
239
- ),
240
- )
241
- )
242
- logger.debug("Sent tool call: {name}", name=tool_call.function.name)
243
-
244
- async def _send_tool_call_part(self, part: ToolCallPart):
245
- """Send tool call part (streaming arguments)."""
246
- assert self.run_state is not None
247
- if not self.session_id or not part.arguments_part or self.run_state.last_tool_call is None:
248
- return
249
-
250
- # Append new arguments part to the last tool call
251
- self.run_state.last_tool_call.append_args_part(part.arguments_part)
252
-
253
- # Update the tool call with new content and title
254
- update = acp.schema.ToolCallProgress(
255
- sessionUpdate="tool_call_update",
256
- toolCallId=self.run_state.last_tool_call.acp_tool_call_id,
257
- title=self.run_state.last_tool_call.get_title(),
258
- status="in_progress",
259
- content=[
260
- acp.schema.ContentToolCallContent(
261
- type="content",
262
- content=acp.schema.TextContentBlock(
263
- type="text", text=self.run_state.last_tool_call.args
264
- ),
265
- )
266
- ],
267
- )
268
-
269
- await self.connection.sessionUpdate(
270
- acp.SessionNotification(sessionId=self.session_id, update=update)
271
- )
272
- logger.debug("Sent tool call update: {delta}", delta=part.arguments_part[:50])
273
-
274
- async def _send_tool_result(self, result: ToolResult):
275
- """Send tool result to client."""
276
- assert self.run_state is not None
277
- if not self.session_id:
278
- return
279
-
280
- tool_result = result.result
281
- is_error = isinstance(tool_result, ToolError)
282
-
283
- state = self.run_state.tool_calls.pop(result.tool_call_id, None)
284
- if state is None:
285
- logger.warning("Tool call not found: {id}", id=result.tool_call_id)
286
- return
287
-
288
- update = acp.schema.ToolCallProgress(
289
- sessionUpdate="tool_call_update",
290
- toolCallId=state.acp_tool_call_id,
291
- status="failed" if is_error else "completed",
292
- )
293
-
294
- if state.tool_call.function.name == "SetTodoList" and not is_error:
295
- update.content = _tool_result_to_acp_content(tool_result)
296
-
297
- await self.connection.sessionUpdate(
298
- acp.SessionNotification(sessionId=self.session_id, update=update)
299
- )
300
-
301
- logger.debug("Sent tool result: {id}", id=result.tool_call_id)
302
-
303
- async def _handle_approval_request(self, request: ApprovalRequest):
304
- """Handle approval request by sending permission request to client."""
305
- assert self.run_state is not None
306
- if not self.session_id:
307
- logger.warning("No session ID, auto-rejecting approval request")
308
- request.resolve(ApprovalResponse.REJECT)
309
- return
310
-
311
- state = self.run_state.tool_calls.get(request.tool_call_id, None)
312
- if state is None:
313
- logger.warning("Tool call not found: {id}", id=request.tool_call_id)
314
- request.resolve(ApprovalResponse.REJECT)
315
- return
316
-
317
- # Create permission request with options
318
- permission_request = acp.RequestPermissionRequest(
319
- sessionId=self.session_id,
320
- toolCall=acp.schema.ToolCall(
321
- toolCallId=state.acp_tool_call_id,
322
- content=[
323
- acp.schema.ContentToolCallContent(
324
- type="content",
325
- content=acp.schema.TextContentBlock(
326
- type="text",
327
- text=f"Requesting approval to perform: {request.description}",
328
- ),
329
- ),
330
- ],
331
- ),
332
- options=[
333
- acp.schema.PermissionOption(
334
- optionId="approve",
335
- name="Approve",
336
- kind="allow_once",
337
- ),
338
- acp.schema.PermissionOption(
339
- optionId="approve_for_session",
340
- name="Approve for this session",
341
- kind="allow_always",
342
- ),
343
- acp.schema.PermissionOption(
344
- optionId="reject",
345
- name="Reject",
346
- kind="reject_once",
347
- ),
348
- ],
349
- )
350
-
351
- try:
352
- # Send permission request and wait for response
353
- logger.debug("Requesting permission for action: {action}", action=request.action)
354
- response = await self.connection.requestPermission(permission_request)
355
- logger.debug("Received permission response: {response}", response=response)
356
-
357
- # Process the outcome
358
- if isinstance(response.outcome, acp.schema.AllowedOutcome):
359
- # selected
360
- if response.outcome.optionId == "approve":
361
- logger.debug("Permission granted for: {action}", action=request.action)
362
- request.resolve(ApprovalResponse.APPROVE)
363
- elif response.outcome.optionId == "approve_for_session":
364
- logger.debug("Permission granted for session: {action}", action=request.action)
365
- request.resolve(ApprovalResponse.APPROVE_FOR_SESSION)
366
- else:
367
- logger.debug("Permission denied for: {action}", action=request.action)
368
- request.resolve(ApprovalResponse.REJECT)
369
- else:
370
- # cancelled
371
- logger.debug("Permission request cancelled for: {action}", action=request.action)
372
- request.resolve(ApprovalResponse.REJECT)
373
- except Exception:
374
- logger.exception("Error handling approval request:")
375
- # On error, reject the request
376
- request.resolve(ApprovalResponse.REJECT)
377
-
378
-
379
- def _tool_result_to_acp_content(
380
- tool_result: ToolOk | ToolError,
381
- ) -> list[
382
- acp.schema.ContentToolCallContent
383
- | acp.schema.FileEditToolCallContent
384
- | acp.schema.TerminalToolCallContent
385
- ]:
386
- def _to_acp_content(part: ContentPart) -> acp.schema.ContentToolCallContent:
387
- if isinstance(part, TextPart):
388
- return acp.schema.ContentToolCallContent(
389
- type="content", content=acp.schema.TextContentBlock(type="text", text=part.text)
390
- )
391
- else:
392
- logger.warning("Unsupported content part in tool result: {part}", part=part)
393
- return acp.schema.ContentToolCallContent(
394
- type="content",
395
- content=acp.schema.TextContentBlock(
396
- type="text", text=f"[{part.__class__.__name__}]"
397
- ),
398
- )
399
-
400
- content = []
401
- if isinstance(tool_result.output, str):
402
- content.append(_to_acp_content(TextPart(text=tool_result.output)))
403
- elif isinstance(tool_result.output, ContentPart):
404
- content.append(_to_acp_content(tool_result.output))
405
- elif isinstance(tool_result.output, list):
406
- content.extend(_to_acp_content(part) for part in tool_result.output)
407
-
408
- return content
409
-
410
-
411
- class ACPServer:
80
+ class ACP:
412
81
  """ACP server using the official acp library."""
413
82
 
414
83
  def __init__(self, soul: Soul):
415
84
  self.soul = soul
416
85
 
417
- async def run(self) -> bool:
86
+ async def run(self):
418
87
  """Run the ACP server."""
419
- logger.info("Starting ACP server on stdio")
420
-
421
- # Get stdio streams
422
- reader, writer = await acp.stdio_streams()
423
-
424
- # Create connection - the library handles all JSON-RPC details!
425
- _ = acp.AgentSideConnection(
426
- lambda conn: ACPAgent(self.soul, conn),
427
- writer,
428
- reader,
429
- )
430
-
431
- logger.info("ACP server ready")
432
-
433
- # Keep running - connection handles everything
434
- await asyncio.Event().wait()
435
-
436
- return True
88
+ logger.info("Starting ACP server (single session) on stdio")
89
+ await acp.run_agent(ACPServerSingleSession(self.soul))
@@ -1,24 +1,31 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import json
3
5
  import sys
4
6
  from functools import partial
5
7
  from pathlib import Path
6
8
 
7
- import aiofiles
8
- from kosong.base.message import Message
9
9
  from kosong.chat_provider import ChatProviderError
10
+ from kosong.message import Message
10
11
  from rich import print
11
12
 
12
13
  from kimi_cli.cli import InputFormat, OutputFormat
13
- from kimi_cli.soul import LLMNotSet, MaxStepsReached, RunCancelled, Soul, run_soul
14
+ from kimi_cli.soul import (
15
+ LLMNotSet,
16
+ LLMNotSupported,
17
+ MaxStepsReached,
18
+ RunCancelled,
19
+ Soul,
20
+ run_soul,
21
+ )
22
+ from kimi_cli.soul.kimisoul import KimiSoul
23
+ from kimi_cli.ui.print.visualize import visualize
14
24
  from kimi_cli.utils.logging import logger
15
- from kimi_cli.utils.message import message_extract_text
16
25
  from kimi_cli.utils.signals import install_sigint_handler
17
- from kimi_cli.wire import WireUISide
18
- from kimi_cli.wire.message import StepInterrupted
19
26
 
20
27
 
21
- class PrintApp:
28
+ class Print:
22
29
  """
23
30
  An app implementation that prints the agent behavior to the console.
24
31
 
@@ -27,6 +34,7 @@ class PrintApp:
27
34
  input_format (InputFormat): The input format to use.
28
35
  output_format (OutputFormat): The output format to use.
29
36
  context_file (Path): The file to store the context.
37
+ final_only (bool): Whether to only print the final assistant message.
30
38
  """
31
39
 
32
40
  def __init__(
@@ -35,11 +43,14 @@ class PrintApp:
35
43
  input_format: InputFormat,
36
44
  output_format: OutputFormat,
37
45
  context_file: Path,
46
+ *,
47
+ final_only: bool = False,
38
48
  ):
39
49
  self.soul = soul
40
- self.input_format = input_format
41
- self.output_format = output_format
50
+ self.input_format: InputFormat = input_format
51
+ self.output_format: OutputFormat = output_format
42
52
  self.context_file = context_file
53
+ self.final_only = final_only
43
54
 
44
55
  async def run(self, command: str | None = None) -> bool:
45
56
  cancel_event = asyncio.Event()
@@ -68,26 +79,31 @@ class PrintApp:
68
79
 
69
80
  if command:
70
81
  logger.info("Running agent with command: {command}", command=command)
71
- if self.output_format == "text":
72
- visualize_fn = self._visualize_text
82
+ if self.output_format == "text" and not self.final_only:
73
83
  print(command)
74
- else:
75
- assert self.output_format == "stream-json"
76
- visualize_fn = partial(self._visualize_stream_json, start_position=0)
77
- await run_soul(self.soul, command, visualize_fn, cancel_event)
84
+ await run_soul(
85
+ self.soul,
86
+ command,
87
+ partial(visualize, self.output_format, self.final_only),
88
+ cancel_event,
89
+ self.soul.wire_file if isinstance(self.soul, KimiSoul) else None,
90
+ )
78
91
  else:
79
92
  logger.info("Empty command, skipping")
80
93
 
81
94
  command = None
82
- except LLMNotSet:
83
- logger.error("LLM not set")
84
- print("LLM not set")
95
+ except LLMNotSet as e:
96
+ logger.exception("LLM not set:")
97
+ print(str(e))
98
+ except LLMNotSupported as e:
99
+ logger.exception("LLM not supported:")
100
+ print(str(e))
85
101
  except ChatProviderError as e:
86
102
  logger.exception("LLM provider error:")
87
- print(f"LLM provider error: {e}")
103
+ print(str(e))
88
104
  except MaxStepsReached as e:
89
105
  logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps)
90
- print(f"Max steps reached: {e.n_steps}")
106
+ print(str(e))
91
107
  except RunCancelled:
92
108
  logger.error("Interrupted by user")
93
109
  print("Interrupted by user")
@@ -115,7 +131,7 @@ class PrintApp:
115
131
  data = json.loads(json_line)
116
132
  message = Message.model_validate(data)
117
133
  if message.role == "user":
118
- return message_extract_text(message)
134
+ return message.extract_text(sep="\n")
119
135
  logger.warning(
120
136
  "Ignoring message with role `{role}`: {json_line}",
121
137
  role=message.role,
@@ -123,31 +139,3 @@ class PrintApp:
123
139
  )
124
140
  except Exception:
125
141
  logger.warning("Ignoring invalid user message: {json_line}", json_line=json_line)
126
-
127
- async def _visualize_text(self, wire: WireUISide):
128
- while True:
129
- msg = await wire.receive()
130
- print(msg)
131
- if isinstance(msg, StepInterrupted):
132
- break
133
-
134
- async def _visualize_stream_json(self, wire: WireUISide, start_position: int):
135
- # TODO: be aware of context compaction
136
- # FIXME: this is only a temporary impl, may miss the last lines of the context file
137
- if not self.context_file.exists():
138
- self.context_file.touch()
139
- async with aiofiles.open(self.context_file, encoding="utf-8") as f:
140
- await f.seek(start_position)
141
- while True:
142
- should_end = False
143
- while (msg := wire.receive_nowait()) is not None:
144
- if isinstance(msg, StepInterrupted):
145
- should_end = True
146
-
147
- line = await f.readline()
148
- if not line:
149
- if should_end:
150
- break
151
- await asyncio.sleep(0.1)
152
- continue
153
- print(line, end="")