xg-cli 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 (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. xg_cli-1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,267 @@
1
+ """Provider 配置服务:inline 斜杠命令与 TUI 面板共享的 CRUD 入口。
2
+
3
+ 设计口径:
4
+ - config.json 是 provider 定义与 API Key 的唯一存储,不再写 .env。
5
+ - 写 config.json 复用 :class:`xg.config.manager.ConfigManager` 的分层读写。
6
+ - 所有写入在保存前经过校验(api_base 合法、default_model 必填、name 合法、
7
+ key 非占位值),并把错误前移到输入阶段。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+ from urllib.parse import urlsplit
15
+
16
+ from xg.config.manager import ConfigManager, _is_placeholder, mask_key
17
+
18
+ _NAME_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
19
+ _ALLOWED_FIELDS = ("api_base", "default_model", "display_name")
20
+
21
+
22
+ @dataclass
23
+ class OpResult:
24
+ ok: bool
25
+ message: str
26
+ data: object | None = None
27
+
28
+
29
+ def validate_api_base(url: str) -> str | None:
30
+ """校验 api_base;非法时返回错误提示,合法返回 None(对齐反引号坑 J6)。"""
31
+ if not url or url != url.strip():
32
+ return "api_base 不能为空或包含首尾空白"
33
+ if any(ch in url for ch in ("`", " ")):
34
+ return "api_base 含非法字符(如反引号或空格),请去掉后重试"
35
+ try:
36
+ parsed = urlsplit(url)
37
+ except ValueError as exc:
38
+ return f"api_base 无法解析为 URL: {exc}"
39
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
40
+ return "api_base 必须是合法的 http(s) URL(如 https://gateway.my.com/v1)"
41
+ return None
42
+
43
+
44
+ def validate_name(name: str) -> str | None:
45
+ """校验 provider 名称;非法时返回提示。"""
46
+ if not name:
47
+ return "provider 名称不能为空"
48
+ if not _NAME_RE.fullmatch(name):
49
+ return "provider 名称只能包含字母、数字、下划线或连字符,且不含空格"
50
+ return None
51
+
52
+
53
+ def validate_model(model: str) -> str | None:
54
+ """校验模型名;非法时返回提示(非法字符/空白)。"""
55
+ if not model or model != model.strip():
56
+ return "模型名不能为空或包含首尾空白"
57
+ if any(ch in model for ch in ("`", " ")):
58
+ return "模型名含非法字符(如反引号或空格),请去掉后重试"
59
+ return None
60
+
61
+
62
+ class ProviderConfigService:
63
+ """面向界面的 provider 管理服务(无内置预设,纯用户自定义)。"""
64
+
65
+ def __init__(self, manager: ConfigManager, settings: object | None = None) -> None:
66
+ self.manager = manager
67
+ self.settings = settings
68
+
69
+ # ---------- 读取 ----------
70
+
71
+ def _active_provider(self) -> str:
72
+ if self.settings is not None and getattr(self.settings, "provider", ""):
73
+ return str(self.settings.provider)
74
+ try:
75
+ return self.manager.active().provider_name
76
+ except Exception:
77
+ return ""
78
+
79
+ def list(self) -> list[dict]:
80
+ """返回每个 provider 的视图:脱敏、来源层、是否 base、key 是否已配置。"""
81
+ active = self._active_provider()
82
+ rows = []
83
+ for name in self.manager.provider_names():
84
+ provider = self.manager.resolve_provider(name)
85
+ if provider is None:
86
+ continue
87
+ raw = provider.api_key
88
+ configured = bool(raw) and not _is_placeholder(raw)
89
+ rows.append(
90
+ {
91
+ "name": name,
92
+ "display_name": provider.display_name,
93
+ "api_base": provider.api_base,
94
+ "default_model": provider.default_model,
95
+ "models": list(provider.models),
96
+ "has_key": configured,
97
+ "is_base": name == active,
98
+ "layer": self.manager.provider_layer(name) or "user",
99
+ }
100
+ )
101
+ return rows
102
+
103
+ def get(self, name: str) -> dict | None:
104
+ provider = self.manager.resolve_provider(name)
105
+ if provider is None:
106
+ return None
107
+ raw = provider.api_key
108
+ configured = bool(raw) and not _is_placeholder(raw)
109
+ return {
110
+ "name": name,
111
+ "display_name": provider.display_name,
112
+ "api_base": provider.api_base,
113
+ "default_model": provider.default_model,
114
+ "models": list(provider.models),
115
+ "api_key": raw if configured else "",
116
+ "api_key_masked": mask_key(raw if configured else ""),
117
+ "has_key": configured,
118
+ "is_base": name == self._active_provider(),
119
+ "layer": self.manager.provider_layer(name) or "user",
120
+ }
121
+
122
+ def referenced_by(self, name: str) -> list[str]:
123
+ """返回引用该 provider 的 SmartRouter 档位名(缺省空)。"""
124
+ tiers = (self.manager.smart_router_config().get("tiers") or {})
125
+ return [t for t, cfg in tiers.items() if cfg.get("provider") == name]
126
+
127
+ # ---------- 写入 ----------
128
+
129
+ def add(
130
+ self,
131
+ name: str,
132
+ api_base: str,
133
+ default_model: str,
134
+ display_name: str | None = None,
135
+ api_key: str | None = None,
136
+ ) -> OpResult:
137
+ if self.manager.resolve_provider(name) is not None:
138
+ return OpResult(False, f"provider 已存在: {name},如需修改请用 update/set(J4)")
139
+ err = self._validate_fields(api_base, default_model)
140
+ if err:
141
+ return OpResult(False, err)
142
+ fields = {
143
+ "api_base": api_base.strip(),
144
+ "default_model": default_model.strip(),
145
+ "display_name": display_name.strip() if display_name else None,
146
+ }
147
+ layer = self.manager.upsert_provider(name, fields)
148
+ out = f"已添加 provider: {name}(写入 {layer} 配置)"
149
+ if api_key:
150
+ key_result = self.set_api_key(name, api_key)
151
+ if not key_result.ok:
152
+ out += f"{key_result.message}"
153
+ return OpResult(True, out, {"layer": layer})
154
+
155
+ def update(self, name: str, fields: dict) -> OpResult:
156
+ provider = self.manager.resolve_provider(name)
157
+ if provider is None:
158
+ return OpResult(False, f"未知 provider: {name},可用: {', '.join(self.manager.provider_names()) or '(无)'}")
159
+ unknown = [k for k in fields if k not in _ALLOWED_FIELDS]
160
+ if unknown:
161
+ return OpResult(False, f"不支持字段: {', '.join(unknown)};可用: {', '.join(_ALLOWED_FIELDS)}")
162
+ api_base = fields.get("api_base")
163
+ if api_base is not None:
164
+ err = validate_api_base(str(api_base))
165
+ if err:
166
+ return OpResult(False, err)
167
+ default_model = fields.get("default_model")
168
+ if default_model is not None and not str(default_model).strip():
169
+ return OpResult(False, "default_model 必填,不能为空(J7)")
170
+ normalized = {}
171
+ if api_base is not None:
172
+ normalized["api_base"] = str(api_base).strip()
173
+ if default_model is not None:
174
+ normalized["default_model"] = str(default_model).strip()
175
+ if "display_name" in fields:
176
+ normalized["display_name"] = (
177
+ str(fields["display_name"]).strip() if fields["display_name"] or fields["display_name"] == "" else None
178
+ )
179
+ layer = self.manager.upsert_provider(name, normalized)
180
+ return OpResult(True, f"已更新 provider: {name}(写入 {layer} 配置)")
181
+
182
+ def remove(self, name: str, *, yes: bool = False) -> OpResult:
183
+ provider = self.manager.resolve_provider(name)
184
+ if provider is None:
185
+ return OpResult(False, f"未知 provider: {name}")
186
+ if name == self._active_provider():
187
+ return OpResult(
188
+ False,
189
+ f"不能删除当前 base provider: {name}(否则没有可用 base)。请先 /provider switch <其它> 再删。",
190
+ )
191
+ referenced = self.referenced_by(name)
192
+ warnings = []
193
+ if referenced:
194
+ warnings.append(f"被 SmartRouter 档位引用:{', '.join(referenced)}(删除后这些档位会回落 base)")
195
+ if not yes:
196
+ head = ";".join(warnings) + "。" if warnings else "此操作会删除该 provider。"
197
+ return OpResult(False, f"{head} 确认请加 --yes(/provider remove {name} --yes)")
198
+ self.manager.delete_provider(name)
199
+ msg = f"已删除 provider: {name}"
200
+ if warnings:
201
+ msg += "(" + ";".join(warnings) + ")"
202
+ return OpResult(True, msg)
203
+
204
+ def switch(self, name: str, model: str | None = None) -> OpResult:
205
+ provider = self.manager.resolve_provider(name)
206
+ if provider is None:
207
+ return OpResult(False, f"未知 provider: {name},可用: {', '.join(self.manager.provider_names()) or '(无)'}")
208
+ target_model = (model or "").strip() or provider.default_model
209
+ self.manager.set_active(provider.name, target_model)
210
+ return OpResult(True, f"base 已切换: {provider.name} / {target_model}")
211
+
212
+ def set_api_key(self, name: str, key: str, *, overwrite: bool = False, yes: bool = False) -> OpResult:
213
+ provider = self.manager.resolve_provider(name)
214
+ if provider is None:
215
+ return OpResult(False, f"未知 provider: {name}")
216
+ if _is_placeholder(key):
217
+ return OpResult(False, "占位值(如 sk-xxx)视为未配置,已拒绝写入")
218
+ existing = provider.api_key
219
+ if existing and not _is_placeholder(existing) and not overwrite:
220
+ if not yes:
221
+ return OpResult(
222
+ False,
223
+ f"{name} 已配置 key {mask_key(existing)},覆盖请加 --yes(/provider key {name} <KEY> --yes)",
224
+ )
225
+ overwrite = True
226
+ self.manager.upsert_provider(name, {"api_key": key})
227
+ status = "已写入" if (existing and existing != key) or not existing else "未改动"
228
+ extra = f"(覆盖旧值 {mask_key(existing)})" if existing and existing != key else ""
229
+ return OpResult(True, f"{status} providers.{name}.api_key -> config.json,仅显示 {mask_key(key)}{extra}")
230
+
231
+ def add_model(self, name: str, model: str) -> OpResult:
232
+ """把模型加入 provider 的模型列表(去重;写入生效层)。"""
233
+ provider = self.manager.resolve_provider(name)
234
+ if provider is None:
235
+ return OpResult(False, f"未知 provider: {name},可用: {', '.join(self.manager.provider_names()) or '(无)'}")
236
+ err = validate_model(model)
237
+ if err:
238
+ return OpResult(False, err)
239
+ model = model.strip()
240
+ current = list(provider.models)
241
+ if model in current:
242
+ return OpResult(False, f"模型已存在: {name} 的列表中已有 {model}")
243
+ current.append(model)
244
+ layer = self.manager.upsert_provider(name, {"models": current})
245
+ return OpResult(True, f"已为 {name} 添加模型: {model}(写入 {layer} 配置。可用: /model {model} 切换)")
246
+
247
+ def remove_model(self, name: str, model: str) -> OpResult:
248
+ """把模型从 provider 的模型列表移除。"""
249
+ provider = self.manager.resolve_provider(name)
250
+ if provider is None:
251
+ return OpResult(False, f"未知 provider: {name},可用: {', '.join(self.manager.provider_names()) or '(无)'}")
252
+ model = model.strip()
253
+ if model not in provider.models:
254
+ return OpResult(False, f"模型不在列表: {name} 的模型列表中没有 {model}")
255
+ remaining = [m for m in provider.models if m != model]
256
+ layer = self.manager.upsert_provider(name, {"models": remaining})
257
+ return OpResult(True, f"已从 {name} 移除模型: {model}(写入 {layer} 配置)")
258
+
259
+ # ---------- 校验 ----------
260
+
261
+ def _validate_fields(self, api_base: str, default_model: str) -> str | None:
262
+ err = validate_api_base(str(api_base))
263
+ if err:
264
+ return err
265
+ if not str(default_model).strip():
266
+ return "default_model 必填,不能为空(J7)"
267
+ return None
xg/config/providers.py ADDED
@@ -0,0 +1,46 @@
1
+ """Provider 数据模型与注册表。
2
+
3
+ provider 不内置预设,全部由用户在配置(config.json 的 ``providers`` 节)
4
+ 中自定义 name / api_base / default_model / api_key 等;API Key 与 provider
5
+ 的一切配置都写入 config.json,不再走 ``.env``。能力参数(window / cache /
6
+ vision)为可选预设值。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Provider:
16
+ name: str # 用户自定义名称
17
+ display_name: str
18
+ api_base: str # base url(来自 config.json)
19
+ default_model: str
20
+ models: tuple[str, ...] = () # 该 provider 可切换的模型列表(config.json 的 providers.<name>.models)
21
+ api_key: str = "" # API Key(明文存于 config.json 的 providers.<name>.api_key)
22
+ api_key_env: str = "" # 兼容保留项:旧「环境变量读取」模式,现已不使用
23
+ context_window: int = 0 # 上下文窗口(token),用于预算控制
24
+ supports_cache: bool = False # 是否支持 prompt cache(预留)
25
+ supports_vision: bool = False # 是否支持图片输入(预留)
26
+
27
+
28
+ class ProviderRegistry:
29
+ """用户自定义 provider 的注册表(无内置预设,初始为空)。"""
30
+
31
+ def __init__(self, providers: list[Provider] | None = None) -> None:
32
+ self._providers: dict[str, Provider] = {
33
+ p.name: p for p in (providers if providers is not None else [])
34
+ }
35
+
36
+ def get(self, name: str) -> Provider | None:
37
+ return self._providers.get(name)
38
+
39
+ def all(self) -> list[Provider]:
40
+ return list(self._providers.values())
41
+
42
+ def names(self) -> list[str]:
43
+ return list(self._providers)
44
+
45
+ def register(self, provider: Provider) -> None:
46
+ self._providers[provider.name] = provider
xg/config/settings.py ADDED
@@ -0,0 +1,258 @@
1
+ """运行时配置快照与加载。
2
+
3
+ Settings 是解析后的运行时快照(provider / model / key / 窗口等),
4
+ 由 ConfigManager 按「默认 < 用户级 < 项目级 < 环境变量」合并产出。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from xg.config.manager import ConfigManager, ProviderNotConfigured
12
+ from xg.config.skills import SkillConfigManager
13
+ from xg.tui.i18n import UiLanguage, normalize_language
14
+
15
+
16
+ @dataclass
17
+ class Settings:
18
+ """运行时配置快照。provider / model 可被 /model 命令运行时修改。"""
19
+
20
+ provider: str = ""
21
+ api_base: str = ""
22
+ api_key: str = ""
23
+ model: str = ""
24
+ context_window: int = 128_000
25
+ tool_steps: int = 20
26
+ # LLM transient request retry policy.
27
+ llm_retry_enabled: bool = True
28
+ llm_max_retries: int = 2
29
+ llm_retry_base_delay: float = 1.0
30
+ llm_retry_max_delay: float = 8.0
31
+ llm_retry_jitter: float = 0.25
32
+ llm_retry_total_timeout: float = 30.0
33
+ llm_respect_retry_after: bool = True
34
+ # token 预算阈值:messages 估算 token 超过 window * budget_ratio 时终止循环
35
+ budget_ratio: float = 0.8
36
+ # 第 5 期:自动压缩时保留的最近完整对话轮次
37
+ context_keep_recent_turns: int = 4
38
+ # 第 5 期:摘要输出动态预留上限
39
+ context_summary_max_tokens: int = 4096
40
+ # 第 5 期:自动注入长期记忆的字符上限
41
+ memory_prompt_max_chars: int = 8000
42
+ # 第 5 期:单个 XG.md/XG.local.md 的读取字符上限
43
+ project_memory_max_chars: int = 32000
44
+ # 工具输出超出该字符数则截断,防止撑爆上下文
45
+ max_tool_output_chars: int = 20_000
46
+ # 并行工具执行并发数(第 3 期)
47
+ max_parallel: int = 4
48
+ # 单工具执行超时(秒,第 3 期)
49
+ tool_timeout: float = 120.0
50
+ # HITL 审批默认开启(第 3 期)
51
+ hitl: bool = True
52
+ # 计划模式(第 4 期):子任务数上限
53
+ plan_max_subtasks: int = 12
54
+ # 计划模式:每个子任务的最大工具步数
55
+ plan_subtask_steps: int = 10
56
+ # 计划模式:计划级允许的子任务失败数(超出终止剩余批次)
57
+ plan_max_failures: int = 3
58
+ # 第 10 期:Team 协作限制
59
+ team_max_agents: int = 4
60
+ team_max_repairs: int = 2
61
+ # 第 V3 期:Plan/Team 任务自然语言断点续跑
62
+ # 单个任务最多自动恢复次数(防「失败→继续→失败」死循环消耗预算)
63
+ task_max_resumes: int = 3
64
+ # 恢复意图是否用 LLM 识别;关闭后仅用关键词白名单
65
+ resume_intent_llm: bool = True
66
+ # Reviewer 输出协议重试次数;与 API 重试和 Repairer 次数分离。
67
+ team_review_output_retries: int = 1
68
+ # Team Worker role-specific execution budgets. None keeps the legacy fallback.
69
+ team_researcher_steps: int | None = None
70
+ team_reviewer_steps: int | None = None
71
+ team_coder_steps: int | None = None
72
+ team_tester_steps: int | None = None
73
+ team_repairer_steps: int | None = None
74
+ team_synthesizer_steps: int | None = None
75
+ team_max_steps: int = 40
76
+ team_recovery_steps: int = 10
77
+ team_max_recoveries: int = 1
78
+ team_review: bool = True
79
+ # Textual TUI 最大刷新频率(第 6 期)
80
+ tui_refresh_fps: int = 20
81
+ # 第 7 期:MCP 客户端与资源限制
82
+ mcp_enabled: bool = True
83
+ mcp_startup_timeout: float = 15.0
84
+ mcp_request_timeout: float = 120.0
85
+ mcp_shutdown_timeout: float = 5.0
86
+ mcp_max_servers: int = 32
87
+ mcp_max_tools: int = 256
88
+ mcp_max_resources: int = 512
89
+ mcp_max_message_bytes: int = 2_097_152
90
+ mcp_resource_max_chars: int = 32_000
91
+ mcp_resource_total_chars: int = 64_000
92
+ mcp_log_lines: int = 200
93
+ # 第 8 期:只读 Web 能力限制
94
+ web_enabled: bool = True
95
+ web_search_provider: str = "none"
96
+ web_timeout: float = 15.0
97
+ web_max_results: int = 5
98
+ web_max_response_bytes: int = 2_097_152
99
+ web_fetch_max_chars: int = 32_000
100
+ web_max_redirects: int = 5
101
+ web_rate_limit_per_minute: int = 30
102
+ # 第 9 期:Skill 索引和按需加载限制
103
+ skills_enabled: bool = True
104
+ skills_max_index_items: int = 20
105
+ skills_max_index_chars: int = 4_096
106
+ skills_max_chars: int = 32_000
107
+ skills_max_reference_chars: int = 16_000
108
+ skills_max_loaded_chars: int = 64_000
109
+ # 输入历史:只影响本地 Composer/inline 输入,不进入 Agent 上下文
110
+ input_history_enabled: bool = True
111
+ input_history_persist: bool = True
112
+ input_history_max_entries: int = 100
113
+ input_history_max_chars: int = 8_000
114
+ input_history_max_bytes: int = 1_048_576
115
+ extra: dict[str, str] = field(default_factory=dict)
116
+ # Inspector UI language; this does not affect Agent prompts or responses.
117
+ ui_language: UiLanguage = "en"
118
+ # SmartRouter(phase-01):智能路由开关,默认关闭;开启后每轮输入自动路由换档。
119
+ # 路由结果只改内存中的 provider/model,不写回 active_provider/active_model。
120
+ smart_router_enabled: bool = False
121
+ # 开启前的手动模型快照 (provider, model),/smartRouter off 时恢复;未开启时为 None。
122
+ smart_router_saved: tuple[str, str] | None = None
123
+ # base provider 未配置时置 True(active() 触发 ProviderNotConfigured),供启动提示。
124
+ provider_missing: bool = False
125
+
126
+ @property
127
+ def token_budget(self) -> int:
128
+ return int(self.context_window * self.budget_ratio)
129
+
130
+ def estimate_tokens(self, text: str) -> int:
131
+ """字符近似估算:ASCII 约 4 字符/token,非 ASCII(如中文)约 1 字符/token。"""
132
+ if not text:
133
+ return 0
134
+ ascii_chars = sum(1 for ch in text if ord(ch) < 128)
135
+ non_ascii = len(text) - ascii_chars
136
+ return ascii_chars // 4 + non_ascii
137
+
138
+
139
+ def load_settings(manager: ConfigManager | None = None) -> Settings:
140
+ """加载配置并产出运行时快照。"""
141
+ manager = manager or ConfigManager()
142
+ try:
143
+ active = manager.active()
144
+ provider_missing = False
145
+ except ProviderNotConfigured:
146
+ active = None
147
+ provider_missing = True
148
+ skill_config = SkillConfigManager(
149
+ user_dir=manager.user_dir,
150
+ project_root=manager.project_dir.parent,
151
+ env=manager.env,
152
+ ).load()
153
+ return Settings(
154
+ provider=active.provider_name if active else "",
155
+ api_base=active.api_base if active else "",
156
+ api_key=active.api_key if active else "",
157
+ model=active.model if active else "",
158
+ context_window=active.context_window if active else 128_000,
159
+ provider_missing=provider_missing,
160
+ tool_steps=_get_int(manager.env, "XG_TOOL_STEPS", 20),
161
+ llm_retry_enabled=manager.env.get("XG_LLM_RETRY_ENABLED", "on").lower() not in ("off", "0", "false"),
162
+ llm_max_retries=max(0, _get_int(manager.env, "XG_LLM_MAX_RETRIES", 2)),
163
+ llm_retry_base_delay=max(0.0, _get_float(manager.env, "XG_LLM_RETRY_BASE_DELAY", 1.0)),
164
+ llm_retry_max_delay=max(0.0, _get_float(manager.env, "XG_LLM_RETRY_MAX_DELAY", 8.0)),
165
+ llm_retry_jitter=_clamp_float(_get_float(manager.env, "XG_LLM_RETRY_JITTER", 0.25), 0.0, 1.0),
166
+ llm_retry_total_timeout=max(0.0, _get_float(manager.env, "XG_LLM_RETRY_TOTAL_TIMEOUT", 30.0)),
167
+ llm_respect_retry_after=manager.env.get("XG_LLM_RESPECT_RETRY_AFTER", "on").lower() not in ("off", "0", "false"),
168
+ budget_ratio=_clamp_float(_get_float(manager.env, "XG_CONTEXT_BUDGET_RATIO", 0.8), 0.5, 0.9),
169
+ context_keep_recent_turns=max(0, _get_int(manager.env, "XG_CONTEXT_KEEP_RECENT_TURNS", 4)),
170
+ context_summary_max_tokens=max(512, _get_int(manager.env, "XG_CONTEXT_SUMMARY_MAX_TOKENS", 4096)),
171
+ memory_prompt_max_chars=max(256, _get_int(manager.env, "XG_MEMORY_PROMPT_MAX_CHARS", 8000)),
172
+ project_memory_max_chars=max(256, _get_int(manager.env, "XG_PROJECT_MEMORY_MAX_CHARS", 32000)),
173
+ max_parallel=_get_int(manager.env, "XG_MAX_PARALLEL", 4),
174
+ tool_timeout=_get_float(manager.env, "XG_TOOL_TIMEOUT", 120.0),
175
+ hitl=manager.env.get("XG_HITL", "on").lower() not in ("off", "0", "false"),
176
+ plan_max_subtasks=_get_int(manager.env, "XG_PLAN_MAX_SUBTASKS", 12),
177
+ plan_subtask_steps=_get_int(manager.env, "XG_PLAN_SUBTASK_STEPS", 10),
178
+ plan_max_failures=_get_int(manager.env, "XG_PLAN_MAX_FAILURES", 3),
179
+ task_max_resumes=max(1, _get_int(manager.env, "XG_TASK_MAX_RESUMES", 3)),
180
+ resume_intent_llm=manager.env.get("XG_RESUME_INTENT_LLM", "on").lower() not in ("off", "0", "false"),
181
+ team_max_agents=max(1, _get_int(manager.env, "XG_TEAM_MAX_AGENTS", 4)),
182
+ team_max_repairs=max(0, _get_int(manager.env, "XG_TEAM_MAX_REPAIRS", 2)),
183
+ team_review_output_retries=max(0, _get_int(manager.env, "XG_TEAM_REVIEW_OUTPUT_RETRIES", 1)),
184
+ team_researcher_steps=_get_optional_positive_int(manager.env, "XG_TEAM_RESEARCHER_STEPS"),
185
+ team_reviewer_steps=_get_optional_positive_int(manager.env, "XG_TEAM_REVIEWER_STEPS"),
186
+ team_coder_steps=_get_optional_positive_int(manager.env, "XG_TEAM_CODER_STEPS"),
187
+ team_tester_steps=_get_optional_positive_int(manager.env, "XG_TEAM_TESTER_STEPS"),
188
+ team_repairer_steps=_get_optional_positive_int(manager.env, "XG_TEAM_REPAIRER_STEPS"),
189
+ team_synthesizer_steps=_get_optional_positive_int(manager.env, "XG_TEAM_SYNTHESIZER_STEPS"),
190
+ team_max_steps=max(1, _get_int(manager.env, "XG_TEAM_MAX_STEPS", 40)),
191
+ team_recovery_steps=max(1, _get_int(manager.env, "XG_TEAM_RECOVERY_STEPS", 10)),
192
+ team_max_recoveries=max(0, _get_int(manager.env, "XG_TEAM_MAX_RECOVERIES", 1)),
193
+ team_review=manager.env.get("XG_TEAM_REVIEW", "on").lower() not in ("off", "0", "false"),
194
+ tui_refresh_fps=max(5, min(60, _get_int(manager.env, "XG_TUI_REFRESH_FPS", 20))),
195
+ ui_language=normalize_language(manager.get_ui_language()),
196
+ mcp_enabled=manager.env.get("XG_MCP_ENABLED", "on").lower() not in ("off", "0", "false"),
197
+ mcp_startup_timeout=max(0.1, _get_float(manager.env, "XG_MCP_STARTUP_TIMEOUT", 15.0)),
198
+ mcp_request_timeout=max(0.1, _get_float(manager.env, "XG_MCP_REQUEST_TIMEOUT", 120.0)),
199
+ mcp_shutdown_timeout=max(0.1, _get_float(manager.env, "XG_MCP_SHUTDOWN_TIMEOUT", 5.0)),
200
+ mcp_max_servers=max(1, _get_int(manager.env, "XG_MCP_MAX_SERVERS", 32)),
201
+ mcp_max_tools=max(1, _get_int(manager.env, "XG_MCP_MAX_TOOLS", 256)),
202
+ mcp_max_resources=max(1, _get_int(manager.env, "XG_MCP_MAX_RESOURCES", 512)),
203
+ mcp_max_message_bytes=max(1024, _get_int(manager.env, "XG_MCP_MAX_MESSAGE_BYTES", 2_097_152)),
204
+ mcp_resource_max_chars=max(256, _get_int(manager.env, "XG_MCP_RESOURCE_MAX_CHARS", 32_000)),
205
+ mcp_resource_total_chars=max(256, _get_int(manager.env, "XG_MCP_RESOURCE_TOTAL_CHARS", 64_000)),
206
+ mcp_log_lines=max(10, _get_int(manager.env, "XG_MCP_LOG_LINES", 200)),
207
+ web_enabled=manager.env.get("XG_WEB_ENABLED", "on").lower() not in ("off", "0", "false"),
208
+ web_search_provider=manager.env.get("XG_WEB_SEARCH_PROVIDER", "none").lower() or "none",
209
+ web_timeout=max(0.1, _get_float(manager.env, "XG_WEB_TIMEOUT", 15.0)),
210
+ web_max_results=max(1, min(10, _get_int(manager.env, "XG_WEB_MAX_RESULTS", 5))),
211
+ web_max_response_bytes=max(1024, _get_int(manager.env, "XG_WEB_MAX_RESPONSE_BYTES", 2_097_152)),
212
+ web_fetch_max_chars=max(256, _get_int(manager.env, "XG_WEB_FETCH_MAX_CHARS", 32_000)),
213
+ web_max_redirects=max(0, _get_int(manager.env, "XG_WEB_MAX_REDIRECTS", 5)),
214
+ web_rate_limit_per_minute=max(1, _get_int(manager.env, "XG_WEB_RATE_LIMIT_PER_MINUTE", 30)),
215
+ skills_enabled=skill_config.enabled,
216
+ skills_max_index_items=skill_config.max_index_items,
217
+ skills_max_index_chars=skill_config.max_index_chars,
218
+ skills_max_chars=skill_config.max_skill_chars,
219
+ skills_max_reference_chars=skill_config.max_reference_chars,
220
+ skills_max_loaded_chars=skill_config.max_loaded_chars,
221
+ input_history_enabled=manager.env.get("XG_INPUT_HISTORY_ENABLED", "on").lower() not in ("off", "0", "false"),
222
+ input_history_persist=manager.env.get("XG_INPUT_HISTORY_PERSIST", "on").lower() not in ("off", "0", "false"),
223
+ input_history_max_entries=max(1, _get_int(manager.env, "XG_INPUT_HISTORY_MAX_ENTRIES", 100)),
224
+ input_history_max_chars=max(256, _get_int(manager.env, "XG_INPUT_HISTORY_MAX_CHARS", 8_000)),
225
+ input_history_max_bytes=max(4_096, _get_int(manager.env, "XG_INPUT_HISTORY_MAX_BYTES", 1_048_576)),
226
+ smart_router_enabled=bool(manager.smart_router_config().get("enabled", False)),
227
+ )
228
+
229
+
230
+ def _get_float(env: dict[str, str], name: str, default: float) -> float:
231
+ raw = env.get(name, "")
232
+ try:
233
+ return float(raw) if raw else default
234
+ except ValueError:
235
+ return default
236
+
237
+
238
+ def _get_int(env: dict[str, str], name: str, default: int) -> int:
239
+ raw = env.get(name, "")
240
+ try:
241
+ return int(raw) if raw else default
242
+ except ValueError:
243
+ return default
244
+
245
+
246
+ def _get_optional_positive_int(env: dict[str, str], name: str) -> int | None:
247
+ raw = env.get(name, "")
248
+ if not raw:
249
+ return None
250
+ try:
251
+ value = int(raw)
252
+ except ValueError:
253
+ return None
254
+ return value if value > 0 else None
255
+
256
+
257
+ def _clamp_float(value: float, low: float, high: float) -> float:
258
+ return max(low, min(high, value))
xg/config/skills.py ADDED
@@ -0,0 +1,100 @@
1
+ """User/project Skill configuration and environment overrides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from xg.skill.models import SkillConfig
11
+
12
+
13
+ SKILLS_CONFIG_FILE = "skills.json"
14
+
15
+
16
+ def _merge(base: dict, override: dict) -> dict:
17
+ result = dict(base)
18
+ for key, value in override.items():
19
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
20
+ result[key] = _merge(result[key], value)
21
+ else:
22
+ result[key] = value
23
+ return result
24
+
25
+
26
+ class SkillConfigManager:
27
+ def __init__(self, *, user_dir: str | Path | None = None,
28
+ project_root: str | Path | None = None,
29
+ env: dict[str, str] | None = None) -> None:
30
+ self.user_dir = Path(user_dir) if user_dir else Path.home() / ".xg"
31
+ self.project_root = (Path(project_root) if project_root else Path.cwd()).resolve()
32
+ self.user_config_path = self.user_dir / SKILLS_CONFIG_FILE
33
+ self.project_config_path = self.project_root / ".xg" / SKILLS_CONFIG_FILE
34
+ self.env = env if env is not None else os.environ
35
+ self.errors: list[str] = []
36
+
37
+ def _read(self, path: Path) -> dict:
38
+ if not path.is_file():
39
+ return {}
40
+ try:
41
+ data = json.loads(path.read_text(encoding="utf-8"))
42
+ except (OSError, json.JSONDecodeError):
43
+ self.errors.append(f"{path}: JSON 配置读取失败")
44
+ return {}
45
+ if not isinstance(data, dict):
46
+ self.errors.append(f"{path}: 顶层必须是 JSON 对象")
47
+ return {}
48
+ return data
49
+
50
+ @staticmethod
51
+ def _bool(value: Any, default: bool) -> bool:
52
+ if isinstance(value, bool):
53
+ return value
54
+ if isinstance(value, str):
55
+ return value.lower() not in {"off", "false", "0", "no"}
56
+ return default
57
+
58
+ @staticmethod
59
+ def _int(value: Any, default: int, minimum: int = 1) -> int:
60
+ try:
61
+ return max(minimum, int(value))
62
+ except (TypeError, ValueError):
63
+ return default
64
+
65
+ def load(self) -> SkillConfig:
66
+ self.errors.clear()
67
+ user = self._read(self.user_config_path)
68
+ project = self._read(self.project_config_path)
69
+ raw = _merge(user, project)
70
+ env = self.env
71
+ return SkillConfig(
72
+ enabled=self._bool(env.get("XG_SKILLS_ENABLED", raw.get("enabled", True)), True),
73
+ default_enabled=self._bool(raw.get("default_enabled", True), True),
74
+ max_index_items=self._int(env.get("XG_SKILLS_MAX_INDEX_ITEMS", raw.get("max_index_items", 20)), 20),
75
+ max_index_chars=self._int(env.get("XG_SKILLS_MAX_INDEX_CHARS", raw.get("max_index_chars", 4_096)), 4_096),
76
+ max_skill_chars=self._int(env.get("XG_SKILLS_MAX_CHARS", raw.get("max_skill_chars", 32_000)), 32_000),
77
+ max_reference_chars=self._int(env.get("XG_SKILLS_MAX_REFERENCE_CHARS", raw.get("max_reference_chars", 16_000)), 16_000),
78
+ max_loaded_chars=self._int(env.get("XG_SKILLS_MAX_LOADED_CHARS", raw.get("max_loaded_chars", 64_000)), 64_000),
79
+ max_references=self._int(raw.get("max_references", 8), 8),
80
+ )
81
+
82
+ def enabled_overrides(self) -> dict[str, bool]:
83
+ raw = _merge(self._read(self.user_config_path), self._read(self.project_config_path))
84
+ enabled = raw.get("enabled", {})
85
+ return {str(name): bool(value) for name, value in enabled.items()} if isinstance(enabled, dict) else {}
86
+
87
+ def set_enabled(self, name: str, enabled: bool, *, project: bool = True) -> None:
88
+ path = self.project_config_path if project else self.user_config_path
89
+ data = self._read(path)
90
+ mapping = data.setdefault("enabled", {})
91
+ if not isinstance(mapping, dict):
92
+ mapping = {}
93
+ data["enabled"] = mapping
94
+ mapping[name] = bool(enabled)
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
97
+
98
+
99
+ def load_skill_config(*, user_dir=None, project_root=None, env=None) -> SkillConfig:
100
+ return SkillConfigManager(user_dir=user_dir, project_root=project_root, env=env).load()