kimi-cli 0.41__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.

kimi_cli/metadata.py CHANGED
@@ -1,8 +1,6 @@
1
1
  import json
2
- import uuid
3
2
  from hashlib import md5
4
3
  from pathlib import Path
5
- from typing import NamedTuple
6
4
 
7
5
  from pydantic import BaseModel, Field
8
6
 
@@ -33,10 +31,12 @@ class WorkDirMeta(BaseModel):
33
31
  class Metadata(BaseModel):
34
32
  """Kimi metadata structure."""
35
33
 
36
- work_dirs: list[WorkDirMeta] = Field(default_factory=list, description="Work directory list")
34
+ work_dirs: list[WorkDirMeta] = Field(
35
+ default_factory=list[WorkDirMeta], description="Work directory list"
36
+ )
37
37
 
38
38
 
39
- def _load_metadata() -> Metadata:
39
+ def load_metadata() -> Metadata:
40
40
  metadata_file = get_metadata_file()
41
41
  logger.debug("Loading metadata from file: {file}", file=metadata_file)
42
42
  if not metadata_file.exists():
@@ -47,71 +47,8 @@ def _load_metadata() -> Metadata:
47
47
  return Metadata(**data)
48
48
 
49
49
 
50
- def _save_metadata(metadata: Metadata):
50
+ def save_metadata(metadata: Metadata):
51
51
  metadata_file = get_metadata_file()
52
52
  logger.debug("Saving metadata to file: {file}", file=metadata_file)
53
53
  with open(metadata_file, "w", encoding="utf-8") as f:
54
54
  json.dump(metadata.model_dump(), f, indent=2, ensure_ascii=False)
55
-
56
-
57
- class Session(NamedTuple):
58
- """A session of a work directory."""
59
-
60
- id: str
61
- work_dir: WorkDirMeta
62
- history_file: Path
63
-
64
-
65
- def new_session(work_dir: Path, _history_file: Path | None = None) -> Session:
66
- """Create a new session for a work directory."""
67
- logger.debug("Creating new session for work directory: {work_dir}", work_dir=work_dir)
68
-
69
- metadata = _load_metadata()
70
- work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
71
- if work_dir_meta is None:
72
- work_dir_meta = WorkDirMeta(path=str(work_dir))
73
- metadata.work_dirs.append(work_dir_meta)
74
-
75
- session_id = str(uuid.uuid4())
76
- if _history_file is None:
77
- history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
78
- work_dir_meta.last_session_id = session_id
79
- else:
80
- logger.warning("Using provided history file: {history_file}", history_file=_history_file)
81
- _history_file.parent.mkdir(parents=True, exist_ok=True)
82
- if _history_file.exists():
83
- assert _history_file.is_file()
84
- history_file = _history_file
85
-
86
- if history_file.exists():
87
- # truncate if exists
88
- logger.warning(
89
- "History file already exists, truncating: {history_file}", history_file=history_file
90
- )
91
- history_file.unlink()
92
- history_file.touch()
93
-
94
- _save_metadata(metadata)
95
- return Session(id=session_id, work_dir=work_dir_meta, history_file=history_file)
96
-
97
-
98
- def continue_session(work_dir: Path) -> Session | None:
99
- """Get the last session for a work directory."""
100
- logger.debug("Continuing session for work directory: {work_dir}", work_dir=work_dir)
101
-
102
- metadata = _load_metadata()
103
- work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
104
- if work_dir_meta is None:
105
- logger.debug("Work directory never been used")
106
- return None
107
- if work_dir_meta.last_session_id is None:
108
- logger.debug("Work directory never had a session")
109
- return None
110
-
111
- logger.debug(
112
- "Found last session for work directory: {session_id}",
113
- session_id=work_dir_meta.last_session_id,
114
- )
115
- session_id = work_dir_meta.last_session_id
116
- history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
117
- return Session(id=session_id, work_dir=work_dir_meta, history_file=history_file)
kimi_cli/session.py ADDED
@@ -0,0 +1,81 @@
1
+ import uuid
2
+ from pathlib import Path
3
+ from typing import NamedTuple
4
+
5
+ from kimi_cli.metadata import WorkDirMeta, load_metadata, save_metadata
6
+ from kimi_cli.utils.logging import logger
7
+
8
+
9
+ class Session(NamedTuple):
10
+ """A session of a work directory."""
11
+
12
+ id: str
13
+ work_dir: Path
14
+ history_file: Path
15
+
16
+ @staticmethod
17
+ def create(work_dir: Path, _history_file: Path | None = None) -> "Session":
18
+ """Create a new session for a work directory."""
19
+ logger.debug("Creating new session for work directory: {work_dir}", work_dir=work_dir)
20
+
21
+ metadata = load_metadata()
22
+ work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
23
+ if work_dir_meta is None:
24
+ work_dir_meta = WorkDirMeta(path=str(work_dir))
25
+ metadata.work_dirs.append(work_dir_meta)
26
+
27
+ session_id = str(uuid.uuid4())
28
+ if _history_file is None:
29
+ history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
30
+ work_dir_meta.last_session_id = session_id
31
+ else:
32
+ logger.warning(
33
+ "Using provided history file: {history_file}", history_file=_history_file
34
+ )
35
+ _history_file.parent.mkdir(parents=True, exist_ok=True)
36
+ if _history_file.exists():
37
+ assert _history_file.is_file()
38
+ history_file = _history_file
39
+
40
+ if history_file.exists():
41
+ # truncate if exists
42
+ logger.warning(
43
+ "History file already exists, truncating: {history_file}", history_file=history_file
44
+ )
45
+ history_file.unlink()
46
+ history_file.touch()
47
+
48
+ save_metadata(metadata)
49
+
50
+ return Session(
51
+ id=session_id,
52
+ work_dir=work_dir,
53
+ history_file=history_file,
54
+ )
55
+
56
+ @staticmethod
57
+ def continue_(work_dir: Path) -> "Session | None":
58
+ """Get the last session for a work directory."""
59
+ logger.debug("Continuing session for work directory: {work_dir}", work_dir=work_dir)
60
+
61
+ metadata = load_metadata()
62
+ work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
63
+ if work_dir_meta is None:
64
+ logger.debug("Work directory never been used")
65
+ return None
66
+ if work_dir_meta.last_session_id is None:
67
+ logger.debug("Work directory never had a session")
68
+ return None
69
+
70
+ logger.debug(
71
+ "Found last session for work directory: {session_id}",
72
+ session_id=work_dir_meta.last_session_id,
73
+ )
74
+ session_id = work_dir_meta.last_session_id
75
+ history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
76
+
77
+ return Session(
78
+ id=session_id,
79
+ work_dir=work_dir,
80
+ history_file=history_file,
81
+ )
kimi_cli/soul/agent.py CHANGED
@@ -9,10 +9,10 @@ from kosong.tooling import CallableTool, CallableTool2, Toolset
9
9
 
10
10
  from kimi_cli.agentspec import ResolvedAgentSpec, load_agent_spec
11
11
  from kimi_cli.config import Config
12
- from kimi_cli.metadata import Session
12
+ from kimi_cli.session import Session
13
13
  from kimi_cli.soul.approval import Approval
14
14
  from kimi_cli.soul.denwarenji import DenwaRenji
15
- from kimi_cli.soul.globals import AgentGlobals, BuiltinSystemPromptArgs
15
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs, Runtime
16
16
  from kimi_cli.soul.toolset import CustomToolset
17
17
  from kimi_cli.tools.mcp import MCPTool
18
18
  from kimi_cli.utils.logging import logger
@@ -26,27 +26,18 @@ class Agent(NamedTuple):
26
26
  toolset: Toolset
27
27
 
28
28
 
29
- async def load_agent_with_mcp(
29
+ async def load_agent(
30
30
  agent_file: Path,
31
- globals_: AgentGlobals,
31
+ runtime: Runtime,
32
+ *,
32
33
  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
34
  ) -> Agent:
45
35
  """
46
36
  Load agent from specification file.
47
37
 
48
38
  Raises:
49
- ValueError: If the agent spec is not valid.
39
+ FileNotFoundError: If the agent spec file does not exist.
40
+ AgentSpecError: If the agent spec is not valid.
50
41
  """
51
42
  logger.info("Loading agent: {agent_file}", agent_file=agent_file)
52
43
  agent_spec = load_agent_spec(agent_file)
@@ -54,17 +45,17 @@ def load_agent(
54
45
  system_prompt = _load_system_prompt(
55
46
  agent_spec.system_prompt_path,
56
47
  agent_spec.system_prompt_args,
57
- globals_.builtin_args,
48
+ runtime.builtin_args,
58
49
  )
59
50
 
60
51
  tool_deps = {
61
52
  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,
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,
68
59
  }
69
60
  tools = agent_spec.tools
70
61
  if agent_spec.exclude_tools:
@@ -75,6 +66,10 @@ def load_agent(
75
66
  if bad_tools:
76
67
  raise ValueError(f"Invalid tools: {bad_tools}")
77
68
 
69
+ assert isinstance(toolset, CustomToolset)
70
+ if mcp_configs:
71
+ await _load_mcp_tools(toolset, mcp_configs)
72
+
78
73
  return Agent(
79
74
  name=agent_spec.name,
80
75
  system_prompt=system_prompt,
kimi_cli/soul/context.py CHANGED
@@ -19,11 +19,6 @@ 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
-
27
22
  async def restore(self) -> bool:
28
23
  logger.debug("Restoring context from file: {file_backend}", file_backend=self._file_backend)
29
24
  if self._history:
kimi_cli/soul/kimisoul.py CHANGED
@@ -16,13 +16,12 @@ 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.config import LoopControl
20
19
  from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul, StatusSnapshot, wire_send
21
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
- from kimi_cli.soul.globals import AgentGlobals
25
23
  from kimi_cli.soul.message import system, tool_result_to_messages
24
+ from kimi_cli.soul.runtime import Runtime
26
25
  from kimi_cli.tools.dmail import NAME as SendDMail_NAME
27
26
  from kimi_cli.tools.utils import ToolRejectedError
28
27
  from kimi_cli.utils.logging import logger
@@ -43,30 +42,29 @@ class KimiSoul(Soul):
43
42
  def __init__(
44
43
  self,
45
44
  agent: Agent,
46
- agent_globals: AgentGlobals,
45
+ runtime: Runtime,
47
46
  *,
48
47
  context: Context,
49
- loop_control: LoopControl,
50
48
  ):
51
49
  """
52
50
  Initialize the soul.
53
51
 
54
52
  Args:
55
53
  agent (Agent): The agent to run.
56
- agent_globals (AgentGlobals): Global states and parameters.
54
+ runtime (Runtime): Runtime parameters and states.
57
55
  context (Context): The context of the agent.
58
56
  loop_control (LoopControl): The control parameters for the agent loop.
59
57
  """
60
58
  self._agent = agent
61
- self._agent_globals = agent_globals
62
- self._denwa_renji = agent_globals.denwa_renji
63
- self._approval = agent_globals.approval
59
+ self._runtime = runtime
60
+ self._denwa_renji = runtime.denwa_renji
61
+ self._approval = runtime.approval
64
62
  self._context = context
65
- self._loop_control = loop_control
63
+ self._loop_control = runtime.config.loop_control
66
64
  self._compaction = SimpleCompaction() # TODO: maybe configurable and composable
67
65
  self._reserved_tokens = RESERVED_TOKENS
68
- if self._agent_globals.llm is not None:
69
- 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
70
68
 
71
69
  for tool in agent.toolset.tools:
72
70
  if tool.name == SendDMail_NAME:
@@ -81,7 +79,7 @@ class KimiSoul(Soul):
81
79
 
82
80
  @property
83
81
  def model(self) -> str:
84
- 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 ""
85
83
 
86
84
  @property
87
85
  def status(self) -> StatusSnapshot:
@@ -89,15 +87,15 @@ class KimiSoul(Soul):
89
87
 
90
88
  @property
91
89
  def _context_usage(self) -> float:
92
- if self._agent_globals.llm is not None:
93
- 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
94
92
  return 0.0
95
93
 
96
94
  async def _checkpoint(self):
97
95
  await self._context.checkpoint(self._checkpoint_with_user_message)
98
96
 
99
97
  async def run(self, user_input: str):
100
- if self._agent_globals.llm is None:
98
+ if self._runtime.llm is None:
101
99
  raise LLMNotSet()
102
100
 
103
101
  await self._checkpoint() # this creates the checkpoint 0 on first run
@@ -107,7 +105,7 @@ class KimiSoul(Soul):
107
105
 
108
106
  async def _agent_loop(self):
109
107
  """The main agent loop for one run."""
110
- assert self._agent_globals.llm is not None
108
+ assert self._runtime.llm is not None
111
109
 
112
110
  async def _pipe_approval_to_wire():
113
111
  while True:
@@ -126,7 +124,7 @@ class KimiSoul(Soul):
126
124
  # compact the context if needed
127
125
  if (
128
126
  self._context.token_count + self._reserved_tokens
129
- >= self._agent_globals.llm.max_context_size
127
+ >= self._runtime.llm.max_context_size
130
128
  ):
131
129
  logger.info("Context too long, compacting...")
132
130
  wire_send(CompactionBegin())
@@ -159,8 +157,8 @@ class KimiSoul(Soul):
159
157
  async def _step(self) -> bool:
160
158
  """Run an single step and return whether the run should be stopped."""
161
159
  # already checked in `run`
162
- assert self._agent_globals.llm is not None
163
- chat_provider = self._agent_globals.llm.chat_provider
160
+ assert self._runtime.llm is not None
161
+ chat_provider = self._runtime.llm.chat_provider
164
162
 
165
163
  @tenacity.retry(
166
164
  retry=retry_if_exception(self._is_retryable_error),
@@ -255,9 +253,9 @@ class KimiSoul(Soul):
255
253
  reraise=True,
256
254
  )
257
255
  async def _compact_with_retry() -> Sequence[Message]:
258
- if self._agent_globals.llm is None:
256
+ if self._runtime.llm is None:
259
257
  raise LLMNotSet()
260
- return await self._compaction.compact(self._context.history, self._agent_globals.llm)
258
+ return await self._compaction.compact(self._context.history, self._runtime.llm)
261
259
 
262
260
  compacted_messages = await _compact_with_retry()
263
261
  await self._context.revert_to(0)
@@ -6,7 +6,7 @@ from typing import NamedTuple
6
6
 
7
7
  from kimi_cli.config import Config
8
8
  from kimi_cli.llm import LLM
9
- from kimi_cli.metadata import Session
9
+ from kimi_cli.session import Session
10
10
  from kimi_cli.soul.approval import Approval
11
11
  from kimi_cli.soul.denwarenji import DenwaRenji
12
12
  from kimi_cli.utils.logging import logger
@@ -58,7 +58,7 @@ def _list_work_dir(work_dir: Path) -> str:
58
58
  return ls.stdout.strip()
59
59
 
60
60
 
61
- class AgentGlobals(NamedTuple):
61
+ class Runtime(NamedTuple):
62
62
  """Agent globals."""
63
63
 
64
64
  config: Config
@@ -68,22 +68,24 @@ class AgentGlobals(NamedTuple):
68
68
  denwa_renji: DenwaRenji
69
69
  approval: Approval
70
70
 
71
- @classmethod
71
+ @staticmethod
72
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)
73
+ config: Config,
74
+ llm: LLM | None,
75
+ session: Session,
76
+ yolo: bool,
77
+ ) -> "Runtime":
76
78
  # FIXME: do these asynchronously
77
- ls_output = _list_work_dir(work_dir)
78
- agents_md = load_agents_md(work_dir) or ""
79
+ ls_output = _list_work_dir(session.work_dir)
80
+ agents_md = load_agents_md(session.work_dir) or ""
79
81
 
80
- return cls(
82
+ return Runtime(
81
83
  config=config,
82
84
  llm=llm,
83
85
  session=session,
84
86
  builtin_args=BuiltinSystemPromptArgs(
85
87
  KIMI_NOW=datetime.now().astimezone().isoformat(),
86
- KIMI_WORK_DIR=work_dir,
88
+ KIMI_WORK_DIR=session.work_dir,
87
89
  KIMI_WORK_DIR_LS=ls_output,
88
90
  KIMI_AGENTS_MD=agents_md,
89
91
  ),
@@ -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.soul.globals 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
@@ -7,7 +7,7 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
7
7
  from pydantic import BaseModel, Field
8
8
 
9
9
  from kimi_cli.soul.approval import Approval
10
- from kimi_cli.soul.globals import BuiltinSystemPromptArgs
10
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
11
11
  from kimi_cli.tools.file import FileActions
12
12
  from kimi_cli.tools.utils import ToolRejectedError
13
13
 
@@ -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.soul.globals import BuiltinSystemPromptArgs
8
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
9
9
  from kimi_cli.tools.utils import load_desc, truncate_line
10
10
 
11
11
  MAX_LINES = 1000
@@ -6,7 +6,7 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
  from kimi_cli.soul.approval import Approval
9
- from kimi_cli.soul.globals import BuiltinSystemPromptArgs
9
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
10
10
  from kimi_cli.tools.file import FileActions
11
11
  from kimi_cli.tools.utils import ToolRejectedError
12
12
 
@@ -6,7 +6,7 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
  from kimi_cli.soul.approval import Approval
9
- from kimi_cli.soul.globals import BuiltinSystemPromptArgs
9
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
10
10
  from kimi_cli.tools.file import FileActions
11
11
  from kimi_cli.tools.utils import ToolRejectedError
12
12
 
@@ -5,12 +5,12 @@ from typing import override
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from kimi_cli.agentspec import ResolvedAgentSpec
8
+ from kimi_cli.agentspec import ResolvedAgentSpec, SubagentSpec
9
9
  from kimi_cli.soul import MaxStepsReached, get_wire_or_none, run_soul
10
10
  from kimi_cli.soul.agent import Agent, load_agent
11
11
  from kimi_cli.soul.context import Context
12
- from kimi_cli.soul.globals import AgentGlobals
13
12
  from kimi_cli.soul.kimisoul import KimiSoul
13
+ from kimi_cli.soul.runtime import Runtime
14
14
  from kimi_cli.tools.utils import load_desc
15
15
  from kimi_cli.utils.message import message_extract_text
16
16
  from kimi_cli.utils.path import next_available_rotation
@@ -49,28 +49,36 @@ class Task(CallableTool2[Params]):
49
49
  name: str = "Task"
50
50
  params: type[Params] = Params
51
51
 
52
- def __init__(self, agent_spec: ResolvedAgentSpec, agent_globals: AgentGlobals, **kwargs):
53
- subagents: dict[str, Agent] = {}
54
- descs = []
55
-
56
- # load all subagents
57
- for name, spec in agent_spec.subagents.items():
58
- subagents[name] = load_agent(spec.path, agent_globals)
59
- descs.append(f"- `{name}`: {spec.description}")
60
-
52
+ def __init__(self, agent_spec: ResolvedAgentSpec, runtime: Runtime, **kwargs):
61
53
  super().__init__(
62
54
  description=load_desc(
63
55
  Path(__file__).parent / "task.md",
64
56
  {
65
- "SUBAGENTS_MD": "\n".join(descs),
57
+ "SUBAGENTS_MD": "\n".join(
58
+ f"- `{name}`: {spec.description}"
59
+ for name, spec in agent_spec.subagents.items()
60
+ ),
66
61
  },
67
62
  ),
68
63
  **kwargs,
69
64
  )
70
65
 
71
- self._agent_globals = agent_globals
72
- self._session = agent_globals.session
73
- self._subagents = subagents
66
+ self._runtime = runtime
67
+ self._session = runtime.session
68
+ self._subagents: dict[str, Agent] = {}
69
+
70
+ try:
71
+ self._load_task = asyncio.create_task(self._load_subagents(agent_spec.subagents))
72
+ except RuntimeError:
73
+ # In case there's no running event loop, e.g., during synchronous tests
74
+ self._load_task = None
75
+ asyncio.run(self._load_subagents(agent_spec.subagents))
76
+
77
+ async def _load_subagents(self, subagent_specs: dict[str, SubagentSpec]) -> None:
78
+ """Load all subagents specified in the agent spec."""
79
+ for name, spec in subagent_specs.items():
80
+ agent = await load_agent(spec.path, self._runtime, mcp_configs=[])
81
+ self._subagents[name] = agent
74
82
 
75
83
  async def _get_subagent_history_file(self) -> Path:
76
84
  """Generate a unique history file path for subagent."""
@@ -85,6 +93,10 @@ class Task(CallableTool2[Params]):
85
93
 
86
94
  @override
87
95
  async def __call__(self, params: Params) -> ToolReturnType:
96
+ if self._load_task is not None:
97
+ await self._load_task
98
+ self._load_task = None
99
+
88
100
  if params.subagent_name not in self._subagents:
89
101
  return ToolError(
90
102
  message=f"Subagent not found: {params.subagent_name}",
@@ -117,12 +129,7 @@ class Task(CallableTool2[Params]):
117
129
 
118
130
  subagent_history_file = await self._get_subagent_history_file()
119
131
  context = Context(file_backend=subagent_history_file)
120
- soul = KimiSoul(
121
- agent,
122
- agent_globals=self._agent_globals,
123
- context=context,
124
- loop_control=self._agent_globals.config.loop_control,
125
- )
132
+ soul = KimiSoul(agent, runtime=self._runtime, context=context)
126
133
 
127
134
  try:
128
135
  await run_soul(soul, prompt, _ui_loop_fn, asyncio.Event())
@@ -44,9 +44,11 @@ class SearchWeb(CallableTool2[Params]):
44
44
  if config.services.moonshot_search is not None:
45
45
  self._base_url = config.services.moonshot_search.base_url
46
46
  self._api_key = config.services.moonshot_search.api_key.get_secret_value()
47
+ self._custom_headers = config.services.moonshot_search.custom_headers
47
48
  else:
48
49
  self._base_url = ""
49
50
  self._api_key = ""
51
+ self._custom_headers = {}
50
52
 
51
53
  @override
52
54
  async def __call__(self, params: Params) -> ToolReturnType:
@@ -69,6 +71,7 @@ class SearchWeb(CallableTool2[Params]):
69
71
  "User-Agent": USER_AGENT,
70
72
  "Authorization": f"Bearer {self._api_key}",
71
73
  "X-Msh-Tool-Call-Id": tool_call.id,
74
+ **self._custom_headers,
72
75
  },
73
76
  json={
74
77
  "text_query": params.query,
@@ -138,13 +138,15 @@ class PrintApp:
138
138
 
139
139
  async def _visualize_stream_json(self, wire: WireUISide, start_position: int):
140
140
  # TODO: be aware of context compaction
141
+ # FIXME: this is only a temporary impl, may miss the last lines of the context file
142
+ if not self.context_file.exists():
143
+ self.context_file.touch()
141
144
  try:
142
145
  async with aiofiles.open(self.context_file, encoding="utf-8") as f:
143
146
  await f.seek(start_position)
144
147
  while True:
145
148
  should_end = False
146
- while wire._queue.qsize() > 0:
147
- msg = wire._queue.get_nowait()
149
+ while (msg := wire.receive_nowait()) is not None:
148
150
  if isinstance(msg, StepInterrupted):
149
151
  should_end = True
150
152
 
@@ -31,7 +31,7 @@ class ShellApp:
31
31
  logger.info("Running agent with command: {command}", command=command)
32
32
  return await self._run_soul_command(command)
33
33
 
34
- self._start_auto_update_task()
34
+ self._start_background_task(self._auto_update())
35
35
 
36
36
  _print_welcome_info(self.soul.name or "Kimi CLI", self.soul.model, self.welcome_info)
37
37
 
@@ -191,10 +191,7 @@ class ShellApp:
191
191
  loop.remove_signal_handler(signal.SIGINT)
192
192
  return False
193
193
 
194
- def _start_auto_update_task(self) -> None:
195
- self._add_background_task(self._auto_update_background())
196
-
197
- async def _auto_update_background(self) -> None:
194
+ async def _auto_update(self) -> None:
198
195
  toast("checking for updates...", duration=2.0)
199
196
  result = await do_update(print=False, check_only=True)
200
197
  if result == UpdateResult.UPDATE_AVAILABLE:
@@ -204,7 +201,7 @@ class ShellApp:
204
201
  elif result == UpdateResult.UPDATED:
205
202
  toast("auto updated, restart to use the new version", duration=5.0)
206
203
 
207
- def _add_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
204
+ def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
208
205
  task = asyncio.create_task(coro)
209
206
  self._background_tasks.add(task)
210
207