622 def steer(self, content: str | list[ContentPart]) -> None:
623 """Queue a steer message for injection into the current turn."""
624 self._steer_queue.put_nowait(content)
625
626 async def _consume_pending_steers(self) -> bool:
627 """Drain the steer queue and inject as follow-up user messages.
628
629 Returns True if any steers were consumed.
630
631 Note: /btw is intercepted at the UI layer (``classify_input``) before
632 reaching the steer queue, so it never appears here.
633 """
634 consumed = False
635 while not self._steer_queue.empty():
636 content = self._steer_queue.get_nowait()
637 await self._inject_steer(content)
638 wire_send(SteerInput(user_input=content))
639 consumed = True
640 return consumed
641
642 async def _inject_steer(self, content: str | list[ContentPart]) -> None:
643 """Inject a single steer as a regular follow-up user message."""
644 parts = cast(
645 list[ContentPart],
646 [TextPart(text=content)] if isinstance(content, str) else list(content),
647 )
648 message = Message(role="user", content=parts)
649 if self._runtime.llm is None:
650 raise LLMNotSet()
651 if missing_caps := check_message(message, self._runtime.llm.capabilities):
652 raise LLMNotSupported(self._runtime.llm, list(missing_caps))
653 await self._context.append_message(message)
52 message = Message(role="assistant", content=[])
53 pending_part: StreamedMessagePart | None = None # message part that is currently incomplete
54
55 logger.trace("Generating with history: {history}", history=history)
56 stream = await chat_provider.generate(system_prompt, tools, history)
57 if on_trace_id:
58 # getattr for robustness against third-party StreamedMessage
59 # implementations that predate the trace_id property.
60 await callback(on_trace_id, getattr(stream, "trace_id", None))
61 async for part in stream:
62 logger.trace("Received part: {part}", part=part)
63 if on_message_part:
64 await callback(on_message_part, part.model_copy(deep=True))
65
66 if pending_part is None:
67 pending_part = part
68 elif not pending_part.merge_in_place(part): # try merge into the pending part
69 # unmergeable part must push the pending part to the buffer
70 _message_append(message, pending_part)
71 if isinstance(pending_part, ToolCall) and on_tool_call:
72 await callback(on_tool_call, pending_part)
73 pending_part = part
74
75 # end of message
76 if pending_part is not None:
… 16 lines omitted; exact range 52–103 …
93 "without any text or tool calls. This usually indicates the "
94 "stream was interrupted or the output token budget was exhausted "
95 "during reasoning."
96 )
97
98 return GenerateResult(
99 id=stream.id,
100 message=message,
101 usage=stream.usage,
102 trace_id=getattr(stream, "trace_id", None),
103 )
181 def compute_max_completion_tokens(
182 *,
183 max_context_size: int,
184 input_tokens: int,
185 response_budget: int | None,
186 fallback_budget: int = DEFAULT_UNKNOWN_CONTEXT_COMPLETION_TOKENS,
187 ) -> int:
188 """Compute the Kimi completion cap from the hard cap and remaining context."""
189 if max_context_size <= 0:
190 return max(1, response_budget if response_budget is not None else fallback_budget)
191
192 input_tokens = max(0, input_tokens)
193 remaining = max(1, max_context_size - input_tokens)
194 requested = response_budget if response_budget is not None else max_context_size
195 return max(1, min(requested, remaining))
196
197
198 def estimate_request_tokens(
199 system_prompt: str,
200 tools: Sequence[Tool],
201 history: Sequence[Message],
202 ) -> int:
203 """Estimate all token-bearing parts of a chat request.
204
205 The estimate is deliberately request-scoped: unlike ``Context.token_count_with_pending``,
… 47 lines omitted; exact range 181–263 …
253 total += _estimate_text_tokens(part.model_dump_json(exclude_none=True))
254
255 for tool_call in message.tool_calls or ():
256 total += _estimate_text_tokens(tool_call.id)
257 total += _estimate_text_tokens(tool_call.function.name)
258 total += _estimate_text_tokens(tool_call.function.arguments or "")
259 if tool_call.extras:
260 total += _estimate_text_tokens(
261 json.dumps(tool_call.extras, ensure_ascii=False, separators=(",", ":"))
262 )
263 return total
116 _REMINDER_TEXT_1 = (
117 "\n\n<system-reminder>\n"
118 "You are repeating the exact same tool call with identical parameters."
119 " Please carefully analyze the previous result. If the task is not yet complete,"
120 " try a different method or parameters instead of repeating the same call."
121 "\n</system-reminder>"
122 )
123
124
125 def _make_reminder_text_2(tool_name: str, repeat_count: int, canonical_args: str) -> str:
126 return (
127 "\n\n<system-reminder>\n"
128 "You have repeatedly called the same tool with identical parameters many times.\n"
129 "Repeated tool call detected:\n"
130 f"- tool: {tool_name}\n"
131 f"- repeated_times: {repeat_count}\n"
132 f"- arguments: {canonical_args}\n"
133 "The previous repeated calls did not make progress. Do not call this exact same tool "
134 "with the exact same arguments again.\n"
135 "Carefully inspect the latest tool result and choose a different next action, "
136 "different parameters, or finish the task if enough evidence has been gathered."
137 "\n</system-reminder>"
138 )
139
140
… 21 lines omitted; exact range 116–172 …
162 streak: int, tool_name: str, canonical_args: str
163 ) -> tuple[RepeatAction, str | None]:
164 if streak >= _REPEAT_FORCE_STOP_STREAK:
165 return "stop", _REMINDER_TEXT_3
166 if streak >= _REPEAT_REMINDER_3_START:
167 return "r3", _REMINDER_TEXT_3
168 if streak >= _REPEAT_REMINDER_2_START:
169 return "r2", _make_reminder_text_2(tool_name, streak, canonical_args)
170 if streak >= _REPEAT_REMINDER_1_START:
171 return "r1", _REMINDER_TEXT_1
172 return "none", None
22 MAX_FOREGROUND_TIMEOUT = 5 * 60
23 MAX_BACKGROUND_TIMEOUT = 24 * 60 * 60
24
25
26 class Params(BaseModel):
27 command: str = Field(description="The command to execute.")
28 timeout: int = Field(
29 description=(
30 "The timeout in seconds for the command to execute. "
31 "If the command takes longer than this, it will be killed."
32 ),
33 default=60,
34 ge=1,
35 le=MAX_BACKGROUND_TIMEOUT,
36 )
37 run_in_background: bool = Field(
38 default=False,
39 description="Whether to run the command as a background task.",
40 )
41 description: str = Field(
42 default="",
43 description=(
44 "A short description for the background task. Required when run_in_background=true."
45 ),
46 )
47
48 @model_validator(mode="after")
49 def _validate_background_fields(self) -> Self:
50 if self.run_in_background and not self.description.strip():
51 raise ValueError("description is required when run_in_background is true")
52 if not self.run_in_background and self.timeout > MAX_FOREGROUND_TIMEOUT:
53 raise ValueError(
54 f"timeout must be <= {MAX_FOREGROUND_TIMEOUT}s for foreground commands; "
55 f"use run_in_background=true for longer timeouts (up to {MAX_BACKGROUND_TIMEOUT}s)"
56 )
57 return self
123 async def checkpoint(self, add_user_message: bool):
124 checkpoint_id = self._next_checkpoint_id
125 self._next_checkpoint_id += 1
126 logger.debug("Checkpointing, ID: {id}", id=checkpoint_id)
127
128 async with aiofiles.open(self._file_backend, "a", encoding="utf-8") as f:
129 await f.write(json.dumps({"role": "_checkpoint", "id": checkpoint_id}) + "\n")
130 if add_user_message:
131 await self.append_message(
132 Message(role="user", content=[system(f"CHECKPOINT {checkpoint_id}")])
133 )
134
135 async def revert_to(self, checkpoint_id: int):
136 """
137 Revert the context to the specified checkpoint.
138 After this, the specified checkpoint and all subsequent content will be
139 removed from the context. File backend will be rotated.
140
141 Args:
142 checkpoint_id (int): The ID of the checkpoint to revert to. 0 is the first checkpoint.
143
144 Raises:
145 ValueError: When the checkpoint does not exist.
146 RuntimeError: When no available rotation path is found.
147 """
… 42 lines omitted; exact range 123–200 …
190 keep_line = self._apply_context_record(
191 line_json,
192 history=self._history,
193 messages_after_last_usage=messages_after_last_usage,
194 file_backend=rotated_file_path,
195 line_no=line_no,
196 )
197 if keep_line:
198 await new_file.write(line)
199
200 self._pending_token_estimate = estimate_text_tokens(messages_after_last_usage)
37 The estimate is intentionally conservative — it will be replaced by the
38 real value on the next LLM call.
39 """
40 if self.usage is not None and len(self.messages) > 0:
41 summary_tokens = self.usage.output
42 preserved_tokens = estimate_text_tokens(self.messages[1:])
43 return summary_tokens + preserved_tokens
44
45 return estimate_text_tokens(self.messages)
46
47
48 def estimate_text_tokens(messages: Sequence[Message]) -> int:
49 """Estimate tokens from message text content using a character-based heuristic."""
50 total_chars = 0
51 for msg in messages:
52 for part in msg.content:
53 if isinstance(part, TextPart):
54 total_chars += len(part.text)
55 # ~4 chars per token for English; somewhat underestimates for CJK text,
56 # but this is a temporary estimate that gets corrected on the next LLM call.
57 return total_chars // 4
58
59
60 def should_auto_compact(
61 token_count: int,
… 10 lines omitted; exact range 37–82 …
72 """
73 return (
74 token_count >= max_context_size * trigger_ratio
75 or token_count + reserved_context_size >= max_context_size
76 )
77
78
79 @runtime_checkable
80 class Compaction(Protocol):
81 async def compact(
82 self,
默认 system prompt 明确 operating environment is not in a sandbox;LocalKaos 直接用 pathlib/aiofiles 操作宿主路径,并用 asyncio.create_subprocess_exec 启动宿主进程,没有容器、seatbelt、seccomp 或 restricted token。
67 # Working Environment
68
69 ## Operating System
70
71 You are running on **${KIMI_OS}**. The Shell tool executes commands using **${KIMI_SHELL}**.{% if KIMI_OS == "Windows" %} Use Unix shell syntax inside Shell commands — `/dev/null` not `NUL`, forward slashes in paths (backslashes are escape characters in bash).{% endif +%}
72
73 The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory.
74
75 ## Date and Time
76
77 The current date and time in ISO format is `${KIMI_NOW}`. This is only a reference for you when searching the web, or checking file modification time, etc. If you need the exact time, use Shell tool with proper command.
78
79 ## Working Directory
80
81 The current working directory is `${KIMI_WORK_DIR}`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters.
87 async def load_agents_md(work_dir: KaosPath) -> str | None:
88 """Discover and merge ``AGENTS.md`` files from the project root down to *work_dir*.
89
90 For each directory on the path, the following candidates are checked in order:
91
92 1. ``.kimi/AGENTS.md`` — project-local kimi config (highest priority)
93 2. ``AGENTS.md`` — standard location
94 3. ``agents.md`` — lowercase variant (mutually exclusive with 2)
95
96 Within a single directory, ``.kimi/AGENTS.md`` and ``AGENTS.md``/``agents.md``
97 are **both** loaded (with ``.kimi/`` first), but ``AGENTS.md`` and ``agents.md``
98 are mutually exclusive (uppercase wins).
99
100 All discovered files are concatenated root→leaf, separated by ``\\n\\n``, with
101 source annotations. Total size is capped at :data:`_AGENTS_MD_MAX_BYTES`.
102 Budget is allocated leaf-first so deeper (more specific) files are never
103 truncated in favour of shallower ones.
104 """
105 project_root = await find_project_root(work_dir)
106 dirs = await _dirs_root_to_leaf(work_dir, project_root)
107
108 # Phase 1: collect all candidate files (root → leaf order)
109 discovered: list[tuple[KaosPath, str]] = [] # (path, content)
110 for d in dirs:
111 # .kimi/AGENTS.md is always checked independently (can coexist with root-level file)
… 46 lines omitted; exact range 87–168 …
158 logger.warning("AGENTS.md truncated due to size limit: {path}", path=path)
159 remaining -= len(content.encode())
160 budgeted[i] = (path, content)
161
162 # Phase 3: assemble in root → leaf order, skipping entries emptied by truncation
163 parts: list[str] = []
164 for path, content in budgeted:
165 if content:
166 parts.append(f"<!-- From: {path} -->\n{content}")
167
168 return "\n\n".join(parts) if parts else None
242 merge_all_available_skills: bool = Field(
243 default=True,
244 description=(
245 "Merge skills from all existing brand directories (kimi/claude/codex) "
246 "instead of using only the first one found. Defaults to true so users "
247 "who keep skills in multiple brand directories see everything out of "
248 "the box; set to false to restore the first-match-only behaviour."
249 ),
250 )
251 extra_skill_dirs: list[str] = Field(
252 default_factory=list,
253 description=(
254 "Extra directories to discover skills from, added on top of the "
255 "built-in / user / project locations. Each entry may be an absolute "
256 "path, ``~``-prefixed (expanded against $HOME), or relative to the "
257 "project root (the nearest ``.git`` directory above the work dir). "
258 "Missing paths are silently skipped."
259 ),