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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
import aiofiles
|
|
5
|
+
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from kimi_cli.agent import BuiltinSystemPromptArgs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Edit(BaseModel):
|
|
12
|
+
old: str = Field(description="The old string to replace. Can be multi-line.")
|
|
13
|
+
new: str = Field(description="The new string to replace with. Can be multi-line.")
|
|
14
|
+
replace_all: bool = Field(description="Whether to replace all occurrences.", default=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Params(BaseModel):
|
|
18
|
+
path: str = Field(description="The absolute path to the file to edit.")
|
|
19
|
+
edit: Edit | list[Edit] = Field(
|
|
20
|
+
description=(
|
|
21
|
+
"The edit(s) to apply to the file. "
|
|
22
|
+
"You can provide a single edit or a list of edits here."
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StrReplaceFile(CallableTool2[Params]):
|
|
28
|
+
name: str = "StrReplaceFile"
|
|
29
|
+
description: str = (Path(__file__).parent / "replace.md").read_text()
|
|
30
|
+
params: type[Params] = Params
|
|
31
|
+
|
|
32
|
+
def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
|
|
33
|
+
super().__init__(**kwargs)
|
|
34
|
+
self._work_dir = builtin_args.KIMI_WORK_DIR
|
|
35
|
+
|
|
36
|
+
def _validate_path(self, path: Path) -> ToolError | None:
|
|
37
|
+
"""Validate that the path is safe to edit."""
|
|
38
|
+
# Check for path traversal attempts
|
|
39
|
+
resolved_path = path.resolve()
|
|
40
|
+
resolved_work_dir = self._work_dir.resolve()
|
|
41
|
+
|
|
42
|
+
# Ensure the path is within work directory
|
|
43
|
+
if not str(resolved_path).startswith(str(resolved_work_dir)):
|
|
44
|
+
return ToolError(
|
|
45
|
+
message=(
|
|
46
|
+
f"`{path}` is outside the working directory. "
|
|
47
|
+
"You can only edit files within the working directory."
|
|
48
|
+
),
|
|
49
|
+
brief="Path outside working directory",
|
|
50
|
+
)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def _apply_edit(self, content: str, edit: Edit) -> str:
|
|
54
|
+
"""Apply a single edit to the content."""
|
|
55
|
+
if edit.replace_all:
|
|
56
|
+
return content.replace(edit.old, edit.new)
|
|
57
|
+
else:
|
|
58
|
+
return content.replace(edit.old, edit.new, 1)
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
62
|
+
try:
|
|
63
|
+
p = Path(params.path)
|
|
64
|
+
|
|
65
|
+
if not p.is_absolute():
|
|
66
|
+
return ToolError(
|
|
67
|
+
message=(
|
|
68
|
+
f"`{params.path}` is not an absolute path. "
|
|
69
|
+
"You must provide an absolute path to edit a file."
|
|
70
|
+
),
|
|
71
|
+
brief="Invalid path",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Validate path safety
|
|
75
|
+
path_error = self._validate_path(p)
|
|
76
|
+
if path_error:
|
|
77
|
+
return path_error
|
|
78
|
+
|
|
79
|
+
if not p.exists():
|
|
80
|
+
return ToolError(
|
|
81
|
+
message=f"`{params.path}` does not exist.",
|
|
82
|
+
brief="File not found",
|
|
83
|
+
)
|
|
84
|
+
if not p.is_file():
|
|
85
|
+
return ToolError(
|
|
86
|
+
message=f"`{params.path}` is not a file.",
|
|
87
|
+
brief="Invalid path",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Read the file content
|
|
91
|
+
async with aiofiles.open(p, encoding="utf-8", errors="replace") as f:
|
|
92
|
+
content = await f.read()
|
|
93
|
+
|
|
94
|
+
original_content = content
|
|
95
|
+
edits = [params.edit] if isinstance(params.edit, Edit) else params.edit
|
|
96
|
+
|
|
97
|
+
# Apply all edits
|
|
98
|
+
for edit in edits:
|
|
99
|
+
content = self._apply_edit(content, edit)
|
|
100
|
+
|
|
101
|
+
# Check if any changes were made
|
|
102
|
+
if content == original_content:
|
|
103
|
+
return ToolError(
|
|
104
|
+
message="No replacements were made. The old string was not found in the file.",
|
|
105
|
+
brief="No replacements made",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Write the modified content back to the file
|
|
109
|
+
async with aiofiles.open(p, mode="w", encoding="utf-8") as f:
|
|
110
|
+
await f.write(content)
|
|
111
|
+
|
|
112
|
+
# Count changes for success message
|
|
113
|
+
total_replacements = 0
|
|
114
|
+
for edit in edits:
|
|
115
|
+
if edit.replace_all:
|
|
116
|
+
total_replacements += original_content.count(edit.old)
|
|
117
|
+
else:
|
|
118
|
+
total_replacements += 1 if edit.old in original_content else 0
|
|
119
|
+
|
|
120
|
+
return ToolOk(
|
|
121
|
+
output="",
|
|
122
|
+
message=(
|
|
123
|
+
f"File successfully edited. "
|
|
124
|
+
f"Applied {len(edits)} edit(s) with {total_replacements} total replacement(s)."
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
return ToolError(
|
|
130
|
+
message=f"Failed to edit. Error: {e}",
|
|
131
|
+
brief="Failed to edit file",
|
|
132
|
+
)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Write content to a file.
|
|
2
|
+
|
|
3
|
+
**Tips:**
|
|
4
|
+
- When `mode` is not specified, it defaults to `overwrite`. Always write with caution.
|
|
5
|
+
- When the content to write is too long (e.g. > 100 lines), use this tool multiple times instead of a single call. Use `overwrite` mode at the first time, then use `append` mode after the first write.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal, override
|
|
3
|
+
|
|
4
|
+
import aiofiles
|
|
5
|
+
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from kimi_cli.agent import BuiltinSystemPromptArgs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Params(BaseModel):
|
|
12
|
+
path: str = Field(description="The absolute path to the file to write")
|
|
13
|
+
content: str = Field(description="The content to write to the file")
|
|
14
|
+
mode: Literal["overwrite", "append"] = Field(
|
|
15
|
+
description=(
|
|
16
|
+
"The mode to use to write to the file. "
|
|
17
|
+
"Two modes are supported: `overwrite` for overwriting the whole file and "
|
|
18
|
+
"`append` for appending to the end of an existing file."
|
|
19
|
+
),
|
|
20
|
+
default="overwrite",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WriteFile(CallableTool2[Params]):
|
|
25
|
+
name: str = "WriteFile"
|
|
26
|
+
description: str = (Path(__file__).parent / "write.md").read_text()
|
|
27
|
+
params: type[Params] = Params
|
|
28
|
+
|
|
29
|
+
def __init__(self, builtin_args: BuiltinSystemPromptArgs, **kwargs):
|
|
30
|
+
super().__init__(**kwargs)
|
|
31
|
+
self._work_dir = builtin_args.KIMI_WORK_DIR
|
|
32
|
+
|
|
33
|
+
def _validate_path(self, path: Path) -> ToolError | None:
|
|
34
|
+
"""Validate that the path is safe to write."""
|
|
35
|
+
# Check for path traversal attempts
|
|
36
|
+
resolved_path = path.resolve()
|
|
37
|
+
resolved_work_dir = self._work_dir.resolve()
|
|
38
|
+
|
|
39
|
+
# Ensure the path is within work directory
|
|
40
|
+
if not str(resolved_path).startswith(str(resolved_work_dir)):
|
|
41
|
+
return ToolError(
|
|
42
|
+
message=(
|
|
43
|
+
f"`{path}` is outside the working directory. "
|
|
44
|
+
"You can only write files within the working directory."
|
|
45
|
+
),
|
|
46
|
+
brief="Path outside working directory",
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
@override
|
|
51
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
52
|
+
# TODO: checks:
|
|
53
|
+
# - check if the path may contain secrets
|
|
54
|
+
# - check if the file format is writable
|
|
55
|
+
try:
|
|
56
|
+
p = Path(params.path)
|
|
57
|
+
|
|
58
|
+
if not p.is_absolute():
|
|
59
|
+
return ToolError(
|
|
60
|
+
message=(
|
|
61
|
+
f"`{params.path}` is not an absolute path. "
|
|
62
|
+
"You must provide an absolute path to write a file."
|
|
63
|
+
),
|
|
64
|
+
brief="Invalid path",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Validate path safety
|
|
68
|
+
path_error = self._validate_path(p)
|
|
69
|
+
if path_error:
|
|
70
|
+
return path_error
|
|
71
|
+
|
|
72
|
+
if not p.parent.exists():
|
|
73
|
+
return ToolError(
|
|
74
|
+
message=f"`{params.path}` parent directory does not exist.",
|
|
75
|
+
brief="Parent directory not found",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Validate mode parameter
|
|
79
|
+
if params.mode not in ["overwrite", "append"]:
|
|
80
|
+
return ToolError(
|
|
81
|
+
message=(
|
|
82
|
+
f"Invalid write mode: `{params.mode}`. "
|
|
83
|
+
"Mode must be either `overwrite` or `append`."
|
|
84
|
+
),
|
|
85
|
+
brief="Invalid write mode",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Determine file mode for aiofiles
|
|
89
|
+
file_mode = "w" if params.mode == "overwrite" else "a"
|
|
90
|
+
|
|
91
|
+
# Write content to file
|
|
92
|
+
async with aiofiles.open(p, mode=file_mode, encoding="utf-8") as f:
|
|
93
|
+
await f.write(params.content)
|
|
94
|
+
|
|
95
|
+
# Get file info for success message
|
|
96
|
+
file_size = p.stat().st_size
|
|
97
|
+
action = "overwritten" if params.mode == "overwrite" else "appended to"
|
|
98
|
+
return ToolOk(
|
|
99
|
+
output="",
|
|
100
|
+
message=(f"File successfully {action}. Current size: {file_size} bytes."),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return ToolError(
|
|
105
|
+
message=f"Failed to write to {params.path}. Error: {e}",
|
|
106
|
+
brief="Failed to write file",
|
|
107
|
+
)
|
kimi_cli/tools/mcp.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fastmcp
|
|
2
|
+
import mcp
|
|
3
|
+
from fastmcp.client.client import CallToolResult
|
|
4
|
+
from kosong.base.message import AudioURLPart, ContentPart, ImageURLPart, TextPart
|
|
5
|
+
from kosong.tooling import CallableTool, ToolOk, ToolReturnType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MCPTool(CallableTool):
|
|
9
|
+
def __init__(self, mcp_tool: mcp.Tool, client: fastmcp.Client, **kwargs):
|
|
10
|
+
super().__init__(
|
|
11
|
+
name=mcp_tool.name,
|
|
12
|
+
description=mcp_tool.description or "",
|
|
13
|
+
parameters=mcp_tool.inputSchema,
|
|
14
|
+
**kwargs,
|
|
15
|
+
)
|
|
16
|
+
self._mcp_tool = mcp_tool
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
async def __call__(self, *args, **kwargs) -> ToolReturnType:
|
|
20
|
+
async with self._client as client:
|
|
21
|
+
result = await client.call_tool(self._mcp_tool.name, kwargs, timeout=20)
|
|
22
|
+
return convert_tool_result(result)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def convert_tool_result(result: CallToolResult) -> ToolReturnType:
|
|
26
|
+
content: list[ContentPart] = []
|
|
27
|
+
for part in result.content:
|
|
28
|
+
match part:
|
|
29
|
+
case mcp.types.TextContent(text=text):
|
|
30
|
+
content.append(TextPart(text=text))
|
|
31
|
+
case mcp.types.ImageContent(data=data, mimeType=mimeType):
|
|
32
|
+
content.append(
|
|
33
|
+
ImageURLPart(
|
|
34
|
+
image_url=ImageURLPart.ImageURL(url=f"data:{mimeType};base64,{data}")
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
case mcp.types.AudioContent(data=data, mimeType=mimeType):
|
|
38
|
+
content.append(
|
|
39
|
+
AudioURLPart(
|
|
40
|
+
audio_url=AudioURLPart.AudioURL(url=f"data:{mimeType};base64,{data}")
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
case mcp.types.EmbeddedResource(
|
|
44
|
+
resource=mcp.types.BlobResourceContents(uri=_uri, mimeType=mimeType, blob=blob)
|
|
45
|
+
):
|
|
46
|
+
mimeType = mimeType or "application/octet-stream"
|
|
47
|
+
if mimeType.startswith("image/"):
|
|
48
|
+
content.append(
|
|
49
|
+
ImageURLPart(
|
|
50
|
+
type="image_url",
|
|
51
|
+
image_url=ImageURLPart.ImageURL(
|
|
52
|
+
url=f"data:{mimeType};base64,{blob}",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
elif mimeType.startswith("audio/"):
|
|
57
|
+
content.append(
|
|
58
|
+
AudioURLPart(
|
|
59
|
+
type="audio_url",
|
|
60
|
+
audio_url=AudioURLPart.AudioURL(url=f"data:{mimeType};base64,{blob}"),
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError(f"Unsupported mime type: {mimeType}")
|
|
65
|
+
case mcp.types.ResourceLink(uri=uri, mimeType=mimeType, description=_description):
|
|
66
|
+
mimeType = mimeType or "application/octet-stream"
|
|
67
|
+
if mimeType.startswith("image/"):
|
|
68
|
+
content.append(
|
|
69
|
+
ImageURLPart(
|
|
70
|
+
type="image_url",
|
|
71
|
+
image_url=ImageURLPart.ImageURL(url=str(uri)),
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
elif mimeType.startswith("audio/"):
|
|
75
|
+
content.append(
|
|
76
|
+
AudioURLPart(
|
|
77
|
+
type="audio_url",
|
|
78
|
+
audio_url=AudioURLPart.AudioURL(url=str(uri)),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
raise ValueError(f"Unsupported mime type: {mimeType}")
|
|
83
|
+
case _:
|
|
84
|
+
raise ValueError(f"Unsupported MCP tool result part: {part}")
|
|
85
|
+
return ToolOk(output=content)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from kimi_cli.agent import Agent, AgentGlobals, AgentSpec, load_agent
|
|
8
|
+
from kimi_cli.soul import MaxStepsReached
|
|
9
|
+
from kimi_cli.soul.context import Context
|
|
10
|
+
from kimi_cli.soul.kimisoul import KimiSoul
|
|
11
|
+
from kimi_cli.soul.wire import ApprovalRequest, Wire, WireMessage, get_wire_or_none
|
|
12
|
+
from kimi_cli.tools.utils import load_desc
|
|
13
|
+
from kimi_cli.utils.message import message_extract_text
|
|
14
|
+
from kimi_cli.utils.path import next_available_rotation
|
|
15
|
+
|
|
16
|
+
# Maximum continuation attempts for task summary
|
|
17
|
+
MAX_CONTINUE_ATTEMPTS = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
CONTINUE_PROMPT = """
|
|
21
|
+
Your previous response was too brief. Please provide a more comprehensive summary that includes:
|
|
22
|
+
|
|
23
|
+
1. Specific technical details and implementations
|
|
24
|
+
2. Complete code examples if relevant
|
|
25
|
+
3. Detailed findings and analysis
|
|
26
|
+
4. All important information that should be aware of by the caller
|
|
27
|
+
""".strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Params(BaseModel):
|
|
31
|
+
description: str = Field(description="A short (3-5 word) description of the task")
|
|
32
|
+
subagent_name: str = Field(
|
|
33
|
+
description="The name of the specialized subagent to use for this task"
|
|
34
|
+
)
|
|
35
|
+
prompt: str = Field(
|
|
36
|
+
description=(
|
|
37
|
+
"The task for the subagent to perform. "
|
|
38
|
+
"You must provide a detailed prompt with all necessary background information "
|
|
39
|
+
"because the subagent cannot see anything in your context."
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Task(CallableTool2[Params]):
|
|
45
|
+
name: str = "Task"
|
|
46
|
+
params: type[Params] = Params
|
|
47
|
+
|
|
48
|
+
def __init__(self, agent_spec: AgentSpec, agent_globals: AgentGlobals, **kwargs):
|
|
49
|
+
subagents: dict[str, Agent] = {}
|
|
50
|
+
descs = []
|
|
51
|
+
|
|
52
|
+
# load all subagents
|
|
53
|
+
assert agent_spec.subagents is not None, "Task tool expects subagents"
|
|
54
|
+
for name, spec in agent_spec.subagents.items():
|
|
55
|
+
subagents[name] = load_agent(spec.path, agent_globals)
|
|
56
|
+
descs.append(f"- `{name}`: {spec.description}")
|
|
57
|
+
|
|
58
|
+
super().__init__(
|
|
59
|
+
description=load_desc(
|
|
60
|
+
Path(__file__).parent / "task.md",
|
|
61
|
+
{
|
|
62
|
+
"SUBAGENTS_MD": "\n".join(descs),
|
|
63
|
+
},
|
|
64
|
+
),
|
|
65
|
+
**kwargs,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self._agent_globals = agent_globals
|
|
69
|
+
self._session = agent_globals.session
|
|
70
|
+
self._subagents = subagents
|
|
71
|
+
|
|
72
|
+
async def _get_subagent_history_file(self) -> Path:
|
|
73
|
+
"""Generate a unique history file path for subagent."""
|
|
74
|
+
main_history_file = self._session.history_file
|
|
75
|
+
subagent_base_name = f"{main_history_file.stem}_sub"
|
|
76
|
+
main_history_file.parent.mkdir(parents=True, exist_ok=True) # just in case
|
|
77
|
+
sub_history_file = await next_available_rotation(
|
|
78
|
+
main_history_file.parent / f"{subagent_base_name}{main_history_file.suffix}"
|
|
79
|
+
)
|
|
80
|
+
assert sub_history_file is not None
|
|
81
|
+
return sub_history_file
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
85
|
+
if params.subagent_name not in self._subagents:
|
|
86
|
+
return ToolError(
|
|
87
|
+
message=f"Subagent not found: {params.subagent_name}",
|
|
88
|
+
brief="Subagent not found",
|
|
89
|
+
)
|
|
90
|
+
agent = self._subagents[params.subagent_name]
|
|
91
|
+
try:
|
|
92
|
+
result = await self._run_subagent(agent, params.prompt)
|
|
93
|
+
return result
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return ToolError(
|
|
96
|
+
message=f"Failed to run subagent: {e}",
|
|
97
|
+
brief="Failed to run subagent",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def _run_subagent(self, agent: Agent, prompt: str) -> ToolReturnType:
|
|
101
|
+
"""Run subagent with optional continuation for task summary."""
|
|
102
|
+
subagent_history_file = await self._get_subagent_history_file()
|
|
103
|
+
context = Context(file_backend=subagent_history_file)
|
|
104
|
+
soul = KimiSoul(
|
|
105
|
+
agent,
|
|
106
|
+
agent_globals=self._agent_globals,
|
|
107
|
+
context=context,
|
|
108
|
+
loop_control=self._agent_globals.config.loop_control,
|
|
109
|
+
)
|
|
110
|
+
wire = get_wire_or_none()
|
|
111
|
+
assert wire is not None, "Wire is expected to be set"
|
|
112
|
+
sub_wire = _SubWire(wire)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
await soul.run(prompt, sub_wire)
|
|
116
|
+
except MaxStepsReached as e:
|
|
117
|
+
return ToolError(
|
|
118
|
+
message=(
|
|
119
|
+
f"Max steps {e.n_steps} reached when running subagent. "
|
|
120
|
+
"Please try splitting the task into smaller subtasks."
|
|
121
|
+
),
|
|
122
|
+
brief="Max steps reached",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
_error_msg = (
|
|
126
|
+
"The subagent seemed not to run properly. Maybe you have to do the task yourself."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Check if the subagent context is valid
|
|
130
|
+
if len(context.history) == 0 or context.history[-1].role != "assistant":
|
|
131
|
+
return ToolError(message=_error_msg, brief="Failed to run subagent")
|
|
132
|
+
|
|
133
|
+
final_response = message_extract_text(context.history[-1])
|
|
134
|
+
|
|
135
|
+
# Check if response is too brief, if so, run again with continuation prompt
|
|
136
|
+
n_attempts_remaining = MAX_CONTINUE_ATTEMPTS
|
|
137
|
+
if len(final_response) < 200 and n_attempts_remaining > 0:
|
|
138
|
+
await soul.run(CONTINUE_PROMPT, sub_wire)
|
|
139
|
+
|
|
140
|
+
if len(context.history) == 0 or context.history[-1].role != "assistant":
|
|
141
|
+
return ToolError(message=_error_msg, brief="Failed to run subagent")
|
|
142
|
+
final_response = message_extract_text(context.history[-1])
|
|
143
|
+
|
|
144
|
+
return ToolOk(output=final_response)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class _SubWire(Wire):
|
|
148
|
+
def __init__(self, super_wire: Wire):
|
|
149
|
+
super().__init__()
|
|
150
|
+
self._super_wire = super_wire
|
|
151
|
+
|
|
152
|
+
@override
|
|
153
|
+
def send(self, msg: WireMessage):
|
|
154
|
+
if isinstance(msg, ApprovalRequest):
|
|
155
|
+
self._super_wire.send(msg)
|
|
156
|
+
# TODO: visualize subagent behavior by sending other messages in some way
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Spawn a subagent to perform a specific task. Subagent will be spawned with a fresh context without any history of yours.
|
|
2
|
+
|
|
3
|
+
**Context Isolation**
|
|
4
|
+
|
|
5
|
+
Context isolation is one of the key benefits of using subagents. By delegating tasks to subagents, you can keep your main context clean and focused on the main goal requested by the user.
|
|
6
|
+
|
|
7
|
+
Here are some scenerios you may want this tool for context isolation:
|
|
8
|
+
|
|
9
|
+
- You wrote some code and it did not work as expected. In this case you can spawn a subagent to fix the code, asking the subagent to return how it is fixed. This can potentially benefit because the detailed process of fixing the code may not be relevant to your main goal, and may clutter your context.
|
|
10
|
+
- When you need some latest knowledge of a specific library, framework or technology to proceed with your task, you can spawn a subagent to search on the internet for the needed information and return to you the gathered relevant information, for example code examples, API references, etc. This can avoid ton of irrelevant search results in your own context.
|
|
11
|
+
|
|
12
|
+
DO NOT directly forward the user prompt to Task tool. DO NOT simply spawn Task tool for each todo item. This will cause the user confused because the user cannot see what the subagent do. Only you can see the response from the subagent. So, only spawn subagents for very specific and narrow tasks like fixing a compilation error, or searching for a specific solution.
|
|
13
|
+
|
|
14
|
+
**Parallel Multi-Tasking**
|
|
15
|
+
|
|
16
|
+
Parallel multi-tasking is another key benefit of this tool. When the user request involves multiple subtasks that are independent of each other, you can use Task tool multiple times in a single response to let subagents work in parallel for you.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
|
|
20
|
+
- User requests to code, refactor or fix multiple modules/files in a project, and they can be tested independently. In this case you can spawn multiple subagents each working on a different module/file.
|
|
21
|
+
- When you need to analyze a huge codebase (> hundreds of thousands of lines), you can spawn multiple subagents each exploring on a different part of the codebase and gather the summarized results.
|
|
22
|
+
- When you need to search the web for multiple queries, you can spawn multiple subagents for better efficiency.
|
|
23
|
+
|
|
24
|
+
**Available Subagents:**
|
|
25
|
+
|
|
26
|
+
${SUBAGENTS_MD}
|
kimi_cli/tools/test.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
from kosong.tooling import CallableTool2, ToolOk, ToolReturnType
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlusParams(BaseModel):
|
|
9
|
+
a: float
|
|
10
|
+
b: float
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Plus(CallableTool2[PlusParams]):
|
|
14
|
+
name: str = "plus"
|
|
15
|
+
description: str = "Add two numbers"
|
|
16
|
+
params: type[PlusParams] = PlusParams
|
|
17
|
+
|
|
18
|
+
@override
|
|
19
|
+
async def __call__(self, params: PlusParams) -> ToolReturnType:
|
|
20
|
+
return ToolOk(output=str(params.a + params.b))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CompareParams(BaseModel):
|
|
24
|
+
a: float
|
|
25
|
+
b: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Compare(CallableTool2[CompareParams]):
|
|
29
|
+
name: str = "compare"
|
|
30
|
+
description: str = "Compare two numbers"
|
|
31
|
+
params: type[CompareParams] = CompareParams
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
async def __call__(self, params: CompareParams) -> ToolReturnType:
|
|
35
|
+
if params.a > params.b:
|
|
36
|
+
return ToolOk(output="greater")
|
|
37
|
+
elif params.a < params.b:
|
|
38
|
+
return ToolOk(output="less")
|
|
39
|
+
else:
|
|
40
|
+
return ToolOk(output="equal")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PanicParams(BaseModel):
|
|
44
|
+
message: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Panic(CallableTool2[PanicParams]):
|
|
48
|
+
name: str = "panic"
|
|
49
|
+
description: str = "Raise an exception to cause the tool call to fail."
|
|
50
|
+
params: type[PanicParams] = PanicParams
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
async def __call__(self, params: PanicParams) -> ToolReturnType:
|
|
54
|
+
await asyncio.sleep(2)
|
|
55
|
+
raise Exception(f"panicked with a message with {len(params.message)} characters")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
from kosong.tooling import CallableTool2, ToolOk, ToolReturnType
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from kimi_cli.tools.utils import load_desc
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Params(BaseModel):
|
|
11
|
+
thought: str = Field(description=("A thought to think about."))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Think(CallableTool2[Params]):
|
|
15
|
+
name: str = "Think"
|
|
16
|
+
description: str = load_desc(Path(__file__).parent / "think.md", {})
|
|
17
|
+
params: type[Params] = Params
|
|
18
|
+
|
|
19
|
+
@override
|
|
20
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
21
|
+
return ToolOk(output="", message="Thought logged")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Use the tool to think about something. It will not obtain new information or change the database, but just append the thought to the log. Use it when complex reasoning or some cache memory is needed.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal, override
|
|
3
|
+
|
|
4
|
+
from kosong.tooling import CallableTool2, ToolOk, ToolReturnType
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Todo(BaseModel):
|
|
9
|
+
title: str = Field(description="The title of the todo", min_length=1)
|
|
10
|
+
status: Literal["Pending", "In Progress", "Done"] = Field(description="The status of the todo")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Params(BaseModel):
|
|
14
|
+
todos: list[Todo] = Field(description="The updated todo list")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SetTodoList(CallableTool2[Params]):
|
|
18
|
+
name: str = "SetTodoList"
|
|
19
|
+
description: str = (Path(__file__).parent / "set_todo_list.md").read_text()
|
|
20
|
+
params: type[Params] = Params
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
24
|
+
rendered = ""
|
|
25
|
+
for todo in params.todos:
|
|
26
|
+
rendered += f"- {todo.title} [{todo.status}]\n"
|
|
27
|
+
return ToolOk(output=rendered)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Update the whole todo list.
|
|
2
|
+
|
|
3
|
+
Todo list is a simple yet powerful tool to help you get things done. You typically want to use this tool when the given task involves multiple subtasks/milestones, or, multiple tasks are given in a single request. This tool can help you to break down the task and track the progress.
|
|
4
|
+
|
|
5
|
+
This is the only todo list tool available to you. That said, each time you want to operate on the todo list, you need to update the whole. Make sure to maintain the todo items and their statuses properly.
|
|
6
|
+
|
|
7
|
+
Once you finished a subtask/milestone, remember to update the todo list to reflect the progress. Also, you can give yourself a self-encouragement to keep you motivated.
|
|
8
|
+
|
|
9
|
+
Abusing this tool to track too small steps will just waste your time and make your context messy. For example, here are some cases you should not use this tool:
|
|
10
|
+
|
|
11
|
+
- When the user just simply ask you a question. E.g. "What language and framework is used in the project?", "What is the best practice for x?"
|
|
12
|
+
- When it only takes a few steps/tool calls to complete the task. E.g. "Fix the unit test function 'test_xxx'", "Refactor the function 'xxx' to make it more solid."
|
|
13
|
+
- When the user prompt is very specific and the only thing you need to do is brainlessly following the instructions. E.g. "Replace xxx to yyy in the file zzz", "Create a file xxx with content yyy."
|
|
14
|
+
|
|
15
|
+
However, do not get stuck in a rut. Be flexible. Sometimes, you may try to use todo list at first, then realize the task is too simple and you can simply stop using it; or, sometimes, you may realize the task is complex after a few steps and then you can start using todo list to break it down.
|