kimi-cli 0.40__py3-none-any.whl → 0.41__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 (48) hide show
  1. kimi_cli/CHANGELOG.md +12 -0
  2. kimi_cli/__init__.py +18 -280
  3. kimi_cli/agents/koder/system.md +1 -1
  4. kimi_cli/agentspec.py +104 -0
  5. kimi_cli/cli.py +235 -0
  6. kimi_cli/constant.py +4 -0
  7. kimi_cli/llm.py +69 -0
  8. kimi_cli/prompts/__init__.py +2 -2
  9. kimi_cli/soul/__init__.py +102 -6
  10. kimi_cli/soul/agent.py +157 -0
  11. kimi_cli/soul/approval.py +1 -1
  12. kimi_cli/soul/compaction.py +4 -4
  13. kimi_cli/soul/context.py +5 -0
  14. kimi_cli/soul/globals.py +92 -0
  15. kimi_cli/soul/kimisoul.py +21 -26
  16. kimi_cli/tools/dmail/__init__.py +1 -1
  17. kimi_cli/tools/file/glob.md +1 -1
  18. kimi_cli/tools/file/glob.py +2 -2
  19. kimi_cli/tools/file/grep.py +1 -1
  20. kimi_cli/tools/file/patch.py +2 -2
  21. kimi_cli/tools/file/read.py +1 -1
  22. kimi_cli/tools/file/replace.py +2 -2
  23. kimi_cli/tools/file/write.py +2 -2
  24. kimi_cli/tools/task/__init__.py +23 -22
  25. kimi_cli/tools/task/task.md +1 -1
  26. kimi_cli/tools/todo/__init__.py +1 -1
  27. kimi_cli/tools/utils.py +1 -1
  28. kimi_cli/tools/web/search.py +2 -2
  29. kimi_cli/ui/__init__.py +0 -69
  30. kimi_cli/ui/acp/__init__.py +8 -9
  31. kimi_cli/ui/print/__init__.py +17 -35
  32. kimi_cli/ui/shell/__init__.py +5 -13
  33. kimi_cli/ui/shell/liveview.py +1 -1
  34. kimi_cli/ui/shell/metacmd.py +3 -3
  35. kimi_cli/ui/shell/setup.py +5 -5
  36. kimi_cli/ui/shell/update.py +2 -2
  37. kimi_cli/ui/shell/visualize.py +10 -7
  38. kimi_cli/utils/changelog.py +3 -1
  39. kimi_cli/wire/__init__.py +57 -0
  40. kimi_cli/{soul/wire.py → wire/message.py} +4 -39
  41. {kimi_cli-0.40.dist-info → kimi_cli-0.41.dist-info}/METADATA +34 -1
  42. kimi_cli-0.41.dist-info/RECORD +85 -0
  43. kimi_cli-0.41.dist-info/entry_points.txt +3 -0
  44. kimi_cli/agent.py +0 -261
  45. kimi_cli/utils/provider.py +0 -70
  46. kimi_cli-0.40.dist-info/RECORD +0 -81
  47. kimi_cli-0.40.dist-info/entry_points.txt +0 -3
  48. {kimi_cli-0.40.dist-info → kimi_cli-0.41.dist-info}/WHEEL +0 -0
kimi_cli/cli.py ADDED
@@ -0,0 +1,235 @@
1
+ import asyncio
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from kimi_cli import UIMode, kimi_run
9
+ from kimi_cli.agentspec import DEFAULT_AGENT_FILE
10
+ from kimi_cli.config import ConfigError, load_config
11
+ from kimi_cli.constant import VERSION
12
+ from kimi_cli.metadata import continue_session, new_session
13
+ from kimi_cli.share import get_share_dir
14
+ from kimi_cli.ui.print import InputFormat, OutputFormat
15
+ from kimi_cli.utils.logging import logger
16
+
17
+
18
+ class Reload(Exception):
19
+ """Reload configuration."""
20
+
21
+ pass
22
+
23
+
24
+ @click.command(context_settings=dict(help_option_names=["-h", "--help"]))
25
+ @click.version_option(VERSION)
26
+ @click.option(
27
+ "--verbose",
28
+ is_flag=True,
29
+ default=False,
30
+ help="Print verbose information. Default: no.",
31
+ )
32
+ @click.option(
33
+ "--debug",
34
+ is_flag=True,
35
+ default=False,
36
+ help="Log debug information. Default: no.",
37
+ )
38
+ @click.option(
39
+ "--agent-file",
40
+ type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path),
41
+ default=DEFAULT_AGENT_FILE,
42
+ help="Custom agent specification file. Default: builtin Kimi Koder.",
43
+ )
44
+ @click.option(
45
+ "--model",
46
+ "-m",
47
+ "model_name",
48
+ type=str,
49
+ default=None,
50
+ help="LLM model to use. Default: default model set in config file.",
51
+ )
52
+ @click.option(
53
+ "--work-dir",
54
+ "-w",
55
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
56
+ default=Path.cwd(),
57
+ help="Working directory for the agent. Default: current directory.",
58
+ )
59
+ @click.option(
60
+ "--continue",
61
+ "-C",
62
+ "continue_",
63
+ is_flag=True,
64
+ default=False,
65
+ help="Continue the previous session for the working directory. Default: no.",
66
+ )
67
+ @click.option(
68
+ "--command",
69
+ "-c",
70
+ "--query",
71
+ "-q",
72
+ "command",
73
+ type=str,
74
+ default=None,
75
+ help="User query to the agent. Default: prompt interactively.",
76
+ )
77
+ @click.option(
78
+ "--ui",
79
+ "ui",
80
+ type=click.Choice(["shell", "print", "acp"]),
81
+ default="shell",
82
+ help="UI mode to use. Default: shell.",
83
+ )
84
+ @click.option(
85
+ "--print",
86
+ "ui",
87
+ flag_value="print",
88
+ help="Run in print mode. Shortcut for `--ui print`.",
89
+ )
90
+ @click.option(
91
+ "--acp",
92
+ "ui",
93
+ flag_value="acp",
94
+ help="Start ACP server. Shortcut for `--ui acp`.",
95
+ )
96
+ @click.option(
97
+ "--input-format",
98
+ type=click.Choice(["text", "stream-json"]),
99
+ default=None,
100
+ help=(
101
+ "Input format to use. Must be used with `--print` "
102
+ "and the input must be piped in via stdin. "
103
+ "Default: text."
104
+ ),
105
+ )
106
+ @click.option(
107
+ "--output-format",
108
+ type=click.Choice(["text", "stream-json"]),
109
+ default=None,
110
+ help="Output format to use. Must be used with `--print`. Default: text.",
111
+ )
112
+ @click.option(
113
+ "--mcp-config-file",
114
+ type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path),
115
+ multiple=True,
116
+ help=(
117
+ "MCP config file to load. Add this option multiple times to specify multiple MCP configs. "
118
+ "Default: none."
119
+ ),
120
+ )
121
+ @click.option(
122
+ "--mcp-config",
123
+ type=str,
124
+ multiple=True,
125
+ help=(
126
+ "MCP config JSON to load. Add this option multiple times to specify multiple MCP configs. "
127
+ "Default: none."
128
+ ),
129
+ )
130
+ @click.option(
131
+ "--yolo",
132
+ "--yes",
133
+ "-y",
134
+ "--auto-approve",
135
+ "yolo",
136
+ is_flag=True,
137
+ default=False,
138
+ help="Automatically approve all actions. Default: no.",
139
+ )
140
+ def kimi(
141
+ verbose: bool,
142
+ debug: bool,
143
+ agent_file: Path,
144
+ model_name: str | None,
145
+ work_dir: Path,
146
+ continue_: bool,
147
+ command: str | None,
148
+ ui: UIMode,
149
+ input_format: InputFormat | None,
150
+ output_format: OutputFormat | None,
151
+ mcp_config_file: list[Path],
152
+ mcp_config: list[str],
153
+ yolo: bool,
154
+ ):
155
+ """Kimi, your next CLI agent."""
156
+ echo = click.echo if verbose else lambda *args, **kwargs: None
157
+
158
+ logger.add(
159
+ get_share_dir() / "logs" / "kimi.log",
160
+ level="DEBUG" if debug else "INFO",
161
+ rotation="06:00",
162
+ retention="10 days",
163
+ )
164
+
165
+ work_dir = work_dir.absolute()
166
+
167
+ if continue_:
168
+ session = continue_session(work_dir)
169
+ if session is None:
170
+ raise click.BadOptionUsage(
171
+ "--continue", "No previous session found for the working directory"
172
+ )
173
+ echo(f"✓ Continuing previous session: {session.id}")
174
+ else:
175
+ session = new_session(work_dir)
176
+ echo(f"✓ Created new session: {session.id}")
177
+ echo(f"✓ Session history file: {session.history_file}")
178
+
179
+ if input_format is not None and ui != "print":
180
+ raise click.BadOptionUsage(
181
+ "--input-format",
182
+ "Input format is only supported for print UI",
183
+ )
184
+ if output_format is not None and ui != "print":
185
+ raise click.BadOptionUsage(
186
+ "--output-format",
187
+ "Output format is only supported for print UI",
188
+ )
189
+
190
+ try:
191
+ mcp_configs = [json.loads(conf.read_text()) for conf in mcp_config_file]
192
+ except json.JSONDecodeError as e:
193
+ raise click.BadOptionUsage("--mcp-config-file", f"Invalid JSON: {e}") from e
194
+
195
+ try:
196
+ mcp_configs += [json.loads(conf) for conf in mcp_config]
197
+ except json.JSONDecodeError as e:
198
+ raise click.BadOptionUsage("--mcp-config", f"Invalid JSON: {e}") from e
199
+
200
+ while True:
201
+ try:
202
+ try:
203
+ config = load_config()
204
+ except ConfigError as e:
205
+ raise click.ClickException(f"Failed to load config: {e}") from e
206
+ echo(f"✓ Loaded config: {config}")
207
+
208
+ succeeded = asyncio.run(
209
+ kimi_run(
210
+ config=config,
211
+ model_name=model_name,
212
+ work_dir=work_dir,
213
+ session=session,
214
+ command=command,
215
+ agent_file=agent_file,
216
+ ui=ui,
217
+ input_format=input_format,
218
+ output_format=output_format,
219
+ mcp_configs=mcp_configs,
220
+ yolo=yolo,
221
+ )
222
+ )
223
+ if not succeeded:
224
+ sys.exit(1)
225
+ break
226
+ except Reload:
227
+ continue
228
+
229
+
230
+ def main():
231
+ kimi()
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()
kimi_cli/constant.py ADDED
@@ -0,0 +1,4 @@
1
+ import importlib.metadata
2
+
3
+ VERSION = importlib.metadata.version("kimi-cli")
4
+ USER_AGENT = f"KimiCLI/{VERSION}"
kimi_cli/llm.py CHANGED
@@ -1,8 +1,77 @@
1
+ import os
1
2
  from typing import NamedTuple
2
3
 
3
4
  from kosong.base.chat_provider import ChatProvider
5
+ from kosong.chat_provider.chaos import ChaosChatProvider, ChaosConfig
6
+ from kosong.chat_provider.kimi import Kimi
7
+ from kosong.chat_provider.openai_legacy import OpenAILegacy
8
+ from pydantic import SecretStr
9
+
10
+ from kimi_cli.config import LLMModel, LLMProvider
11
+ from kimi_cli.constant import USER_AGENT
4
12
 
5
13
 
6
14
  class LLM(NamedTuple):
7
15
  chat_provider: ChatProvider
8
16
  max_context_size: int
17
+
18
+
19
+ def augment_provider_with_env_vars(provider: LLMProvider, model: LLMModel):
20
+ match provider.type:
21
+ case "kimi":
22
+ if base_url := os.getenv("KIMI_BASE_URL"):
23
+ provider.base_url = base_url
24
+ if api_key := os.getenv("KIMI_API_KEY"):
25
+ provider.api_key = SecretStr(api_key)
26
+ if model_name := os.getenv("KIMI_MODEL_NAME"):
27
+ model.model = model_name
28
+ if max_context_size := os.getenv("KIMI_MODEL_MAX_CONTEXT_SIZE"):
29
+ model.max_context_size = int(max_context_size)
30
+ case "openai_legacy":
31
+ if base_url := os.getenv("OPENAI_BASE_URL"):
32
+ provider.base_url = base_url
33
+ if api_key := os.getenv("OPENAI_API_KEY"):
34
+ provider.api_key = SecretStr(api_key)
35
+ case _:
36
+ pass
37
+
38
+
39
+ def create_llm(
40
+ provider: LLMProvider,
41
+ model: LLMModel,
42
+ *,
43
+ stream: bool = True,
44
+ session_id: str | None = None,
45
+ ) -> LLM:
46
+ match provider.type:
47
+ case "kimi":
48
+ chat_provider = Kimi(
49
+ model=model.model,
50
+ base_url=provider.base_url,
51
+ api_key=provider.api_key.get_secret_value(),
52
+ stream=stream,
53
+ default_headers={
54
+ "User-Agent": USER_AGENT,
55
+ },
56
+ )
57
+ if session_id:
58
+ chat_provider = chat_provider.with_generation_kwargs(prompt_cache_key=session_id)
59
+ case "openai_legacy":
60
+ chat_provider = OpenAILegacy(
61
+ model=model.model,
62
+ base_url=provider.base_url,
63
+ api_key=provider.api_key.get_secret_value(),
64
+ stream=stream,
65
+ )
66
+ case "_chaos":
67
+ chat_provider = ChaosChatProvider(
68
+ model=model.model,
69
+ base_url=provider.base_url,
70
+ api_key=provider.api_key.get_secret_value(),
71
+ chaos_config=ChaosConfig(
72
+ error_probability=0.8,
73
+ error_types=[429, 500, 503],
74
+ ),
75
+ )
76
+
77
+ return LLM(chat_provider=chat_provider, max_context_size=model.max_context_size)
@@ -1,4 +1,4 @@
1
1
  from pathlib import Path
2
2
 
3
- INIT = (Path(__file__).parent / "init.md").read_text()
4
- COMPACT = (Path(__file__).parent / "compact.md").read_text()
3
+ INIT = (Path(__file__).parent / "init.md").read_text(encoding="utf-8")
4
+ COMPACT = (Path(__file__).parent / "compact.md").read_text(encoding="utf-8")
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,157 @@
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.metadata import Session
13
+ from kimi_cli.soul.approval import Approval
14
+ from kimi_cli.soul.denwarenji import DenwaRenji
15
+ from kimi_cli.soul.globals import AgentGlobals, BuiltinSystemPromptArgs
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_with_mcp(
30
+ agent_file: Path,
31
+ globals_: AgentGlobals,
32
+ mcp_configs: list[dict[str, Any]],
33
+ ) -> Agent:
34
+ agent = load_agent(agent_file, globals_)
35
+ assert isinstance(agent.toolset, CustomToolset)
36
+ if mcp_configs:
37
+ await _load_mcp_tools(agent.toolset, mcp_configs)
38
+ return agent
39
+
40
+
41
+ def load_agent(
42
+ agent_file: Path,
43
+ globals_: AgentGlobals,
44
+ ) -> Agent:
45
+ """
46
+ Load agent from specification file.
47
+
48
+ Raises:
49
+ ValueError: If the agent spec is not valid.
50
+ """
51
+ logger.info("Loading agent: {agent_file}", agent_file=agent_file)
52
+ agent_spec = load_agent_spec(agent_file)
53
+
54
+ system_prompt = _load_system_prompt(
55
+ agent_spec.system_prompt_path,
56
+ agent_spec.system_prompt_args,
57
+ globals_.builtin_args,
58
+ )
59
+
60
+ tool_deps = {
61
+ ResolvedAgentSpec: agent_spec,
62
+ AgentGlobals: globals_,
63
+ Config: globals_.config,
64
+ BuiltinSystemPromptArgs: globals_.builtin_args,
65
+ Session: globals_.session,
66
+ DenwaRenji: globals_.denwa_renji,
67
+ Approval: globals_.approval,
68
+ }
69
+ tools = agent_spec.tools
70
+ if agent_spec.exclude_tools:
71
+ logger.debug("Excluding tools: {tools}", tools=agent_spec.exclude_tools)
72
+ tools = [tool for tool in tools if tool not in agent_spec.exclude_tools]
73
+ toolset = CustomToolset()
74
+ bad_tools = _load_tools(toolset, tools, tool_deps)
75
+ if bad_tools:
76
+ raise ValueError(f"Invalid tools: {bad_tools}")
77
+
78
+ return Agent(
79
+ name=agent_spec.name,
80
+ system_prompt=system_prompt,
81
+ toolset=toolset,
82
+ )
83
+
84
+
85
+ def _load_system_prompt(
86
+ path: Path, args: dict[str, str], builtin_args: BuiltinSystemPromptArgs
87
+ ) -> str:
88
+ logger.info("Loading system prompt: {path}", path=path)
89
+ system_prompt = path.read_text(encoding="utf-8").strip()
90
+ logger.debug(
91
+ "Substituting system prompt with builtin args: {builtin_args}, spec args: {spec_args}",
92
+ builtin_args=builtin_args,
93
+ spec_args=args,
94
+ )
95
+ return string.Template(system_prompt).substitute(builtin_args._asdict(), **args)
96
+
97
+
98
+ type ToolType = CallableTool | CallableTool2[Any]
99
+ # TODO: move this to kosong.tooling.simple
100
+
101
+
102
+ def _load_tools(
103
+ toolset: CustomToolset,
104
+ tool_paths: list[str],
105
+ dependencies: dict[type[Any], Any],
106
+ ) -> list[str]:
107
+ bad_tools: list[str] = []
108
+ for tool_path in tool_paths:
109
+ tool = _load_tool(tool_path, dependencies)
110
+ if tool:
111
+ toolset += tool
112
+ else:
113
+ bad_tools.append(tool_path)
114
+ logger.info("Loaded tools: {tools}", tools=[tool.name for tool in toolset.tools])
115
+ if bad_tools:
116
+ logger.error("Bad tools: {bad_tools}", bad_tools=bad_tools)
117
+ return bad_tools
118
+
119
+
120
+ def _load_tool(tool_path: str, dependencies: dict[type[Any], Any]) -> ToolType | None:
121
+ logger.debug("Loading tool: {tool_path}", tool_path=tool_path)
122
+ module_name, class_name = tool_path.rsplit(":", 1)
123
+ try:
124
+ module = importlib.import_module(module_name)
125
+ except ImportError:
126
+ return None
127
+ cls = getattr(module, class_name, None)
128
+ if cls is None:
129
+ return None
130
+ args: list[type[Any]] = []
131
+ for param in inspect.signature(cls).parameters.values():
132
+ if param.kind == inspect.Parameter.KEYWORD_ONLY:
133
+ # once we encounter a keyword-only parameter, we stop injecting dependencies
134
+ break
135
+ # all positional parameters should be dependencies to be injected
136
+ if param.annotation not in dependencies:
137
+ raise ValueError(f"Tool dependency not found: {param.annotation}")
138
+ args.append(dependencies[param.annotation])
139
+ return cls(*args)
140
+
141
+
142
+ async def _load_mcp_tools(
143
+ toolset: CustomToolset,
144
+ mcp_configs: list[dict[str, Any]],
145
+ ):
146
+ """
147
+ Raises:
148
+ ValueError: If the MCP config is not valid.
149
+ RuntimeError: If the MCP server cannot be connected.
150
+ """
151
+ for mcp_config in mcp_configs:
152
+ logger.info("Loading MCP tools from: {mcp_config}", mcp_config=mcp_config)
153
+ client = fastmcp.Client(mcp_config)
154
+ async with client:
155
+ for tool in await client.list_tools():
156
+ toolset += MCPTool(tool, client)
157
+ 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/context.py CHANGED
@@ -19,6 +19,11 @@ class Context:
19
19
  self._next_checkpoint_id: int = 0
20
20
  """The ID of the next checkpoint, starting from 0, incremented after each checkpoint."""
21
21
 
22
+ @property
23
+ def file_backend(self) -> Path:
24
+ """The JSONL file backend of the context."""
25
+ return self._file_backend
26
+
22
27
  async def restore(self) -> bool:
23
28
  logger.debug("Restoring context from file: {file_backend}", file_backend=self._file_backend)
24
29
  if self._history: