mosscode 0.1.0__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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/config.py ADDED
@@ -0,0 +1,337 @@
1
+ """Endpoint configuration is independent of the service's vendor name."""
2
+
3
+ import math
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ import yaml
12
+
13
+ from .context import ContextBudget
14
+ from .prompts import DEFAULT_SYSTEM_PROMPT, stable_prompt
15
+
16
+
17
+ class ConfigError(ValueError):
18
+ pass
19
+
20
+
21
+ ALIASES = {
22
+ "anthropic": "anthropic-messages",
23
+ "openai": "openai-responses",
24
+ "openai-compat": "openai-chat-completions",
25
+ }
26
+ PROTOCOLS = set(ALIASES.values())
27
+ TEAMMATE_MODES = ("auto", "in-process", "windows-terminal", "tmux")
28
+
29
+
30
+ def _validate_env_name(value: str | None) -> None:
31
+ if value is not None and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", value) is None:
32
+ raise ConfigError(
33
+ "api_key_env 请填写环境变量名称(例如 DEEPSEEK_API_KEY),不能填写密钥;直接填写密钥请使用 api_key。"
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ProviderConfig:
39
+ name: str
40
+ protocol: str
41
+ model: str
42
+ base_url: str | None = None
43
+ api_key_env: str | None = field(default=None, repr=False)
44
+ api_key: str | None = field(default=None, repr=False)
45
+ auth_type: str = "api_key"
46
+ headers: dict[str, str] = field(default_factory=dict, repr=False)
47
+ thinking: dict[str, Any] | None = None
48
+ max_output_tokens: int = 16384
49
+ timeout: float = 60.0
50
+ max_retries: int = 4
51
+ stream_usage: bool = False
52
+ max_tokens_field: str = "max_tokens"
53
+ include_encrypted_reasoning: bool = True
54
+ prompt_cache: bool | None = None
55
+ context_window: int | None = None
56
+ compact_threshold: int | None = None
57
+
58
+ def budget(self) -> ContextBudget:
59
+ return ContextBudget.for_provider(self.context_window, self.max_output_tokens, self.compact_threshold)
60
+
61
+ def credential(self) -> str:
62
+ _validate_env_name(self.api_key_env)
63
+ value = os.environ.get(self.api_key_env, "") if self.api_key_env else self.api_key
64
+ if not value:
65
+ hint = (
66
+ f"请设置环境变量 {self.api_key_env}(api_key_env 只填写变量名;直接填写密钥请使用 api_key)"
67
+ if self.api_key_env
68
+ else "请配置 api_key_env(环境变量名)或 api_key(密钥)"
69
+ )
70
+ raise ConfigError(f"{self.name}:{hint}。")
71
+ return value
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Settings:
76
+ providers: tuple[ProviderConfig, ...]
77
+ default_provider: str | None = None
78
+ system_prompt: str = DEFAULT_SYSTEM_PROMPT
79
+ max_iterations: int = 200
80
+ source: Path | None = None
81
+ shell: str = "auto"
82
+ auto_compact: bool = True
83
+ persist_sessions: bool = True
84
+ memory: bool = True
85
+ auto_memory: bool = True
86
+ memory_provider: str | None = None # Background extraction; None uses the current provider.
87
+ agent_auto_background: float = 120.0 # Seconds before a foreground sub-agent moves on; 0 never.
88
+ agent_max_background: int = 4 # Running background tasks at most.
89
+ worktree_links: tuple[str, ...] = () # Paths relative to the repository linked into each worktree.
90
+ worktree_stale_hours: float = 24.0 # Unused sub-agent worktrees older than this go; 0 never.
91
+ team_max_members: int = 4 # Members of one team at most (1–8).
92
+ teammate_mode: str = "auto" # auto / in-process / windows-terminal / tmux.
93
+ coordinator_mode: bool = False # Also needs MOSSCODE_COORDINATOR_MODE.
94
+
95
+ def select(self, name: str | None = None) -> ProviderConfig:
96
+ selected = name or self.default_provider or self.providers[0].name
97
+ for provider in self.providers:
98
+ if provider.name == selected:
99
+ return provider
100
+ raise ConfigError(f"未知 Provider:{selected}。")
101
+
102
+
103
+ def _positive_int(value: Any, label: str) -> int:
104
+ if type(value) is not int or value <= 0:
105
+ raise ConfigError(f"{label} 必须是正整数。")
106
+ return value
107
+
108
+
109
+ def _links(raw: Any) -> tuple[str, ...]:
110
+ """Relative paths inside the repository."""
111
+ problem = "worktree_links 需为相对仓库根的路径列表(不能是绝对路径,不能含 ..)。"
112
+ if not isinstance(raw, list):
113
+ raise ConfigError(problem)
114
+ links = []
115
+ for item in raw:
116
+ if not isinstance(item, str) or not item.strip():
117
+ raise ConfigError(problem)
118
+ text = item.strip().replace("\\", "/").strip("/")
119
+ parts = text.split("/")
120
+ if (
121
+ not text
122
+ or Path(item.strip()).is_absolute()
123
+ or item.strip().startswith(("/", "\\"))
124
+ or ":" in text
125
+ or ".." in parts
126
+ or "." in parts
127
+ or parts[0] == ".git"
128
+ or parts[:2] == [".mosscode", "worktrees"]
129
+ ):
130
+ raise ConfigError(problem)
131
+ if text not in links:
132
+ links.append(text)
133
+ return tuple(links)
134
+
135
+
136
+ def _provider(raw: Any) -> ProviderConfig:
137
+ if not isinstance(raw, dict):
138
+ raise ConfigError("providers 的每一项必须是配置对象。")
139
+ allowed = set(ProviderConfig.__dataclass_fields__)
140
+ if set(raw) - allowed:
141
+ raise ConfigError("Provider 包含未知字段,请对照 config.example.yaml。")
142
+ for key in ("name", "protocol", "model"):
143
+ if not isinstance(raw.get(key), str) or not raw[key].strip():
144
+ raise ConfigError(f"Provider 的 {key} 必须是非空字符串。")
145
+ data = dict(raw)
146
+ data["protocol"] = ALIASES.get(data["protocol"], data["protocol"])
147
+ if data["protocol"] not in PROTOCOLS:
148
+ raise ConfigError("protocol 必须是 anthropic-messages、openai-responses 或 openai-chat-completions。")
149
+ url = data.get("base_url")
150
+ if url is not None:
151
+ if not isinstance(url, str):
152
+ raise ConfigError("base_url 必须是 HTTP(S) URL。")
153
+ parsed = urlsplit(url)
154
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
155
+ raise ConfigError("base_url 必须是没有嵌入凭据的 HTTP(S) URL。")
156
+ for key in ("api_key", "api_key_env"):
157
+ if key in data and (not isinstance(data[key], str) or not data[key].strip()):
158
+ raise ConfigError(f"{key} 必须是非空字符串。")
159
+ if data.get("api_key") and data.get("api_key_env"):
160
+ raise ConfigError("api_key 和 api_key_env 只选择一种。")
161
+ _validate_env_name(data.get("api_key_env"))
162
+ headers = data.get("headers", {})
163
+ if not isinstance(headers, dict) or any(
164
+ not isinstance(k, str) or not isinstance(v, str) for k, v in headers.items()
165
+ ):
166
+ raise ConfigError("headers 必须是字符串键值对象。")
167
+ data["max_output_tokens"] = _positive_int(
168
+ data.get("max_output_tokens", ProviderConfig.max_output_tokens), "max_output_tokens"
169
+ )
170
+ retries = data.get("max_retries", ProviderConfig.max_retries)
171
+ if type(retries) is not int or retries < 0:
172
+ raise ConfigError("max_retries 必须是非负整数。")
173
+ timeout = data.get("timeout", 60.0)
174
+ if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0:
175
+ raise ConfigError("timeout 必须是有限的正数秒数。")
176
+ if data.get("auth_type", "api_key") not in {"api_key", "bearer"}:
177
+ raise ConfigError("auth_type 必须是 api_key 或 bearer。")
178
+ if type(data.get("stream_usage", False)) is not bool:
179
+ raise ConfigError("stream_usage 必须是布尔值。")
180
+ if type(data.get("include_encrypted_reasoning", True)) is not bool:
181
+ raise ConfigError("include_encrypted_reasoning 必须是布尔值。")
182
+ if data.get("prompt_cache") is not None and type(data["prompt_cache"]) is not bool:
183
+ raise ConfigError("prompt_cache 必须是布尔值或 null。")
184
+ if data.get("max_tokens_field", "max_tokens") not in {"max_tokens", "max_completion_tokens"}:
185
+ raise ConfigError("max_tokens_field 必须是 max_tokens 或 max_completion_tokens。")
186
+ thinking = data.get("thinking")
187
+ if thinking is True:
188
+ thinking = {"type": "adaptive"}
189
+ elif thinking is False:
190
+ thinking = {"type": "disabled"}
191
+ if thinking is not None:
192
+ if data["protocol"] != "anthropic-messages":
193
+ raise ConfigError("本章 thinking 配置用于 Anthropic Messages。")
194
+ if not isinstance(thinking, dict) or thinking.get("type") not in {"adaptive", "enabled", "disabled"}:
195
+ raise ConfigError("thinking.type 必须是 adaptive、enabled 或 disabled。")
196
+ if set(thinking) - {"type", "budget_tokens", "effort"}:
197
+ raise ConfigError("thinking 包含未知字段。")
198
+ if thinking["type"] == "enabled":
199
+ budget = _positive_int(thinking.get("budget_tokens"), "thinking.budget_tokens")
200
+ if not 1024 <= budget < data["max_output_tokens"]:
201
+ raise ConfigError("手动思考预算需至少 1024 且小于 max_output_tokens。")
202
+ elif "budget_tokens" in thinking:
203
+ raise ConfigError("budget_tokens 仅用于手动 enabled 模式。")
204
+ if "effort" in thinking and (thinking["type"] != "adaptive" or not isinstance(thinking["effort"], str)):
205
+ raise ConfigError("effort 需用于 adaptive 模式并为字符串。")
206
+ data["thinking"] = thinking
207
+ for key in ("context_window", "compact_threshold"):
208
+ if data.get(key) is not None:
209
+ data[key] = _positive_int(data[key], key)
210
+ config = ProviderConfig(**data)
211
+ problem = config.budget().problem()
212
+ if problem:
213
+ raise ConfigError(f"Provider {config.name}:{problem}")
214
+ return config
215
+
216
+
217
+ def config_candidates(cwd: Path | None = None, home: Path | None = None) -> tuple[Path, ...]:
218
+ """A config.yaml in the working directory is a project's own file, not ours."""
219
+ cwd = Path.cwd() if cwd is None else cwd
220
+ home = Path.home() if home is None else home
221
+ project, user = cwd / ".mosscode" / "config.yaml", home / ".mosscode" / "config.yaml"
222
+ same = os.path.normcase(os.path.abspath(project)) == os.path.normcase(os.path.abspath(user))
223
+ return (user,) if same else (project, user) # Started in the home folder, both are one file.
224
+
225
+
226
+ def find_config(cwd: Path | None = None, home: Path | None = None) -> Path:
227
+ """Project file, then the user file; the first one wins."""
228
+ candidates = config_candidates(cwd, home)
229
+ for path in candidates:
230
+ if path.is_file():
231
+ return path
232
+ raise ConfigError(
233
+ "找不到配置文件,已查找:"
234
+ + "、".join(str(p) for p in candidates)
235
+ + "。运行 moss setup 创建配置,或用 --config 指定。"
236
+ )
237
+
238
+
239
+ def render_config(provider: dict[str, Any]) -> str:
240
+ """The file `moss setup` writes, with one provider made the default."""
241
+ body = yaml.safe_dump(
242
+ {"default_provider": provider["name"], "providers": [provider]}, sort_keys=False, allow_unicode=True
243
+ )
244
+ return (
245
+ "# MossCode 配置,由 moss setup 生成。\n"
246
+ "# 字段说明见 config.example.yaml;追加 Provider:在 providers 下再写一项。\n" + body
247
+ )
248
+
249
+
250
+ def load_settings(path: str | Path) -> Settings:
251
+ try:
252
+ raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
253
+ except OSError:
254
+ raise ConfigError("无法读取配置文件,请检查 --config 路径。") from None
255
+ except (yaml.YAMLError, UnicodeError):
256
+ raise ConfigError("配置文件需为有效的 UTF-8 YAML。") from None
257
+ if not isinstance(raw, dict) or not isinstance(raw.get("providers"), list) or not raw["providers"]:
258
+ raise ConfigError("配置需包含非空 providers 列表。")
259
+ if set(raw) - {
260
+ "providers",
261
+ "default_provider",
262
+ "system_prompt",
263
+ "max_iterations",
264
+ "shell",
265
+ "auto_compact",
266
+ "persist_sessions",
267
+ "memory",
268
+ "auto_memory",
269
+ "memory_provider",
270
+ "agent_auto_background",
271
+ "agent_max_background",
272
+ "worktree_links",
273
+ "worktree_stale_hours",
274
+ "team_max_members",
275
+ "teammate_mode",
276
+ "coordinator_mode",
277
+ }:
278
+ raise ConfigError("配置顶层包含未知字段。")
279
+ providers = tuple(_provider(item) for item in raw["providers"])
280
+ if len({p.name for p in providers}) != len(providers):
281
+ raise ConfigError("Provider name 不能重复。")
282
+ default = raw.get("default_provider")
283
+ system = raw.get("system_prompt", Settings.__dataclass_fields__["system_prompt"].default)
284
+ if default is not None and (not isinstance(default, str) or not default.strip()):
285
+ raise ConfigError("default_provider 需为非空配置名称;不设置时多配置启动会显示选择列表。")
286
+ if not isinstance(system, str):
287
+ raise ConfigError("system_prompt 必须是字符串。")
288
+ iterations = _positive_int(raw.get("max_iterations", Settings.max_iterations), "max_iterations")
289
+ shell = raw.get("shell", Settings.shell)
290
+ if not isinstance(shell, str) or not shell.strip():
291
+ raise ConfigError("shell 需为 auto、bash、powershell 或 shell 可执行文件路径。")
292
+ switches = {}
293
+ for key in ("auto_compact", "persist_sessions", "memory", "auto_memory"):
294
+ switches[key] = raw.get(key, True)
295
+ if type(switches[key]) is not bool:
296
+ raise ConfigError(f"{key} 必须是布尔值。")
297
+ memory_provider = raw.get("memory_provider")
298
+ if memory_provider is not None and memory_provider not in {p.name for p in providers}:
299
+ raise ConfigError("memory_provider 必须是已配置的 Provider 名称。")
300
+ auto_background = raw.get("agent_auto_background", Settings.agent_auto_background)
301
+ if type(auto_background) not in {int, float} or not 0 <= auto_background <= 3600:
302
+ raise ConfigError("agent_auto_background 需为 0–3600 之间的秒数(0 表示不自动转入后台)。")
303
+ max_background = _positive_int(
304
+ raw.get("agent_max_background", Settings.agent_max_background), "agent_max_background"
305
+ )
306
+ links = _links(raw.get("worktree_links", []))
307
+ stale = raw.get("worktree_stale_hours", Settings.worktree_stale_hours)
308
+ if type(stale) not in {int, float} or not 0 <= stale <= 8760:
309
+ raise ConfigError("worktree_stale_hours 需为 0–8760 之间的小时数(0 表示不自动清理)。")
310
+ members = raw.get("team_max_members", Settings.team_max_members)
311
+ if type(members) is not int or not 1 <= members <= 8:
312
+ raise ConfigError("team_max_members 需为 1–8 之间的整数。")
313
+ teammate_mode = raw.get("teammate_mode", Settings.teammate_mode)
314
+ if teammate_mode not in TEAMMATE_MODES:
315
+ raise ConfigError("teammate_mode 需为 " + "、".join(TEAMMATE_MODES) + " 之一。")
316
+ coordinator = raw.get("coordinator_mode", Settings.coordinator_mode)
317
+ if type(coordinator) is not bool:
318
+ raise ConfigError("coordinator_mode 必须是布尔值。")
319
+ settings = Settings(
320
+ providers,
321
+ default,
322
+ stable_prompt(system),
323
+ iterations,
324
+ Path(path),
325
+ shell.strip(),
326
+ memory_provider=memory_provider,
327
+ agent_auto_background=float(auto_background),
328
+ agent_max_background=max_background,
329
+ worktree_links=links,
330
+ worktree_stale_hours=float(stale),
331
+ team_max_members=members,
332
+ teammate_mode=teammate_mode,
333
+ coordinator_mode=coordinator,
334
+ **switches,
335
+ )
336
+ settings.select()
337
+ return settings
mosscode/context.py ADDED
@@ -0,0 +1,102 @@
1
+ """Approximate context accounting: no tokenizer, anchored on the last reported usage."""
2
+
3
+ import math
4
+ from collections.abc import Callable, Sequence
5
+ from dataclasses import dataclass
6
+
7
+ from .messages import Message, Usage
8
+
9
+ DEFAULT_CONTEXT_WINDOW = 128_000
10
+ SUMMARY_RESERVE = 20_000 # Room for a structured summary; normal replies use max_output_tokens.
11
+ AUTO_MARGIN = 13_000 # Absorbs one round of growth plus estimation error between checks.
12
+ MANUAL_MARGIN = 3_000
13
+ MIN_THRESHOLD = 16_000
14
+ MESSAGE_OVERHEAD = 4
15
+
16
+
17
+ def estimate_text(text: str) -> int:
18
+ """ASCII at 4 characters per token, every other character (CJK etc.) as one token: errs high."""
19
+ if not text:
20
+ return 0
21
+ ascii_count = len(text.encode("ascii", "ignore"))
22
+ return math.ceil(ascii_count / 4) + len(text) - ascii_count
23
+
24
+
25
+ def estimate_message(message: Message) -> int:
26
+ total = MESSAGE_OVERHEAD + sum(estimate_text(block.text) for block in message.content)
27
+ total += sum(estimate_text(call.name) + estimate_text(call.arguments) for call in message.tool_calls)
28
+ return total + sum(estimate_text(result.model_text()) for result in message.tool_results)
29
+
30
+
31
+ def estimate_messages(messages: Sequence[Message]) -> int:
32
+ return sum(estimate_message(m) for m in messages)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ContextBudget:
37
+ """Thresholds for one provider. The window comes from configuration, never from a model table."""
38
+
39
+ window: int
40
+ max_output_tokens: int
41
+ compact_threshold: int | None = None
42
+ configured: bool = True
43
+
44
+ @classmethod
45
+ def for_provider(
46
+ cls, context_window: int | None, max_output_tokens: int, compact_threshold: int | None = None
47
+ ) -> "ContextBudget":
48
+ return cls(
49
+ context_window or DEFAULT_CONTEXT_WINDOW, max_output_tokens, compact_threshold, context_window is not None
50
+ )
51
+
52
+ @property
53
+ def effective(self) -> int:
54
+ # Services usually require input + max output to fit the window.
55
+ return self.window - max(self.max_output_tokens, SUMMARY_RESERVE)
56
+
57
+ @property
58
+ def window_threshold(self) -> int:
59
+ return self.effective - AUTO_MARGIN
60
+
61
+ @property
62
+ def auto_threshold(self) -> int:
63
+ limit = self.window_threshold
64
+ return min(limit, self.compact_threshold) if self.compact_threshold else limit
65
+
66
+ @property
67
+ def manual_limit(self) -> int:
68
+ return self.effective - MANUAL_MARGIN
69
+
70
+ def problem(self) -> str | None:
71
+ if self.window_threshold < MIN_THRESHOLD:
72
+ return (
73
+ f"context_window({self.window})减去输出预留和安全余量后不足 {MIN_THRESHOLD},"
74
+ "请调大 context_window 或调小 max_output_tokens。"
75
+ )
76
+ if self.compact_threshold is not None and not MIN_THRESHOLD <= self.compact_threshold <= self.window_threshold:
77
+ return f"compact_threshold 需在 {MIN_THRESHOLD} 与 {self.window_threshold} 之间。"
78
+ return None
79
+
80
+
81
+ class ContextMeter:
82
+ """Size of the next request: last reported input + output, plus an estimate of what came after."""
83
+
84
+ def __init__(self):
85
+ self.anchor: int | None = None
86
+ self.anchor_length = 0
87
+
88
+ def record(self, usage: Usage, history_length: int) -> None:
89
+ if usage.input_tokens is None:
90
+ self.anchor = None
91
+ return
92
+ self.anchor = usage.input_tokens + (usage.output_tokens or 0)
93
+ self.anchor_length = history_length
94
+
95
+ def invalidate(self) -> None:
96
+ self.anchor = None
97
+
98
+ def estimate(self, history: Sequence[Message], extra: str = "", fixed: Callable[[], str] = lambda: "") -> int:
99
+ if self.anchor is not None and self.anchor_length <= len(history):
100
+ return self.anchor + estimate_messages(history[self.anchor_length :]) + estimate_text(extra)
101
+ # No usable anchor: count the whole request, including system prompt and tool definitions.
102
+ return estimate_text(fixed()) + estimate_messages(history) + estimate_text(extra)