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/soul/message.py
CHANGED
|
@@ -1,69 +1,56 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from kosong.message import Message
|
|
3
6
|
from kosong.tooling.error import ToolRuntimeError
|
|
4
7
|
|
|
8
|
+
from kimi_cli.llm import ModelCapability
|
|
9
|
+
from kimi_cli.wire.types import (
|
|
10
|
+
ContentPart,
|
|
11
|
+
ImageURLPart,
|
|
12
|
+
TextPart,
|
|
13
|
+
ThinkPart,
|
|
14
|
+
ToolResult,
|
|
15
|
+
VideoURLPart,
|
|
16
|
+
)
|
|
17
|
+
|
|
5
18
|
|
|
6
19
|
def system(message: str) -> ContentPart:
|
|
7
20
|
return TextPart(text=f"<system>{message}</system>")
|
|
8
21
|
|
|
9
22
|
|
|
10
|
-
def
|
|
11
|
-
"""Convert a tool result to a
|
|
12
|
-
if
|
|
13
|
-
assert tool_result.
|
|
14
|
-
message = tool_result.
|
|
15
|
-
if isinstance(tool_result.
|
|
23
|
+
def tool_result_to_message(tool_result: ToolResult) -> Message:
|
|
24
|
+
"""Convert a tool result to a message."""
|
|
25
|
+
if tool_result.return_value.is_error:
|
|
26
|
+
assert tool_result.return_value.message, "Error return value should have a message"
|
|
27
|
+
message = tool_result.return_value.message
|
|
28
|
+
if isinstance(tool_result.return_value, ToolRuntimeError):
|
|
16
29
|
message += "\nThis is an unexpected error and the tool is probably not working."
|
|
17
30
|
content: list[ContentPart] = [system(f"ERROR: {message}")]
|
|
18
|
-
if tool_result.
|
|
19
|
-
content.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
content = tool_ok_to_message_content(tool_result.result)
|
|
29
|
-
text_parts: list[ContentPart] = []
|
|
30
|
-
non_text_parts: list[ContentPart] = []
|
|
31
|
-
for part in content:
|
|
32
|
-
if isinstance(part, TextPart):
|
|
33
|
-
text_parts.append(part)
|
|
34
|
-
else:
|
|
35
|
-
non_text_parts.append(part)
|
|
31
|
+
if tool_result.return_value.output:
|
|
32
|
+
content.extend(_output_to_content_parts(tool_result.return_value.output))
|
|
33
|
+
else:
|
|
34
|
+
content: list[ContentPart] = []
|
|
35
|
+
if tool_result.return_value.message:
|
|
36
|
+
content.append(system(tool_result.return_value.message))
|
|
37
|
+
if tool_result.return_value.output:
|
|
38
|
+
content.extend(_output_to_content_parts(tool_result.return_value.output))
|
|
39
|
+
if not content:
|
|
40
|
+
content.append(system("Tool output is empty."))
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
content=text_parts,
|
|
42
|
-
tool_call_id=tool_result.tool_call_id,
|
|
43
|
-
)
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
text_parts.append(
|
|
47
|
-
system(
|
|
48
|
-
"Tool output contains non-text parts. Non-text parts are sent as a user message below."
|
|
49
|
-
)
|
|
42
|
+
return Message(
|
|
43
|
+
role="tool",
|
|
44
|
+
content=content,
|
|
45
|
+
tool_call_id=tool_result.tool_call_id,
|
|
50
46
|
)
|
|
51
|
-
return [
|
|
52
|
-
Message(
|
|
53
|
-
role="tool",
|
|
54
|
-
content=text_parts,
|
|
55
|
-
tool_call_id=tool_result.tool_call_id,
|
|
56
|
-
),
|
|
57
|
-
Message(role="user", content=non_text_parts),
|
|
58
|
-
]
|
|
59
47
|
|
|
60
48
|
|
|
61
|
-
def
|
|
62
|
-
|
|
49
|
+
def _output_to_content_parts(
|
|
50
|
+
output: str | ContentPart | Sequence[ContentPart],
|
|
51
|
+
) -> list[ContentPart]:
|
|
63
52
|
content: list[ContentPart] = []
|
|
64
|
-
|
|
65
|
-
content.append(system(result.message))
|
|
66
|
-
match output := result.output:
|
|
53
|
+
match output:
|
|
67
54
|
case str(text):
|
|
68
55
|
if text:
|
|
69
56
|
content.append(TextPart(text=text))
|
|
@@ -71,6 +58,19 @@ def tool_ok_to_message_content(result: ToolOk) -> list[ContentPart]:
|
|
|
71
58
|
content.append(output)
|
|
72
59
|
case _:
|
|
73
60
|
content.extend(output)
|
|
74
|
-
if not content:
|
|
75
|
-
content.append(system("Tool output is empty."))
|
|
76
61
|
return content
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_message(
|
|
65
|
+
message: Message, model_capabilities: set[ModelCapability]
|
|
66
|
+
) -> set[ModelCapability]:
|
|
67
|
+
"""Check the message content, return the missing model capabilities."""
|
|
68
|
+
capabilities_needed = set[ModelCapability]()
|
|
69
|
+
for part in message.content:
|
|
70
|
+
if isinstance(part, ImageURLPart):
|
|
71
|
+
capabilities_needed.add("image_in")
|
|
72
|
+
elif isinstance(part, VideoURLPart):
|
|
73
|
+
capabilities_needed.add("video_in")
|
|
74
|
+
elif isinstance(part, ThinkPart):
|
|
75
|
+
capabilities_needed.add("thinking")
|
|
76
|
+
return capabilities_needed - model_capabilities
|
kimi_cli/soul/slash.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from kosong.message import Message
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
import kimi_cli.prompts as prompts
|
|
12
|
+
from kimi_cli.soul import wire_send
|
|
13
|
+
from kimi_cli.soul.agent import load_agents_md
|
|
14
|
+
from kimi_cli.soul.context import Context
|
|
15
|
+
from kimi_cli.soul.message import system
|
|
16
|
+
from kimi_cli.utils.slashcmd import SlashCommandRegistry
|
|
17
|
+
from kimi_cli.wire.types import TextPart
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from kimi_cli.soul.kimisoul import KimiSoul
|
|
21
|
+
|
|
22
|
+
type SoulSlashCmdFunc = Callable[[KimiSoul, str], None | Awaitable[None]]
|
|
23
|
+
"""
|
|
24
|
+
A function that runs as a KimiSoul-level slash command.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
Any exception that can be raised by `Soul.run`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
registry = SlashCommandRegistry[SoulSlashCmdFunc]()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@registry.command
|
|
34
|
+
async def init(soul: KimiSoul, args: str):
|
|
35
|
+
"""Analyze the codebase and generate an `AGENTS.md` file"""
|
|
36
|
+
from kimi_cli.soul.kimisoul import KimiSoul
|
|
37
|
+
|
|
38
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
39
|
+
tmp_context = Context(file_backend=Path(temp_dir) / "context.jsonl")
|
|
40
|
+
tmp_soul = KimiSoul(soul.agent, context=tmp_context)
|
|
41
|
+
await tmp_soul.run(prompts.INIT)
|
|
42
|
+
|
|
43
|
+
agents_md = load_agents_md(soul.runtime.builtin_args.KIMI_WORK_DIR)
|
|
44
|
+
system_message = system(
|
|
45
|
+
"The user just ran `/init` slash command. "
|
|
46
|
+
"The system has analyzed the codebase and generated an `AGENTS.md` file. "
|
|
47
|
+
f"Latest AGENTS.md file content:\n{agents_md}"
|
|
48
|
+
)
|
|
49
|
+
await soul.context.append_message(Message(role="user", content=[system_message]))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@registry.command
|
|
53
|
+
async def compact(soul: KimiSoul, args: str):
|
|
54
|
+
"""Compact the context"""
|
|
55
|
+
if soul.context.n_checkpoints == 0:
|
|
56
|
+
wire_send(TextPart(text="The context is empty."))
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
logger.info("Running `/compact`")
|
|
60
|
+
await soul.compact_context()
|
|
61
|
+
wire_send(TextPart(text="The context has been compacted."))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@registry.command
|
|
65
|
+
async def yolo(soul: KimiSoul, args: str):
|
|
66
|
+
"""Toggle YOLO mode (auto-approve all actions)"""
|
|
67
|
+
if soul.runtime.approval.is_yolo():
|
|
68
|
+
soul.runtime.approval.set_yolo(False)
|
|
69
|
+
wire_send(TextPart(text="You only die once! Actions will require approval."))
|
|
70
|
+
else:
|
|
71
|
+
soul.runtime.approval.set_yolo(True)
|
|
72
|
+
wire_send(TextPart(text="You only live once! All actions will be auto-approved."))
|
kimi_cli/soul/toolset.py
CHANGED
|
@@ -1,8 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
1
8
|
from contextvars import ContextVar
|
|
2
|
-
from
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
12
|
+
|
|
13
|
+
from kosong.tooling import (
|
|
14
|
+
CallableTool,
|
|
15
|
+
CallableTool2,
|
|
16
|
+
HandleResult,
|
|
17
|
+
Tool,
|
|
18
|
+
ToolError,
|
|
19
|
+
ToolOk,
|
|
20
|
+
Toolset,
|
|
21
|
+
)
|
|
22
|
+
from kosong.tooling.error import (
|
|
23
|
+
ToolNotFoundError,
|
|
24
|
+
ToolParseError,
|
|
25
|
+
ToolRuntimeError,
|
|
26
|
+
)
|
|
27
|
+
from kosong.tooling.mcp import convert_mcp_content
|
|
28
|
+
from kosong.utils.typing import JsonType
|
|
29
|
+
from loguru import logger
|
|
30
|
+
|
|
31
|
+
from kimi_cli.exception import InvalidToolError, MCPRuntimeError
|
|
32
|
+
from kimi_cli.tools import SkipThisTool
|
|
33
|
+
from kimi_cli.tools.utils import ToolRejectedError
|
|
34
|
+
from kimi_cli.wire.types import ContentPart, ToolCall, ToolResult, ToolReturnValue
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
import fastmcp
|
|
38
|
+
import mcp
|
|
39
|
+
from fastmcp.client.client import CallToolResult
|
|
40
|
+
from fastmcp.client.transports import ClientTransport
|
|
41
|
+
from fastmcp.mcp_config import MCPConfig
|
|
3
42
|
|
|
4
|
-
from
|
|
5
|
-
from kosong.tooling import HandleResult, SimpleToolset
|
|
43
|
+
from kimi_cli.soul.agent import Runtime
|
|
6
44
|
|
|
7
45
|
current_tool_call = ContextVar[ToolCall | None]("current_tool_call", default=None)
|
|
8
46
|
|
|
@@ -15,11 +53,354 @@ def get_current_tool_call_or_none() -> ToolCall | None:
|
|
|
15
53
|
return current_tool_call.get()
|
|
16
54
|
|
|
17
55
|
|
|
18
|
-
|
|
19
|
-
|
|
56
|
+
type ToolType = CallableTool | CallableTool2[Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
|
|
61
|
+
def type_check(kimi_toolset: KimiToolset):
|
|
62
|
+
_: Toolset = kimi_toolset
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class KimiToolset:
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._tool_dict: dict[str, ToolType] = {}
|
|
68
|
+
self._mcp_servers: dict[str, MCPServerInfo] = {}
|
|
69
|
+
self._mcp_loading_task: asyncio.Task[None] | None = None
|
|
70
|
+
|
|
71
|
+
def add(self, tool: ToolType) -> None:
|
|
72
|
+
self._tool_dict[tool.name] = tool
|
|
73
|
+
|
|
74
|
+
@overload
|
|
75
|
+
def find(self, tool_name_or_type: str) -> ToolType | None: ...
|
|
76
|
+
@overload
|
|
77
|
+
def find[T: ToolType](self, tool_name_or_type: type[T]) -> T | None: ...
|
|
78
|
+
def find(self, tool_name_or_type: str | type[ToolType]) -> ToolType | None:
|
|
79
|
+
if isinstance(tool_name_or_type, str):
|
|
80
|
+
return self._tool_dict.get(tool_name_or_type)
|
|
81
|
+
else:
|
|
82
|
+
for tool in self._tool_dict.values():
|
|
83
|
+
if isinstance(tool, tool_name_or_type):
|
|
84
|
+
return tool
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def tools(self) -> list[Tool]:
|
|
89
|
+
return [tool.base for tool in self._tool_dict.values()]
|
|
90
|
+
|
|
20
91
|
def handle(self, tool_call: ToolCall) -> HandleResult:
|
|
21
92
|
token = current_tool_call.set(tool_call)
|
|
22
93
|
try:
|
|
23
|
-
|
|
94
|
+
if tool_call.function.name not in self._tool_dict:
|
|
95
|
+
return ToolResult(
|
|
96
|
+
tool_call_id=tool_call.id,
|
|
97
|
+
return_value=ToolNotFoundError(tool_call.function.name),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
tool = self._tool_dict[tool_call.function.name]
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
arguments: JsonType = json.loads(tool_call.function.arguments or "{}")
|
|
104
|
+
except json.JSONDecodeError as e:
|
|
105
|
+
return ToolResult(tool_call_id=tool_call.id, return_value=ToolParseError(str(e)))
|
|
106
|
+
|
|
107
|
+
async def _call():
|
|
108
|
+
try:
|
|
109
|
+
ret = await tool.call(arguments)
|
|
110
|
+
return ToolResult(tool_call_id=tool_call.id, return_value=ret)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return ToolResult(
|
|
113
|
+
tool_call_id=tool_call.id, return_value=ToolRuntimeError(str(e))
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return asyncio.create_task(_call())
|
|
24
117
|
finally:
|
|
25
118
|
current_tool_call.reset(token)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def mcp_servers(self) -> dict[str, MCPServerInfo]:
|
|
122
|
+
"""Get MCP servers info."""
|
|
123
|
+
return self._mcp_servers
|
|
124
|
+
|
|
125
|
+
def load_tools(self, tool_paths: list[str], dependencies: dict[type[Any], Any]) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Load tools from paths like `kimi_cli.tools.shell:Shell`.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
good_tools: list[str] = []
|
|
134
|
+
bad_tools: list[str] = []
|
|
135
|
+
|
|
136
|
+
for tool_path in tool_paths:
|
|
137
|
+
try:
|
|
138
|
+
tool = self._load_tool(tool_path, dependencies)
|
|
139
|
+
except SkipThisTool:
|
|
140
|
+
logger.info("Skipping tool: {tool_path}", tool_path=tool_path)
|
|
141
|
+
continue
|
|
142
|
+
if tool:
|
|
143
|
+
self.add(tool)
|
|
144
|
+
good_tools.append(tool_path)
|
|
145
|
+
else:
|
|
146
|
+
bad_tools.append(tool_path)
|
|
147
|
+
logger.info("Loaded tools: {good_tools}", good_tools=good_tools)
|
|
148
|
+
if bad_tools:
|
|
149
|
+
raise InvalidToolError(f"Invalid tools: {bad_tools}")
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _load_tool(tool_path: str, dependencies: dict[type[Any], Any]) -> ToolType | None:
|
|
153
|
+
logger.debug("Loading tool: {tool_path}", tool_path=tool_path)
|
|
154
|
+
module_name, class_name = tool_path.rsplit(":", 1)
|
|
155
|
+
try:
|
|
156
|
+
module = importlib.import_module(module_name)
|
|
157
|
+
except ImportError:
|
|
158
|
+
return None
|
|
159
|
+
tool_cls = getattr(module, class_name, None)
|
|
160
|
+
if tool_cls is None:
|
|
161
|
+
return None
|
|
162
|
+
args: list[Any] = []
|
|
163
|
+
if "__init__" in tool_cls.__dict__:
|
|
164
|
+
# the tool class overrides the `__init__` of base class
|
|
165
|
+
for param in inspect.signature(tool_cls).parameters.values():
|
|
166
|
+
if param.kind == inspect.Parameter.KEYWORD_ONLY:
|
|
167
|
+
# once we encounter a keyword-only parameter, we stop injecting dependencies
|
|
168
|
+
break
|
|
169
|
+
# all positional parameters should be dependencies to be injected
|
|
170
|
+
if param.annotation not in dependencies:
|
|
171
|
+
raise ValueError(f"Tool dependency not found: {param.annotation}")
|
|
172
|
+
args.append(dependencies[param.annotation])
|
|
173
|
+
return tool_cls(*args)
|
|
174
|
+
|
|
175
|
+
# TODO(rc): remove `in_background` parameter and always load in background
|
|
176
|
+
async def load_mcp_tools(
|
|
177
|
+
self, mcp_configs: list[MCPConfig], runtime: Runtime, in_background: bool = True
|
|
178
|
+
) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Load MCP tools from specified MCP configs.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be
|
|
184
|
+
connected.
|
|
185
|
+
"""
|
|
186
|
+
import fastmcp
|
|
187
|
+
from fastmcp.mcp_config import MCPConfig, RemoteMCPServer
|
|
188
|
+
|
|
189
|
+
from kimi_cli.ui.shell.prompt import toast
|
|
190
|
+
|
|
191
|
+
async def _check_oauth_tokens(server_url: str) -> bool:
|
|
192
|
+
"""Check if OAuth tokens exist for the server."""
|
|
193
|
+
try:
|
|
194
|
+
from fastmcp.client.auth.oauth import FileTokenStorage
|
|
195
|
+
|
|
196
|
+
storage = FileTokenStorage(server_url=server_url)
|
|
197
|
+
tokens = await storage.get_tokens()
|
|
198
|
+
return tokens is not None
|
|
199
|
+
except Exception:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def _toast_mcp(message: str) -> None:
|
|
203
|
+
if in_background:
|
|
204
|
+
toast(
|
|
205
|
+
message,
|
|
206
|
+
duration=10.0,
|
|
207
|
+
topic="mcp",
|
|
208
|
+
immediate=True,
|
|
209
|
+
position="right",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
oauth_servers: dict[str, str] = {}
|
|
213
|
+
|
|
214
|
+
async def _connect_server(
|
|
215
|
+
server_name: str, server_info: MCPServerInfo
|
|
216
|
+
) -> tuple[str, Exception | None]:
|
|
217
|
+
if server_info.status != "pending":
|
|
218
|
+
return server_name, None
|
|
219
|
+
|
|
220
|
+
server_info.status = "connecting"
|
|
221
|
+
try:
|
|
222
|
+
async with server_info.client as client:
|
|
223
|
+
for tool in await client.list_tools():
|
|
224
|
+
server_info.tools.append(
|
|
225
|
+
MCPTool(server_name, tool, client, runtime=runtime)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
for tool in server_info.tools:
|
|
229
|
+
self.add(tool)
|
|
230
|
+
|
|
231
|
+
server_info.status = "connected"
|
|
232
|
+
logger.info("Connected MCP server: {server_name}", server_name=server_name)
|
|
233
|
+
return server_name, None
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.error(
|
|
236
|
+
"Failed to connect MCP server: {server_name}, error: {error}",
|
|
237
|
+
server_name=server_name,
|
|
238
|
+
error=e,
|
|
239
|
+
)
|
|
240
|
+
server_info.status = "failed"
|
|
241
|
+
return server_name, e
|
|
242
|
+
|
|
243
|
+
async def _connect():
|
|
244
|
+
_toast_mcp("connecting to mcp servers...")
|
|
245
|
+
unauthorized_servers: dict[str, str] = {}
|
|
246
|
+
for server_name, server_info in self._mcp_servers.items():
|
|
247
|
+
server_url = oauth_servers.get(server_name)
|
|
248
|
+
if not server_url:
|
|
249
|
+
continue
|
|
250
|
+
if not await _check_oauth_tokens(server_url):
|
|
251
|
+
logger.warning(
|
|
252
|
+
"Skipping OAuth MCP server '{server_name}': not authorized. "
|
|
253
|
+
"Run 'kimi mcp auth {server_name}' first.",
|
|
254
|
+
server_name=server_name,
|
|
255
|
+
)
|
|
256
|
+
server_info.status = "unauthorized"
|
|
257
|
+
unauthorized_servers[server_name] = server_url
|
|
258
|
+
|
|
259
|
+
tasks = [
|
|
260
|
+
asyncio.create_task(_connect_server(server_name, server_info))
|
|
261
|
+
for server_name, server_info in self._mcp_servers.items()
|
|
262
|
+
if server_info.status == "pending"
|
|
263
|
+
]
|
|
264
|
+
results = await asyncio.gather(*tasks) if tasks else []
|
|
265
|
+
failed_servers = {name: error for name, error in results if error is not None}
|
|
266
|
+
|
|
267
|
+
for mcp_config in mcp_configs:
|
|
268
|
+
# Skip empty MCP configs (no servers defined)
|
|
269
|
+
if not mcp_config.mcpServers:
|
|
270
|
+
logger.debug("Skipping empty MCP config: {mcp_config}", mcp_config=mcp_config)
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
if failed_servers:
|
|
274
|
+
_toast_mcp("mcp connection failed")
|
|
275
|
+
raise MCPRuntimeError(f"Failed to connect MCP servers: {failed_servers}")
|
|
276
|
+
if unauthorized_servers:
|
|
277
|
+
_toast_mcp("mcp authorization needed")
|
|
278
|
+
else:
|
|
279
|
+
_toast_mcp("mcp servers connected")
|
|
280
|
+
|
|
281
|
+
for mcp_config in mcp_configs:
|
|
282
|
+
if not mcp_config.mcpServers:
|
|
283
|
+
logger.debug("Skipping empty MCP config: {mcp_config}", mcp_config=mcp_config)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
for server_name, server_config in mcp_config.mcpServers.items():
|
|
287
|
+
if isinstance(server_config, RemoteMCPServer) and server_config.auth == "oauth":
|
|
288
|
+
oauth_servers[server_name] = server_config.url
|
|
289
|
+
|
|
290
|
+
# Add mcp-session-id header for HTTP transports (skip OAuth servers)
|
|
291
|
+
if (
|
|
292
|
+
isinstance(server_config, RemoteMCPServer)
|
|
293
|
+
and server_config.auth != "oauth"
|
|
294
|
+
and not any(key.lower() == "mcp-session-id" for key in server_config.headers)
|
|
295
|
+
):
|
|
296
|
+
server_config = server_config.model_copy(deep=True)
|
|
297
|
+
server_config.headers["Mcp-Session-Id"] = runtime.session.id
|
|
298
|
+
|
|
299
|
+
client = fastmcp.Client(MCPConfig(mcpServers={server_name: server_config}))
|
|
300
|
+
self._mcp_servers[server_name] = MCPServerInfo(
|
|
301
|
+
status="pending", client=client, tools=[]
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if in_background:
|
|
305
|
+
self._mcp_loading_task = asyncio.create_task(_connect())
|
|
306
|
+
else:
|
|
307
|
+
await _connect()
|
|
308
|
+
|
|
309
|
+
async def wait_for_mcp_tools(self) -> None:
|
|
310
|
+
"""Wait for background MCP tool loading to finish."""
|
|
311
|
+
task = self._mcp_loading_task
|
|
312
|
+
if not task:
|
|
313
|
+
return
|
|
314
|
+
try:
|
|
315
|
+
await task
|
|
316
|
+
finally:
|
|
317
|
+
if self._mcp_loading_task is task and task.done():
|
|
318
|
+
self._mcp_loading_task = None
|
|
319
|
+
|
|
320
|
+
async def cleanup(self) -> None:
|
|
321
|
+
"""Cleanup any resources held by the toolset."""
|
|
322
|
+
if self._mcp_loading_task:
|
|
323
|
+
self._mcp_loading_task.cancel()
|
|
324
|
+
with contextlib.suppress(Exception):
|
|
325
|
+
await self._mcp_loading_task
|
|
326
|
+
for server_info in self._mcp_servers.values():
|
|
327
|
+
await server_info.client.close()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@dataclass(slots=True)
|
|
331
|
+
class MCPServerInfo:
|
|
332
|
+
status: Literal["pending", "connecting", "connected", "failed", "unauthorized"]
|
|
333
|
+
client: fastmcp.Client[Any]
|
|
334
|
+
tools: list[MCPTool[Any]]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class MCPTool[T: ClientTransport](CallableTool):
|
|
338
|
+
def __init__(
|
|
339
|
+
self,
|
|
340
|
+
server_name: str,
|
|
341
|
+
mcp_tool: mcp.Tool,
|
|
342
|
+
client: fastmcp.Client[T],
|
|
343
|
+
*,
|
|
344
|
+
runtime: Runtime,
|
|
345
|
+
**kwargs: Any,
|
|
346
|
+
):
|
|
347
|
+
super().__init__(
|
|
348
|
+
name=mcp_tool.name,
|
|
349
|
+
description=(
|
|
350
|
+
f"This is an MCP (Model Context Protocol) tool from MCP server `{server_name}`.\n\n"
|
|
351
|
+
f"{mcp_tool.description or 'No description provided.'}"
|
|
352
|
+
),
|
|
353
|
+
parameters=mcp_tool.inputSchema,
|
|
354
|
+
**kwargs,
|
|
355
|
+
)
|
|
356
|
+
self._mcp_tool = mcp_tool
|
|
357
|
+
self._client = client
|
|
358
|
+
self._runtime = runtime
|
|
359
|
+
self._timeout = timedelta(milliseconds=runtime.config.mcp.client.tool_call_timeout_ms)
|
|
360
|
+
self._action_name = f"mcp:{mcp_tool.name}"
|
|
361
|
+
|
|
362
|
+
async def __call__(self, *args: Any, **kwargs: Any) -> ToolReturnValue:
|
|
363
|
+
description = f"Call MCP tool `{self._mcp_tool.name}`."
|
|
364
|
+
if not await self._runtime.approval.request(self.name, self._action_name, description):
|
|
365
|
+
return ToolRejectedError()
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
async with self._client as client:
|
|
369
|
+
result = await client.call_tool(
|
|
370
|
+
self._mcp_tool.name,
|
|
371
|
+
kwargs,
|
|
372
|
+
timeout=self._timeout,
|
|
373
|
+
raise_on_error=False,
|
|
374
|
+
)
|
|
375
|
+
return convert_mcp_tool_result(result)
|
|
376
|
+
except Exception as e:
|
|
377
|
+
# fastmcp raises `RuntimeError` on timeout and we cannot tell it from other errors
|
|
378
|
+
exc_msg = str(e).lower()
|
|
379
|
+
if "timeout" in exc_msg or "timed out" in exc_msg:
|
|
380
|
+
return ToolError(
|
|
381
|
+
message=(
|
|
382
|
+
f"Timeout while calling MCP tool `{self._mcp_tool.name}`. "
|
|
383
|
+
"You may explain to the user that the timeout config is set too low."
|
|
384
|
+
),
|
|
385
|
+
brief="Timeout",
|
|
386
|
+
)
|
|
387
|
+
raise
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def convert_mcp_tool_result(result: CallToolResult) -> ToolReturnValue:
|
|
391
|
+
"""Convert MCP tool result to kosong tool return value.
|
|
392
|
+
|
|
393
|
+
Raises:
|
|
394
|
+
ValueError: If any content part has unsupported type or mime type.
|
|
395
|
+
"""
|
|
396
|
+
content: list[ContentPart] = []
|
|
397
|
+
for part in result.content:
|
|
398
|
+
content.append(convert_mcp_content(part))
|
|
399
|
+
if result.is_error:
|
|
400
|
+
return ToolError(
|
|
401
|
+
output=content,
|
|
402
|
+
message="Tool returned an error. The output may be error message or incomplete output",
|
|
403
|
+
brief="",
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
return ToolOk(output=content)
|
kimi_cli/toad.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import shlex
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _default_acp_command() -> list[str]:
|
|
12
|
+
argv0 = sys.argv[0]
|
|
13
|
+
if argv0:
|
|
14
|
+
resolved = shutil.which(argv0)
|
|
15
|
+
resolved_path = Path(resolved).expanduser() if resolved else Path(argv0).expanduser()
|
|
16
|
+
if (
|
|
17
|
+
resolved_path.exists()
|
|
18
|
+
and resolved_path.suffix != ".py"
|
|
19
|
+
and not resolved_path.name.startswith(("python", "pypy"))
|
|
20
|
+
):
|
|
21
|
+
return [str(resolved_path), "acp"]
|
|
22
|
+
|
|
23
|
+
return [sys.executable, "-m", "kimi_cli.cli", "acp"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default_toad_command() -> list[str]:
|
|
27
|
+
if sys.version_info < (3, 14):
|
|
28
|
+
typer.echo("`kimi term` requires Python 3.14+ because Toad requires it.", err=True)
|
|
29
|
+
raise typer.Exit(code=1)
|
|
30
|
+
if importlib.util.find_spec("toad") is None:
|
|
31
|
+
typer.echo(
|
|
32
|
+
"Toad dependency is missing. Run `uv sync --python 3.14` or install kimi-cli with "
|
|
33
|
+
"Python 3.14.",
|
|
34
|
+
err=True,
|
|
35
|
+
)
|
|
36
|
+
raise typer.Exit(code=1)
|
|
37
|
+
return [sys.executable, "-m", "toad.cli"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_project_dir(extra_args: list[str]) -> Path | None:
|
|
41
|
+
work_dir: str | None = None
|
|
42
|
+
idx = 0
|
|
43
|
+
while idx < len(extra_args):
|
|
44
|
+
arg = extra_args[idx]
|
|
45
|
+
if arg in ("--work-dir", "-w"):
|
|
46
|
+
if idx + 1 < len(extra_args):
|
|
47
|
+
work_dir = extra_args[idx + 1]
|
|
48
|
+
idx += 2
|
|
49
|
+
continue
|
|
50
|
+
elif arg.startswith("--work-dir=") or arg.startswith("-w="):
|
|
51
|
+
work_dir = arg.split("=", 1)[1]
|
|
52
|
+
elif arg.startswith("-w") and len(arg) > 2:
|
|
53
|
+
work_dir = arg[2:]
|
|
54
|
+
idx += 1
|
|
55
|
+
|
|
56
|
+
if not work_dir:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
return Path(work_dir).expanduser().resolve()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_term(ctx: typer.Context) -> None:
|
|
63
|
+
extra_args = list(ctx.args)
|
|
64
|
+
acp_args = _default_acp_command()
|
|
65
|
+
acp_command = shlex.join(acp_args)
|
|
66
|
+
toad_parts = _default_toad_command()
|
|
67
|
+
args = [*toad_parts, "acp", acp_command]
|
|
68
|
+
project_dir = _extract_project_dir(extra_args)
|
|
69
|
+
if project_dir is not None:
|
|
70
|
+
args.append(str(project_dir))
|
|
71
|
+
|
|
72
|
+
result = subprocess.run(args)
|
|
73
|
+
if result.returncode != 0:
|
|
74
|
+
raise typer.Exit(code=result.returncode)
|