kimi-cli 0.44__py3-none-any.whl → 0.78__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kimi-cli might be problematic. Click here for more details.
- kimi_cli/CHANGELOG.md +349 -40
- kimi_cli/__init__.py +6 -0
- kimi_cli/acp/AGENTS.md +91 -0
- kimi_cli/acp/__init__.py +13 -0
- kimi_cli/acp/convert.py +111 -0
- kimi_cli/acp/kaos.py +270 -0
- kimi_cli/acp/mcp.py +46 -0
- kimi_cli/acp/server.py +335 -0
- kimi_cli/acp/session.py +445 -0
- kimi_cli/acp/tools.py +158 -0
- kimi_cli/acp/types.py +13 -0
- kimi_cli/agents/default/agent.yaml +4 -4
- kimi_cli/agents/default/sub.yaml +2 -1
- kimi_cli/agents/default/system.md +79 -21
- kimi_cli/agents/okabe/agent.yaml +17 -0
- kimi_cli/agentspec.py +53 -25
- kimi_cli/app.py +180 -52
- kimi_cli/cli/__init__.py +595 -0
- kimi_cli/cli/__main__.py +8 -0
- kimi_cli/cli/info.py +63 -0
- kimi_cli/cli/mcp.py +349 -0
- kimi_cli/config.py +153 -17
- kimi_cli/constant.py +3 -0
- kimi_cli/exception.py +23 -2
- kimi_cli/flow/__init__.py +117 -0
- kimi_cli/flow/d2.py +376 -0
- kimi_cli/flow/mermaid.py +218 -0
- kimi_cli/llm.py +129 -23
- kimi_cli/metadata.py +32 -7
- kimi_cli/platforms.py +262 -0
- kimi_cli/prompts/__init__.py +2 -0
- kimi_cli/prompts/compact.md +4 -5
- kimi_cli/session.py +223 -31
- kimi_cli/share.py +2 -0
- kimi_cli/skill.py +145 -0
- kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
- kimi_cli/skills/skill-creator/SKILL.md +351 -0
- kimi_cli/soul/__init__.py +51 -20
- kimi_cli/soul/agent.py +213 -85
- kimi_cli/soul/approval.py +86 -17
- kimi_cli/soul/compaction.py +64 -53
- kimi_cli/soul/context.py +38 -5
- kimi_cli/soul/denwarenji.py +2 -0
- kimi_cli/soul/kimisoul.py +442 -60
- kimi_cli/soul/message.py +54 -54
- kimi_cli/soul/slash.py +72 -0
- kimi_cli/soul/toolset.py +387 -6
- kimi_cli/toad.py +74 -0
- kimi_cli/tools/AGENTS.md +5 -0
- kimi_cli/tools/__init__.py +42 -34
- kimi_cli/tools/display.py +25 -0
- kimi_cli/tools/dmail/__init__.py +10 -10
- kimi_cli/tools/dmail/dmail.md +11 -9
- kimi_cli/tools/file/__init__.py +1 -3
- kimi_cli/tools/file/glob.py +20 -23
- kimi_cli/tools/file/grep.md +1 -1
- kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
- kimi_cli/tools/file/read.md +24 -6
- kimi_cli/tools/file/read.py +134 -50
- kimi_cli/tools/file/replace.md +1 -1
- kimi_cli/tools/file/replace.py +36 -29
- kimi_cli/tools/file/utils.py +282 -0
- kimi_cli/tools/file/write.py +43 -22
- kimi_cli/tools/multiagent/__init__.py +7 -0
- kimi_cli/tools/multiagent/create.md +11 -0
- kimi_cli/tools/multiagent/create.py +50 -0
- kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
- kimi_cli/tools/shell/__init__.py +120 -0
- kimi_cli/tools/{bash → shell}/bash.md +1 -2
- kimi_cli/tools/shell/powershell.md +25 -0
- kimi_cli/tools/test.py +4 -4
- kimi_cli/tools/think/__init__.py +2 -2
- kimi_cli/tools/todo/__init__.py +14 -8
- kimi_cli/tools/utils.py +64 -24
- kimi_cli/tools/web/fetch.py +68 -13
- kimi_cli/tools/web/search.py +10 -12
- kimi_cli/ui/acp/__init__.py +65 -412
- kimi_cli/ui/print/__init__.py +37 -49
- kimi_cli/ui/print/visualize.py +179 -0
- kimi_cli/ui/shell/__init__.py +141 -84
- kimi_cli/ui/shell/console.py +2 -0
- kimi_cli/ui/shell/debug.py +28 -23
- kimi_cli/ui/shell/keyboard.py +5 -1
- kimi_cli/ui/shell/prompt.py +220 -194
- kimi_cli/ui/shell/replay.py +111 -46
- kimi_cli/ui/shell/setup.py +89 -82
- kimi_cli/ui/shell/slash.py +422 -0
- kimi_cli/ui/shell/update.py +4 -2
- kimi_cli/ui/shell/usage.py +271 -0
- kimi_cli/ui/shell/visualize.py +574 -72
- kimi_cli/ui/wire/__init__.py +267 -0
- kimi_cli/ui/wire/jsonrpc.py +142 -0
- kimi_cli/ui/wire/protocol.py +1 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +2 -0
- kimi_cli/utils/aioqueue.py +72 -0
- kimi_cli/utils/broadcast.py +37 -0
- kimi_cli/utils/changelog.py +12 -7
- kimi_cli/utils/clipboard.py +12 -0
- kimi_cli/utils/datetime.py +37 -0
- kimi_cli/utils/environment.py +58 -0
- kimi_cli/utils/envvar.py +12 -0
- kimi_cli/utils/frontmatter.py +44 -0
- kimi_cli/utils/logging.py +7 -6
- kimi_cli/utils/message.py +9 -14
- kimi_cli/utils/path.py +99 -9
- kimi_cli/utils/pyinstaller.py +6 -0
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/columns.py +99 -0
- kimi_cli/utils/rich/markdown.py +961 -0
- kimi_cli/utils/rich/markdown_sample.md +108 -0
- kimi_cli/utils/rich/markdown_sample_short.md +2 -0
- kimi_cli/utils/signals.py +2 -0
- kimi_cli/utils/slashcmd.py +124 -0
- kimi_cli/utils/string.py +2 -0
- kimi_cli/utils/term.py +168 -0
- kimi_cli/utils/typing.py +20 -0
- kimi_cli/wire/__init__.py +98 -29
- kimi_cli/wire/serde.py +45 -0
- kimi_cli/wire/types.py +299 -0
- kimi_cli-0.78.dist-info/METADATA +200 -0
- kimi_cli-0.78.dist-info/RECORD +135 -0
- kimi_cli-0.78.dist-info/entry_points.txt +4 -0
- kimi_cli/cli.py +0 -250
- kimi_cli/soul/runtime.py +0 -96
- kimi_cli/tools/bash/__init__.py +0 -99
- kimi_cli/tools/file/patch.md +0 -8
- kimi_cli/tools/file/patch.py +0 -143
- kimi_cli/tools/mcp.py +0 -85
- kimi_cli/ui/shell/liveview.py +0 -386
- kimi_cli/ui/shell/metacmd.py +0 -262
- kimi_cli/wire/message.py +0 -91
- kimi_cli-0.44.dist-info/METADATA +0 -188
- kimi_cli-0.44.dist-info/RECORD +0 -89
- kimi_cli-0.44.dist-info/entry_points.txt +0 -3
- /kimi_cli/tools/{task → multiagent}/task.md +0 -0
- {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
kimi_cli/acp/session.py
ADDED
|
@@ -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.
|
|
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.
|
|
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:
|
kimi_cli/agents/default/sub.yaml
CHANGED
|
@@ -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.
|
|
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
|