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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/router/postprocess.py
ADDED
|
@@ -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
|
xg/router/rule_router.py
ADDED
|
@@ -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
|
+
]
|