kimi-cli 0.35__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 +304 -0
- kimi_cli/__init__.py +374 -0
- kimi_cli/agent.py +261 -0
- kimi_cli/agents/koder/README.md +3 -0
- kimi_cli/agents/koder/agent.yaml +24 -0
- kimi_cli/agents/koder/sub.yaml +11 -0
- kimi_cli/agents/koder/system.md +72 -0
- kimi_cli/config.py +138 -0
- kimi_cli/llm.py +8 -0
- kimi_cli/metadata.py +117 -0
- kimi_cli/prompts/metacmds/__init__.py +4 -0
- kimi_cli/prompts/metacmds/compact.md +74 -0
- kimi_cli/prompts/metacmds/init.md +21 -0
- kimi_cli/py.typed +0 -0
- kimi_cli/share.py +8 -0
- kimi_cli/soul/__init__.py +59 -0
- kimi_cli/soul/approval.py +69 -0
- kimi_cli/soul/context.py +142 -0
- kimi_cli/soul/denwarenji.py +37 -0
- kimi_cli/soul/kimisoul.py +248 -0
- kimi_cli/soul/message.py +76 -0
- kimi_cli/soul/toolset.py +25 -0
- kimi_cli/soul/wire.py +101 -0
- kimi_cli/tools/__init__.py +85 -0
- kimi_cli/tools/bash/__init__.py +97 -0
- kimi_cli/tools/bash/bash.md +31 -0
- kimi_cli/tools/dmail/__init__.py +38 -0
- kimi_cli/tools/dmail/dmail.md +15 -0
- kimi_cli/tools/file/__init__.py +21 -0
- kimi_cli/tools/file/glob.md +17 -0
- kimi_cli/tools/file/glob.py +149 -0
- kimi_cli/tools/file/grep.md +5 -0
- kimi_cli/tools/file/grep.py +285 -0
- kimi_cli/tools/file/patch.md +8 -0
- kimi_cli/tools/file/patch.py +131 -0
- kimi_cli/tools/file/read.md +14 -0
- kimi_cli/tools/file/read.py +139 -0
- kimi_cli/tools/file/replace.md +7 -0
- kimi_cli/tools/file/replace.py +132 -0
- kimi_cli/tools/file/write.md +5 -0
- kimi_cli/tools/file/write.py +107 -0
- kimi_cli/tools/mcp.py +85 -0
- kimi_cli/tools/task/__init__.py +156 -0
- kimi_cli/tools/task/task.md +26 -0
- kimi_cli/tools/test.py +55 -0
- kimi_cli/tools/think/__init__.py +21 -0
- kimi_cli/tools/think/think.md +1 -0
- kimi_cli/tools/todo/__init__.py +27 -0
- kimi_cli/tools/todo/set_todo_list.md +15 -0
- kimi_cli/tools/utils.py +150 -0
- kimi_cli/tools/web/__init__.py +4 -0
- kimi_cli/tools/web/fetch.md +1 -0
- kimi_cli/tools/web/fetch.py +94 -0
- kimi_cli/tools/web/search.md +1 -0
- kimi_cli/tools/web/search.py +126 -0
- kimi_cli/ui/__init__.py +68 -0
- kimi_cli/ui/acp/__init__.py +441 -0
- kimi_cli/ui/print/__init__.py +176 -0
- kimi_cli/ui/shell/__init__.py +326 -0
- kimi_cli/ui/shell/console.py +3 -0
- kimi_cli/ui/shell/liveview.py +158 -0
- kimi_cli/ui/shell/metacmd.py +309 -0
- kimi_cli/ui/shell/prompt.py +574 -0
- kimi_cli/ui/shell/setup.py +192 -0
- kimi_cli/ui/shell/update.py +204 -0
- kimi_cli/utils/changelog.py +101 -0
- kimi_cli/utils/logging.py +18 -0
- kimi_cli/utils/message.py +8 -0
- kimi_cli/utils/path.py +23 -0
- kimi_cli/utils/provider.py +64 -0
- kimi_cli/utils/pyinstaller.py +24 -0
- kimi_cli/utils/string.py +12 -0
- kimi_cli-0.35.dist-info/METADATA +24 -0
- kimi_cli-0.35.dist-info/RECORD +76 -0
- kimi_cli-0.35.dist-info/WHEEL +4 -0
- kimi_cli-0.35.dist-info/entry_points.txt +3 -0
kimi_cli/soul/message.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from kosong.base.message import ContentPart, Message, TextPart
|
|
2
|
+
from kosong.tooling import ToolError, ToolOk, ToolResult
|
|
3
|
+
from kosong.tooling.error import ToolRuntimeError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def system(message: str) -> ContentPart:
|
|
7
|
+
return TextPart(text=f"<system>{message}</system>")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def tool_result_to_messages(tool_result: ToolResult) -> list[Message]:
|
|
11
|
+
"""Convert a tool result to a list of messages."""
|
|
12
|
+
if isinstance(tool_result.result, ToolError):
|
|
13
|
+
assert tool_result.result.message, "ToolError should have a message"
|
|
14
|
+
message = tool_result.result.message
|
|
15
|
+
if isinstance(tool_result.result, ToolRuntimeError):
|
|
16
|
+
message += "\nThis is an unexpected error and the tool is probably not working."
|
|
17
|
+
content = [system(message)]
|
|
18
|
+
if tool_result.result.output:
|
|
19
|
+
content.append(TextPart(text=tool_result.result.output))
|
|
20
|
+
return [
|
|
21
|
+
Message(
|
|
22
|
+
role="tool",
|
|
23
|
+
content=content,
|
|
24
|
+
tool_call_id=tool_result.tool_call_id,
|
|
25
|
+
)
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
content = tool_ok_to_message_content(tool_result.result)
|
|
29
|
+
text_parts = []
|
|
30
|
+
non_text_parts = []
|
|
31
|
+
for part in content:
|
|
32
|
+
if isinstance(part, TextPart):
|
|
33
|
+
text_parts.append(part)
|
|
34
|
+
else:
|
|
35
|
+
non_text_parts.append(part)
|
|
36
|
+
|
|
37
|
+
if not non_text_parts:
|
|
38
|
+
return [
|
|
39
|
+
Message(
|
|
40
|
+
role="tool",
|
|
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
|
+
)
|
|
50
|
+
)
|
|
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
|
+
|
|
60
|
+
|
|
61
|
+
def tool_ok_to_message_content(result: ToolOk) -> list[ContentPart]:
|
|
62
|
+
"""Convert a tool return value to a list of message content parts."""
|
|
63
|
+
content = []
|
|
64
|
+
if result.message:
|
|
65
|
+
content.append(system(result.message))
|
|
66
|
+
match output := result.output:
|
|
67
|
+
case str(text):
|
|
68
|
+
if text:
|
|
69
|
+
content.append(TextPart(text=text))
|
|
70
|
+
case ContentPart():
|
|
71
|
+
content.append(output)
|
|
72
|
+
case _:
|
|
73
|
+
content.extend(list(output))
|
|
74
|
+
if not content:
|
|
75
|
+
content.append(system("Tool output is empty."))
|
|
76
|
+
return content
|
kimi_cli/soul/toolset.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
from kosong.base.message import ToolCall
|
|
5
|
+
from kosong.tooling import HandleResult, SimpleToolset
|
|
6
|
+
|
|
7
|
+
current_tool_call = ContextVar[ToolCall | None]("current_tool_call", default=None)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_current_tool_call_or_none() -> ToolCall | None:
|
|
11
|
+
"""
|
|
12
|
+
Get the current tool call or None.
|
|
13
|
+
Expect to be not None when called from a `__call__` method of a tool.
|
|
14
|
+
"""
|
|
15
|
+
return current_tool_call.get()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CustomToolset(SimpleToolset):
|
|
19
|
+
@override
|
|
20
|
+
def handle(self, tool_call: ToolCall) -> HandleResult:
|
|
21
|
+
token = current_tool_call.set(tool_call)
|
|
22
|
+
try:
|
|
23
|
+
return super().handle(tool_call)
|
|
24
|
+
finally:
|
|
25
|
+
current_tool_call.reset(token)
|
kimi_cli/soul/wire.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import uuid
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from kosong.base.message import ContentPart, ToolCall, ToolCallPart
|
|
8
|
+
from kosong.tooling import ToolResult
|
|
9
|
+
|
|
10
|
+
from kimi_cli.soul import StatusSnapshot
|
|
11
|
+
from kimi_cli.utils.logging import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StepBegin(NamedTuple):
|
|
15
|
+
n: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StepInterrupted(NamedTuple):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StatusUpdate(NamedTuple):
|
|
23
|
+
status: StatusSnapshot
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
type ControlFlowEvent = StepBegin | StepInterrupted | StatusUpdate
|
|
27
|
+
type Event = ControlFlowEvent | ContentPart | ToolCall | ToolCallPart | ToolResult
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ApprovalResponse(Enum):
|
|
31
|
+
APPROVE = "approve"
|
|
32
|
+
APPROVE_FOR_SESSION = "approve_for_session"
|
|
33
|
+
REJECT = "reject"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApprovalRequest:
|
|
37
|
+
def __init__(self, tool_call_id: str, action: str, description: str):
|
|
38
|
+
self.id = str(uuid.uuid4())
|
|
39
|
+
self.tool_call_id = tool_call_id
|
|
40
|
+
self.action = action
|
|
41
|
+
self.description = description
|
|
42
|
+
self._future = asyncio.Future[ApprovalResponse]()
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
return (
|
|
46
|
+
f"ApprovalRequest(id={self.id}, tool_call_id={self.tool_call_id}, "
|
|
47
|
+
f"action={self.action}, description={self.description})"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def wait(self) -> ApprovalResponse:
|
|
51
|
+
"""
|
|
52
|
+
Wait for the request to be resolved or cancelled.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
ApprovalResponse: The response to the approval request.
|
|
56
|
+
"""
|
|
57
|
+
return await self._future
|
|
58
|
+
|
|
59
|
+
def resolve(self, response: ApprovalResponse) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Resolve the approval request with the given response.
|
|
62
|
+
This will cause the `wait()` method to return the response.
|
|
63
|
+
"""
|
|
64
|
+
self._future.set_result(response)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
type WireMessage = Event | ApprovalRequest
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Wire:
|
|
71
|
+
"""
|
|
72
|
+
A channel for communication between the soul and the UI.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self._queue = asyncio.Queue[WireMessage]()
|
|
77
|
+
|
|
78
|
+
def send(self, msg: WireMessage) -> None:
|
|
79
|
+
if not isinstance(msg, ContentPart | ToolCallPart):
|
|
80
|
+
logger.debug("Sending wire message: {msg}", msg=msg)
|
|
81
|
+
self._queue.put_nowait(msg)
|
|
82
|
+
|
|
83
|
+
async def receive(self) -> WireMessage:
|
|
84
|
+
msg = await self._queue.get()
|
|
85
|
+
if not isinstance(msg, ContentPart | ToolCallPart):
|
|
86
|
+
logger.debug("Receiving wire message: {msg}", msg=msg)
|
|
87
|
+
return msg
|
|
88
|
+
|
|
89
|
+
def shutdown(self) -> None:
|
|
90
|
+
self._queue.shutdown()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
current_wire = ContextVar[Wire | None]("current_wire", default=None)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_wire_or_none() -> Wire | None:
|
|
97
|
+
"""
|
|
98
|
+
Get the current wire or None.
|
|
99
|
+
Expect to be not None when called from anywhere in the agent loop.
|
|
100
|
+
"""
|
|
101
|
+
return current_wire.get()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import streamingjson
|
|
5
|
+
from kosong.utils.typing import JsonType
|
|
6
|
+
|
|
7
|
+
from kimi_cli.utils.string import shorten_middle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_subtitle(lexer: streamingjson.Lexer, tool_name: str) -> str | None:
|
|
11
|
+
try:
|
|
12
|
+
curr_args: JsonType = json.loads(lexer.complete_json())
|
|
13
|
+
except json.JSONDecodeError:
|
|
14
|
+
return None
|
|
15
|
+
if not curr_args:
|
|
16
|
+
return None
|
|
17
|
+
subtitle: str = ""
|
|
18
|
+
match tool_name:
|
|
19
|
+
case "Task":
|
|
20
|
+
if not isinstance(curr_args, dict) or not curr_args.get("description"):
|
|
21
|
+
return None
|
|
22
|
+
subtitle = str(curr_args["description"])
|
|
23
|
+
case "SendDMail":
|
|
24
|
+
return "El Psy Kongroo"
|
|
25
|
+
case "Think":
|
|
26
|
+
if not isinstance(curr_args, dict) or not curr_args.get("thought"):
|
|
27
|
+
return None
|
|
28
|
+
subtitle = str(curr_args["thought"])
|
|
29
|
+
case "SetTodoList":
|
|
30
|
+
if not isinstance(curr_args, dict) or not curr_args.get("todos"):
|
|
31
|
+
return None
|
|
32
|
+
if not isinstance(curr_args["todos"], list):
|
|
33
|
+
return None
|
|
34
|
+
for todo in curr_args["todos"]:
|
|
35
|
+
if not isinstance(todo, dict) or not todo.get("title"):
|
|
36
|
+
continue
|
|
37
|
+
subtitle += f"• {todo['title']}"
|
|
38
|
+
if todo.get("status"):
|
|
39
|
+
subtitle += f" [{todo['status']}]"
|
|
40
|
+
subtitle += "\n"
|
|
41
|
+
return "\n" + subtitle.strip()
|
|
42
|
+
case "Bash":
|
|
43
|
+
if not isinstance(curr_args, dict) or not curr_args.get("command"):
|
|
44
|
+
return None
|
|
45
|
+
subtitle = str(curr_args["command"])
|
|
46
|
+
case "ReadFile":
|
|
47
|
+
if not isinstance(curr_args, dict) or not curr_args.get("path"):
|
|
48
|
+
return None
|
|
49
|
+
subtitle = _normalize_path(str(curr_args["path"]))
|
|
50
|
+
case "Glob":
|
|
51
|
+
if not isinstance(curr_args, dict) or not curr_args.get("pattern"):
|
|
52
|
+
return None
|
|
53
|
+
subtitle = str(curr_args["pattern"])
|
|
54
|
+
case "Grep":
|
|
55
|
+
if not isinstance(curr_args, dict) or not curr_args.get("pattern"):
|
|
56
|
+
return None
|
|
57
|
+
subtitle = str(curr_args["pattern"])
|
|
58
|
+
case "WriteFile":
|
|
59
|
+
if not isinstance(curr_args, dict) or not curr_args.get("path"):
|
|
60
|
+
return None
|
|
61
|
+
subtitle = _normalize_path(str(curr_args["path"]))
|
|
62
|
+
case "StrReplaceFile":
|
|
63
|
+
if not isinstance(curr_args, dict) or not curr_args.get("path"):
|
|
64
|
+
return None
|
|
65
|
+
subtitle = _normalize_path(str(curr_args["path"]))
|
|
66
|
+
case "SearchWeb":
|
|
67
|
+
if not isinstance(curr_args, dict) or not curr_args.get("query"):
|
|
68
|
+
return None
|
|
69
|
+
subtitle = str(curr_args["query"])
|
|
70
|
+
case "FetchURL":
|
|
71
|
+
if not isinstance(curr_args, dict) or not curr_args.get("url"):
|
|
72
|
+
return None
|
|
73
|
+
subtitle = str(curr_args["url"])
|
|
74
|
+
case _:
|
|
75
|
+
subtitle = "".join(lexer.json_content)
|
|
76
|
+
if tool_name not in ["SetTodoList"]:
|
|
77
|
+
subtitle = shorten_middle(subtitle, width=50)
|
|
78
|
+
return subtitle
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _normalize_path(path: str) -> str:
|
|
82
|
+
cwd = str(Path.cwd().absolute())
|
|
83
|
+
if path.startswith(cwd):
|
|
84
|
+
path = path[len(cwd) :].lstrip("/\\")
|
|
85
|
+
return path
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import override
|
|
4
|
+
|
|
5
|
+
from kosong.tooling import CallableTool2, ToolReturnType
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from kimi_cli.soul.approval import Approval
|
|
9
|
+
from kimi_cli.tools.utils import ToolRejectedError, ToolResultBuilder, load_desc
|
|
10
|
+
|
|
11
|
+
MAX_TIMEOUT = 5 * 60
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Params(BaseModel):
|
|
15
|
+
command: str = Field(description="The bash command to execute.")
|
|
16
|
+
timeout: int = Field(
|
|
17
|
+
description=(
|
|
18
|
+
"The timeout in seconds for the command to execute. "
|
|
19
|
+
"If the command takes longer than this, it will be killed."
|
|
20
|
+
),
|
|
21
|
+
default=60,
|
|
22
|
+
ge=1,
|
|
23
|
+
le=MAX_TIMEOUT,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Bash(CallableTool2[Params]):
|
|
28
|
+
name: str = "Bash"
|
|
29
|
+
description: str = load_desc(Path(__file__).parent / "bash.md", {})
|
|
30
|
+
params: type[Params] = Params
|
|
31
|
+
|
|
32
|
+
def __init__(self, approval: Approval, **kwargs):
|
|
33
|
+
super().__init__(**kwargs)
|
|
34
|
+
self._approval = approval
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
38
|
+
builder = ToolResultBuilder()
|
|
39
|
+
|
|
40
|
+
if not await self._approval.request(
|
|
41
|
+
f"run command {params.command}", f"Run command `{params.command}`"
|
|
42
|
+
):
|
|
43
|
+
return ToolRejectedError()
|
|
44
|
+
|
|
45
|
+
def stdout_cb(line: bytes):
|
|
46
|
+
line_str = line.decode()
|
|
47
|
+
builder.write(line_str)
|
|
48
|
+
|
|
49
|
+
def stderr_cb(line: bytes):
|
|
50
|
+
line_str = line.decode()
|
|
51
|
+
builder.write(line_str)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
exitcode = await _stream_subprocess(
|
|
55
|
+
params.command, stdout_cb, stderr_cb, params.timeout
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if exitcode == 0:
|
|
59
|
+
return builder.ok("Command executed successfully.")
|
|
60
|
+
else:
|
|
61
|
+
return builder.error(
|
|
62
|
+
f"Command failed with exit code: {exitcode}.",
|
|
63
|
+
brief=f"Failed with exit code: {exitcode}",
|
|
64
|
+
)
|
|
65
|
+
except TimeoutError:
|
|
66
|
+
return builder.error(
|
|
67
|
+
f"Command killed by timeout ({params.timeout}s)",
|
|
68
|
+
brief=f"Killed by timeout ({params.timeout}s)",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _stream_subprocess(command: str, stdout_cb, stderr_cb, timeout: int) -> int:
|
|
73
|
+
async def _read_stream(stream, cb):
|
|
74
|
+
while True:
|
|
75
|
+
line = await stream.readline()
|
|
76
|
+
if line:
|
|
77
|
+
cb(line)
|
|
78
|
+
else:
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
# FIXME: if the event loop is cancelled, an exception may be raised when the process finishes
|
|
82
|
+
process = await asyncio.create_subprocess_shell(
|
|
83
|
+
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
await asyncio.wait_for(
|
|
88
|
+
asyncio.gather(
|
|
89
|
+
_read_stream(process.stdout, stdout_cb),
|
|
90
|
+
_read_stream(process.stderr, stderr_cb),
|
|
91
|
+
),
|
|
92
|
+
timeout,
|
|
93
|
+
)
|
|
94
|
+
return await process.wait()
|
|
95
|
+
except TimeoutError:
|
|
96
|
+
process.kill()
|
|
97
|
+
raise
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Execute a shell command. Use this tool to explore the filesystem, edit files, run scripts, get system information, etc.
|
|
2
|
+
|
|
3
|
+
**Output:**
|
|
4
|
+
The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the exit code will be provided in a system tag.
|
|
5
|
+
|
|
6
|
+
**Guidelines for safety and security:**
|
|
7
|
+
- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls.
|
|
8
|
+
- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, you shall set `timeout` argument to a reasonable value.
|
|
9
|
+
- Avoid using `..` to access files or directories outside of the working directory.
|
|
10
|
+
- Avoid modifying files outside of the working directory unless explicitly instructed to do so.
|
|
11
|
+
- Never run commands that require superuser privileges unless explicitly instructed to do so.
|
|
12
|
+
|
|
13
|
+
**Guidelines for efficiency:**
|
|
14
|
+
- For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la`
|
|
15
|
+
- Use `;` to run commands sequentially regardless of success/failure
|
|
16
|
+
- Use `||` for conditional execution (run second command only if first fails)
|
|
17
|
+
- Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands
|
|
18
|
+
- Always quote file paths containing spaces with double quotes (e.g., cd "/path with spaces/")
|
|
19
|
+
- Use `if`, `case`, `for`, `while` control flows to execute complex logic in a single call.
|
|
20
|
+
- Verify directory structure before create/edit/delete files or directories to reduce the risk of failure.
|
|
21
|
+
|
|
22
|
+
**Commands available:**
|
|
23
|
+
- Shell environment: cd, pwd, export, unset, env
|
|
24
|
+
- File system operations: ls, find, mkdir, rm, cp, mv, touch, chmod, chown
|
|
25
|
+
- File viewing/editing: cat, grep, head, tail, diff, patch
|
|
26
|
+
- Text processing: awk, sed, sort, uniq, wc
|
|
27
|
+
- System information/operations: ps, kill, top, df, free, uname, whoami, id, date
|
|
28
|
+
- Package management: pip, uv, npm, yarn, bun, cargo
|
|
29
|
+
- Network operations: curl, wget, ping, telnet, ssh
|
|
30
|
+
- Archive operations: tar, zip, unzip
|
|
31
|
+
- Other: Other commands available in the shell environment. Check the existence of a command by running `which <command>` before using it.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
from kosong.tooling import CallableTool2, ToolError, ToolReturnType
|
|
5
|
+
|
|
6
|
+
from kimi_cli.soul.denwarenji import DenwaRenji, DenwaRenjiError, DMail
|
|
7
|
+
|
|
8
|
+
NAME = "SendDMail"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SendDMail(CallableTool2):
|
|
12
|
+
name: str = NAME
|
|
13
|
+
description: str = (Path(__file__).parent / "dmail.md").read_text()
|
|
14
|
+
params: type[DMail] = DMail
|
|
15
|
+
|
|
16
|
+
def __init__(self, denwa_renji: DenwaRenji, **kwargs):
|
|
17
|
+
super().__init__(**kwargs)
|
|
18
|
+
self._denwa_renji = denwa_renji
|
|
19
|
+
|
|
20
|
+
@override
|
|
21
|
+
async def __call__(self, params: DMail) -> ToolReturnType:
|
|
22
|
+
try:
|
|
23
|
+
self._denwa_renji.send_dmail(params)
|
|
24
|
+
except DenwaRenjiError as e:
|
|
25
|
+
return ToolError(
|
|
26
|
+
output="",
|
|
27
|
+
message=f"Failed to send D-Mail. Error: {str(e)}",
|
|
28
|
+
brief="Failed to send D-Mail",
|
|
29
|
+
)
|
|
30
|
+
# always return an error because a successful SendDMail call will never return
|
|
31
|
+
return ToolError(
|
|
32
|
+
output="",
|
|
33
|
+
message=(
|
|
34
|
+
"If you see this message, the D-Mail was not sent successfully. "
|
|
35
|
+
"This may be because some other tool that needs approval was rejected."
|
|
36
|
+
),
|
|
37
|
+
brief="D-Mail not sent",
|
|
38
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Send a message to the past, just like sending a D-Mail in Steins;Gate.
|
|
2
|
+
|
|
3
|
+
You can see some `user` messages with `CHECKPOINT {checkpoint_id}` wrapped in `<system>` tags in the context. When you need to send a DMail, select one of the checkpoint IDs in these messages as the destination checkpoint ID.
|
|
4
|
+
|
|
5
|
+
When a DMail is sent, the system will revert the current context to the specified checkpoint. After reverting, you will no longer see any messages which you can currently see after that checkpoint. The message in the DMail will be appended to the end of the context. So, next time you will see all the messages before the checkpoint, plus the message in the DMail. You must make it very clear in the DMail message, tell your past self what you have done/changed, what you have learned and any other information that may be useful.
|
|
6
|
+
|
|
7
|
+
When sending a DMail, DO NOT do much explanation to the user. The user do not care about this. Just explain to your past self.
|
|
8
|
+
|
|
9
|
+
Here are some typical scenarios you may want to send a DMail:
|
|
10
|
+
|
|
11
|
+
- You read a file, found it very large and most of the content is not relevant to the current task. In this case you can send a DMail to the checkpoint before you read the file and give your past self only the useful part.
|
|
12
|
+
- You searched the web, found the result very large.
|
|
13
|
+
- If you got what you need, you may send a DMail to the checkpoint before you searched the web and give your past self the useful part.
|
|
14
|
+
- If you did not get what you need, you may send a DMail to tell your past self to try another query.
|
|
15
|
+
- You wrote some code and it did not work as expected. You spent many struggling steps to fix it but the process is not relevant to the ultimate goal. In this case you can send a DMail to the checkpoint before you wrote the code and give your past self the fixed version of the code and tell yourself no need to write it again because you already wrote to the filesystem.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class FileOpsWindow:
|
|
2
|
+
"""Maintains a window of file operations."""
|
|
3
|
+
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from .glob import Glob # noqa: E402
|
|
8
|
+
from .grep import Grep # noqa: E402
|
|
9
|
+
from .patch import PatchFile # noqa: E402
|
|
10
|
+
from .read import ReadFile # noqa: E402
|
|
11
|
+
from .replace import StrReplaceFile # noqa: E402
|
|
12
|
+
from .write import WriteFile # noqa: E402
|
|
13
|
+
|
|
14
|
+
__all__ = (
|
|
15
|
+
"ReadFile",
|
|
16
|
+
"Glob",
|
|
17
|
+
"Grep",
|
|
18
|
+
"WriteFile",
|
|
19
|
+
"StrReplaceFile",
|
|
20
|
+
"PatchFile",
|
|
21
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Find files and directories using glob patterns. This tool supports standard glob syntax like `*`, `?`, and `**` for recursive searches.
|
|
2
|
+
|
|
3
|
+
**When to use:**
|
|
4
|
+
- Find files matching specific patterns (e.g., all Python files: `*.py`)
|
|
5
|
+
- Search for files recursively in subdirectories (e.g., `src/**/*.js`)
|
|
6
|
+
- Locate configuration files (e.g., `*.config.*`, `*.json`)
|
|
7
|
+
- Find test files (e.g., `test_*.py`, `*_test.go`)
|
|
8
|
+
|
|
9
|
+
**Example patterns:**
|
|
10
|
+
- `*.py` - All Python files in current directory
|
|
11
|
+
- `src/**/*.js` - All JavaScript files in src directory recursively
|
|
12
|
+
- `test_*.py` - Python test files starting with "test_"
|
|
13
|
+
- `*.config.{js,ts}` - Config files with .js or .ts extension
|
|
14
|
+
|
|
15
|
+
**Bad example patterns:**
|
|
16
|
+
- `**`, `**/*.py` - Any pattern starting with '**' will be rejected. Because it would recursively search all directories and subdirectories, which is very likely to yield large result that exceeds your context size. Always use more specific patterns like `src/**/*.py` instead.
|
|
17
|
+
- `node_modules/**/*.js` - Although this does not start with '**', it would still highly possible to yield large result because `node_modules` is well-known to contain too many directories and files. Avoid recursivelly searching in such directories, other examples include `venv`, `.venv`, `__pycache__`, `target`. If you really need to search in a dependency, use more specific patterns like `node_modules/react/src/*` instead.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Glob tool implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import override
|
|
6
|
+
|
|
7
|
+
import aiofiles.os
|
|
8
|
+
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from kimi_cli.agent import BuiltinSystemPromptArgs
|
|
12
|
+
from kimi_cli.tools.utils import load_desc
|
|
13
|
+
|
|
14
|
+
MAX_MATCHES = 1000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Params(BaseModel):
|
|
18
|
+
pattern: str = Field(description=("Glob pattern to match files/directories."))
|
|
19
|
+
directory: str | None = Field(
|
|
20
|
+
description=(
|
|
21
|
+
"Absolute path to the directory to search in (defaults to working directory)."
|
|
22
|
+
),
|
|
23
|
+
default=None,
|
|
24
|
+
)
|
|
25
|
+
include_dirs: bool = Field(
|
|
26
|
+
description="Whether to include directories in results.",
|
|
27
|
+
default=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Glob(CallableTool2[Params]):
|
|
32
|
+
name: str = "Glob"
|
|
33
|
+
description: str = load_desc(
|
|
34
|
+
Path(__file__).parent / "glob.md",
|
|
35
|
+
{
|
|
36
|
+
"MAX_MATCHES": str(MAX_MATCHES),
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
params: type[Params] = Params
|
|
40
|
+
|
|
41
|
+
def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
|
|
42
|
+
super().__init__(**kwargs)
|
|
43
|
+
self._work_dir = builtin_args.KIMI_WORK_DIR
|
|
44
|
+
|
|
45
|
+
async def _validate_pattern(self, pattern: str) -> ToolError | None:
|
|
46
|
+
"""Validate that the pattern is safe to use."""
|
|
47
|
+
if pattern.startswith("**"):
|
|
48
|
+
# TODO: give a `ls -la` result as the output
|
|
49
|
+
ls_result = await aiofiles.os.listdir(self._work_dir)
|
|
50
|
+
return ToolError(
|
|
51
|
+
output="\n".join(ls_result),
|
|
52
|
+
message=(
|
|
53
|
+
f"Pattern `{pattern}` starts with '**' which is not allowed. "
|
|
54
|
+
"This would recursively search all directories and may include large "
|
|
55
|
+
"directories like `node_modules`. Use more specific patterns instead. "
|
|
56
|
+
"For your convenience, a list of all files and directories in the "
|
|
57
|
+
"top level of the working directory is provided below."
|
|
58
|
+
),
|
|
59
|
+
brief="Unsafe pattern",
|
|
60
|
+
)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def _validate_directory(self, directory: Path) -> ToolError | None:
|
|
64
|
+
"""Validate that the directory is safe to search."""
|
|
65
|
+
resolved_dir = directory.resolve()
|
|
66
|
+
resolved_work_dir = self._work_dir.resolve()
|
|
67
|
+
|
|
68
|
+
# Ensure the directory is within work directory
|
|
69
|
+
if not str(resolved_dir).startswith(str(resolved_work_dir)):
|
|
70
|
+
return ToolError(
|
|
71
|
+
message=(
|
|
72
|
+
f"`{directory}` is outside the working directory. "
|
|
73
|
+
"You can only search within the working directory."
|
|
74
|
+
),
|
|
75
|
+
brief="Directory outside working directory",
|
|
76
|
+
)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
@override
|
|
80
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
81
|
+
try:
|
|
82
|
+
# Validate pattern safety
|
|
83
|
+
pattern_error = await self._validate_pattern(params.pattern)
|
|
84
|
+
if pattern_error:
|
|
85
|
+
return pattern_error
|
|
86
|
+
|
|
87
|
+
dir_path = Path(params.directory) if params.directory else self._work_dir
|
|
88
|
+
|
|
89
|
+
if not dir_path.is_absolute():
|
|
90
|
+
return ToolError(
|
|
91
|
+
message=(
|
|
92
|
+
f"`{params.directory}` is not an absolute path. "
|
|
93
|
+
"You must provide an absolute path to search."
|
|
94
|
+
),
|
|
95
|
+
brief="Invalid directory",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Validate directory safety
|
|
99
|
+
dir_error = self._validate_directory(dir_path)
|
|
100
|
+
if dir_error:
|
|
101
|
+
return dir_error
|
|
102
|
+
|
|
103
|
+
if not dir_path.exists():
|
|
104
|
+
return ToolError(
|
|
105
|
+
message=f"`{params.directory}` does not exist.",
|
|
106
|
+
brief="Directory not found",
|
|
107
|
+
)
|
|
108
|
+
if not dir_path.is_dir():
|
|
109
|
+
return ToolError(
|
|
110
|
+
message=f"`{params.directory}` is not a directory.",
|
|
111
|
+
brief="Invalid directory",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _glob(pattern: str) -> list[Path]:
|
|
115
|
+
return list(dir_path.glob(pattern))
|
|
116
|
+
|
|
117
|
+
# Perform the glob search - users can use ** directly in pattern
|
|
118
|
+
matches = await asyncio.to_thread(_glob, params.pattern)
|
|
119
|
+
|
|
120
|
+
# Filter out directories if not requested
|
|
121
|
+
if not params.include_dirs:
|
|
122
|
+
matches = [p for p in matches if p.is_file()]
|
|
123
|
+
|
|
124
|
+
# Sort for consistent output
|
|
125
|
+
matches.sort()
|
|
126
|
+
|
|
127
|
+
# Limit matches
|
|
128
|
+
message = (
|
|
129
|
+
f"Found {len(matches)} matches for pattern `{params.pattern}`."
|
|
130
|
+
if len(matches) > 0
|
|
131
|
+
else "No matches found for pattern `{params.pattern}`."
|
|
132
|
+
)
|
|
133
|
+
if len(matches) > MAX_MATCHES:
|
|
134
|
+
matches = matches[:MAX_MATCHES]
|
|
135
|
+
message += (
|
|
136
|
+
f" Only the first {MAX_MATCHES} matches are returned. "
|
|
137
|
+
"You may want to use a more specific pattern."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return ToolOk(
|
|
141
|
+
output="\n".join(str(p.relative_to(dir_path)) for p in matches),
|
|
142
|
+
message=message,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
return ToolError(
|
|
147
|
+
message=f"Failed to search for pattern {params.pattern}. Error: {e}",
|
|
148
|
+
brief="Glob failed",
|
|
149
|
+
)
|