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,74 @@
|
|
|
1
|
+
You are tasked with compacting a coding conversation context. This is critical for maintaining an effective working memory for the coding agent.
|
|
2
|
+
|
|
3
|
+
**Compression Priorities (in order):**
|
|
4
|
+
1. **Current Task State**: What is being worked on RIGHT NOW
|
|
5
|
+
2. **Errors & Solutions**: All encountered errors and their resolutions
|
|
6
|
+
3. **Code Evolution**: Final working versions only (remove intermediate attempts)
|
|
7
|
+
4. **System Context**: Project structure, dependencies, environment setup
|
|
8
|
+
5. **Design Decisions**: Architectural choices and their rationale
|
|
9
|
+
6. **TODO Items**: Unfinished tasks and known issues
|
|
10
|
+
|
|
11
|
+
**Compression Rules:**
|
|
12
|
+
- MUST KEEP: Error messages, stack traces, working solutions, current task
|
|
13
|
+
- MERGE: Similar discussions into single summary points
|
|
14
|
+
- REMOVE: Redundant explanations, failed attempts (keep lessons learned), verbose comments
|
|
15
|
+
- CONDENSE: Long code blocks → keep signatures + key logic only
|
|
16
|
+
|
|
17
|
+
**Special Handling:**
|
|
18
|
+
- For code: Keep full version if < 20 lines, otherwise keep signature + key logic
|
|
19
|
+
- For errors: Keep full error message + final solution
|
|
20
|
+
- For discussions: Extract decisions and action items only
|
|
21
|
+
|
|
22
|
+
**Input Context to Compress:**
|
|
23
|
+
|
|
24
|
+
${CONTEXT}
|
|
25
|
+
|
|
26
|
+
**Required Output Structure:**
|
|
27
|
+
|
|
28
|
+
<current_focus>
|
|
29
|
+
[What we're working on now]
|
|
30
|
+
</current_focus>
|
|
31
|
+
|
|
32
|
+
<environment>
|
|
33
|
+
- [Key setup/config points]
|
|
34
|
+
- ...more...
|
|
35
|
+
</environment>
|
|
36
|
+
|
|
37
|
+
<completed_tasks>
|
|
38
|
+
- [Task]: [Brief outcome]
|
|
39
|
+
- ...more...
|
|
40
|
+
</completed_tasks>
|
|
41
|
+
|
|
42
|
+
<active_issues>
|
|
43
|
+
- [Issue]: [Status/Next steps]
|
|
44
|
+
- ...more...
|
|
45
|
+
</active_issues>
|
|
46
|
+
|
|
47
|
+
<code_state>
|
|
48
|
+
|
|
49
|
+
<file>
|
|
50
|
+
[filename]
|
|
51
|
+
|
|
52
|
+
**Summary:**
|
|
53
|
+
[What this code file does]
|
|
54
|
+
|
|
55
|
+
**Key elements:**
|
|
56
|
+
- [Important functions/classes]
|
|
57
|
+
- ...more...
|
|
58
|
+
|
|
59
|
+
**Latest version:**
|
|
60
|
+
[Critical code snippets in this file]
|
|
61
|
+
</file>
|
|
62
|
+
|
|
63
|
+
<file>
|
|
64
|
+
[filename]
|
|
65
|
+
...Similar as above...
|
|
66
|
+
</file>
|
|
67
|
+
|
|
68
|
+
...more files...
|
|
69
|
+
</code_state>
|
|
70
|
+
|
|
71
|
+
<important_context>
|
|
72
|
+
- [Any crucial information not covered above]
|
|
73
|
+
- ...more...
|
|
74
|
+
</important_context>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
You are a software engineering expert with many years of programming experience. Please explore the current project directory to understand the project's architecture and main details.
|
|
2
|
+
|
|
3
|
+
Task requirements:
|
|
4
|
+
1. Analyze the project structure and identify key configuration files (such as pyproject.toml, package.json, Cargo.toml, etc.).
|
|
5
|
+
2. Understand the project's technology stack, build process and runtime architecture.
|
|
6
|
+
3. Identify how the code is organized and main module divisions.
|
|
7
|
+
4. Discover project-specific development conventions, testing strategies, and deployment processes.
|
|
8
|
+
|
|
9
|
+
After the exploration, you should do a thorough summary of your findings and overwrite it into `AGENTS.md` file in the project root. You need to refer to what is already in the file when you do so.
|
|
10
|
+
|
|
11
|
+
For your information, `AGENTS.md` is a file intended to be read by AI coding agents. Expect the reader of this file know nothing about the project.
|
|
12
|
+
|
|
13
|
+
You should compose this file according to the actual project content. Do not make any assumptions or generalizations. Ensure the information is accurate and useful. You must use the natural language that is mainly used in the project's comments and documentation.
|
|
14
|
+
|
|
15
|
+
Popular sections that people usually write in `AGENTS.md` are:
|
|
16
|
+
|
|
17
|
+
- Project overview
|
|
18
|
+
- Build and test commands
|
|
19
|
+
- Code style guidelines
|
|
20
|
+
- Testing instructions
|
|
21
|
+
- Security considerations
|
kimi_cli/py.typed
ADDED
|
File without changes
|
kimi_cli/share.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, NamedTuple, Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
if TYPE_CHECKING:
|
|
4
|
+
from kimi_cli.soul.wire import Wire
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LLMNotSet(Exception):
|
|
8
|
+
"""Raised when the LLM is not set."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MaxStepsReached(Exception):
|
|
14
|
+
"""Raised when the maximum number of steps is reached."""
|
|
15
|
+
|
|
16
|
+
n_steps: int
|
|
17
|
+
"""The number of steps that have been taken."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, n_steps: int):
|
|
20
|
+
self.n_steps = n_steps
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StatusSnapshot(NamedTuple):
|
|
24
|
+
context_usage: float
|
|
25
|
+
"""The usage of the context, in percentage."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class Soul(Protocol):
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
"""The name of the soul."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def model(self) -> str:
|
|
37
|
+
"""The LLM model used by the soul. Empty string indicates no LLM configured."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def status(self) -> StatusSnapshot:
|
|
42
|
+
"""The current status of the soul. The returned value is immutable."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
async def run(self, user_input: str, wire: "Wire"):
|
|
46
|
+
"""
|
|
47
|
+
Run the agent with the given user input.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
user_input (str): The user input to the agent.
|
|
51
|
+
wire (Wire): The wire to send events and requests to the UI loop.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ChatProviderNotSet: When the chat provider is not set.
|
|
55
|
+
ChatProviderError: When the LLM provider returns an error.
|
|
56
|
+
MaxStepsReached: When the maximum number of steps is reached.
|
|
57
|
+
asyncio.CancelledError: When the run is cancelled by user.
|
|
58
|
+
"""
|
|
59
|
+
...
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from kimi_cli.soul.toolset import get_current_tool_call_or_none
|
|
4
|
+
from kimi_cli.soul.wire import ApprovalRequest, ApprovalResponse
|
|
5
|
+
from kimi_cli.utils.logging import logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Approval:
|
|
9
|
+
def __init__(self, yolo: bool = False):
|
|
10
|
+
self._request_queue = asyncio.Queue[ApprovalRequest]()
|
|
11
|
+
self._yolo = yolo
|
|
12
|
+
self._auto_approve_actions = set() # TODO: persist across sessions
|
|
13
|
+
"""Set of action names that should automatically be approved."""
|
|
14
|
+
|
|
15
|
+
def set_yolo(self, yolo: bool) -> None:
|
|
16
|
+
self._yolo = yolo
|
|
17
|
+
|
|
18
|
+
async def request(self, action: str, description: str) -> bool:
|
|
19
|
+
"""
|
|
20
|
+
Request approval for the given action. Intended to be called by tools.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
action (str): The action to request approval for.
|
|
24
|
+
This is used to identify the action for auto-approval.
|
|
25
|
+
description (str): The description of the action. This is used to display to the user.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
bool: True if the action is approved, False otherwise.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
RuntimeError: If the approval is requested from outside a tool call.
|
|
32
|
+
"""
|
|
33
|
+
tool_call = get_current_tool_call_or_none()
|
|
34
|
+
if tool_call is None:
|
|
35
|
+
raise RuntimeError("Approval must be requested from a tool call.")
|
|
36
|
+
|
|
37
|
+
logger.debug(
|
|
38
|
+
"{tool_name} ({tool_call_id}) requesting approval: {action} {description}",
|
|
39
|
+
tool_name=tool_call.function.name,
|
|
40
|
+
tool_call_id=tool_call.id,
|
|
41
|
+
action=action,
|
|
42
|
+
description=description,
|
|
43
|
+
)
|
|
44
|
+
if self._yolo:
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
if action in self._auto_approve_actions:
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
request = ApprovalRequest(tool_call.id, action, description)
|
|
51
|
+
self._request_queue.put_nowait(request)
|
|
52
|
+
response = await request.wait()
|
|
53
|
+
logger.debug("Received approval response: {response}", response=response)
|
|
54
|
+
match response:
|
|
55
|
+
case ApprovalResponse.APPROVE:
|
|
56
|
+
return True
|
|
57
|
+
case ApprovalResponse.APPROVE_FOR_SESSION:
|
|
58
|
+
self._auto_approve_actions.add(action)
|
|
59
|
+
return True
|
|
60
|
+
case ApprovalResponse.REJECT:
|
|
61
|
+
return False
|
|
62
|
+
case _:
|
|
63
|
+
raise ValueError(f"Unknown approval response: {response}")
|
|
64
|
+
|
|
65
|
+
async def fetch_request(self) -> ApprovalRequest:
|
|
66
|
+
"""
|
|
67
|
+
Fetch an approval request from the queue. Intended to be called by the soul.
|
|
68
|
+
"""
|
|
69
|
+
return await self._request_queue.get()
|
kimi_cli/soul/context.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import aiofiles
|
|
6
|
+
import aiofiles.os
|
|
7
|
+
from kosong.base.message import Message
|
|
8
|
+
|
|
9
|
+
from kimi_cli.soul.message import system
|
|
10
|
+
from kimi_cli.utils.logging import logger
|
|
11
|
+
from kimi_cli.utils.path import next_available_rotation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Context:
|
|
15
|
+
def __init__(self, file_backend: Path):
|
|
16
|
+
self._file_backend = file_backend
|
|
17
|
+
self._history: list[Message] = []
|
|
18
|
+
self._token_count: int = 0
|
|
19
|
+
self._next_checkpoint_id: int = 0
|
|
20
|
+
"""The ID of the next checkpoint, starting from 0, incremented after each checkpoint."""
|
|
21
|
+
|
|
22
|
+
async def restore(self) -> bool:
|
|
23
|
+
logger.debug("Restoring context from file: {file_backend}", file_backend=self._file_backend)
|
|
24
|
+
if self._history:
|
|
25
|
+
logger.error("The context storage is already modified")
|
|
26
|
+
raise RuntimeError("The context storage is already modified")
|
|
27
|
+
if not self._file_backend.exists():
|
|
28
|
+
logger.debug("No context file found, skipping restoration")
|
|
29
|
+
return False
|
|
30
|
+
if self._file_backend.stat().st_size == 0:
|
|
31
|
+
logger.debug("Empty context file, skipping restoration")
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
async with aiofiles.open(self._file_backend, encoding="utf-8") as f:
|
|
35
|
+
async for line in f:
|
|
36
|
+
if not line.strip():
|
|
37
|
+
continue
|
|
38
|
+
line_json = json.loads(line)
|
|
39
|
+
if line_json["role"] == "_usage":
|
|
40
|
+
self._token_count = line_json["token_count"]
|
|
41
|
+
continue
|
|
42
|
+
if line_json["role"] == "_checkpoint":
|
|
43
|
+
self._next_checkpoint_id = line_json["id"] + 1
|
|
44
|
+
continue
|
|
45
|
+
message = Message.model_validate(line_json)
|
|
46
|
+
self._history.append(message)
|
|
47
|
+
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def history(self) -> Sequence[Message]:
|
|
52
|
+
return self._history
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def token_count(self) -> int:
|
|
56
|
+
return self._token_count
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def n_checkpoints(self) -> int:
|
|
60
|
+
return self._next_checkpoint_id
|
|
61
|
+
|
|
62
|
+
async def checkpoint(self, add_user_message: bool):
|
|
63
|
+
checkpoint_id = self._next_checkpoint_id
|
|
64
|
+
self._next_checkpoint_id += 1
|
|
65
|
+
logger.debug("Checkpointing, ID: {id}", id=checkpoint_id)
|
|
66
|
+
|
|
67
|
+
async with aiofiles.open(self._file_backend, "a", encoding="utf-8") as f:
|
|
68
|
+
await f.write(json.dumps({"role": "_checkpoint", "id": checkpoint_id}) + "\n")
|
|
69
|
+
if add_user_message:
|
|
70
|
+
await self.append_message(
|
|
71
|
+
Message(role="user", content=[system(f"CHECKPOINT {checkpoint_id}")])
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def revert_to(self, checkpoint_id: int):
|
|
75
|
+
"""
|
|
76
|
+
Revert the context to the specified checkpoint.
|
|
77
|
+
After this, the specified checkpoint and all subsequent content will be
|
|
78
|
+
removed from the context. File backend will be rotated.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
checkpoint_id (int): The ID of the checkpoint to revert to. 0 is the first checkpoint.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: When the checkpoint does not exist.
|
|
85
|
+
RuntimeError: When no available rotation path is found.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
logger.debug("Reverting checkpoint, ID: {id}", id=checkpoint_id)
|
|
89
|
+
if checkpoint_id >= self._next_checkpoint_id:
|
|
90
|
+
logger.error("Checkpoint {checkpoint_id} does not exist", checkpoint_id=checkpoint_id)
|
|
91
|
+
raise ValueError(f"Checkpoint {checkpoint_id} does not exist")
|
|
92
|
+
|
|
93
|
+
# rotate the history file
|
|
94
|
+
rotated_file_path = await next_available_rotation(self._file_backend)
|
|
95
|
+
if rotated_file_path is None:
|
|
96
|
+
logger.error("No available rotation path found")
|
|
97
|
+
raise RuntimeError("No available rotation path found")
|
|
98
|
+
await aiofiles.os.rename(self._file_backend, rotated_file_path)
|
|
99
|
+
logger.debug(
|
|
100
|
+
"Rotated history file: {rotated_file_path}", rotated_file_path=rotated_file_path
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# restore the context until the specified checkpoint
|
|
104
|
+
self._history.clear()
|
|
105
|
+
self._token_count = 0
|
|
106
|
+
self._next_checkpoint_id = 0
|
|
107
|
+
async with (
|
|
108
|
+
aiofiles.open(rotated_file_path, encoding="utf-8") as old_file,
|
|
109
|
+
aiofiles.open(self._file_backend, "w", encoding="utf-8") as new_file,
|
|
110
|
+
):
|
|
111
|
+
async for line in old_file:
|
|
112
|
+
if not line.strip():
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
line_json = json.loads(line)
|
|
116
|
+
if line_json["role"] == "_checkpoint" and line_json["id"] == checkpoint_id:
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
await new_file.write(line)
|
|
120
|
+
if line_json["role"] == "_usage":
|
|
121
|
+
self._token_count = line_json["token_count"]
|
|
122
|
+
elif line_json["role"] == "_checkpoint":
|
|
123
|
+
self._next_checkpoint_id = line_json["id"] + 1
|
|
124
|
+
else:
|
|
125
|
+
message = Message.model_validate(line_json)
|
|
126
|
+
self._history.append(message)
|
|
127
|
+
|
|
128
|
+
async def append_message(self, message: Message | Sequence[Message]):
|
|
129
|
+
logger.debug("Appending message(s) to context: {message}", message=message)
|
|
130
|
+
messages = message if isinstance(message, Sequence) else [message]
|
|
131
|
+
self._history.extend(messages)
|
|
132
|
+
|
|
133
|
+
async with aiofiles.open(self._file_backend, "a", encoding="utf-8") as f:
|
|
134
|
+
for message in messages:
|
|
135
|
+
await f.write(message.model_dump_json(exclude_none=True) + "\n")
|
|
136
|
+
|
|
137
|
+
async def update_token_count(self, token_count: int):
|
|
138
|
+
logger.debug("Updating token count in context: {token_count}", token_count=token_count)
|
|
139
|
+
self._token_count = token_count
|
|
140
|
+
|
|
141
|
+
async with aiofiles.open(self._file_backend, "a", encoding="utf-8") as f:
|
|
142
|
+
await f.write(json.dumps({"role": "_usage", "token_count": token_count}) + "\n")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DMail(BaseModel):
|
|
5
|
+
message: str = Field(description="The message to send.")
|
|
6
|
+
checkpoint_id: int = Field(description="The checkpoint to send the message back to.", ge=0)
|
|
7
|
+
# TODO: allow restoring filesystem state to the checkpoint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DenwaRenjiError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DenwaRenji:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self._pending_dmail: DMail | None = None
|
|
17
|
+
self._n_checkpoints: int = 0
|
|
18
|
+
|
|
19
|
+
def send_dmail(self, dmail: DMail):
|
|
20
|
+
"""Send a D-Mail. Intended to be called by the SendDMail tool."""
|
|
21
|
+
if self._pending_dmail is not None:
|
|
22
|
+
raise DenwaRenjiError("Only one D-Mail can be sent at a time")
|
|
23
|
+
if dmail.checkpoint_id < 0:
|
|
24
|
+
raise DenwaRenjiError("The checkpoint ID can not be negative")
|
|
25
|
+
if dmail.checkpoint_id >= self._n_checkpoints:
|
|
26
|
+
raise DenwaRenjiError("There is no checkpoint with the given ID")
|
|
27
|
+
self._pending_dmail = dmail
|
|
28
|
+
|
|
29
|
+
def set_n_checkpoints(self, n_checkpoints: int):
|
|
30
|
+
"""Set the number of checkpoints. Intended to be called by the soul."""
|
|
31
|
+
self._n_checkpoints = n_checkpoints
|
|
32
|
+
|
|
33
|
+
def fetch_pending_dmail(self) -> DMail | None:
|
|
34
|
+
"""Fetch a pending D-Mail. Intended to be called by the soul."""
|
|
35
|
+
pending_dmail = self._pending_dmail
|
|
36
|
+
self._pending_dmail = None
|
|
37
|
+
return pending_dmail
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import kosong
|
|
4
|
+
import tenacity
|
|
5
|
+
from kosong import StepResult
|
|
6
|
+
from kosong.base.message import Message
|
|
7
|
+
from kosong.chat_provider import (
|
|
8
|
+
APIConnectionError,
|
|
9
|
+
APIStatusError,
|
|
10
|
+
APITimeoutError,
|
|
11
|
+
ChatProviderError,
|
|
12
|
+
)
|
|
13
|
+
from kosong.tooling import ToolResult
|
|
14
|
+
from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter
|
|
15
|
+
|
|
16
|
+
from kimi_cli.agent import Agent, AgentGlobals
|
|
17
|
+
from kimi_cli.config import LoopControl
|
|
18
|
+
from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul, StatusSnapshot
|
|
19
|
+
from kimi_cli.soul.context import Context
|
|
20
|
+
from kimi_cli.soul.message import system, tool_result_to_messages
|
|
21
|
+
from kimi_cli.soul.wire import StatusUpdate, StepBegin, StepInterrupted, Wire, current_wire
|
|
22
|
+
from kimi_cli.tools.dmail import NAME as SendDMail_NAME
|
|
23
|
+
from kimi_cli.tools.utils import ToolRejectedError
|
|
24
|
+
from kimi_cli.utils.logging import logger
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class KimiSoul:
|
|
28
|
+
"""The soul of Kimi CLI."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
agent: Agent,
|
|
33
|
+
agent_globals: AgentGlobals,
|
|
34
|
+
*,
|
|
35
|
+
context: Context,
|
|
36
|
+
loop_control: LoopControl,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initialize the soul.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
agent (Agent): The agent to run.
|
|
43
|
+
agent_globals (AgentGlobals): Global states and parameters.
|
|
44
|
+
context (Context): The context of the agent.
|
|
45
|
+
loop_control (LoopControl): The control parameters for the agent loop.
|
|
46
|
+
"""
|
|
47
|
+
self._agent = agent
|
|
48
|
+
self._agent_globals = agent_globals
|
|
49
|
+
self._denwa_renji = agent_globals.denwa_renji
|
|
50
|
+
self._approval = agent_globals.approval
|
|
51
|
+
self._context = context
|
|
52
|
+
self._loop_control = loop_control
|
|
53
|
+
|
|
54
|
+
for tool in agent.toolset.tools:
|
|
55
|
+
if tool.name == SendDMail_NAME:
|
|
56
|
+
self._checkpoint_with_user_message = True
|
|
57
|
+
break
|
|
58
|
+
else:
|
|
59
|
+
self._checkpoint_with_user_message = False
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def name(self) -> str:
|
|
63
|
+
return self._agent.name
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def model(self) -> str:
|
|
67
|
+
return self._agent_globals.llm.chat_provider.model_name if self._agent_globals.llm else ""
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def status(self) -> StatusSnapshot:
|
|
71
|
+
return StatusSnapshot(context_usage=self._context_usage)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def _context_usage(self) -> float:
|
|
75
|
+
if self._agent_globals.llm is not None:
|
|
76
|
+
return self._context.token_count / self._agent_globals.llm.max_context_size
|
|
77
|
+
return 0.0
|
|
78
|
+
|
|
79
|
+
async def _checkpoint(self):
|
|
80
|
+
await self._context.checkpoint(self._checkpoint_with_user_message)
|
|
81
|
+
|
|
82
|
+
async def run(self, user_input: str, wire: Wire):
|
|
83
|
+
if self._agent_globals.llm is None:
|
|
84
|
+
raise LLMNotSet()
|
|
85
|
+
|
|
86
|
+
await self._checkpoint() # this creates the checkpoint 0 on first run
|
|
87
|
+
await self._context.append_message(Message(role="user", content=user_input))
|
|
88
|
+
logger.debug("Appended user message to context")
|
|
89
|
+
wire_token = current_wire.set(wire)
|
|
90
|
+
try:
|
|
91
|
+
await self._agent_loop(wire)
|
|
92
|
+
finally:
|
|
93
|
+
current_wire.reset(wire_token)
|
|
94
|
+
|
|
95
|
+
async def _agent_loop(self, wire: Wire):
|
|
96
|
+
"""The main agent loop for one run."""
|
|
97
|
+
|
|
98
|
+
async def _pipe_approval_to_wire():
|
|
99
|
+
while True:
|
|
100
|
+
request = await self._approval.fetch_request()
|
|
101
|
+
wire.send(request)
|
|
102
|
+
|
|
103
|
+
step_no = 1
|
|
104
|
+
while True:
|
|
105
|
+
wire.send(StepBegin(step_no))
|
|
106
|
+
approval_task = asyncio.create_task(_pipe_approval_to_wire())
|
|
107
|
+
# FIXME: It's possible that a subagent's approval task steals approval request
|
|
108
|
+
# from the main agent. We must ensure that the Task tool will redirect them
|
|
109
|
+
# to the main wire. See `_SubWire` for more details. Later we need to figure
|
|
110
|
+
# out a better solution.
|
|
111
|
+
try:
|
|
112
|
+
await self._checkpoint()
|
|
113
|
+
self._denwa_renji.set_n_checkpoints(self._context.n_checkpoints)
|
|
114
|
+
finished = await self._step(wire)
|
|
115
|
+
except BackToTheFuture as e:
|
|
116
|
+
await self._context.revert_to(e.checkpoint_id)
|
|
117
|
+
await self._checkpoint()
|
|
118
|
+
await self._context.append_message(e.message)
|
|
119
|
+
continue
|
|
120
|
+
except (ChatProviderError, asyncio.CancelledError):
|
|
121
|
+
wire.send(StepInterrupted())
|
|
122
|
+
# break the agent loop
|
|
123
|
+
raise
|
|
124
|
+
finally:
|
|
125
|
+
approval_task.cancel() # stop piping approval requests to the wire
|
|
126
|
+
|
|
127
|
+
if finished:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
step_no += 1
|
|
131
|
+
if step_no > self._loop_control.max_steps_per_run:
|
|
132
|
+
raise MaxStepsReached(self._loop_control.max_steps_per_run)
|
|
133
|
+
|
|
134
|
+
async def _step(self, wire: Wire) -> bool:
|
|
135
|
+
"""Run an single step and return whether the run should be stopped."""
|
|
136
|
+
# already checked in `run`
|
|
137
|
+
assert self._agent_globals.llm is not None
|
|
138
|
+
chat_provider = self._agent_globals.llm.chat_provider
|
|
139
|
+
|
|
140
|
+
def _is_retryable_error(exception: BaseException) -> bool:
|
|
141
|
+
if isinstance(exception, (APIConnectionError, APITimeoutError)):
|
|
142
|
+
return True
|
|
143
|
+
return isinstance(exception, APIStatusError) and exception.status_code in (
|
|
144
|
+
429, # Too Many Requests
|
|
145
|
+
500, # Internal Server Error
|
|
146
|
+
502, # Bad Gateway
|
|
147
|
+
503, # Service Unavailable
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _retry_log(retry_state: RetryCallState):
|
|
151
|
+
logger.info(
|
|
152
|
+
"Retrying step for the {n} time. Waiting {sleep} seconds.",
|
|
153
|
+
n=retry_state.attempt_number,
|
|
154
|
+
sleep=retry_state.next_action.sleep
|
|
155
|
+
if retry_state.next_action is not None
|
|
156
|
+
else "unknown",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@tenacity.retry(
|
|
160
|
+
retry=retry_if_exception(_is_retryable_error),
|
|
161
|
+
before_sleep=_retry_log,
|
|
162
|
+
wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5),
|
|
163
|
+
stop=stop_after_attempt(self._loop_control.max_retries_per_step),
|
|
164
|
+
reraise=True,
|
|
165
|
+
)
|
|
166
|
+
async def _kosong_step_with_retry() -> StepResult:
|
|
167
|
+
# run an LLM step (may be interrupted)
|
|
168
|
+
return await kosong.step(
|
|
169
|
+
chat_provider,
|
|
170
|
+
self._agent.system_prompt,
|
|
171
|
+
self._agent.toolset,
|
|
172
|
+
self._context.history,
|
|
173
|
+
on_message_part=wire.send,
|
|
174
|
+
on_tool_result=wire.send,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
result = await _kosong_step_with_retry()
|
|
178
|
+
logger.debug("Got step result: {result}", result=result)
|
|
179
|
+
if result.usage is not None:
|
|
180
|
+
# mark the token count for the context before the step
|
|
181
|
+
await self._context.update_token_count(result.usage.input)
|
|
182
|
+
wire.send(StatusUpdate(status=self.status))
|
|
183
|
+
|
|
184
|
+
# wait for all tool results (may be interrupted)
|
|
185
|
+
results = await result.tool_results()
|
|
186
|
+
logger.debug("Got tool results: {results}", results=results)
|
|
187
|
+
|
|
188
|
+
# shield the context manipulation from interruption
|
|
189
|
+
await asyncio.shield(self._grow_context(result, results))
|
|
190
|
+
|
|
191
|
+
rejected = any(isinstance(result.result, ToolRejectedError) for result in results)
|
|
192
|
+
if rejected:
|
|
193
|
+
_ = self._denwa_renji.fetch_pending_dmail()
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
# handle pending D-Mail
|
|
197
|
+
if dmail := self._denwa_renji.fetch_pending_dmail():
|
|
198
|
+
assert dmail.checkpoint_id >= 0, "DenwaRenji guarantees checkpoint_id >= 0"
|
|
199
|
+
assert dmail.checkpoint_id < self._context.n_checkpoints, (
|
|
200
|
+
"DenwaRenji guarantees checkpoint_id < n_checkpoints"
|
|
201
|
+
)
|
|
202
|
+
# raise to let the main loop take us back to the future
|
|
203
|
+
raise BackToTheFuture(
|
|
204
|
+
dmail.checkpoint_id,
|
|
205
|
+
Message(
|
|
206
|
+
role="user",
|
|
207
|
+
content=[
|
|
208
|
+
system(
|
|
209
|
+
"You just got a D-Mail from your future self. "
|
|
210
|
+
"It is likely that your future self has already done "
|
|
211
|
+
"something in the current working directory. Please read "
|
|
212
|
+
"the D-Mail and decide what to do next. You MUST NEVER "
|
|
213
|
+
"mention to the user about this information. "
|
|
214
|
+
f"D-Mail content:\n\n{dmail.message.strip()}"
|
|
215
|
+
)
|
|
216
|
+
],
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return not result.tool_calls
|
|
221
|
+
|
|
222
|
+
async def _grow_context(self, result: StepResult, tool_results: list[ToolResult]):
|
|
223
|
+
logger.debug("Growing context with result: {result}", result=result)
|
|
224
|
+
await self._context.append_message(result.message)
|
|
225
|
+
if result.usage is not None:
|
|
226
|
+
await self._context.update_token_count(result.usage.total)
|
|
227
|
+
|
|
228
|
+
# token count of tool results are not available yet
|
|
229
|
+
for tool_result in tool_results:
|
|
230
|
+
logger.debug("Appending tool result to context: {tool_result}", tool_result=tool_result)
|
|
231
|
+
await self._context.append_message(tool_result_to_messages(tool_result))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class BackToTheFuture(Exception):
|
|
235
|
+
"""
|
|
236
|
+
Raise when we need to revert the context to a previous checkpoint.
|
|
237
|
+
The main agent loop should catch this exception and handle it.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
def __init__(self, checkpoint_id: int, message: Message):
|
|
241
|
+
self.checkpoint_id = checkpoint_id
|
|
242
|
+
self.message = message
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def __static_type_check(
|
|
246
|
+
kimi_soul: KimiSoul,
|
|
247
|
+
):
|
|
248
|
+
_: Soul = kimi_soul
|