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.
- kimi_cli/CHANGELOG.md +12 -0
- kimi_cli/__init__.py +18 -280
- kimi_cli/agents/koder/system.md +1 -1
- kimi_cli/agentspec.py +104 -0
- kimi_cli/cli.py +235 -0
- kimi_cli/constant.py +4 -0
- kimi_cli/llm.py +69 -0
- kimi_cli/prompts/__init__.py +2 -2
- kimi_cli/soul/__init__.py +102 -6
- kimi_cli/soul/agent.py +157 -0
- kimi_cli/soul/approval.py +1 -1
- kimi_cli/soul/compaction.py +4 -4
- kimi_cli/soul/context.py +5 -0
- kimi_cli/soul/globals.py +92 -0
- kimi_cli/soul/kimisoul.py +21 -26
- kimi_cli/tools/dmail/__init__.py +1 -1
- kimi_cli/tools/file/glob.md +1 -1
- kimi_cli/tools/file/glob.py +2 -2
- kimi_cli/tools/file/grep.py +1 -1
- kimi_cli/tools/file/patch.py +2 -2
- kimi_cli/tools/file/read.py +1 -1
- kimi_cli/tools/file/replace.py +2 -2
- kimi_cli/tools/file/write.py +2 -2
- kimi_cli/tools/task/__init__.py +23 -22
- kimi_cli/tools/task/task.md +1 -1
- kimi_cli/tools/todo/__init__.py +1 -1
- kimi_cli/tools/utils.py +1 -1
- kimi_cli/tools/web/search.py +2 -2
- kimi_cli/ui/__init__.py +0 -69
- kimi_cli/ui/acp/__init__.py +8 -9
- kimi_cli/ui/print/__init__.py +17 -35
- kimi_cli/ui/shell/__init__.py +5 -13
- kimi_cli/ui/shell/liveview.py +1 -1
- kimi_cli/ui/shell/metacmd.py +3 -3
- kimi_cli/ui/shell/setup.py +5 -5
- kimi_cli/ui/shell/update.py +2 -2
- kimi_cli/ui/shell/visualize.py +10 -7
- kimi_cli/utils/changelog.py +3 -1
- kimi_cli/wire/__init__.py +57 -0
- kimi_cli/{soul/wire.py → wire/message.py} +4 -39
- {kimi_cli-0.40.dist-info → kimi_cli-0.41.dist-info}/METADATA +34 -1
- kimi_cli-0.41.dist-info/RECORD +85 -0
- kimi_cli-0.41.dist-info/entry_points.txt +3 -0
- kimi_cli/agent.py +0 -261
- kimi_cli/utils/provider.py +0 -70
- kimi_cli-0.40.dist-info/RECORD +0 -81
- kimi_cli-0.40.dist-info/entry_points.txt +0 -3
- {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
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)
|
kimi_cli/prompts/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
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:
|
kimi_cli/soul/compaction.py
CHANGED
|
@@ -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:
|