kimi-cli 0.40__py3-none-any.whl → 0.42__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 (55) hide show
  1. kimi_cli/CHANGELOG.md +27 -0
  2. kimi_cli/__init__.py +127 -359
  3. kimi_cli/agents/{koder → default}/agent.yaml +1 -1
  4. kimi_cli/agents/{koder → default}/system.md +1 -1
  5. kimi_cli/agentspec.py +115 -0
  6. kimi_cli/cli.py +249 -0
  7. kimi_cli/config.py +28 -14
  8. kimi_cli/constant.py +4 -0
  9. kimi_cli/exception.py +16 -0
  10. kimi_cli/llm.py +70 -0
  11. kimi_cli/metadata.py +5 -68
  12. kimi_cli/prompts/__init__.py +2 -2
  13. kimi_cli/session.py +81 -0
  14. kimi_cli/soul/__init__.py +102 -6
  15. kimi_cli/soul/agent.py +152 -0
  16. kimi_cli/soul/approval.py +1 -1
  17. kimi_cli/soul/compaction.py +4 -4
  18. kimi_cli/soul/kimisoul.py +39 -46
  19. kimi_cli/soul/runtime.py +94 -0
  20. kimi_cli/tools/dmail/__init__.py +1 -1
  21. kimi_cli/tools/file/glob.md +1 -1
  22. kimi_cli/tools/file/glob.py +2 -2
  23. kimi_cli/tools/file/grep.py +1 -1
  24. kimi_cli/tools/file/patch.py +2 -2
  25. kimi_cli/tools/file/read.py +1 -1
  26. kimi_cli/tools/file/replace.py +2 -2
  27. kimi_cli/tools/file/write.py +2 -2
  28. kimi_cli/tools/task/__init__.py +48 -40
  29. kimi_cli/tools/task/task.md +1 -1
  30. kimi_cli/tools/todo/__init__.py +1 -1
  31. kimi_cli/tools/utils.py +1 -1
  32. kimi_cli/tools/web/search.py +5 -2
  33. kimi_cli/ui/__init__.py +0 -69
  34. kimi_cli/ui/acp/__init__.py +8 -9
  35. kimi_cli/ui/print/__init__.py +21 -37
  36. kimi_cli/ui/shell/__init__.py +8 -19
  37. kimi_cli/ui/shell/liveview.py +1 -1
  38. kimi_cli/ui/shell/metacmd.py +5 -10
  39. kimi_cli/ui/shell/prompt.py +10 -3
  40. kimi_cli/ui/shell/setup.py +5 -5
  41. kimi_cli/ui/shell/update.py +2 -2
  42. kimi_cli/ui/shell/visualize.py +10 -7
  43. kimi_cli/utils/changelog.py +3 -1
  44. kimi_cli/wire/__init__.py +69 -0
  45. kimi_cli/{soul/wire.py → wire/message.py} +4 -39
  46. {kimi_cli-0.40.dist-info → kimi_cli-0.42.dist-info}/METADATA +51 -18
  47. kimi_cli-0.42.dist-info/RECORD +86 -0
  48. kimi_cli-0.42.dist-info/entry_points.txt +3 -0
  49. kimi_cli/agent.py +0 -261
  50. kimi_cli/agents/koder/README.md +0 -3
  51. kimi_cli/utils/provider.py +0 -70
  52. kimi_cli-0.40.dist-info/RECORD +0 -81
  53. kimi_cli-0.40.dist-info/entry_points.txt +0 -3
  54. /kimi_cli/agents/{koder → default}/sub.yaml +0 -0
  55. {kimi_cli-0.40.dist-info → kimi_cli-0.42.dist-info}/WHEEL +0 -0
kimi_cli/soul/__init__.py CHANGED
@@ -1,7 +1,12 @@
1
- from typing import TYPE_CHECKING, NamedTuple, Protocol, runtime_checkable
1
+ import asyncio
2
+ import contextlib
3
+ from collections.abc import Callable, Coroutine
4
+ from contextvars import ContextVar
5
+ from typing import Any, NamedTuple, Protocol, runtime_checkable
2
6
 
3
- if TYPE_CHECKING:
4
- from kimi_cli.soul.wire import Wire
7
+ from kimi_cli.utils.logging import logger
8
+ from kimi_cli.wire import Wire, WireUISide
9
+ from kimi_cli.wire.message import WireMessage
5
10
 
6
11
 
7
12
  class LLMNotSet(Exception):
@@ -42,13 +47,12 @@ class Soul(Protocol):
42
47
  """The current status of the soul. The returned value is immutable."""
43
48
  ...
44
49
 
45
- async def run(self, user_input: str, wire: "Wire"):
50
+ async def run(self, user_input: str):
46
51
  """
47
- Run the agent with the given user input.
52
+ Run the agent with the given user input until the max steps or no more tool calls.
48
53
 
49
54
  Args:
50
55
  user_input (str): The user input to the agent.
51
- wire (Wire): The wire to send events and requests to the UI loop.
52
56
 
53
57
  Raises:
54
58
  LLMNotSet: When the LLM is not set.
@@ -57,3 +61,95 @@ class Soul(Protocol):
57
61
  asyncio.CancelledError: When the run is cancelled by user.
58
62
  """
59
63
  ...
64
+
65
+
66
+ type UILoopFn = Callable[[WireUISide], Coroutine[Any, Any, None]]
67
+ """A long-running async function to visualize the agent behavior."""
68
+
69
+
70
+ class RunCancelled(Exception):
71
+ """The run was cancelled by the cancel event."""
72
+
73
+
74
+ async def run_soul(
75
+ soul: "Soul",
76
+ user_input: str,
77
+ ui_loop_fn: UILoopFn,
78
+ cancel_event: asyncio.Event,
79
+ ) -> None:
80
+ """
81
+ Run the soul with the given user input, connecting it to the UI loop with a wire.
82
+
83
+ `cancel_event` is a outside handle that can be used to cancel the run. When the
84
+ event is set, the run will be gracefully stopped and a `RunCancelled` will be raised.
85
+
86
+ Raises:
87
+ LLMNotSet: When the LLM is not set.
88
+ ChatProviderError: When the LLM provider returns an error.
89
+ MaxStepsReached: When the maximum number of steps is reached.
90
+ RunCancelled: When the run is cancelled by the cancel event.
91
+ """
92
+ wire = Wire()
93
+ wire_token = _current_wire.set(wire)
94
+
95
+ logger.debug("Starting UI loop with function: {ui_loop_fn}", ui_loop_fn=ui_loop_fn)
96
+ ui_task = asyncio.create_task(ui_loop_fn(wire.ui_side))
97
+
98
+ logger.debug("Starting soul run")
99
+ soul_task = asyncio.create_task(soul.run(user_input))
100
+
101
+ cancel_event_task = asyncio.create_task(cancel_event.wait())
102
+ await asyncio.wait(
103
+ [soul_task, cancel_event_task],
104
+ return_when=asyncio.FIRST_COMPLETED,
105
+ )
106
+
107
+ try:
108
+ if cancel_event.is_set():
109
+ logger.debug("Cancelling the run task")
110
+ soul_task.cancel()
111
+ try:
112
+ await soul_task
113
+ except asyncio.CancelledError:
114
+ raise RunCancelled from None
115
+ else:
116
+ assert soul_task.done() # either stop event is set or the run task is done
117
+ cancel_event_task.cancel()
118
+ with contextlib.suppress(asyncio.CancelledError):
119
+ await cancel_event_task
120
+ soul_task.result() # this will raise if any exception was raised in the run task
121
+ finally:
122
+ logger.debug("Shutting down the UI loop")
123
+ # shutting down the wire should break the UI loop
124
+ wire.shutdown()
125
+ try:
126
+ await asyncio.wait_for(ui_task, timeout=0.5)
127
+ except asyncio.QueueShutDown:
128
+ # expected
129
+ pass
130
+ except TimeoutError:
131
+ logger.warning("UI loop timed out")
132
+
133
+ _current_wire.reset(wire_token)
134
+
135
+
136
+ _current_wire = ContextVar[Wire | None]("current_wire", default=None)
137
+
138
+
139
+ def get_wire_or_none() -> Wire | None:
140
+ """
141
+ Get the current wire or None.
142
+ Expect to be not None when called from anywhere in the agent loop.
143
+ """
144
+ return _current_wire.get()
145
+
146
+
147
+ def wire_send(msg: WireMessage) -> None:
148
+ """
149
+ Send a wire message to the current wire.
150
+ Take this as `print` and `input` for souls.
151
+ Souls should always use this function to send wire messages.
152
+ """
153
+ wire = get_wire_or_none()
154
+ assert wire is not None, "Wire is expected to be set when soul is running"
155
+ wire.soul_side.send(msg)
kimi_cli/soul/agent.py ADDED
@@ -0,0 +1,152 @@
1
+ import importlib
2
+ import inspect
3
+ import string
4
+ from pathlib import Path
5
+ from typing import Any, NamedTuple
6
+
7
+ import fastmcp
8
+ from kosong.tooling import CallableTool, CallableTool2, Toolset
9
+
10
+ from kimi_cli.agentspec import ResolvedAgentSpec, load_agent_spec
11
+ from kimi_cli.config import Config
12
+ from kimi_cli.session import Session
13
+ from kimi_cli.soul.approval import Approval
14
+ from kimi_cli.soul.denwarenji import DenwaRenji
15
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime
16
+ from kimi_cli.soul.toolset import CustomToolset
17
+ from kimi_cli.tools.mcp import MCPTool
18
+ from kimi_cli.utils.logging import logger
19
+
20
+
21
+ class Agent(NamedTuple):
22
+ """The loaded agent."""
23
+
24
+ name: str
25
+ system_prompt: str
26
+ toolset: Toolset
27
+
28
+
29
+ async def load_agent(
30
+ agent_file: Path,
31
+ runtime: Runtime,
32
+ *,
33
+ mcp_configs: list[dict[str, Any]],
34
+ ) -> Agent:
35
+ """
36
+ Load agent from specification file.
37
+
38
+ Raises:
39
+ FileNotFoundError: If the agent spec file does not exist.
40
+ AgentSpecError: If the agent spec is not valid.
41
+ """
42
+ logger.info("Loading agent: {agent_file}", agent_file=agent_file)
43
+ agent_spec = load_agent_spec(agent_file)
44
+
45
+ system_prompt = _load_system_prompt(
46
+ agent_spec.system_prompt_path,
47
+ agent_spec.system_prompt_args,
48
+ runtime.builtin_args,
49
+ )
50
+
51
+ tool_deps = {
52
+ ResolvedAgentSpec: agent_spec,
53
+ Runtime: runtime,
54
+ Config: runtime.config,
55
+ BuiltinSystemPromptArgs: runtime.builtin_args,
56
+ Session: runtime.session,
57
+ DenwaRenji: runtime.denwa_renji,
58
+ Approval: runtime.approval,
59
+ }
60
+ tools = agent_spec.tools
61
+ if agent_spec.exclude_tools:
62
+ logger.debug("Excluding tools: {tools}", tools=agent_spec.exclude_tools)
63
+ tools = [tool for tool in tools if tool not in agent_spec.exclude_tools]
64
+ toolset = CustomToolset()
65
+ bad_tools = _load_tools(toolset, tools, tool_deps)
66
+ if bad_tools:
67
+ raise ValueError(f"Invalid tools: {bad_tools}")
68
+
69
+ assert isinstance(toolset, CustomToolset)
70
+ if mcp_configs:
71
+ await _load_mcp_tools(toolset, mcp_configs)
72
+
73
+ return Agent(
74
+ name=agent_spec.name,
75
+ system_prompt=system_prompt,
76
+ toolset=toolset,
77
+ )
78
+
79
+
80
+ def _load_system_prompt(
81
+ path: Path, args: dict[str, str], builtin_args: BuiltinSystemPromptArgs
82
+ ) -> str:
83
+ logger.info("Loading system prompt: {path}", path=path)
84
+ system_prompt = path.read_text(encoding="utf-8").strip()
85
+ logger.debug(
86
+ "Substituting system prompt with builtin args: {builtin_args}, spec args: {spec_args}",
87
+ builtin_args=builtin_args,
88
+ spec_args=args,
89
+ )
90
+ return string.Template(system_prompt).substitute(builtin_args._asdict(), **args)
91
+
92
+
93
+ type ToolType = CallableTool | CallableTool2[Any]
94
+ # TODO: move this to kosong.tooling.simple
95
+
96
+
97
+ def _load_tools(
98
+ toolset: CustomToolset,
99
+ tool_paths: list[str],
100
+ dependencies: dict[type[Any], Any],
101
+ ) -> list[str]:
102
+ bad_tools: list[str] = []
103
+ for tool_path in tool_paths:
104
+ tool = _load_tool(tool_path, dependencies)
105
+ if tool:
106
+ toolset += tool
107
+ else:
108
+ bad_tools.append(tool_path)
109
+ logger.info("Loaded tools: {tools}", tools=[tool.name for tool in toolset.tools])
110
+ if bad_tools:
111
+ logger.error("Bad tools: {bad_tools}", bad_tools=bad_tools)
112
+ return bad_tools
113
+
114
+
115
+ def _load_tool(tool_path: str, dependencies: dict[type[Any], Any]) -> ToolType | None:
116
+ logger.debug("Loading tool: {tool_path}", tool_path=tool_path)
117
+ module_name, class_name = tool_path.rsplit(":", 1)
118
+ try:
119
+ module = importlib.import_module(module_name)
120
+ except ImportError:
121
+ return None
122
+ cls = getattr(module, class_name, None)
123
+ if cls is None:
124
+ return None
125
+ args: list[type[Any]] = []
126
+ for param in inspect.signature(cls).parameters.values():
127
+ if param.kind == inspect.Parameter.KEYWORD_ONLY:
128
+ # once we encounter a keyword-only parameter, we stop injecting dependencies
129
+ break
130
+ # all positional parameters should be dependencies to be injected
131
+ if param.annotation not in dependencies:
132
+ raise ValueError(f"Tool dependency not found: {param.annotation}")
133
+ args.append(dependencies[param.annotation])
134
+ return cls(*args)
135
+
136
+
137
+ async def _load_mcp_tools(
138
+ toolset: CustomToolset,
139
+ mcp_configs: list[dict[str, Any]],
140
+ ):
141
+ """
142
+ Raises:
143
+ ValueError: If the MCP config is not valid.
144
+ RuntimeError: If the MCP server cannot be connected.
145
+ """
146
+ for mcp_config in mcp_configs:
147
+ logger.info("Loading MCP tools from: {mcp_config}", mcp_config=mcp_config)
148
+ client = fastmcp.Client(mcp_config)
149
+ async with client:
150
+ for tool in await client.list_tools():
151
+ toolset += MCPTool(tool, client)
152
+ return toolset
kimi_cli/soul/approval.py CHANGED
@@ -1,8 +1,8 @@
1
1
  import asyncio
2
2
 
3
3
  from kimi_cli.soul.toolset import get_current_tool_call_or_none
4
- from kimi_cli.soul.wire import ApprovalRequest, ApprovalResponse
5
4
  from kimi_cli.utils.logging import logger
5
+ from kimi_cli.wire.message import ApprovalRequest, ApprovalResponse
6
6
 
7
7
 
8
8
  class Approval:
@@ -10,8 +10,6 @@ from kimi_cli.llm import LLM
10
10
  from kimi_cli.soul.message import system
11
11
  from kimi_cli.utils.logging import logger
12
12
 
13
- MAX_PRESERVED_MESSAGES = 2
14
-
15
13
 
16
14
  @runtime_checkable
17
15
  class Compaction(Protocol):
@@ -33,6 +31,8 @@ class Compaction(Protocol):
33
31
 
34
32
 
35
33
  class SimpleCompaction(Compaction):
34
+ MAX_PRESERVED_MESSAGES = 2
35
+
36
36
  async def compact(self, messages: Sequence[Message], llm: LLM) -> Sequence[Message]:
37
37
  history = list(messages)
38
38
  if not history:
@@ -43,11 +43,11 @@ class SimpleCompaction(Compaction):
43
43
  for index in range(len(history) - 1, -1, -1):
44
44
  if history[index].role in {"user", "assistant"}:
45
45
  n_preserved += 1
46
- if n_preserved == MAX_PRESERVED_MESSAGES:
46
+ if n_preserved == self.MAX_PRESERVED_MESSAGES:
47
47
  preserve_start_index = index
48
48
  break
49
49
 
50
- if n_preserved < MAX_PRESERVED_MESSAGES:
50
+ if n_preserved < self.MAX_PRESERVED_MESSAGES:
51
51
  return history
52
52
 
53
53
  to_compact = history[:preserve_start_index]
kimi_cli/soul/kimisoul.py CHANGED
@@ -16,58 +16,55 @@ from kosong.chat_provider import (
16
16
  from kosong.tooling import ToolResult
17
17
  from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter
18
18
 
19
- from kimi_cli.agent import Agent, AgentGlobals
20
- from kimi_cli.config import LoopControl
21
- from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul, StatusSnapshot
19
+ from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul, StatusSnapshot, wire_send
20
+ from kimi_cli.soul.agent import Agent
22
21
  from kimi_cli.soul.compaction import SimpleCompaction
23
22
  from kimi_cli.soul.context import Context
24
23
  from kimi_cli.soul.message import system, tool_result_to_messages
25
- from kimi_cli.soul.wire import (
24
+ from kimi_cli.soul.runtime import Runtime
25
+ from kimi_cli.tools.dmail import NAME as SendDMail_NAME
26
+ from kimi_cli.tools.utils import ToolRejectedError
27
+ from kimi_cli.utils.logging import logger
28
+ from kimi_cli.wire.message import (
26
29
  CompactionBegin,
27
30
  CompactionEnd,
28
31
  StatusUpdate,
29
32
  StepBegin,
30
33
  StepInterrupted,
31
- Wire,
32
- current_wire,
33
34
  )
34
- from kimi_cli.tools.dmail import NAME as SendDMail_NAME
35
- from kimi_cli.tools.utils import ToolRejectedError
36
- from kimi_cli.utils.logging import logger
37
35
 
38
36
  RESERVED_TOKENS = 50_000
39
37
 
40
38
 
41
- class KimiSoul:
39
+ class KimiSoul(Soul):
42
40
  """The soul of Kimi CLI."""
43
41
 
44
42
  def __init__(
45
43
  self,
46
44
  agent: Agent,
47
- agent_globals: AgentGlobals,
45
+ runtime: Runtime,
48
46
  *,
49
47
  context: Context,
50
- loop_control: LoopControl,
51
48
  ):
52
49
  """
53
50
  Initialize the soul.
54
51
 
55
52
  Args:
56
53
  agent (Agent): The agent to run.
57
- agent_globals (AgentGlobals): Global states and parameters.
54
+ runtime (Runtime): Runtime parameters and states.
58
55
  context (Context): The context of the agent.
59
56
  loop_control (LoopControl): The control parameters for the agent loop.
60
57
  """
61
58
  self._agent = agent
62
- self._agent_globals = agent_globals
63
- self._denwa_renji = agent_globals.denwa_renji
64
- self._approval = agent_globals.approval
59
+ self._runtime = runtime
60
+ self._denwa_renji = runtime.denwa_renji
61
+ self._approval = runtime.approval
65
62
  self._context = context
66
- self._loop_control = loop_control
63
+ self._loop_control = runtime.config.loop_control
67
64
  self._compaction = SimpleCompaction() # TODO: maybe configurable and composable
68
65
  self._reserved_tokens = RESERVED_TOKENS
69
- if self._agent_globals.llm is not None:
70
- assert self._reserved_tokens <= self._agent_globals.llm.max_context_size
66
+ if self._runtime.llm is not None:
67
+ assert self._reserved_tokens <= self._runtime.llm.max_context_size
71
68
 
72
69
  for tool in agent.toolset.tools:
73
70
  if tool.name == SendDMail_NAME:
@@ -82,7 +79,7 @@ class KimiSoul:
82
79
 
83
80
  @property
84
81
  def model(self) -> str:
85
- return self._agent_globals.llm.chat_provider.model_name if self._agent_globals.llm else ""
82
+ return self._runtime.llm.chat_provider.model_name if self._runtime.llm else ""
86
83
 
87
84
  @property
88
85
  def status(self) -> StatusSnapshot:
@@ -90,38 +87,34 @@ class KimiSoul:
90
87
 
91
88
  @property
92
89
  def _context_usage(self) -> float:
93
- if self._agent_globals.llm is not None:
94
- return self._context.token_count / self._agent_globals.llm.max_context_size
90
+ if self._runtime.llm is not None:
91
+ return self._context.token_count / self._runtime.llm.max_context_size
95
92
  return 0.0
96
93
 
97
94
  async def _checkpoint(self):
98
95
  await self._context.checkpoint(self._checkpoint_with_user_message)
99
96
 
100
- async def run(self, user_input: str, wire: Wire):
101
- if self._agent_globals.llm is None:
97
+ async def run(self, user_input: str):
98
+ if self._runtime.llm is None:
102
99
  raise LLMNotSet()
103
100
 
104
101
  await self._checkpoint() # this creates the checkpoint 0 on first run
105
102
  await self._context.append_message(Message(role="user", content=user_input))
106
103
  logger.debug("Appended user message to context")
107
- wire_token = current_wire.set(wire)
108
- try:
109
- await self._agent_loop(wire)
110
- finally:
111
- current_wire.reset(wire_token)
104
+ await self._agent_loop()
112
105
 
113
- async def _agent_loop(self, wire: Wire):
106
+ async def _agent_loop(self):
114
107
  """The main agent loop for one run."""
115
- assert self._agent_globals.llm is not None
108
+ assert self._runtime.llm is not None
116
109
 
117
110
  async def _pipe_approval_to_wire():
118
111
  while True:
119
112
  request = await self._approval.fetch_request()
120
- wire.send(request)
113
+ wire_send(request)
121
114
 
122
115
  step_no = 1
123
116
  while True:
124
- wire.send(StepBegin(step_no))
117
+ wire_send(StepBegin(step_no))
125
118
  approval_task = asyncio.create_task(_pipe_approval_to_wire())
126
119
  # FIXME: It's possible that a subagent's approval task steals approval request
127
120
  # from the main agent. We must ensure that the Task tool will redirect them
@@ -131,24 +124,24 @@ class KimiSoul:
131
124
  # compact the context if needed
132
125
  if (
133
126
  self._context.token_count + self._reserved_tokens
134
- >= self._agent_globals.llm.max_context_size
127
+ >= self._runtime.llm.max_context_size
135
128
  ):
136
129
  logger.info("Context too long, compacting...")
137
- wire.send(CompactionBegin())
130
+ wire_send(CompactionBegin())
138
131
  await self.compact_context()
139
- wire.send(CompactionEnd())
132
+ wire_send(CompactionEnd())
140
133
 
141
134
  logger.debug("Beginning step {step_no}", step_no=step_no)
142
135
  await self._checkpoint()
143
136
  self._denwa_renji.set_n_checkpoints(self._context.n_checkpoints)
144
- finished = await self._step(wire)
137
+ finished = await self._step()
145
138
  except BackToTheFuture as e:
146
139
  await self._context.revert_to(e.checkpoint_id)
147
140
  await self._checkpoint()
148
141
  await self._context.append_message(e.messages)
149
142
  continue
150
143
  except (ChatProviderError, asyncio.CancelledError):
151
- wire.send(StepInterrupted())
144
+ wire_send(StepInterrupted())
152
145
  # break the agent loop
153
146
  raise
154
147
  finally:
@@ -161,11 +154,11 @@ class KimiSoul:
161
154
  if step_no > self._loop_control.max_steps_per_run:
162
155
  raise MaxStepsReached(self._loop_control.max_steps_per_run)
163
156
 
164
- async def _step(self, wire: Wire) -> bool:
157
+ async def _step(self) -> bool:
165
158
  """Run an single step and return whether the run should be stopped."""
166
159
  # already checked in `run`
167
- assert self._agent_globals.llm is not None
168
- chat_provider = self._agent_globals.llm.chat_provider
160
+ assert self._runtime.llm is not None
161
+ chat_provider = self._runtime.llm.chat_provider
169
162
 
170
163
  @tenacity.retry(
171
164
  retry=retry_if_exception(self._is_retryable_error),
@@ -181,8 +174,8 @@ class KimiSoul:
181
174
  self._agent.system_prompt,
182
175
  self._agent.toolset,
183
176
  self._context.history,
184
- on_message_part=wire.send,
185
- on_tool_result=wire.send,
177
+ on_message_part=wire_send,
178
+ on_tool_result=wire_send,
186
179
  )
187
180
 
188
181
  result = await _kosong_step_with_retry()
@@ -190,7 +183,7 @@ class KimiSoul:
190
183
  if result.usage is not None:
191
184
  # mark the token count for the context before the step
192
185
  await self._context.update_token_count(result.usage.input)
193
- wire.send(StatusUpdate(status=self.status))
186
+ wire_send(StatusUpdate(status=self.status))
194
187
 
195
188
  # wait for all tool results (may be interrupted)
196
189
  results = await result.tool_results()
@@ -260,9 +253,9 @@ class KimiSoul:
260
253
  reraise=True,
261
254
  )
262
255
  async def _compact_with_retry() -> Sequence[Message]:
263
- if self._agent_globals.llm is None:
256
+ if self._runtime.llm is None:
264
257
  raise LLMNotSet()
265
- return await self._compaction.compact(self._context.history, self._agent_globals.llm)
258
+ return await self._compaction.compact(self._context.history, self._runtime.llm)
266
259
 
267
260
  compacted_messages = await _compact_with_retry()
268
261
  await self._context.revert_to(0)
@@ -0,0 +1,94 @@
1
+ import subprocess
2
+ import sys
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import NamedTuple
6
+
7
+ from kimi_cli.config import Config
8
+ from kimi_cli.llm import LLM
9
+ from kimi_cli.session import Session
10
+ from kimi_cli.soul.approval import Approval
11
+ from kimi_cli.soul.denwarenji import DenwaRenji
12
+ from kimi_cli.utils.logging import logger
13
+
14
+
15
+ class BuiltinSystemPromptArgs(NamedTuple):
16
+ """Builtin system prompt arguments."""
17
+
18
+ KIMI_NOW: str
19
+ """The current datetime."""
20
+ KIMI_WORK_DIR: Path
21
+ """The current working directory."""
22
+ KIMI_WORK_DIR_LS: str
23
+ """The directory listing of current working directory."""
24
+ KIMI_AGENTS_MD: str # TODO: move to first message from system prompt
25
+ """The content of AGENTS.md."""
26
+
27
+
28
+ def load_agents_md(work_dir: Path) -> str | None:
29
+ paths = [
30
+ work_dir / "AGENTS.md",
31
+ work_dir / "agents.md",
32
+ ]
33
+ for path in paths:
34
+ if path.is_file():
35
+ logger.info("Loaded agents.md: {path}", path=path)
36
+ return path.read_text(encoding="utf-8").strip()
37
+ logger.info("No AGENTS.md found in {work_dir}", work_dir=work_dir)
38
+ return None
39
+
40
+
41
+ def _list_work_dir(work_dir: Path) -> str:
42
+ if sys.platform == "win32":
43
+ ls = subprocess.run(
44
+ ["cmd", "/c", "dir", work_dir],
45
+ capture_output=True,
46
+ text=True,
47
+ encoding="utf-8",
48
+ errors="replace",
49
+ )
50
+ else:
51
+ ls = subprocess.run(
52
+ ["ls", "-la", work_dir],
53
+ capture_output=True,
54
+ text=True,
55
+ encoding="utf-8",
56
+ errors="replace",
57
+ )
58
+ return ls.stdout.strip()
59
+
60
+
61
+ class Runtime(NamedTuple):
62
+ """Agent globals."""
63
+
64
+ config: Config
65
+ llm: LLM | None
66
+ session: Session
67
+ builtin_args: BuiltinSystemPromptArgs
68
+ denwa_renji: DenwaRenji
69
+ approval: Approval
70
+
71
+ @staticmethod
72
+ async def create(
73
+ config: Config,
74
+ llm: LLM | None,
75
+ session: Session,
76
+ yolo: bool,
77
+ ) -> "Runtime":
78
+ # FIXME: do these asynchronously
79
+ ls_output = _list_work_dir(session.work_dir)
80
+ agents_md = load_agents_md(session.work_dir) or ""
81
+
82
+ return Runtime(
83
+ config=config,
84
+ llm=llm,
85
+ session=session,
86
+ builtin_args=BuiltinSystemPromptArgs(
87
+ KIMI_NOW=datetime.now().astimezone().isoformat(),
88
+ KIMI_WORK_DIR=session.work_dir,
89
+ KIMI_WORK_DIR_LS=ls_output,
90
+ KIMI_AGENTS_MD=agents_md,
91
+ ),
92
+ denwa_renji=DenwaRenji(),
93
+ approval=Approval(yolo=yolo),
94
+ )
@@ -10,7 +10,7 @@ NAME = "SendDMail"
10
10
 
11
11
  class SendDMail(CallableTool2):
12
12
  name: str = NAME
13
- description: str = (Path(__file__).parent / "dmail.md").read_text()
13
+ description: str = (Path(__file__).parent / "dmail.md").read_text(encoding="utf-8")
14
14
  params: type[DMail] = DMail
15
15
 
16
16
  def __init__(self, denwa_renji: DenwaRenji, **kwargs):
@@ -14,4 +14,4 @@ Find files and directories using glob patterns. This tool supports standard glob
14
14
 
15
15
  **Bad example patterns:**
16
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.
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 recursively 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.
@@ -8,7 +8,7 @@ import aiofiles.os
8
8
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
9
9
  from pydantic import BaseModel, Field
10
10
 
11
- from kimi_cli.agent import BuiltinSystemPromptArgs
11
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
12
12
  from kimi_cli.tools.utils import load_desc
13
13
 
14
14
  MAX_MATCHES = 1000
@@ -128,7 +128,7 @@ class Glob(CallableTool2[Params]):
128
128
  message = (
129
129
  f"Found {len(matches)} matches for pattern `{params.pattern}`."
130
130
  if len(matches) > 0
131
- else "No matches found for pattern `{params.pattern}`."
131
+ else f"No matches found for pattern `{params.pattern}`."
132
132
  )
133
133
  if len(matches) > MAX_MATCHES:
134
134
  matches = matches[:MAX_MATCHES]
@@ -220,7 +220,7 @@ async def _ensure_rg_path() -> str:
220
220
 
221
221
  class Grep(CallableTool2[Params]):
222
222
  name: str = "Grep"
223
- description: str = (Path(__file__).parent / "grep.md").read_text()
223
+ description: str = (Path(__file__).parent / "grep.md").read_text(encoding="utf-8")
224
224
  params: type[Params] = Params
225
225
 
226
226
  @override