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.

Files changed (76) hide show
  1. kimi_cli/CHANGELOG.md +304 -0
  2. kimi_cli/__init__.py +374 -0
  3. kimi_cli/agent.py +261 -0
  4. kimi_cli/agents/koder/README.md +3 -0
  5. kimi_cli/agents/koder/agent.yaml +24 -0
  6. kimi_cli/agents/koder/sub.yaml +11 -0
  7. kimi_cli/agents/koder/system.md +72 -0
  8. kimi_cli/config.py +138 -0
  9. kimi_cli/llm.py +8 -0
  10. kimi_cli/metadata.py +117 -0
  11. kimi_cli/prompts/metacmds/__init__.py +4 -0
  12. kimi_cli/prompts/metacmds/compact.md +74 -0
  13. kimi_cli/prompts/metacmds/init.md +21 -0
  14. kimi_cli/py.typed +0 -0
  15. kimi_cli/share.py +8 -0
  16. kimi_cli/soul/__init__.py +59 -0
  17. kimi_cli/soul/approval.py +69 -0
  18. kimi_cli/soul/context.py +142 -0
  19. kimi_cli/soul/denwarenji.py +37 -0
  20. kimi_cli/soul/kimisoul.py +248 -0
  21. kimi_cli/soul/message.py +76 -0
  22. kimi_cli/soul/toolset.py +25 -0
  23. kimi_cli/soul/wire.py +101 -0
  24. kimi_cli/tools/__init__.py +85 -0
  25. kimi_cli/tools/bash/__init__.py +97 -0
  26. kimi_cli/tools/bash/bash.md +31 -0
  27. kimi_cli/tools/dmail/__init__.py +38 -0
  28. kimi_cli/tools/dmail/dmail.md +15 -0
  29. kimi_cli/tools/file/__init__.py +21 -0
  30. kimi_cli/tools/file/glob.md +17 -0
  31. kimi_cli/tools/file/glob.py +149 -0
  32. kimi_cli/tools/file/grep.md +5 -0
  33. kimi_cli/tools/file/grep.py +285 -0
  34. kimi_cli/tools/file/patch.md +8 -0
  35. kimi_cli/tools/file/patch.py +131 -0
  36. kimi_cli/tools/file/read.md +14 -0
  37. kimi_cli/tools/file/read.py +139 -0
  38. kimi_cli/tools/file/replace.md +7 -0
  39. kimi_cli/tools/file/replace.py +132 -0
  40. kimi_cli/tools/file/write.md +5 -0
  41. kimi_cli/tools/file/write.py +107 -0
  42. kimi_cli/tools/mcp.py +85 -0
  43. kimi_cli/tools/task/__init__.py +156 -0
  44. kimi_cli/tools/task/task.md +26 -0
  45. kimi_cli/tools/test.py +55 -0
  46. kimi_cli/tools/think/__init__.py +21 -0
  47. kimi_cli/tools/think/think.md +1 -0
  48. kimi_cli/tools/todo/__init__.py +27 -0
  49. kimi_cli/tools/todo/set_todo_list.md +15 -0
  50. kimi_cli/tools/utils.py +150 -0
  51. kimi_cli/tools/web/__init__.py +4 -0
  52. kimi_cli/tools/web/fetch.md +1 -0
  53. kimi_cli/tools/web/fetch.py +94 -0
  54. kimi_cli/tools/web/search.md +1 -0
  55. kimi_cli/tools/web/search.py +126 -0
  56. kimi_cli/ui/__init__.py +68 -0
  57. kimi_cli/ui/acp/__init__.py +441 -0
  58. kimi_cli/ui/print/__init__.py +176 -0
  59. kimi_cli/ui/shell/__init__.py +326 -0
  60. kimi_cli/ui/shell/console.py +3 -0
  61. kimi_cli/ui/shell/liveview.py +158 -0
  62. kimi_cli/ui/shell/metacmd.py +309 -0
  63. kimi_cli/ui/shell/prompt.py +574 -0
  64. kimi_cli/ui/shell/setup.py +192 -0
  65. kimi_cli/ui/shell/update.py +204 -0
  66. kimi_cli/utils/changelog.py +101 -0
  67. kimi_cli/utils/logging.py +18 -0
  68. kimi_cli/utils/message.py +8 -0
  69. kimi_cli/utils/path.py +23 -0
  70. kimi_cli/utils/provider.py +64 -0
  71. kimi_cli/utils/pyinstaller.py +24 -0
  72. kimi_cli/utils/string.py +12 -0
  73. kimi_cli-0.35.dist-info/METADATA +24 -0
  74. kimi_cli-0.35.dist-info/RECORD +76 -0
  75. kimi_cli-0.35.dist-info/WHEEL +4 -0
  76. 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,8 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def get_share_dir() -> Path:
5
+ """Get the share directory path."""
6
+ share_dir = Path.home() / ".kimi"
7
+ share_dir.mkdir(parents=True, exist_ok=True)
8
+ return share_dir
@@ -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()
@@ -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