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,167 @@
1
+ """SmartRouter 后处理规则引擎(可解释的安全兜底)。
2
+
3
+ 规则与执行顺序来源:XG-docs/smart-docs/ADAPTIVE_ROUTING.md §7(1→6,先升后降、最后防降级)。
4
+
5
+ 与文档代码的一处有意差异:防降级规则按注释意图实现为
6
+ ``t = max(t, prev_tier - 1)``(600s 内最多比上一轮低 1 档)。
7
+ 文档示例代码的 ``t = min(t, prev_tier + 1)`` 实际限制的是"上升",
8
+ 与其注释"不能比上一轮低超过 1 档"矛盾,此处以注释语义为准。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ from .features import code_blocks
16
+ from .keywords import KEYWORDS
17
+
18
+ TIER = ["Basic", "Enhanced", "Superior", "Ultimate"]
19
+
20
+ # 防降级窗口(秒):同会话内两次路由间隔小于该值时,档位最多下降 1 档
21
+ ANTI_DOWNGRADE_WINDOW = 600
22
+ # 迟滞窗口(秒,phase-04 A2):窗口内档位变化超过 max_changes 次即冻结当前档
23
+ HYSTERESIS_WINDOW = 60
24
+
25
+
26
+ @dataclass
27
+ class Hysteresis:
28
+ """会话内迟滞状态(稳定层,phase-04 A2)。
29
+
30
+ 防短时震荡:同一会话窗口内档位变化超过 ``max_changes`` 次即冻结当前档,
31
+ 直到窗口过期或命中硬规则(``forced``)才解冻。与 600s 防降级叠加复用,
32
+ 但语义不同——防降级只限制"下降不能太快",迟滞是"上下都不允许频繁变"。
33
+
34
+ 由调用方按会话持有同一个实例,跨轮传入 ``postprocess``。导演顺序:
35
+ 防降级在前、迟滞最后一道闸(压制包括 learned_rules 在内的所有后续变化)。
36
+ """
37
+
38
+ window: float = HYSTERESIS_WINDOW
39
+ max_changes: int = 1 # 窗口内允许的档位变化次数,超过即冻结
40
+ prev_tier: int | None = None # 上一轮最终档位索引
41
+ prev_ts: float | None = None # 上一轮时间戳
42
+ change_count: int = 0 # 当前窗口内已变化次数
43
+ last_change_ts: float | None = None # 最近一次变化的时间戳
44
+ frozen_tier: int | None = None # 命中冻结档位(None=未冻结)
45
+ frozen_ts: float | None = None # 冻结开始时间
46
+
47
+ def step(self, target: int, forced: bool, ts: float = 0.0) -> int:
48
+ """对本次路由目标档位应用迟滞,返回最终档位索引。
49
+
50
+ ``target`` 为防降级后的目标档;``forced`` 为是否已被硬规则强制
51
+ (风险/长上下文/闲聊),硬规则输入允许突破冻结立即生效。
52
+ """
53
+ # 窗口过期:重置计数,解冻
54
+ if self.last_change_ts is not None and not forced:
55
+ if ts - self.last_change_ts > self.window:
56
+ self.change_count = 0
57
+ self.frozen_tier = None
58
+ self.frozen_ts = None
59
+
60
+ # 冻结持续期:非硬输入一律压回冻结档
61
+ if self.frozen_tier is not None and not forced:
62
+ if self.frozen_ts is not None and ts - self.frozen_ts <= self.window:
63
+ return self.frozen_tier
64
+ self.frozen_tier = None # 冻结窗口过期,自动解冻
65
+ self.frozen_ts = None
66
+
67
+ prev = self.prev_tier
68
+ if prev is None or target == prev:
69
+ # 无历史或档位未变:不计数
70
+ self.prev_tier = target
71
+ self.prev_ts = ts
72
+ return target
73
+
74
+ # 档位将要变化
75
+ if forced:
76
+ # 硬规则强制:解冻并放行
77
+ self.frozen_tier = None
78
+ self.frozen_ts = None
79
+ self.change_count = 0
80
+ else:
81
+ self.change_count += 1
82
+ if self.change_count > self.max_changes:
83
+ # 触发冻结:压回上一档
84
+ self.frozen_tier = prev
85
+ self.frozen_ts = ts
86
+ self.last_change_ts = ts
87
+ self.change_count = 0
88
+ return prev # prev_tier 保持 prev
89
+ self.last_change_ts = ts
90
+
91
+ self.prev_tier = target
92
+ self.prev_ts = ts
93
+ return target
94
+
95
+
96
+ def hit(text: str, cat: str) -> bool:
97
+ """判断文本是否命中某类关键词(英文转小写后子串匹配)。"""
98
+ t = text.lower()
99
+ return any(k in t for k in KEYWORDS[cat])
100
+
101
+
102
+ def postprocess(tier_idx: int, text: str, f: dict,
103
+ prev_tier: int | None = None, prev_ts: float | None = None,
104
+ ts: float = 0.0, context_tokens: int = 0,
105
+ learned_rules=None, hysteresis=None) -> int:
106
+ """按顺序应用规则,返回最终档位索引 0..3。
107
+
108
+ ``learned_rules``(adaptive.LearnedRules,可选)在 6 条规则之后、且仅
109
+ 在未被硬规则强制时应用:命中的 ±1 档微调永不覆盖风险/闲聊/长上下文硬规则
110
+ (phase-04 A1 验收约束)。
111
+ ``hysteresis``(Hysteresis,可选,phase-04 A2)作为最后一道闸应用:
112
+ session 内窗口变化超阈值即冻结当前档,直到窗口过期或命中硬规则才解冻;
113
+ 不传即 A1 之前行为,与其它机制完全向后兼容。
114
+ """
115
+ t = tier_idx
116
+ forced = False # 是否已被某条硬规则强制锁定(learned_rules 不再覆盖)
117
+
118
+ # 1) 风险旗标 → 强制 >= Superior
119
+ if hit(text, "risk"):
120
+ t = max(t, 2)
121
+ forced = True
122
+
123
+ # 2) 长上下文旗标 → 强制 >= Superior
124
+ blocks = code_blocks(text)
125
+ if (len(text) > 6000
126
+ or (blocks and max(len(b) for b in blocks) > 1500)
127
+ or context_tokens > 2000):
128
+ t = max(t, 2)
129
+ forced = True
130
+
131
+ # 3) 架构旗标 → 升一档
132
+ if hit(text, "arch"):
133
+ t = min(t + 1, 3)
134
+
135
+ # 4) 调试旗标 → 升一档
136
+ if hit(text, "debug"):
137
+ t = min(t + 1, 3)
138
+
139
+ # 5) 简短闲聊旗标 → 强制 <= Basic
140
+ if (hit(text, "chatty") and f["num_code_blocks"] == 0
141
+ and not hit(text, "teach") and not hit(text, "arch")
142
+ and not hit(text, "risk") and not hit(text, "planning")):
143
+ t = 0
144
+ forced = True
145
+
146
+ # 6) 防降级:同会话 600s 内,档位最多比上一轮低 1 档
147
+ if prev_tier is not None and prev_ts is not None and ts - prev_ts < ANTI_DOWNGRADE_WINDOW:
148
+ t = max(t, prev_tier - 1)
149
+
150
+ # 7) learned_rules 局部规则(第 4 期 A1):仅未被硬规则强制时 ±1 档微调
151
+ if not forced and learned_rules is not None:
152
+ action = learned_rules.apply(f)
153
+ if action > 0:
154
+ t = min(t + 1, 3)
155
+ elif action < 0:
156
+ t = max(t - 1, 0)
157
+
158
+ # 8) 迟滞稳定层(第 4 期 A2):最后一道闸,压制所有后续变化。
159
+ # 防降级在前、迟滞在后;冻结时连 learned_rules 的微调也一并压住。
160
+ if hysteresis is not None:
161
+ # 首轮用调用方传入的上一轮档态初始化内部 prev(迟滞自身跨轮维护)
162
+ if hysteresis.prev_tier is None and prev_tier is not None:
163
+ hysteresis.prev_tier = prev_tier
164
+ hysteresis.prev_ts = prev_ts if prev_ts is not None else ts
165
+ t = hysteresis.step(t, forced, ts)
166
+
167
+ return t
@@ -0,0 +1,77 @@
1
+ """SmartRouter 规则路由器(开局纯规则判断,零训练、零依赖)。
2
+
3
+ 评分公式与阈值来源:XG-docs/smart-docs/ADAPTIVE_ROUTING.md §6.1。
4
+
5
+ 与文档的两处有意差异(均为文档内部自相矛盾处,按注释意图/验证表修正):
6
+ 1. Basic 档阈值取 ``s < 1.0`` 而非 ``s <= 1.0``:文档验证表中
7
+ "写个二分查找"(score=1)应落 Enhanced,``<=`` 会错判为 Basic。
8
+ 2. 防降级规则见 postprocess.py(不在本模块)。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ # 分档阈值:score < 1.0 → Basic;<= 5.0 → Enhanced;<= 12.0 → Superior;其余 Ultimate
16
+ _THRESHOLDS = (1.0, 5.0, 12.0)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RuleDecision:
21
+ """规则路由结果。"""
22
+
23
+ tier_idx: int # 0..3 = Basic..Ultimate
24
+ score: float # 加权总分(硬规则命中时为占位分数)
25
+ hard_rule: bool # 是否由硬规则直接决定(不参与分数竞争)
26
+
27
+
28
+ def rule_score(f: dict) -> float:
29
+ """对特征做加权求和,返回总分。"""
30
+ s = 0.0
31
+ s += min(f["len_chars"] // 100, 15) * 1.0 # 每 100 字符 +1(max 15)
32
+ s += min(f["num_code_blocks"], 4) * 8.0 # 每个代码块 +8(max 4)
33
+ s += f["num_json"] * 2.0 # 每个 JSON/XML +2
34
+ s += f["num_lists"] * 1.5 # 每个列表项 +1.5
35
+ s += f["num_arch_kw"] * 3.0 # 每个架构词 +3
36
+ s += f["num_risk_kw"] * 5.0 # 每个风险词 +5
37
+ s += f["num_planning_kw"] * 4.0 # 每个规划词 +4
38
+ s += f["num_impl_kw"] * 1.0 # 每个实现词 +1
39
+ s += f["num_teach_kw"] * 1.0 # 每个教学词 +1
40
+ s += f["num_constraint_kw"] * 2.0 # 每个约束词 +2
41
+ s += f["is_chatty"] * -6.0 # 闲聊直接扣大分
42
+ s += f["question_mark"] * -1.5 # 疑问句略偏简单
43
+ return s
44
+
45
+
46
+ def rule_route(f: dict) -> RuleDecision:
47
+ """硬规则优先(不参与分数竞争),其余按总分映射分档。"""
48
+ # 硬规则 1:有风险词 / 强架构 → 至少 Superior
49
+ if f["num_risk_kw"] >= 1 or f["num_arch_kw"] >= 2:
50
+ return RuleDecision(2, rule_score(f), True)
51
+ # 硬规则 2:大量代码块 / 超长文本 → Ultimate
52
+ if f["num_code_blocks"] >= 3 or f["len_chars"] > 12000:
53
+ return RuleDecision(3, rule_score(f), True)
54
+ # 硬规则 3:纯闲聊(无代码块、无教学/架构/风险词)→ Basic
55
+ if (f["is_chatty"] and f["num_code_blocks"] == 0 and f["num_teach_kw"] == 0
56
+ and f["num_arch_kw"] == 0 and f["num_risk_kw"] == 0):
57
+ return RuleDecision(0, rule_score(f), True)
58
+
59
+ # 软规则:按总分映射
60
+ s = rule_score(f)
61
+ if s < _THRESHOLDS[0]:
62
+ idx = 0
63
+ elif s <= _THRESHOLDS[1]:
64
+ idx = 1
65
+ elif s <= _THRESHOLDS[2]:
66
+ idx = 2
67
+ else:
68
+ idx = 3
69
+ return RuleDecision(idx, s, False)
70
+
71
+
72
+ def confidence(decision: RuleDecision) -> float:
73
+ """规则路由的置信度估计:硬规则 1.0;软规则按分数到最近阈值的距离估。"""
74
+ if decision.hard_rule:
75
+ return 1.0
76
+ margin = min(abs(decision.score - b) for b in _THRESHOLDS)
77
+ return 0.5 + 0.5 * min(1.0, margin / 4.0)
xg/router/semantic.py ADDED
@@ -0,0 +1,138 @@
1
+ """SmartRouter 语义编码器(第 6 期 C2,ONNX Runtime 加载 bge 产物)。
2
+
3
+ 职责:把用户输入编码为 512 维语义向量,供 ml_router 精判时并入特征列
4
+ (``[TF-IDF + 数值 + 语义]``);任何缺依赖 / 缺产物 / 产物损坏 情形一律
5
+ **静默回落**(encode 返回 None),行为退化为 ``ml_router`` 无语义时的原貌。
6
+
7
+ 导入保护(主进程零硬性 ML 依赖):
8
+ onnxruntime / tokenizers / numpy 仅在 encode 运行期 import,任一把
9
+ import 失败即在初始化时把本编码器标记为不可用(available=False),
10
+ 主进程绝不因缺依赖而报错。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ _EMBED_DIM = 512 # bge-small-zh-v1.5 输出维度
18
+
19
+
20
+ def _default_onnx_path() -> Path:
21
+ from ..adaptive.store import semantic_onnx_path
22
+ return semantic_onnx_path()
23
+
24
+
25
+ class SemanticEncoder:
26
+ """ONNX 语义编码器;不可用时 encode 返回 None(静默回落)。"""
27
+
28
+ def __init__(self, onnx_path: Path | None = None) -> None:
29
+ self._path = onnx_path or _default_onnx_path()
30
+ self._session = None
31
+ self._tokenizer = None
32
+ # C3 观测:成功编码统计(每轮耗时 + 有效样本量),供 /smartRouter status 展示
33
+ self._calls = 0
34
+ self._total_ms = 0.0
35
+ self._last_ms = 0.0
36
+ self._load()
37
+
38
+ def _load(self) -> None:
39
+ """加载 ONNX session + tokenizer;任何异常 → 不可用。"""
40
+ if not self._path.exists():
41
+ return
42
+ try:
43
+ import onnxruntime # noqa: PLC0415
44
+ from tokenizers import Tokenizer # noqa: PLC0415
45
+ import numpy as np # noqa: PLC0415
46
+
47
+ # bge 的 tokenizer 以 JSON 与产物同级目录存放(C1 导出时同目录)
48
+ tok_path = self._path.with_suffix(".json")
49
+ if not tok_path.exists():
50
+ return
51
+ tok = Tokenizer.from_file(str(tok_path))
52
+ sess = onnxruntime.InferenceSession(
53
+ str(self._path), providers=["CPUExecutionProvider"],
54
+ )
55
+ # 自检输出维度 = 512
56
+ out_meta = sess.get_outputs()[0].shape
57
+ dims = [d for d in out_meta if d not in (None, -1)]
58
+ if dims and dims[-1] != _EMBED_DIM:
59
+ return
60
+ self._session = sess
61
+ self._tokenizer = tok
62
+ except Exception:
63
+ self._session = None
64
+ self._tokenizer = None
65
+
66
+ @property
67
+ def available(self) -> bool:
68
+ return self._session is not None and self._tokenizer is not None
69
+
70
+ @property
71
+ def dim(self) -> int:
72
+ return _EMBED_DIM
73
+
74
+ @property
75
+ def artifact_exists(self) -> bool:
76
+ """产物(.onnx)是否存在于预期路径(C3 观测)。"""
77
+ return self._path.exists()
78
+
79
+ @property
80
+ def calls(self) -> int:
81
+ """成功完成编码的轮次数(有效样本量观测)。"""
82
+ return self._calls
83
+
84
+ @property
85
+ def avg_ms(self) -> float:
86
+ """累计平均每轮编码耗时(毫秒)。"""
87
+ return (self._total_ms / self._calls) if self._calls else 0.0
88
+
89
+ @property
90
+ def last_ms(self) -> float:
91
+ """最近一轮编码耗时(毫秒)。"""
92
+ return self._last_ms
93
+
94
+ def encode(self, text: str) -> list[float] | None:
95
+ """把整段文本编码为 512 维向量;不可用返回 None(静默回落)。
96
+
97
+ bge 取 first token([CLS])向量并做 L2 归一化;成功时记录轮次耗时。
98
+ """
99
+ if not self.available or not text:
100
+ return None
101
+ import time # noqa: PLC0415
102
+ t0 = time.perf_counter()
103
+ try:
104
+ import numpy as np # noqa: PLC0415
105
+ enc = self._tokenizer.encode(text)
106
+ ids = (enc.ids[:512] or [0]).__class__([ # 空输入兜底
107
+ i for i in enc.ids[:512]
108
+ ] or [0])[:512]
109
+ mask = [1] * len(ids)
110
+ input_ids = np.array([ids], dtype=np.int64)
111
+ attention = np.array([mask], dtype=np.int64)
112
+ feed = {"input_ids": input_ids, "attention_mask": attention}
113
+ # bge/BERT 常要求 token_type_ids;单段输入给全零
114
+ if "token_type_ids" in {i.name for i in self._session.get_inputs()}:
115
+ feed["token_type_ids"] = np.zeros_like(input_ids)
116
+ out = self._session.run(None, feed)[0] # (1, seq, dim)
117
+ vec = out[0][0].astype(np.float64) # [CLS]
118
+ norm = float(np.linalg.norm(vec))
119
+ if norm < 1e-9:
120
+ return [0.0] * _EMBED_DIM
121
+ self._last_ms = (time.perf_counter() - t0) * 1000.0
122
+ self._total_ms += self._last_ms
123
+ self._calls += 1
124
+ return (vec / norm).tolist()
125
+ except Exception:
126
+ return None
127
+
128
+
129
+ def load_semantic_encoder(onnx_path: Path | None = None) -> SemanticEncoder:
130
+ """便捷工厂:缺依赖/缺产物时返回一个 available=False 的编码器。
131
+
132
+ 未显式指定路径时,先尝试把随包语义产物(xg/assets/)落位到数据目录,
133
+ 使 clone 后首启即可用语义通道;无随包产物或复制失败时静默跳过。
134
+ """
135
+ if onnx_path is None:
136
+ from ..adaptive.store import ensure_default_artifacts
137
+ ensure_default_artifacts()
138
+ return SemanticEncoder(onnx_path)
xg/safety/__init__.py ADDED
File without changes
xg/safety/audit.py ADDED
@@ -0,0 +1,96 @@
1
+ """审计日志:统一 JSONL 记录 + 敏感字段脱敏。
2
+
3
+ 记录位置:<项目根>/.xg/audit.log
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urlsplit, urlunsplit
14
+ from uuid import uuid4
15
+
16
+ _SENSITIVE_KEY_RE = re.compile(r"^(api_key|apikey|token|authorization|password|secret|private_key)$", re.I)
17
+ _BEARER_RE = re.compile(r"\b(Bearer\s+)[A-Za-z0-9._~+/=-]+", re.I)
18
+ _SECRET_ASSIGN_RE = re.compile(
19
+ r"\b(api[_-]?key|access[_-]?token|auth(?:orization)?|password|secret)"
20
+ r"(\s*[:=]\s*)(?!Bearer\b)([^\s,;]+)",
21
+ re.I,
22
+ )
23
+ _REDACTED = "***"
24
+
25
+
26
+ def _web_audit_value(key: str, value: Any) -> Any:
27
+ """Keep Web audit useful without retaining query tokens or full prompts."""
28
+ if key == "query" and isinstance(value, str):
29
+ return redact_text(value[:120])
30
+ if key in {"requested_url", "final_url", "url"} and isinstance(value, str):
31
+ try:
32
+ parts = urlsplit(value)
33
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
34
+ except ValueError:
35
+ return value[:200]
36
+ if key in {"headers", "header", "authorization", "cookie", "set-cookie"}:
37
+ return _REDACTED
38
+ return value
39
+
40
+
41
+ def redact_text(value: str) -> str:
42
+ """脱敏文本中的 Bearer token 与常见 key=value 形式敏感值。"""
43
+ value = _BEARER_RE.sub(r"\1***", value)
44
+ return _SECRET_ASSIGN_RE.sub(r"\1\2***", value)
45
+
46
+
47
+ def redact(value: Any) -> Any:
48
+ """递归脱敏:敏感键名的值、文本中的 Bearer token(保留键名,脱敏值)。"""
49
+ if isinstance(value, dict):
50
+ return {
51
+ str(k): _REDACTED if _SENSITIVE_KEY_RE.match(str(k)) else redact(v)
52
+ for k, v in value.items()
53
+ }
54
+ if isinstance(value, list):
55
+ return [redact(v) for v in value]
56
+ if isinstance(value, str):
57
+ return redact_text(value)
58
+ return value
59
+
60
+
61
+ class AuditLogger:
62
+ def __init__(self, log_path: str | Path, session_id: str | None = None) -> None:
63
+ self.log_path = Path(log_path)
64
+ self.session_id = session_id or uuid4().hex[:8]
65
+
66
+ def record(self, action: str, **fields: Any) -> None:
67
+ entry: dict[str, Any] = {
68
+ "time": datetime.now().isoformat(timespec="seconds"),
69
+ "session": self.session_id,
70
+ "action": action,
71
+ }
72
+ entry.update({k: redact(_web_audit_value(k, v) if action.startswith("web_") else v) for k, v in fields.items()})
73
+ try:
74
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
75
+ with self.log_path.open("a", encoding="utf-8") as f:
76
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
77
+ except OSError:
78
+ pass
79
+
80
+ def tool_call(self, tool: str, args: dict, ok: bool, duration_ms: int, approved: bool = True) -> None:
81
+ if tool.startswith("web_"):
82
+ args = {key: _web_audit_value(key, value) for key, value in args.items()}
83
+ self.record(
84
+ "tool_call",
85
+ tool=tool,
86
+ args=args,
87
+ approved=approved,
88
+ ok=ok,
89
+ duration_ms=duration_ms,
90
+ )
91
+
92
+ def approval(self, tool: str, args: dict, decision: str, reason: str = "") -> None:
93
+ self.record("approval", tool=tool, args=args, decision=decision, reason=reason)
94
+
95
+ def blocked(self, reason: str, **detail: Any) -> None:
96
+ self.record("blocked", reason=reason, **detail)
xg/safety/guards.py ADDED
@@ -0,0 +1,106 @@
1
+ """PathGuard / CommandGuard:策略层纯函数。
2
+
3
+ 策略层拒绝为终审:黑名单命令 / 越界路径不执行,且不可被 HITL 审批绕过。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ # 受路径约束的工具:read / write / list / glob / grep
13
+ PATH_TOOLS = {"read_file", "write_file", "list_dir", "glob_files", "grep_code"}
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class GuardResult:
18
+ ok: bool
19
+ reason: str = "" # command_blacklist / path_outside_root
20
+ detail: str = ""
21
+
22
+
23
+ # ---------- CommandGuard ----------
24
+
25
+ _COMMAND_BLACKLIST_PATTERNS = [
26
+ # 对根/系统目录的递归删除
27
+ re.compile(r"\brm\s+(-rf|-fr)\s+/\s*$"),
28
+ re.compile(r"\brm\s+(-rf|-fr)\s+/\*"),
29
+ # 磁盘/分区级破坏
30
+ re.compile(r"\bmkfs\b"),
31
+ re.compile(r"\bdd\b.*\bof=/dev/"),
32
+ re.compile(r"\bformat\s+[a-z]:\\?"),
33
+ re.compile(r"\bdiskpart\b"),
34
+ re.compile(r"^\s*>\s*/dev/"),
35
+ # Windows 破坏性删除:递归删除 / 驱动盘根级
36
+ re.compile(r"\bdel\b.*\b/s\b"),
37
+ re.compile(r"\bdel\b.*\b[cd]:\\"),
38
+ re.compile(r"\brd\b.*\b/s\b"),
39
+ re.compile(r"\brd\b.*\b[cd]:\\"),
40
+ # 关机 / 重启
41
+ re.compile(r"\b(?:shutdown|reboot|halt|poweroff)\b"),
42
+ # fork bomb
43
+ re.compile(r":\(\)\s*\{\s*:\|:&\s*\}\s*;"),
44
+ ]
45
+
46
+
47
+ def command_guard(command: str) -> GuardResult:
48
+ """校验命令是否命中黑名单。空命令放行(后续由工具层报错)。"""
49
+ if not command or not command.strip():
50
+ return GuardResult(ok=True)
51
+ lower = command.strip().lower()
52
+ for pattern in _COMMAND_BLACKLIST_PATTERNS:
53
+ if pattern.search(lower):
54
+ return GuardResult(ok=False, reason="command_blacklist", detail=command.strip())
55
+ return GuardResult(ok=True)
56
+
57
+
58
+ # ---------- PathGuard ----------
59
+
60
+ def _resolve_absolute(base: Path, raw: str) -> Path:
61
+ """把工具路径参数解析为绝对路径(解 symlink)。"""
62
+ p = Path(raw)
63
+ abs_path = p if p.is_absolute() else (base / p)
64
+ return abs_path.resolve()
65
+
66
+
67
+ def path_guard(base: Path, tool_name: str, args: dict) -> GuardResult:
68
+ """校验路径工具的参数是否位于项目根内。"""
69
+ if tool_name not in PATH_TOOLS:
70
+ return GuardResult(ok=True)
71
+
72
+ targets: list[tuple[str, str]] = []
73
+ raw_path = args.get("path")
74
+ if raw_path:
75
+ targets.append(("path", str(raw_path)))
76
+ if tool_name in ("write_file", "execute_command"):
77
+ raw_cwd = args.get("cwd")
78
+ if raw_cwd:
79
+ targets.append(("cwd", str(raw_cwd)))
80
+
81
+ for label, raw in targets:
82
+ resolved = _resolve_absolute(base, raw)
83
+ if not _is_within(base, resolved):
84
+ return GuardResult(
85
+ ok=False,
86
+ reason="path_outside_root",
87
+ detail=f"{label}={raw}(解析为 {resolved},不在项目根内)",
88
+ )
89
+ return GuardResult(ok=True)
90
+
91
+
92
+ def _is_within(base: Path, target: Path) -> bool:
93
+ try:
94
+ target.relative_to(base.resolve())
95
+ return True
96
+ except ValueError:
97
+ return False
98
+
99
+
100
+ def guard_tool_call(base: Path, tool_name: str, args: dict) -> GuardResult:
101
+ """策略层统一入口。"""
102
+ if tool_name == "execute_command":
103
+ return command_guard(str(args.get("command", "")))
104
+ if tool_name in PATH_TOOLS:
105
+ return path_guard(base, tool_name, args)
106
+ return GuardResult(ok=True)
xg/safety/hitl.py ADDED
@@ -0,0 +1,73 @@
1
+ """HITL 审批状态机。
2
+
3
+ 敏感度分级:never(不审)/ confirm(默认审)/ always(必审)。
4
+ fail closed:策略启用但无审批回调时,需审批的操作一律拒绝。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Awaitable, Callable
11
+
12
+ DEFAULT_APPROVAL_LEVELS = {
13
+ "read_file": "never",
14
+ "list_dir": "never",
15
+ "glob_files": "never",
16
+ "grep_code": "never",
17
+ "write_file": "confirm",
18
+ "execute_command": "always",
19
+ "web_search": "never",
20
+ "web_fetch": "never",
21
+ }
22
+
23
+
24
+ @dataclass
25
+ class ApprovalDecision:
26
+ allow: bool
27
+ args: dict | None = None # 改参后的参数(allow=True 时生效)
28
+ reason: str = "" # user_approved / user_rejected / user_skipped / auto_allow / auto_deny_no_requester
29
+
30
+
31
+ ApprovalRequester = Callable[[str, str, dict], Awaitable[ApprovalDecision]]
32
+
33
+
34
+ class HITLPolicy:
35
+ """审批策略:是否启用、敏感度表、会话级全部放行、审批回调。"""
36
+
37
+ def __init__(
38
+ self,
39
+ enabled: bool = True,
40
+ levels: dict | None = None,
41
+ requester: ApprovalRequester | None = None,
42
+ ) -> None:
43
+ self.enabled = enabled
44
+ self.levels = dict(DEFAULT_APPROVAL_LEVELS)
45
+ if levels:
46
+ self.levels.update(levels)
47
+ self.requester = requester
48
+ self.session_allow_all = False
49
+
50
+ def sensitivity(self, tool_name: str) -> str:
51
+ return self.levels.get(tool_name, "never")
52
+
53
+ def requires_approval(self, tool_name: str) -> bool:
54
+ return self.enabled and not self.session_allow_all and self.sensitivity(tool_name) != "never"
55
+
56
+ async def decide(self, tool_name: str, args: dict) -> ApprovalDecision:
57
+ """对一次工具调用做审批决策。"""
58
+ if not self.requires_approval(tool_name):
59
+ return ApprovalDecision(allow=True, reason="auto_allow")
60
+ if self.requester is None:
61
+ # fail closed:无交互渠道,需审批的操作一律拒绝
62
+ return ApprovalDecision(allow=False, reason="auto_deny_no_requester")
63
+ return await self.requester(tool_name, self.sensitivity(tool_name), args)
64
+
65
+ def set_enabled(self, enabled: bool) -> None:
66
+ self.enabled = enabled
67
+
68
+ def allow_all(self) -> None:
69
+ """本会话全部放行。"""
70
+ self.session_allow_all = True
71
+
72
+ def reset_session(self) -> None:
73
+ self.session_allow_all = False
xg/skill/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Discoverable, read-only task skills."""
2
+
3
+ from xg.skill.models import SkillConfig, SkillDocument, SkillInfo, SkillLoadRequest, SkillReference
4
+ from xg.skill.registry import SkillRegistry
5
+
6
+ __all__ = [
7
+ "SkillConfig", "SkillDocument", "SkillInfo", "SkillLoadRequest",
8
+ "SkillReference", "SkillRegistry",
9
+ ]