kimi-cli 0.39__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 (52) hide show
  1. kimi_cli/CHANGELOG.md +23 -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 +2 -4
  12. kimi_cli/soul/compaction.py +10 -10
  13. kimi_cli/soul/context.py +5 -0
  14. kimi_cli/soul/globals.py +92 -0
  15. kimi_cli/soul/kimisoul.py +26 -30
  16. kimi_cli/soul/message.py +5 -5
  17. kimi_cli/tools/bash/__init__.py +2 -2
  18. kimi_cli/tools/dmail/__init__.py +1 -1
  19. kimi_cli/tools/file/glob.md +1 -1
  20. kimi_cli/tools/file/glob.py +2 -2
  21. kimi_cli/tools/file/grep.py +3 -2
  22. kimi_cli/tools/file/patch.py +2 -2
  23. kimi_cli/tools/file/read.py +1 -1
  24. kimi_cli/tools/file/replace.py +2 -2
  25. kimi_cli/tools/file/write.py +2 -2
  26. kimi_cli/tools/task/__init__.py +23 -22
  27. kimi_cli/tools/task/task.md +1 -1
  28. kimi_cli/tools/todo/__init__.py +1 -1
  29. kimi_cli/tools/utils.py +1 -1
  30. kimi_cli/tools/web/fetch.py +2 -1
  31. kimi_cli/tools/web/search.py +4 -4
  32. kimi_cli/ui/__init__.py +0 -69
  33. kimi_cli/ui/acp/__init__.py +8 -9
  34. kimi_cli/ui/print/__init__.py +17 -35
  35. kimi_cli/ui/shell/__init__.py +9 -15
  36. kimi_cli/ui/shell/liveview.py +13 -4
  37. kimi_cli/ui/shell/metacmd.py +3 -3
  38. kimi_cli/ui/shell/setup.py +7 -6
  39. kimi_cli/ui/shell/update.py +4 -3
  40. kimi_cli/ui/shell/visualize.py +18 -9
  41. kimi_cli/utils/aiohttp.py +10 -0
  42. kimi_cli/utils/changelog.py +3 -1
  43. kimi_cli/wire/__init__.py +57 -0
  44. kimi_cli/{soul/wire.py → wire/message.py} +4 -39
  45. {kimi_cli-0.39.dist-info → kimi_cli-0.41.dist-info}/METADATA +35 -2
  46. kimi_cli-0.41.dist-info/RECORD +85 -0
  47. kimi_cli-0.41.dist-info/entry_points.txt +3 -0
  48. kimi_cli/agent.py +0 -261
  49. kimi_cli/utils/provider.py +0 -72
  50. kimi_cli-0.39.dist-info/RECORD +0 -80
  51. kimi_cli-0.39.dist-info/entry_points.txt +0 -3
  52. {kimi_cli-0.39.dist-info → kimi_cli-0.41.dist-info}/WHEEL +0 -0
@@ -0,0 +1,92 @@
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.metadata 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 AgentGlobals(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
+ @classmethod
72
+ async def create(
73
+ cls, config: Config, llm: LLM | None, session: Session, yolo: bool
74
+ ) -> "AgentGlobals":
75
+ work_dir = Path(session.work_dir.path)
76
+ # FIXME: do these asynchronously
77
+ ls_output = _list_work_dir(work_dir)
78
+ agents_md = load_agents_md(work_dir) or ""
79
+
80
+ return cls(
81
+ config=config,
82
+ llm=llm,
83
+ session=session,
84
+ builtin_args=BuiltinSystemPromptArgs(
85
+ KIMI_NOW=datetime.now().astimezone().isoformat(),
86
+ KIMI_WORK_DIR=work_dir,
87
+ KIMI_WORK_DIR_LS=ls_output,
88
+ KIMI_AGENTS_MD=agents_md,
89
+ ),
90
+ denwa_renji=DenwaRenji(),
91
+ approval=Approval(yolo=yolo),
92
+ )
kimi_cli/soul/kimisoul.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  from collections.abc import Sequence
3
3
  from functools import partial
4
+ from typing import TYPE_CHECKING
4
5
 
5
6
  import kosong
6
7
  import tenacity
@@ -15,29 +16,28 @@ from kosong.chat_provider import (
15
16
  from kosong.tooling import ToolResult
16
17
  from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter
17
18
 
18
- from kimi_cli.agent import Agent, AgentGlobals
19
19
  from kimi_cli.config import LoopControl
20
- from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul, StatusSnapshot
20
+ from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul, StatusSnapshot, wire_send
21
+ from kimi_cli.soul.agent import Agent
21
22
  from kimi_cli.soul.compaction import SimpleCompaction
22
23
  from kimi_cli.soul.context import Context
24
+ from kimi_cli.soul.globals import AgentGlobals
23
25
  from kimi_cli.soul.message import system, tool_result_to_messages
24
- from kimi_cli.soul.wire import (
26
+ from kimi_cli.tools.dmail import NAME as SendDMail_NAME
27
+ from kimi_cli.tools.utils import ToolRejectedError
28
+ from kimi_cli.utils.logging import logger
29
+ from kimi_cli.wire.message import (
25
30
  CompactionBegin,
26
31
  CompactionEnd,
27
32
  StatusUpdate,
28
33
  StepBegin,
29
34
  StepInterrupted,
30
- Wire,
31
- current_wire,
32
35
  )
33
- from kimi_cli.tools.dmail import NAME as SendDMail_NAME
34
- from kimi_cli.tools.utils import ToolRejectedError
35
- from kimi_cli.utils.logging import logger
36
36
 
37
37
  RESERVED_TOKENS = 50_000
38
38
 
39
39
 
40
- class KimiSoul:
40
+ class KimiSoul(Soul):
41
41
  """The soul of Kimi CLI."""
42
42
 
43
43
  def __init__(
@@ -96,31 +96,27 @@ class KimiSoul:
96
96
  async def _checkpoint(self):
97
97
  await self._context.checkpoint(self._checkpoint_with_user_message)
98
98
 
99
- async def run(self, user_input: str, wire: Wire):
99
+ async def run(self, user_input: str):
100
100
  if self._agent_globals.llm is None:
101
101
  raise LLMNotSet()
102
102
 
103
103
  await self._checkpoint() # this creates the checkpoint 0 on first run
104
104
  await self._context.append_message(Message(role="user", content=user_input))
105
105
  logger.debug("Appended user message to context")
106
- wire_token = current_wire.set(wire)
107
- try:
108
- await self._agent_loop(wire)
109
- finally:
110
- current_wire.reset(wire_token)
106
+ await self._agent_loop()
111
107
 
112
- async def _agent_loop(self, wire: Wire):
108
+ async def _agent_loop(self):
113
109
  """The main agent loop for one run."""
114
110
  assert self._agent_globals.llm is not None
115
111
 
116
112
  async def _pipe_approval_to_wire():
117
113
  while True:
118
114
  request = await self._approval.fetch_request()
119
- wire.send(request)
115
+ wire_send(request)
120
116
 
121
117
  step_no = 1
122
118
  while True:
123
- wire.send(StepBegin(step_no))
119
+ wire_send(StepBegin(step_no))
124
120
  approval_task = asyncio.create_task(_pipe_approval_to_wire())
125
121
  # FIXME: It's possible that a subagent's approval task steals approval request
126
122
  # from the main agent. We must ensure that the Task tool will redirect them
@@ -133,21 +129,21 @@ class KimiSoul:
133
129
  >= self._agent_globals.llm.max_context_size
134
130
  ):
135
131
  logger.info("Context too long, compacting...")
136
- wire.send(CompactionBegin())
132
+ wire_send(CompactionBegin())
137
133
  await self.compact_context()
138
- wire.send(CompactionEnd())
134
+ wire_send(CompactionEnd())
139
135
 
140
136
  logger.debug("Beginning step {step_no}", step_no=step_no)
141
137
  await self._checkpoint()
142
138
  self._denwa_renji.set_n_checkpoints(self._context.n_checkpoints)
143
- finished = await self._step(wire)
139
+ finished = await self._step()
144
140
  except BackToTheFuture as e:
145
141
  await self._context.revert_to(e.checkpoint_id)
146
142
  await self._checkpoint()
147
143
  await self._context.append_message(e.messages)
148
144
  continue
149
145
  except (ChatProviderError, asyncio.CancelledError):
150
- wire.send(StepInterrupted())
146
+ wire_send(StepInterrupted())
151
147
  # break the agent loop
152
148
  raise
153
149
  finally:
@@ -160,7 +156,7 @@ class KimiSoul:
160
156
  if step_no > self._loop_control.max_steps_per_run:
161
157
  raise MaxStepsReached(self._loop_control.max_steps_per_run)
162
158
 
163
- async def _step(self, wire: Wire) -> bool:
159
+ async def _step(self) -> bool:
164
160
  """Run an single step and return whether the run should be stopped."""
165
161
  # already checked in `run`
166
162
  assert self._agent_globals.llm is not None
@@ -180,8 +176,8 @@ class KimiSoul:
180
176
  self._agent.system_prompt,
181
177
  self._agent.toolset,
182
178
  self._context.history,
183
- on_message_part=wire.send,
184
- on_tool_result=wire.send,
179
+ on_message_part=wire_send,
180
+ on_tool_result=wire_send,
185
181
  )
186
182
 
187
183
  result = await _kosong_step_with_retry()
@@ -189,7 +185,7 @@ class KimiSoul:
189
185
  if result.usage is not None:
190
186
  # mark the token count for the context before the step
191
187
  await self._context.update_token_count(result.usage.input)
192
- wire.send(StatusUpdate(status=self.status))
188
+ wire_send(StatusUpdate(status=self.status))
193
189
 
194
190
  # wait for all tool results (may be interrupted)
195
191
  results = await result.tool_results()
@@ -302,7 +298,7 @@ class BackToTheFuture(Exception):
302
298
  self.messages = messages
303
299
 
304
300
 
305
- def __static_type_check(
306
- kimi_soul: KimiSoul,
307
- ):
308
- _: Soul = kimi_soul
301
+ if TYPE_CHECKING:
302
+
303
+ def type_check(kimi_soul: KimiSoul):
304
+ _: Soul = kimi_soul
kimi_cli/soul/message.py CHANGED
@@ -14,7 +14,7 @@ def tool_result_to_messages(tool_result: ToolResult) -> list[Message]:
14
14
  message = tool_result.result.message
15
15
  if isinstance(tool_result.result, ToolRuntimeError):
16
16
  message += "\nThis is an unexpected error and the tool is probably not working."
17
- content = [system(message)]
17
+ content: list[ContentPart] = [system(message)]
18
18
  if tool_result.result.output:
19
19
  content.append(TextPart(text=tool_result.result.output))
20
20
  return [
@@ -26,8 +26,8 @@ def tool_result_to_messages(tool_result: ToolResult) -> list[Message]:
26
26
  ]
27
27
 
28
28
  content = tool_ok_to_message_content(tool_result.result)
29
- text_parts = []
30
- non_text_parts = []
29
+ text_parts: list[ContentPart] = []
30
+ non_text_parts: list[ContentPart] = []
31
31
  for part in content:
32
32
  if isinstance(part, TextPart):
33
33
  text_parts.append(part)
@@ -60,7 +60,7 @@ def tool_result_to_messages(tool_result: ToolResult) -> list[Message]:
60
60
 
61
61
  def tool_ok_to_message_content(result: ToolOk) -> list[ContentPart]:
62
62
  """Convert a tool return value to a list of message content parts."""
63
- content = []
63
+ content: list[ContentPart] = []
64
64
  if result.message:
65
65
  content.append(system(result.message))
66
66
  match output := result.output:
@@ -70,7 +70,7 @@ def tool_ok_to_message_content(result: ToolOk) -> list[ContentPart]:
70
70
  case ContentPart():
71
71
  content.append(output)
72
72
  case _:
73
- content.extend(list(output))
73
+ content.extend(output)
74
74
  if not content:
75
75
  content.append(system("Tool output is empty."))
76
76
  return content
@@ -45,11 +45,11 @@ class Bash(CallableTool2[Params]):
45
45
  return ToolRejectedError()
46
46
 
47
47
  def stdout_cb(line: bytes):
48
- line_str = line.decode()
48
+ line_str = line.decode(errors="replace")
49
49
  builder.write(line_str)
50
50
 
51
51
  def stderr_cb(line: bytes):
52
- line_str = line.decode()
52
+ line_str = line.decode(errors="replace")
53
53
  builder.write(line_str)
54
54
 
55
55
  try:
@@ -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.globals 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]
@@ -15,6 +15,7 @@ from pydantic import BaseModel, Field
15
15
 
16
16
  import kimi_cli
17
17
  from kimi_cli.share import get_share_dir
18
+ from kimi_cli.utils.aiohttp import new_client_session
18
19
  from kimi_cli.utils.logging import logger
19
20
 
20
21
 
@@ -167,7 +168,7 @@ async def _download_and_install_rg(bin_name: str) -> Path:
167
168
  share_bin_dir.mkdir(parents=True, exist_ok=True)
168
169
  destination = share_bin_dir / bin_name
169
170
 
170
- async with aiohttp.ClientSession() as session:
171
+ async with new_client_session() as session:
171
172
  with tempfile.TemporaryDirectory(prefix="kimi-rg-") as tmpdir:
172
173
  tar_path = Path(tmpdir) / filename
173
174
 
@@ -219,7 +220,7 @@ async def _ensure_rg_path() -> str:
219
220
 
220
221
  class Grep(CallableTool2[Params]):
221
222
  name: str = "Grep"
222
- description: str = (Path(__file__).parent / "grep.md").read_text()
223
+ description: str = (Path(__file__).parent / "grep.md").read_text(encoding="utf-8")
223
224
  params: type[Params] = Params
224
225
 
225
226
  @override
@@ -6,8 +6,8 @@ import patch_ng
6
6
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
7
7
  from pydantic import BaseModel, Field
8
8
 
9
- from kimi_cli.agent import BuiltinSystemPromptArgs
10
9
  from kimi_cli.soul.approval import Approval
10
+ from kimi_cli.soul.globals import BuiltinSystemPromptArgs
11
11
  from kimi_cli.tools.file import FileActions
12
12
  from kimi_cli.tools.utils import ToolRejectedError
13
13
 
@@ -19,7 +19,7 @@ class Params(BaseModel):
19
19
 
20
20
  class PatchFile(CallableTool2[Params]):
21
21
  name: str = "PatchFile"
22
- description: str = (Path(__file__).parent / "patch.md").read_text()
22
+ description: str = (Path(__file__).parent / "patch.md").read_text(encoding="utf-8")
23
23
  params: type[Params] = Params
24
24
 
25
25
  def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
@@ -5,7 +5,7 @@ import aiofiles
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from kimi_cli.agent import BuiltinSystemPromptArgs
8
+ from kimi_cli.soul.globals import BuiltinSystemPromptArgs
9
9
  from kimi_cli.tools.utils import load_desc, truncate_line
10
10
 
11
11
  MAX_LINES = 1000
@@ -5,8 +5,8 @@ import aiofiles
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from kimi_cli.agent import BuiltinSystemPromptArgs
9
8
  from kimi_cli.soul.approval import Approval
9
+ from kimi_cli.soul.globals import BuiltinSystemPromptArgs
10
10
  from kimi_cli.tools.file import FileActions
11
11
  from kimi_cli.tools.utils import ToolRejectedError
12
12
 
@@ -29,7 +29,7 @@ class Params(BaseModel):
29
29
 
30
30
  class StrReplaceFile(CallableTool2[Params]):
31
31
  name: str = "StrReplaceFile"
32
- description: str = (Path(__file__).parent / "replace.md").read_text()
32
+ description: str = (Path(__file__).parent / "replace.md").read_text(encoding="utf-8")
33
33
  params: type[Params] = Params
34
34
 
35
35
  def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
@@ -5,8 +5,8 @@ import aiofiles
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from kimi_cli.agent import BuiltinSystemPromptArgs
9
8
  from kimi_cli.soul.approval import Approval
9
+ from kimi_cli.soul.globals import BuiltinSystemPromptArgs
10
10
  from kimi_cli.tools.file import FileActions
11
11
  from kimi_cli.tools.utils import ToolRejectedError
12
12
 
@@ -26,7 +26,7 @@ class Params(BaseModel):
26
26
 
27
27
  class WriteFile(CallableTool2[Params]):
28
28
  name: str = "WriteFile"
29
- description: str = (Path(__file__).parent / "write.md").read_text()
29
+ description: str = (Path(__file__).parent / "write.md").read_text(encoding="utf-8")
30
30
  params: type[Params] = Params
31
31
 
32
32
  def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval, **kwargs):
@@ -1,17 +1,21 @@
1
+ import asyncio
1
2
  from pathlib import Path
2
3
  from typing import override
3
4
 
4
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
5
6
  from pydantic import BaseModel, Field
6
7
 
7
- from kimi_cli.agent import Agent, AgentGlobals, AgentSpec, load_agent
8
- from kimi_cli.soul import MaxStepsReached
8
+ from kimi_cli.agentspec import ResolvedAgentSpec
9
+ from kimi_cli.soul import MaxStepsReached, get_wire_or_none, run_soul
10
+ from kimi_cli.soul.agent import Agent, load_agent
9
11
  from kimi_cli.soul.context import Context
12
+ from kimi_cli.soul.globals import AgentGlobals
10
13
  from kimi_cli.soul.kimisoul import KimiSoul
11
- from kimi_cli.soul.wire import ApprovalRequest, Wire, WireMessage, get_wire_or_none
12
14
  from kimi_cli.tools.utils import load_desc
13
15
  from kimi_cli.utils.message import message_extract_text
14
16
  from kimi_cli.utils.path import next_available_rotation
17
+ from kimi_cli.wire import WireUISide
18
+ from kimi_cli.wire.message import ApprovalRequest, WireMessage
15
19
 
16
20
  # Maximum continuation attempts for task summary
17
21
  MAX_CONTINUE_ATTEMPTS = 1
@@ -45,12 +49,11 @@ class Task(CallableTool2[Params]):
45
49
  name: str = "Task"
46
50
  params: type[Params] = Params
47
51
 
48
- def __init__(self, agent_spec: AgentSpec, agent_globals: AgentGlobals, **kwargs):
52
+ def __init__(self, agent_spec: ResolvedAgentSpec, agent_globals: AgentGlobals, **kwargs):
49
53
  subagents: dict[str, Agent] = {}
50
54
  descs = []
51
55
 
52
56
  # load all subagents
53
- assert agent_spec.subagents is not None, "Task tool expects subagents"
54
57
  for name, spec in agent_spec.subagents.items():
55
58
  subagents[name] = load_agent(spec.path, agent_globals)
56
59
  descs.append(f"- `{name}`: {spec.description}")
@@ -99,6 +102,19 @@ class Task(CallableTool2[Params]):
99
102
 
100
103
  async def _run_subagent(self, agent: Agent, prompt: str) -> ToolReturnType:
101
104
  """Run subagent with optional continuation for task summary."""
105
+ super_wire = get_wire_or_none()
106
+ assert super_wire is not None
107
+
108
+ def _super_wire_send(msg: WireMessage) -> None:
109
+ if isinstance(msg, ApprovalRequest):
110
+ super_wire.soul_side.send(msg)
111
+ # TODO: visualize subagent behavior by sending other messages in some way
112
+
113
+ async def _ui_loop_fn(wire: WireUISide) -> None:
114
+ while True:
115
+ msg = await wire.receive()
116
+ _super_wire_send(msg)
117
+
102
118
  subagent_history_file = await self._get_subagent_history_file()
103
119
  context = Context(file_backend=subagent_history_file)
104
120
  soul = KimiSoul(
@@ -107,12 +123,9 @@ class Task(CallableTool2[Params]):
107
123
  context=context,
108
124
  loop_control=self._agent_globals.config.loop_control,
109
125
  )
110
- wire = get_wire_or_none()
111
- assert wire is not None, "Wire is expected to be set"
112
- sub_wire = _SubWire(wire)
113
126
 
114
127
  try:
115
- await soul.run(prompt, sub_wire)
128
+ await run_soul(soul, prompt, _ui_loop_fn, asyncio.Event())
116
129
  except MaxStepsReached as e:
117
130
  return ToolError(
118
131
  message=(
@@ -135,22 +148,10 @@ class Task(CallableTool2[Params]):
135
148
  # Check if response is too brief, if so, run again with continuation prompt
136
149
  n_attempts_remaining = MAX_CONTINUE_ATTEMPTS
137
150
  if len(final_response) < 200 and n_attempts_remaining > 0:
138
- await soul.run(CONTINUE_PROMPT, sub_wire)
151
+ await run_soul(soul, CONTINUE_PROMPT, _ui_loop_fn, asyncio.Event())
139
152
 
140
153
  if len(context.history) == 0 or context.history[-1].role != "assistant":
141
154
  return ToolError(message=_error_msg, brief="Failed to run subagent")
142
155
  final_response = message_extract_text(context.history[-1])
143
156
 
144
157
  return ToolOk(output=final_response)
145
-
146
-
147
- class _SubWire(Wire):
148
- def __init__(self, super_wire: Wire):
149
- super().__init__()
150
- self._super_wire = super_wire
151
-
152
- @override
153
- def send(self, msg: WireMessage):
154
- if isinstance(msg, ApprovalRequest):
155
- self._super_wire.send(msg)
156
- # TODO: visualize subagent behavior by sending other messages in some way
@@ -4,7 +4,7 @@ Spawn a subagent to perform a specific task. Subagent will be spawned with a fre
4
4
 
5
5
  Context isolation is one of the key benefits of using subagents. By delegating tasks to subagents, you can keep your main context clean and focused on the main goal requested by the user.
6
6
 
7
- Here are some scenerios you may want this tool for context isolation:
7
+ Here are some scenarios you may want this tool for context isolation:
8
8
 
9
9
  - You wrote some code and it did not work as expected. In this case you can spawn a subagent to fix the code, asking the subagent to return how it is fixed. This can potentially benefit because the detailed process of fixing the code may not be relevant to your main goal, and may clutter your context.
10
10
  - When you need some latest knowledge of a specific library, framework or technology to proceed with your task, you can spawn a subagent to search on the internet for the needed information and return to you the gathered relevant information, for example code examples, API references, etc. This can avoid ton of irrelevant search results in your own context.
@@ -16,7 +16,7 @@ class Params(BaseModel):
16
16
 
17
17
  class SetTodoList(CallableTool2[Params]):
18
18
  name: str = "SetTodoList"
19
- description: str = (Path(__file__).parent / "set_todo_list.md").read_text()
19
+ description: str = (Path(__file__).parent / "set_todo_list.md").read_text(encoding="utf-8")
20
20
  params: type[Params] = Params
21
21
 
22
22
  @override
kimi_cli/tools/utils.py CHANGED
@@ -7,7 +7,7 @@ from kosong.tooling import ToolError, ToolOk
7
7
 
8
8
  def load_desc(path: Path, substitutions: dict[str, str] | None = None) -> str:
9
9
  """Load a tool description from a file, with optional substitutions."""
10
- description = path.read_text()
10
+ description = path.read_text(encoding="utf-8")
11
11
  if substitutions:
12
12
  description = string.Template(description).substitute(substitutions)
13
13
  return description
@@ -7,6 +7,7 @@ from kosong.tooling import CallableTool2, ToolReturnType
7
7
  from pydantic import BaseModel, Field
8
8
 
9
9
  from kimi_cli.tools.utils import ToolResultBuilder, load_desc
10
+ from kimi_cli.utils.aiohttp import new_client_session
10
11
 
11
12
 
12
13
  class Params(BaseModel):
@@ -24,7 +25,7 @@ class FetchURL(CallableTool2[Params]):
24
25
 
25
26
  try:
26
27
  async with (
27
- aiohttp.ClientSession() as session,
28
+ new_client_session() as session,
28
29
  session.get(
29
30
  params.url,
30
31
  headers={
@@ -1,14 +1,14 @@
1
1
  from pathlib import Path
2
2
  from typing import override
3
3
 
4
- import aiohttp
5
4
  from kosong.tooling import CallableTool2, ToolReturnType
6
5
  from pydantic import BaseModel, Field, ValidationError
7
6
 
8
- import kimi_cli
9
7
  from kimi_cli.config import Config
8
+ from kimi_cli.constant import USER_AGENT
10
9
  from kimi_cli.soul.toolset import get_current_tool_call_or_none
11
10
  from kimi_cli.tools.utils import ToolResultBuilder, load_desc
11
+ from kimi_cli.utils.aiohttp import new_client_session
12
12
 
13
13
 
14
14
  class Params(BaseModel):
@@ -62,11 +62,11 @@ class SearchWeb(CallableTool2[Params]):
62
62
  assert tool_call is not None, "Tool call is expected to be set"
63
63
 
64
64
  async with (
65
- aiohttp.ClientSession() as session,
65
+ new_client_session() as session,
66
66
  session.post(
67
67
  self._base_url,
68
68
  headers={
69
- "User-Agent": kimi_cli.USER_AGENT,
69
+ "User-Agent": USER_AGENT,
70
70
  "Authorization": f"Bearer {self._api_key}",
71
71
  "X-Msh-Tool-Call-Id": tool_call.id,
72
72
  },
kimi_cli/ui/__init__.py CHANGED
@@ -1,69 +0,0 @@
1
- import asyncio
2
- import contextlib
3
- from collections.abc import Callable, Coroutine
4
- from typing import Any
5
-
6
- from kimi_cli.soul import Soul
7
- from kimi_cli.soul.wire import Wire
8
- from kimi_cli.utils.logging import logger
9
-
10
- type UILoopFn = Callable[[Wire], Coroutine[Any, Any, None]]
11
- """A long-running async function to visualize the agent behavior."""
12
-
13
-
14
- class RunCancelled(Exception):
15
- """The run was cancelled by the cancel event."""
16
-
17
-
18
- async def run_soul(
19
- soul: Soul,
20
- user_input: str,
21
- ui_loop_fn: UILoopFn,
22
- cancel_event: asyncio.Event,
23
- ):
24
- """
25
- Run the soul with the given user input.
26
-
27
- `cancel_event` is a outside handle that can be used to cancel the run. When the event is set,
28
- the run will be gracefully stopped and a `RunCancelled` will be raised.
29
-
30
- Raises:
31
- LLMNotSet: When the LLM is not set.
32
- ChatProviderError: When the LLM provider returns an error.
33
- MaxStepsReached: When the maximum number of steps is reached.
34
- RunCancelled: When the run is cancelled by the cancel event.
35
- """
36
- wire = Wire()
37
- logger.debug("Starting UI loop with function: {ui_loop_fn}", ui_loop_fn=ui_loop_fn)
38
-
39
- ui_task = asyncio.create_task(ui_loop_fn(wire))
40
- soul_task = asyncio.create_task(soul.run(user_input, wire))
41
-
42
- cancel_event_task = asyncio.create_task(cancel_event.wait())
43
- await asyncio.wait(
44
- [soul_task, cancel_event_task],
45
- return_when=asyncio.FIRST_COMPLETED,
46
- )
47
-
48
- try:
49
- if cancel_event.is_set():
50
- logger.debug("Cancelling the run task")
51
- soul_task.cancel()
52
- try:
53
- await soul_task
54
- except asyncio.CancelledError:
55
- raise RunCancelled from None
56
- else:
57
- assert soul_task.done() # either stop event is set or the run task is done
58
- cancel_event_task.cancel()
59
- with contextlib.suppress(asyncio.CancelledError):
60
- await cancel_event_task
61
- soul_task.result() # this will raise if any exception was raised in the run task
62
- finally:
63
- logger.debug("Shutting down the visualization loop")
64
- # shutting down the event queue should break the visualization loop
65
- wire.shutdown()
66
- try:
67
- await asyncio.wait_for(ui_task, timeout=0.5)
68
- except TimeoutError:
69
- logger.warning("Visualization loop timed out")