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/cli/app.py
ADDED
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
"""CLI 交互循环:prompt_toolkit 输入 + rich 渲染 + 斜杠命令。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from prompt_toolkit import PromptSession
|
|
15
|
+
from prompt_toolkit.formatted_text import HTML
|
|
16
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.live import Live
|
|
19
|
+
from rich.markdown import Markdown
|
|
20
|
+
from rich.panel import Panel
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
|
|
24
|
+
from xg.agent.plan import Plan, PlanEvent, PlanExecutor, PlanTask, ReviewDecision
|
|
25
|
+
from xg.agent.react import DEFAULT_SYSTEM_PROMPT, AgentEvent, ReActAgent
|
|
26
|
+
from xg.agent.team import TeamEvent, TeamExecutor, TeamPlan, TeamTask
|
|
27
|
+
from xg.adaptive.feedback import FeedbackRecorder, text_hash
|
|
28
|
+
from xg.adaptive.signals import capture_interrupt, capture_turn_signals
|
|
29
|
+
from xg.config.manager import ConfigManager, ProviderNotConfigured, mask_key
|
|
30
|
+
from xg.config.mcp import McpConfigManager
|
|
31
|
+
from xg.config.settings import Settings, load_settings
|
|
32
|
+
from xg.config.web import WebConfigManager
|
|
33
|
+
from xg.config.skills import SkillConfigManager
|
|
34
|
+
from xg.router import TIER_NAMES, resolve as resolve_tier, route as route_turn
|
|
35
|
+
from xg.cli.help import format_command_help, format_help
|
|
36
|
+
from xg.input_history import HistoryConfig, InputHistory, PromptToolkitHistory
|
|
37
|
+
from xg.llm.client import LlmClient, LlmError
|
|
38
|
+
from xg.llm.factory import create_client
|
|
39
|
+
from xg.memory.manager import MemoryManager, MemoryUnavailableError
|
|
40
|
+
from xg.mcp.manager import McpManager
|
|
41
|
+
from xg.safety.audit import AuditLogger
|
|
42
|
+
from xg.safety.guards import guard_tool_call
|
|
43
|
+
from xg.safety.hitl import ApprovalDecision, HITLPolicy
|
|
44
|
+
from xg.tool.builtin import build_registry
|
|
45
|
+
from xg.web.fetch import WebFetchService
|
|
46
|
+
from xg.web.search import WebSearchService
|
|
47
|
+
from xg.skill.registry import SkillRegistry
|
|
48
|
+
|
|
49
|
+
console = Console()
|
|
50
|
+
|
|
51
|
+
BANNER = """\
|
|
52
|
+
[XG] Agent CLI v0.1.0
|
|
53
|
+
输入任务开始对话;/plan 先拆解计划再执行,/team 使用多 Agent 协作,/provider 管理服务商,/model 在当前 provider 内切换模型,
|
|
54
|
+
/config 查看/设置配置,/init 初始化项目记忆,/save 保存记忆,
|
|
55
|
+
/memory 管理记忆,/mcp 管理外部能力,/web 查看联网能力,/hitl 审批开关,/clear 清空上下文,/exit 退出。
|
|
56
|
+
也可以使用 /skill list|load|enable|disable 管理本地任务规范,/history status|clear 管理输入历史。
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_agent(
|
|
61
|
+
settings: Settings,
|
|
62
|
+
base_dir=None,
|
|
63
|
+
config_manager: ConfigManager | None = None,
|
|
64
|
+
) -> ReActAgent:
|
|
65
|
+
base = Path(base_dir).resolve() if base_dir else Path.cwd().resolve()
|
|
66
|
+
client = create_client(
|
|
67
|
+
settings.api_base, settings.api_key, settings.model,
|
|
68
|
+
retry_enabled=settings.llm_retry_enabled,
|
|
69
|
+
max_retries=settings.llm_max_retries,
|
|
70
|
+
retry_base_delay=settings.llm_retry_base_delay,
|
|
71
|
+
retry_max_delay=settings.llm_retry_max_delay,
|
|
72
|
+
retry_jitter=settings.llm_retry_jitter,
|
|
73
|
+
retry_total_timeout=settings.llm_retry_total_timeout,
|
|
74
|
+
respect_retry_after=settings.llm_respect_retry_after,
|
|
75
|
+
)
|
|
76
|
+
audit = AuditLogger(base / ".xg" / "audit.log")
|
|
77
|
+
guard = lambda name, args: guard_tool_call(base, name, args) # noqa: E731
|
|
78
|
+
config_manager = config_manager or ConfigManager(project_dir=base / ".xg")
|
|
79
|
+
web_config_manager = WebConfigManager(
|
|
80
|
+
user_dir=config_manager.user_dir, project_root=base, env=config_manager.env
|
|
81
|
+
)
|
|
82
|
+
web_config = web_config_manager.load()
|
|
83
|
+
if not settings.web_enabled:
|
|
84
|
+
web_config = replace(web_config, enabled=False)
|
|
85
|
+
web_search = WebSearchService(web_config, audit=audit) if web_config.enabled else None
|
|
86
|
+
web_fetch = WebFetchService(web_config, audit=audit) if web_config.enabled else None
|
|
87
|
+
skill_config_manager = SkillConfigManager(
|
|
88
|
+
user_dir=config_manager.user_dir, project_root=base, env=config_manager.env
|
|
89
|
+
)
|
|
90
|
+
skill_config = skill_config_manager.load()
|
|
91
|
+
if not settings.skills_enabled:
|
|
92
|
+
skill_config = replace(skill_config, enabled=False)
|
|
93
|
+
skill_registry = SkillRegistry(
|
|
94
|
+
project_root=base,
|
|
95
|
+
config=skill_config,
|
|
96
|
+
config_manager=skill_config_manager,
|
|
97
|
+
audit=audit,
|
|
98
|
+
)
|
|
99
|
+
input_history = InputHistory(
|
|
100
|
+
project_root=base,
|
|
101
|
+
user_dir=config_manager.user_dir,
|
|
102
|
+
config=HistoryConfig(
|
|
103
|
+
enabled=settings.input_history_enabled,
|
|
104
|
+
persist=settings.input_history_persist,
|
|
105
|
+
max_entries=settings.input_history_max_entries,
|
|
106
|
+
max_entry_chars=settings.input_history_max_chars,
|
|
107
|
+
max_file_bytes=settings.input_history_max_bytes,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
tools = build_registry(
|
|
111
|
+
base_dir=base,
|
|
112
|
+
max_output_chars=settings.max_tool_output_chars,
|
|
113
|
+
guard=guard,
|
|
114
|
+
audit=audit,
|
|
115
|
+
web_config=web_config,
|
|
116
|
+
web_search=web_search,
|
|
117
|
+
web_fetch=web_fetch,
|
|
118
|
+
skill_registry=skill_registry,
|
|
119
|
+
)
|
|
120
|
+
hitl = HITLPolicy(enabled=settings.hitl)
|
|
121
|
+
memory_manager = MemoryManager(
|
|
122
|
+
base,
|
|
123
|
+
project_memory_max_chars=settings.project_memory_max_chars,
|
|
124
|
+
memory_prompt_max_chars=settings.memory_prompt_max_chars,
|
|
125
|
+
)
|
|
126
|
+
mcp_config = McpConfigManager(
|
|
127
|
+
user_dir=config_manager.user_dir,
|
|
128
|
+
project_root=base,
|
|
129
|
+
env=config_manager.env,
|
|
130
|
+
defaults={
|
|
131
|
+
"startup_timeout": settings.mcp_startup_timeout,
|
|
132
|
+
"request_timeout": settings.mcp_request_timeout,
|
|
133
|
+
"shutdown_timeout": settings.mcp_shutdown_timeout,
|
|
134
|
+
"max_output_chars": settings.max_tool_output_chars,
|
|
135
|
+
"max_tools": settings.mcp_max_tools,
|
|
136
|
+
"max_resources": settings.mcp_max_resources,
|
|
137
|
+
"max_message_bytes": settings.mcp_max_message_bytes,
|
|
138
|
+
"resource_max_chars": settings.mcp_resource_max_chars,
|
|
139
|
+
"log_lines": settings.mcp_log_lines,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
mcp_manager = McpManager(
|
|
143
|
+
tools,
|
|
144
|
+
mcp_config,
|
|
145
|
+
approval_policy=hitl,
|
|
146
|
+
audit=audit,
|
|
147
|
+
enabled=settings.mcp_enabled,
|
|
148
|
+
max_servers=settings.mcp_max_servers,
|
|
149
|
+
resource_total_chars=settings.mcp_resource_total_chars,
|
|
150
|
+
)
|
|
151
|
+
system_prompt = DEFAULT_SYSTEM_PROMPT
|
|
152
|
+
skill_index = skill_registry.index_text()
|
|
153
|
+
if skill_index:
|
|
154
|
+
system_prompt += "\n\n" + skill_index
|
|
155
|
+
agent = ReActAgent(
|
|
156
|
+
llm=client,
|
|
157
|
+
tools=tools,
|
|
158
|
+
settings=settings,
|
|
159
|
+
system_prompt=system_prompt,
|
|
160
|
+
approval_policy=hitl,
|
|
161
|
+
audit=audit,
|
|
162
|
+
memory_manager=memory_manager,
|
|
163
|
+
mcp_manager=mcp_manager,
|
|
164
|
+
)
|
|
165
|
+
agent.web_config = web_config
|
|
166
|
+
agent.web_config_manager = web_config_manager
|
|
167
|
+
agent.web_search = web_search
|
|
168
|
+
agent.web_fetch = web_fetch
|
|
169
|
+
agent.skill_registry = skill_registry
|
|
170
|
+
agent.skill_config_manager = skill_config_manager
|
|
171
|
+
agent.input_history = input_history
|
|
172
|
+
return agent
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ApprovalUI:
|
|
176
|
+
"""HITL 审批交互:绑定 Live 以暂停流式渲染,读取用户决策。"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, session: PromptSession[str], label: str = "plan") -> None:
|
|
179
|
+
self.session = session
|
|
180
|
+
self.label = label
|
|
181
|
+
self._live: Live | None = None
|
|
182
|
+
|
|
183
|
+
def bind_live(self, live: Live) -> None:
|
|
184
|
+
self._live = live
|
|
185
|
+
|
|
186
|
+
def unbind_live(self) -> None:
|
|
187
|
+
"""解绑 Live(进入 /plan 等无 Live 渲染的流程前调用,避免误停旧 Live)。"""
|
|
188
|
+
self._live = None
|
|
189
|
+
|
|
190
|
+
async def __call__(self, tool_name: str, level: str, args: dict) -> ApprovalDecision:
|
|
191
|
+
if self._live is not None:
|
|
192
|
+
self._live.stop()
|
|
193
|
+
console.print()
|
|
194
|
+
console.print(Panel(
|
|
195
|
+
Text(f"需要审批: {tool_name}(敏感度 {level})\nargs: {json.dumps(args, ensure_ascii=False)}"),
|
|
196
|
+
style="yellow",
|
|
197
|
+
))
|
|
198
|
+
decision = await self._read_choice(tool_name)
|
|
199
|
+
if self._live is not None:
|
|
200
|
+
self._live.start()
|
|
201
|
+
return decision
|
|
202
|
+
|
|
203
|
+
async def _read_choice(self, tool_name: str) -> ApprovalDecision:
|
|
204
|
+
while True:
|
|
205
|
+
answer = await self.session.prompt_async(
|
|
206
|
+
HTML("<ansiyellow>[HITL] 批准(Enter) 全部放行(a) 拒绝(r) 跳过(s) 改参(e) ></ansiyellow> ")
|
|
207
|
+
)
|
|
208
|
+
key = answer.strip().lower()
|
|
209
|
+
if key in ("", "y", "yes", "approve"):
|
|
210
|
+
return ApprovalDecision(allow=True, reason="user_approved")
|
|
211
|
+
if key == "a":
|
|
212
|
+
console.print(Text("本会话后续操作全部放行。", style="dim"))
|
|
213
|
+
return ApprovalDecision(allow=True, reason="user_approved_allow_all")
|
|
214
|
+
if key in ("r", "n", "no", "deny"):
|
|
215
|
+
return ApprovalDecision(allow=False, reason="user_rejected")
|
|
216
|
+
if key == "s":
|
|
217
|
+
return ApprovalDecision(allow=False, reason="user_skipped")
|
|
218
|
+
if key == "e":
|
|
219
|
+
raw = await self.session.prompt_async(HTML("<ansicyan>新参数 JSON ></ansicyan> "))
|
|
220
|
+
try:
|
|
221
|
+
new_args = json.loads(raw.strip())
|
|
222
|
+
except json.JSONDecodeError:
|
|
223
|
+
console.print(Text("JSON 解析失败,请重新输入。", style="red"))
|
|
224
|
+
continue
|
|
225
|
+
return ApprovalDecision(allow=True, args=new_args, reason="user_modified")
|
|
226
|
+
console.print(Text("无效输入:Enter/a/r/s/e", style="yellow"))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def handle_turn(agent: ReActAgent, user_input: str, approval_ui: ApprovalUI | None = None) -> None:
|
|
230
|
+
"""执行一轮 ReAct 循环并渲染事件流。"""
|
|
231
|
+
buffer = Text()
|
|
232
|
+
with Live(console=console, vertical_overflow="visible", refresh_per_second=10) as live:
|
|
233
|
+
live.update(Text(""))
|
|
234
|
+
if approval_ui is not None:
|
|
235
|
+
approval_ui.bind_live(live)
|
|
236
|
+
async for event in agent.run(user_input):
|
|
237
|
+
if event.kind == "content":
|
|
238
|
+
buffer.append(event.text)
|
|
239
|
+
live.update(Markdown(buffer.plain))
|
|
240
|
+
elif event.kind == "tool_call" and event.tool_call:
|
|
241
|
+
live.update(Text(""))
|
|
242
|
+
console.print(
|
|
243
|
+
Text(f"→ {event.tool_call.name}({event.tool_call.arguments})", style="dim cyan")
|
|
244
|
+
)
|
|
245
|
+
live.update(Text(""))
|
|
246
|
+
elif event.kind == "approval" and event.tool_call:
|
|
247
|
+
live.update(Text(""))
|
|
248
|
+
style = {
|
|
249
|
+
"approved": "green",
|
|
250
|
+
"modified": "yellow",
|
|
251
|
+
"rejected": "red",
|
|
252
|
+
}.get(event.text, "yellow")
|
|
253
|
+
console.print(
|
|
254
|
+
Text(f" {event.text}: {event.tool_call.name}({event.tool_call.arguments})", style=style)
|
|
255
|
+
)
|
|
256
|
+
live.update(Text(""))
|
|
257
|
+
elif event.kind == "tool_result" and event.tool_result:
|
|
258
|
+
live.update(Text(""))
|
|
259
|
+
style = "green" if event.tool_result.ok else "red"
|
|
260
|
+
preview = (event.tool_result.output or event.tool_result.error).strip()
|
|
261
|
+
if len(preview) > 300:
|
|
262
|
+
preview = preview[:300] + " ..."
|
|
263
|
+
console.print(Text(f" {'OK' if event.tool_result.ok else 'FAIL'}: {preview}", style=style))
|
|
264
|
+
live.update(Text(""))
|
|
265
|
+
elif event.kind == "retrying":
|
|
266
|
+
live.update(Text(""))
|
|
267
|
+
console.print(Text(
|
|
268
|
+
f"API 临时故障,正在重试 {event.retry_attempts}/{event.retry_max_attempts},"
|
|
269
|
+
f"等待 {event.retry_delay or 0:.1f} 秒",
|
|
270
|
+
style="yellow",
|
|
271
|
+
))
|
|
272
|
+
live.update(Text(""))
|
|
273
|
+
elif event.kind == "context_compacted":
|
|
274
|
+
live.update(Text(""))
|
|
275
|
+
console.print(Text(event.text, style="dim cyan"))
|
|
276
|
+
live.update(Text(""))
|
|
277
|
+
elif event.kind == "context_warning":
|
|
278
|
+
live.update(Text(""))
|
|
279
|
+
console.print(Text(f"上下文提示:{event.text}", style="yellow"))
|
|
280
|
+
live.update(Text(""))
|
|
281
|
+
elif event.kind in ("step_limit", "budget_exceeded", "context_overflow", "error"):
|
|
282
|
+
live.update(Text(""))
|
|
283
|
+
if event.kind == "step_limit":
|
|
284
|
+
msg = "已达到单轮工具调用步数上限,循环终止。可继续输入让模型接着完成。"
|
|
285
|
+
elif event.kind in ("budget_exceeded", "context_overflow"):
|
|
286
|
+
msg = event.text or "上下文 token 已接近窗口上限,循环终止。可用 /clear 清空对话后继续。"
|
|
287
|
+
else:
|
|
288
|
+
msg = f"请求失败: {event.text}"
|
|
289
|
+
console.print(Panel(Text(msg), style="yellow"))
|
|
290
|
+
return
|
|
291
|
+
console.print(Text(""))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class PlanReviewUI:
|
|
295
|
+
"""计划审阅交互:Enter 执行 / d 展开详情 / r 补充重规划 / ESC(或 c)取消。"""
|
|
296
|
+
|
|
297
|
+
def __init__(self, session: PromptSession[str]) -> None:
|
|
298
|
+
self.session = session
|
|
299
|
+
|
|
300
|
+
async def __call__(self, plan: Plan) -> ReviewDecision:
|
|
301
|
+
# 面板已由 plan_generated 事件渲染(含 warnings),这里只读决策,不重复打印
|
|
302
|
+
while True:
|
|
303
|
+
answer = (await self.session.prompt_async(
|
|
304
|
+
HTML(f"<ansiyellow>[{self.label}] Enter 执行 / d 详情 / r 重规划 / ESC 取消 ></ansiyellow> "),
|
|
305
|
+
key_bindings=_escape_cancel_bindings(),
|
|
306
|
+
)).strip().lower()
|
|
307
|
+
if answer == "":
|
|
308
|
+
return ReviewDecision(action="execute")
|
|
309
|
+
if answer == "d":
|
|
310
|
+
_print_plan_details(plan)
|
|
311
|
+
continue
|
|
312
|
+
if answer == "r":
|
|
313
|
+
feedback = await self.session.prompt_async(
|
|
314
|
+
HTML(f"<ansicyan>[{self.label}] 补充要求(空行返回不重规划)></ansicyan> ")
|
|
315
|
+
)
|
|
316
|
+
feedback = feedback.strip()
|
|
317
|
+
if not feedback:
|
|
318
|
+
continue
|
|
319
|
+
return ReviewDecision(action="replan", feedback=feedback)
|
|
320
|
+
if answer in ("c", "q", "esc"):
|
|
321
|
+
return ReviewDecision(action="cancel")
|
|
322
|
+
console.print(Text("无效输入:Enter 执行 / d 详情 / r 重规划 / ESC 取消", style="yellow"))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _escape_cancel_bindings() -> KeyBindings:
|
|
326
|
+
"""ESC 直接提交为取消(保留 emacs 元前缀以外的行为)。"""
|
|
327
|
+
kb = KeyBindings()
|
|
328
|
+
|
|
329
|
+
@kb.add("escape")
|
|
330
|
+
def _cancel(event) -> None:
|
|
331
|
+
event.app.current_buffer.text = "c"
|
|
332
|
+
event.app.current_buffer.validate_and_handle()
|
|
333
|
+
|
|
334
|
+
return kb
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _print_plan_panel(plan: Plan, note: str = "") -> None:
|
|
338
|
+
table = Table.grid(padding=(0, 1))
|
|
339
|
+
table.add_column(style="cyan", justify="right", no_wrap=True)
|
|
340
|
+
table.add_column()
|
|
341
|
+
for i, batch in enumerate(plan.batches, 1):
|
|
342
|
+
table.add_row(f"第 {i} 轮", Text(", ".join(batch), style="bold"))
|
|
343
|
+
for tid in batch:
|
|
344
|
+
t = plan.task_by_id(tid)
|
|
345
|
+
assert t is not None
|
|
346
|
+
deps = f"(依赖 {', '.join(t.deps)})" if t.deps else ""
|
|
347
|
+
table.add_row("", f"{t.id} {t.title}{deps}")
|
|
348
|
+
lines = [table]
|
|
349
|
+
if note:
|
|
350
|
+
lines.append(Text(f"提示: {note}", style="yellow"))
|
|
351
|
+
console.print(Panel(*lines, title=f"计划: {plan.goal}", border_style="cyan"))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _print_plan_details(plan: Plan) -> None:
|
|
355
|
+
for i, batch in enumerate(plan.batches, 1):
|
|
356
|
+
console.print(Text(f"── 第 {i} 轮: {', '.join(batch)}", style="cyan"))
|
|
357
|
+
for tid in batch:
|
|
358
|
+
t = plan.task_by_id(tid)
|
|
359
|
+
assert t is not None
|
|
360
|
+
deps = f"(依赖 {', '.join(t.deps)})" if t.deps else ""
|
|
361
|
+
console.print(Text(f" {t.id} {t.title}{deps}", style="bold"))
|
|
362
|
+
console.print(Text(f" {t.description}", style="dim"))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _print_plan_summary(plan: Plan) -> None:
|
|
366
|
+
table = Table.grid(padding=(0, 1))
|
|
367
|
+
table.add_column(style="cyan", no_wrap=True)
|
|
368
|
+
table.add_column(justify="center", no_wrap=True)
|
|
369
|
+
table.add_column()
|
|
370
|
+
status_style = {"done": "green", "failed": "red", "pending": "dim", "running": "yellow"}
|
|
371
|
+
for t in plan.tasks:
|
|
372
|
+
label = {"done": "done", "failed": "FAIL", "pending": "未执行"}.get(t.status, t.status)
|
|
373
|
+
table.add_row(t.id, Text(label, style=status_style.get(t.status, "dim")), t.title)
|
|
374
|
+
if t.result:
|
|
375
|
+
preview = t.result.strip().splitlines()[0] if t.result.strip() else ""
|
|
376
|
+
if len(preview) > 120:
|
|
377
|
+
preview = preview[:120] + " ..."
|
|
378
|
+
if preview:
|
|
379
|
+
table.add_row("", "", Text(preview, style="dim"))
|
|
380
|
+
console.print(Panel(table, title="plan_done", border_style="green"))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _render_plan_event(event: PlanEvent) -> None:
|
|
384
|
+
"""渲染计划事件流(进度行 + 汇总面板)。"""
|
|
385
|
+
task = event.task
|
|
386
|
+
if event.kind == "plan_generated":
|
|
387
|
+
_print_plan_panel(event.plan, note=event.message)
|
|
388
|
+
elif event.kind == "review":
|
|
389
|
+
pass # 审阅交互由 PlanReviewUI 负责
|
|
390
|
+
elif event.kind == "approved":
|
|
391
|
+
console.print(Text("计划已批准,开始执行。", style="green"))
|
|
392
|
+
elif event.kind == "cancelled":
|
|
393
|
+
msg = event.message or "用户取消"
|
|
394
|
+
console.print(Text(f"计划已取消: {msg},未执行任何工具。", style="yellow"))
|
|
395
|
+
elif event.kind == "replanned":
|
|
396
|
+
console.print(Text(f"按反馈重新规划: {event.message}", style="dim"))
|
|
397
|
+
elif event.kind == "batch_started":
|
|
398
|
+
console.print(Text(f"── {event.message}: {', '.join(event.batch)}", style="cyan"))
|
|
399
|
+
elif event.kind == "subtask_started" and task:
|
|
400
|
+
console.print(Text(f"▶ {task.id} {task.title}", style="dim"))
|
|
401
|
+
elif event.kind == "subtask_event" and task and event.agent_event:
|
|
402
|
+
_render_subtask_event(task, event.agent_event)
|
|
403
|
+
elif event.kind == "subtask_done" and task:
|
|
404
|
+
preview = task.result.strip().splitlines()[0] if task.result.strip() else "(无输出)"
|
|
405
|
+
if len(preview) > 200:
|
|
406
|
+
preview = preview[:200] + " ..."
|
|
407
|
+
console.print(Text(f"OK {task.id} {task.title}: {preview}", style="green"))
|
|
408
|
+
elif event.kind == "subtask_failed" and task:
|
|
409
|
+
console.print(Text(f"FAIL {task.id} {task.title}: {task.result}", style="red"))
|
|
410
|
+
elif event.kind == "plan_done":
|
|
411
|
+
if event.plan is not None:
|
|
412
|
+
_print_plan_summary(event.plan)
|
|
413
|
+
console.print(Text(event.message, style="green"))
|
|
414
|
+
elif event.kind == "plan_failed":
|
|
415
|
+
console.print(Panel(Text(event.message), title="plan_failed", border_style="red"))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _print_team_panel(plan: TeamPlan, note: str = "") -> None:
|
|
419
|
+
table = Table.grid(padding=(0, 1))
|
|
420
|
+
table.add_column(style="magenta", justify="right", no_wrap=True)
|
|
421
|
+
table.add_column()
|
|
422
|
+
for index, batch in enumerate(plan.batches, 1):
|
|
423
|
+
table.add_row(f"第 {index} 轮", Text(", ".join(batch), style="bold"))
|
|
424
|
+
for task_id in batch:
|
|
425
|
+
task = plan.task_by_id(task_id)
|
|
426
|
+
assert task is not None
|
|
427
|
+
deps = f"(依赖 {', '.join(task.deps)})" if task.deps else ""
|
|
428
|
+
table.add_row("", f"{task.id} [{task.owner_role}] {task.title}{deps}")
|
|
429
|
+
table.add_row("", Text(f" 资源模式:{task.resource_scope_mode}", style="dim"))
|
|
430
|
+
if getattr(task, "allowed_tools", ()):
|
|
431
|
+
table.add_row("", Text(f" 工具:{', '.join(task.allowed_tools)}", style="dim"))
|
|
432
|
+
for warning in getattr(task, "tool_warnings", ()):
|
|
433
|
+
table.add_row("", Text(f" 工具提示:{warning}", style="yellow"))
|
|
434
|
+
for claim in task.resource_claims:
|
|
435
|
+
table.add_row("", Text(f" 资源声明:{claim.access} {claim.pattern}", style="dim"))
|
|
436
|
+
criteria = ";".join(task.acceptance_criteria[:2])
|
|
437
|
+
if criteria:
|
|
438
|
+
table.add_row("", Text(f" 验收:{criteria}", style="dim"))
|
|
439
|
+
lines = [table]
|
|
440
|
+
if note:
|
|
441
|
+
lines.append(Text(f"提示: {note}", style="yellow"))
|
|
442
|
+
console.print(Panel(*lines, title=f"团队计划: {plan.goal}", border_style="magenta"))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _render_team_event(event: TeamEvent) -> None:
|
|
446
|
+
"""渲染 Team 事件,保留角色、任务和审查身份。"""
|
|
447
|
+
task = event.task
|
|
448
|
+
if event.kind == "team_plan_generated" and event.plan:
|
|
449
|
+
_print_team_panel(event.plan, note=event.message)
|
|
450
|
+
elif event.kind == "approved":
|
|
451
|
+
console.print(Text("团队计划已批准,开始执行。", style="green"))
|
|
452
|
+
elif event.kind == "cancelled":
|
|
453
|
+
console.print(Text(f"团队计划已取消:{event.message}", style="yellow"))
|
|
454
|
+
elif event.kind == "replanned":
|
|
455
|
+
console.print(Text(f"按反馈重新规划团队:{event.message}", style="dim"))
|
|
456
|
+
elif event.kind == "batch_started":
|
|
457
|
+
console.print(Text(f"── {event.message}: {', '.join(event.batch)}", style="cyan"))
|
|
458
|
+
elif event.kind == "task_retry_started" and task:
|
|
459
|
+
console.print(Text(
|
|
460
|
+
f"RETRY [{event.role or task.owner_role}/{task.id}] 第 {event.attempt} 次:"
|
|
461
|
+
f"预算 {event.retry_steps} 步,保留 Artifact {len(event.preserved_artifacts)} 个",
|
|
462
|
+
style="yellow",
|
|
463
|
+
))
|
|
464
|
+
elif event.kind in {"task_started", "agent_started"} and task:
|
|
465
|
+
console.print(Text(f"▶ [{event.role}/{task.id}] {task.title}", style="dim"))
|
|
466
|
+
elif event.kind == "subtask_event" and task and event.agent_event:
|
|
467
|
+
_render_subtask_event(task, event.agent_event, role=event.role)
|
|
468
|
+
elif event.kind == "artifact_produced" and event.artifact:
|
|
469
|
+
summary = event.artifact.summary.replace("\n", " ")[:160]
|
|
470
|
+
console.print(Text(f" [{event.role}/{task.id if task else ''}] Artifact {event.artifact.kind}: {summary}", style="dim cyan"))
|
|
471
|
+
elif event.kind == "task_review_started" and task:
|
|
472
|
+
console.print(Text(f" [reviewer/{task.id}] 开始审查任务证据", style="yellow"))
|
|
473
|
+
elif event.kind == "review_output_invalid" and task:
|
|
474
|
+
console.print(Text(
|
|
475
|
+
f" [reviewer/{task.id}] 输出异常 ({event.failure_category}):{event.message[:240]}",
|
|
476
|
+
style="yellow",
|
|
477
|
+
))
|
|
478
|
+
elif event.kind == "review_output_retry" and task:
|
|
479
|
+
console.print(Text(f" [reviewer/{task.id}] {event.message}", style="yellow"))
|
|
480
|
+
elif event.kind == "task_review_done" and task and event.review:
|
|
481
|
+
style = "green" if event.review.verdict == "pass" else "red"
|
|
482
|
+
detail = ";".join(event.review.findings) or "验收通过"
|
|
483
|
+
console.print(Text(f" [reviewer/{task.id}] {event.review.verdict}: {detail[:240]}", style=style))
|
|
484
|
+
elif event.kind == "repair_requested" and task:
|
|
485
|
+
console.print(Text(f" [repairer/{task.id}] {event.message[:240]}", style="yellow"))
|
|
486
|
+
elif event.kind in {"task_needs_input", "repair_scope_required"} and task:
|
|
487
|
+
scope = ", ".join(claim.pattern for claim in event.scope_claims) or "未提供"
|
|
488
|
+
console.print(Text(
|
|
489
|
+
f"INPUT [{task.id}] {event.message[:240]};修复范围:{scope};"
|
|
490
|
+
f"Repair 配额已启动 {event.repair_attempts_started} 次",
|
|
491
|
+
style="yellow",
|
|
492
|
+
))
|
|
493
|
+
elif event.kind == "task_blocked" and task:
|
|
494
|
+
console.print(Text(f"BLOCKED [{event.role or task.owner_role}/{task.id}]: {event.message[:240]}", style="yellow"))
|
|
495
|
+
elif event.kind == "task_done" and task:
|
|
496
|
+
console.print(Text(f"OK [{task.owner_role}/{task.id}]: {event.message[:200]}", style="green"))
|
|
497
|
+
elif event.kind in {"task_failed", "agent_failed"} and task:
|
|
498
|
+
category = f" ({event.failure_category})" if event.failure_category else ""
|
|
499
|
+
console.print(Text(f"FAIL [{event.role or task.owner_role}/{task.id}]{category}: {event.message[:240]}", style="red"))
|
|
500
|
+
elif event.kind == "team_done":
|
|
501
|
+
console.print(Panel(Text(event.message), title="team_done", border_style="green"))
|
|
502
|
+
elif event.kind == "team_failed":
|
|
503
|
+
console.print(Panel(Text(event.message), title="team_failed", border_style="red"))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _render_subtask_event(task: PlanTask | TeamTask, ae: AgentEvent, role: str = "") -> None:
|
|
507
|
+
"""渲染子任务内部转发的 AgentEvent(前缀子任务 id)。"""
|
|
508
|
+
prefix = f" [{role + '/' if role else ''}{task.id}]"
|
|
509
|
+
if ae.kind == "retrying":
|
|
510
|
+
console.print(Text(
|
|
511
|
+
f"{prefix} API 临时故障,正在重试 {ae.retry_attempts}/{ae.retry_max_attempts},"
|
|
512
|
+
f"等待 {ae.retry_delay or 0:.1f} 秒",
|
|
513
|
+
style="yellow",
|
|
514
|
+
))
|
|
515
|
+
elif ae.kind == "context_compacted":
|
|
516
|
+
console.print(Text(f"{prefix} {ae.text}", style="dim cyan"))
|
|
517
|
+
elif ae.kind == "context_warning":
|
|
518
|
+
console.print(Text(f"{prefix} 上下文提示:{ae.text}", style="yellow"))
|
|
519
|
+
elif ae.kind == "tool_call" and ae.tool_call:
|
|
520
|
+
console.print(Text(f"{prefix} → {ae.tool_call.name}({ae.tool_call.arguments})", style="dim cyan"))
|
|
521
|
+
elif ae.kind == "approval" and ae.tool_call:
|
|
522
|
+
style = {"approved": "green", "modified": "yellow", "rejected": "red"}.get(ae.text, "yellow")
|
|
523
|
+
console.print(Text(f"{prefix} {ae.text}: {ae.tool_call.name}", style=style))
|
|
524
|
+
elif ae.kind == "tool_result" and ae.tool_result:
|
|
525
|
+
style = "green" if ae.tool_result.ok else "red"
|
|
526
|
+
preview = (ae.tool_result.output or ae.tool_result.error).strip()
|
|
527
|
+
if len(preview) > 200:
|
|
528
|
+
preview = preview[:200] + " ..."
|
|
529
|
+
console.print(Text(f"{prefix} {'OK' if ae.tool_result.ok else 'FAIL'}: {preview}", style=style))
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
async def handle_plan_turn(
|
|
533
|
+
agent: ReActAgent,
|
|
534
|
+
settings: Settings,
|
|
535
|
+
goal: str,
|
|
536
|
+
session: PromptSession[str],
|
|
537
|
+
approval_ui: ApprovalUI | None = None,
|
|
538
|
+
) -> None:
|
|
539
|
+
"""执行 /plan 全流程并渲染事件流。"""
|
|
540
|
+
if approval_ui is not None:
|
|
541
|
+
approval_ui.unbind_live() # 计划模式下无 Live,避免误停旧实例
|
|
542
|
+
executor = PlanExecutor(
|
|
543
|
+
llm=agent.llm,
|
|
544
|
+
tools=agent.tools,
|
|
545
|
+
settings=settings,
|
|
546
|
+
reviewer=PlanReviewUI(session),
|
|
547
|
+
approval_policy=agent.approval_policy,
|
|
548
|
+
audit=agent.audit,
|
|
549
|
+
memory_manager=agent.memory_manager,
|
|
550
|
+
mcp_manager=getattr(agent, "mcp_manager", None),
|
|
551
|
+
)
|
|
552
|
+
try:
|
|
553
|
+
async for event in executor.run(goal):
|
|
554
|
+
_render_plan_event(event)
|
|
555
|
+
except LlmError as e:
|
|
556
|
+
console.print(Panel(Text(f"请求失败: {e}"), style="red"))
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
async def handle_team_turn(
|
|
560
|
+
agent: ReActAgent,
|
|
561
|
+
settings: Settings,
|
|
562
|
+
goal: str,
|
|
563
|
+
session: PromptSession[str],
|
|
564
|
+
approval_ui: ApprovalUI | None = None,
|
|
565
|
+
) -> None:
|
|
566
|
+
"""执行 /team 全流程并渲染角色、Artifact 和审查事件。"""
|
|
567
|
+
if approval_ui is not None:
|
|
568
|
+
approval_ui.unbind_live()
|
|
569
|
+
executor = TeamExecutor(
|
|
570
|
+
llm=agent.llm,
|
|
571
|
+
tools=agent.tools,
|
|
572
|
+
settings=settings,
|
|
573
|
+
reviewer=PlanReviewUI(session, label="team"),
|
|
574
|
+
approval_policy=agent.approval_policy,
|
|
575
|
+
audit=agent.audit,
|
|
576
|
+
memory_manager=agent.memory_manager,
|
|
577
|
+
mcp_manager=getattr(agent, "mcp_manager", None),
|
|
578
|
+
project_root=getattr(agent.memory_manager, "project_root", None),
|
|
579
|
+
)
|
|
580
|
+
try:
|
|
581
|
+
async for event in executor.run(goal):
|
|
582
|
+
_render_team_event(event)
|
|
583
|
+
except LlmError as exc:
|
|
584
|
+
console.print(Panel(Text(f"请求失败: {exc}"), style="red"))
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
async def run_loop(agent: ReActAgent, settings: Settings, manager: ConfigManager) -> None:
|
|
588
|
+
mcp_manager = getattr(agent, "mcp_manager", None)
|
|
589
|
+
if mcp_manager is not None:
|
|
590
|
+
await mcp_manager.ensure_started()
|
|
591
|
+
if mcp_manager.config_errors:
|
|
592
|
+
for error in mcp_manager.config_errors:
|
|
593
|
+
console.print(Text(f"MCP 配置提示:{error}", style="yellow"))
|
|
594
|
+
try:
|
|
595
|
+
await _run_loop_body(agent, settings, manager)
|
|
596
|
+
finally:
|
|
597
|
+
if mcp_manager is not None:
|
|
598
|
+
await mcp_manager.close()
|
|
599
|
+
for service_name in ("web_search", "web_fetch"):
|
|
600
|
+
service = getattr(agent, service_name, None)
|
|
601
|
+
if service is not None:
|
|
602
|
+
await service.close()
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def _run_loop_body(agent: ReActAgent, settings: Settings, manager: ConfigManager) -> None:
|
|
606
|
+
input_history = getattr(agent, "input_history", None)
|
|
607
|
+
history_adapter = PromptToolkitHistory(input_history) if input_history is not None else None
|
|
608
|
+
session: PromptSession[str] = PromptSession(history=history_adapter)
|
|
609
|
+
console.print(Panel(BANNER, title="XG", border_style="cyan"))
|
|
610
|
+
|
|
611
|
+
approval_ui = ApprovalUI(session)
|
|
612
|
+
if agent.approval_policy is not None:
|
|
613
|
+
agent.approval_policy.requester = approval_ui
|
|
614
|
+
|
|
615
|
+
# SmartRouter 跨轮路由状态(phase-01 步骤 D)
|
|
616
|
+
prev_tier: str | None = None
|
|
617
|
+
prev_ts: float | None = None
|
|
618
|
+
# SmartRouter 反馈采集(phase-03 步骤 B)
|
|
619
|
+
_feedback = FeedbackRecorder(session=str(Path.cwd()))
|
|
620
|
+
# SmartRouter 校准/自学习/稳定层(phase-03 C + phase-04 A1/A2/A3)
|
|
621
|
+
# 挂到 agent 上,供路由与 /smartRouter reset 重建共享同一份状态
|
|
622
|
+
from xg.adaptive.calibrate import recalibrate
|
|
623
|
+
from xg.adaptive.learned_rules import re_learn
|
|
624
|
+
from xg.router.postprocess import Hysteresis
|
|
625
|
+
from xg.router.ml_router import MLRouter
|
|
626
|
+
from xg.router.semantic import load_semantic_encoder
|
|
627
|
+
|
|
628
|
+
agent._smart_calibration = recalibrate()
|
|
629
|
+
agent._smart_learned = re_learn()
|
|
630
|
+
agent._smart_hysteresis = Hysteresis()
|
|
631
|
+
agent._smart_ml = MLRouter(semantic=load_semantic_encoder()) # 第6期 C2:语义列可选,缺则回落
|
|
632
|
+
_calibration = agent._smart_calibration # 兼容旧引用
|
|
633
|
+
|
|
634
|
+
while True:
|
|
635
|
+
try:
|
|
636
|
+
if history_adapter is not None:
|
|
637
|
+
history_adapter.recording_enabled = True
|
|
638
|
+
user_input = await session.prompt_async(HTML("<ansicyan>xg ></ansicyan> "))
|
|
639
|
+
except (KeyboardInterrupt, EOFError):
|
|
640
|
+
console.print("再见。")
|
|
641
|
+
return
|
|
642
|
+
finally:
|
|
643
|
+
if history_adapter is not None:
|
|
644
|
+
history_adapter.recording_enabled = False
|
|
645
|
+
|
|
646
|
+
user_input = user_input.strip()
|
|
647
|
+
if not user_input:
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
if user_input.startswith("/plan"):
|
|
651
|
+
goal = user_input[5:].strip()
|
|
652
|
+
if not goal:
|
|
653
|
+
console.print(Text("用法: /plan <任务描述>", style="yellow"))
|
|
654
|
+
continue
|
|
655
|
+
try:
|
|
656
|
+
await handle_plan_turn(agent, settings, goal, session, approval_ui)
|
|
657
|
+
except KeyboardInterrupt:
|
|
658
|
+
console.print(Text("(已中断计划执行)", style="yellow"))
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
if user_input.startswith("/team"):
|
|
662
|
+
goal = user_input[5:].strip()
|
|
663
|
+
if not goal:
|
|
664
|
+
console.print(Text("用法: /team <任务描述>", style="yellow"))
|
|
665
|
+
continue
|
|
666
|
+
try:
|
|
667
|
+
await handle_team_turn(agent, settings, goal, session, approval_ui)
|
|
668
|
+
except KeyboardInterrupt:
|
|
669
|
+
console.print(Text("(已中断团队执行)", style="yellow"))
|
|
670
|
+
continue
|
|
671
|
+
|
|
672
|
+
if user_input.lower().startswith(("/init", "/save", "/memory")):
|
|
673
|
+
try:
|
|
674
|
+
message = await _handle_memory_command(agent, user_input, session)
|
|
675
|
+
except KeyboardInterrupt:
|
|
676
|
+
message = "(已取消记忆操作)"
|
|
677
|
+
if message:
|
|
678
|
+
console.print(Text(message, style="dim"))
|
|
679
|
+
continue
|
|
680
|
+
|
|
681
|
+
if user_input.startswith("/"):
|
|
682
|
+
if user_input.split(maxsplit=1)[0].lower() == "/mcp":
|
|
683
|
+
from xg.cli.commands import execute_mcp_command
|
|
684
|
+
|
|
685
|
+
message, _ = await execute_mcp_command(agent, user_input)
|
|
686
|
+
should_exit = False
|
|
687
|
+
elif user_input.split(maxsplit=1)[0].lower() == "/web":
|
|
688
|
+
from xg.cli.commands import execute_web_command
|
|
689
|
+
|
|
690
|
+
message, should_exit = await execute_web_command(agent, user_input)
|
|
691
|
+
elif user_input.split(maxsplit=1)[0].lower() == "/skill":
|
|
692
|
+
from xg.cli.commands import execute_skill_command
|
|
693
|
+
|
|
694
|
+
message, should_exit = await execute_skill_command(agent, user_input)
|
|
695
|
+
elif user_input.split(maxsplit=1)[0].lower() == "/history":
|
|
696
|
+
from xg.cli.commands import execute_history_command
|
|
697
|
+
|
|
698
|
+
message, should_exit = await execute_history_command(agent, user_input)
|
|
699
|
+
elif user_input.split(maxsplit=1)[0].lower() == "/provider":
|
|
700
|
+
from xg.cli.commands import execute_provider_command
|
|
701
|
+
|
|
702
|
+
message, _ok = execute_provider_command(manager, settings, user_input)
|
|
703
|
+
elif user_input.split(maxsplit=1)[0].lower() == "/tier":
|
|
704
|
+
from xg.cli.commands import execute_tier_command
|
|
705
|
+
|
|
706
|
+
message, _ok = execute_tier_command(manager, settings, user_input)
|
|
707
|
+
elif user_input.split(maxsplit=1)[0].lower() == "/train":
|
|
708
|
+
message = _run_train_inline(user_input)
|
|
709
|
+
else:
|
|
710
|
+
message, should_exit = _handle_command(agent, settings, manager, user_input)
|
|
711
|
+
if message:
|
|
712
|
+
console.print(Text(message, style="dim"))
|
|
713
|
+
if should_exit:
|
|
714
|
+
return
|
|
715
|
+
continue
|
|
716
|
+
|
|
717
|
+
try:
|
|
718
|
+
if settings.smart_router_enabled:
|
|
719
|
+
prev_tier, prev_ts = _route_user_turn(
|
|
720
|
+
agent, settings, manager, user_input, prev_tier, prev_ts,
|
|
721
|
+
feedback=_feedback,
|
|
722
|
+
calibration=agent._smart_calibration,
|
|
723
|
+
learned_rules=agent._smart_learned,
|
|
724
|
+
hysteresis=agent._smart_hysteresis,
|
|
725
|
+
ml_router=agent._smart_ml,
|
|
726
|
+
)
|
|
727
|
+
await handle_turn(agent, user_input, approval_ui)
|
|
728
|
+
except KeyboardInterrupt:
|
|
729
|
+
# phase-03 步骤 B:回答问题中途 Ctrl+C 视为"该档不够强"
|
|
730
|
+
if settings.smart_router_enabled:
|
|
731
|
+
if capture_interrupt(_feedback, prev_tier):
|
|
732
|
+
_feedback.flush()
|
|
733
|
+
console.print(Text("(已中断本轮任务)", style="yellow"))
|
|
734
|
+
except LlmError as e:
|
|
735
|
+
console.print(Panel(Text(f"请求失败: {e}"), style="red"))
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _run_train_inline(raw: str) -> str | None:
|
|
739
|
+
"""inline 版 /train:确认 + 同步阻塞逐行打印训练日志,返回汇总消息。
|
|
740
|
+
|
|
741
|
+
训练是显式手动触发:不带 --yes 只回确认提示;带 --yes 才 spawn 阻塞运行,
|
|
742
|
+
每行即时打印到 stdout(实时进度),结束后返回简短汇总。
|
|
743
|
+
"""
|
|
744
|
+
from xg.cli.train import ( # noqa: PLC0415
|
|
745
|
+
check_train_deps,
|
|
746
|
+
confirmation_message,
|
|
747
|
+
parse_train_command,
|
|
748
|
+
run_training_sync,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
plan, err = parse_train_command(raw)
|
|
752
|
+
if err:
|
|
753
|
+
return err
|
|
754
|
+
dep_err = check_train_deps()
|
|
755
|
+
if dep_err:
|
|
756
|
+
return dep_err
|
|
757
|
+
if not plan.overwrite:
|
|
758
|
+
return confirmation_message(plan)
|
|
759
|
+
|
|
760
|
+
ok, lines = run_training_sync(plan, on_line=lambda line: console.print(Text(line, style="dim")))
|
|
761
|
+
# 行日志已逐行实时上屏,这里只返回简短收尾,避免重复。
|
|
762
|
+
if ok:
|
|
763
|
+
return "训练完成,产物已写入。"
|
|
764
|
+
return "训练失败,详见上方日志。"
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _handle_command(
|
|
768
|
+
agent: ReActAgent, settings: Settings, manager: ConfigManager, raw: str
|
|
769
|
+
) -> tuple[str | None, bool]:
|
|
770
|
+
"""处理斜杠命令。返回 (输出消息, 是否退出程序)。"""
|
|
771
|
+
parts = raw.split(maxsplit=1)
|
|
772
|
+
cmd = parts[0].lower()
|
|
773
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
774
|
+
|
|
775
|
+
if cmd in ("/help", "/?"):
|
|
776
|
+
return format_command_help(arg) if arg else format_help(), False
|
|
777
|
+
if cmd in ("/exit", "/quit"):
|
|
778
|
+
return "再见。", True
|
|
779
|
+
if cmd in ("/cancel", "/c"):
|
|
780
|
+
return "inline 模式下没有可取消的排队任务;任务执行中可用 Ctrl+C 中断。", False
|
|
781
|
+
if cmd == "/clear":
|
|
782
|
+
agent.clear()
|
|
783
|
+
return "上下文已清空。", False
|
|
784
|
+
if cmd in ("/lang", "/language"):
|
|
785
|
+
from xg.cli.commands import _execute_language_command
|
|
786
|
+
|
|
787
|
+
result = _execute_language_command(settings, manager, raw)
|
|
788
|
+
return result.message, False
|
|
789
|
+
if cmd == "/model":
|
|
790
|
+
return _cmd_model(agent, settings, manager, arg), False
|
|
791
|
+
if cmd == "/smartrouter":
|
|
792
|
+
return _cmd_smart_router(agent, settings, manager, arg), False
|
|
793
|
+
if cmd == "/config":
|
|
794
|
+
return _cmd_config(agent, settings, manager, arg), False
|
|
795
|
+
if cmd == "/provider":
|
|
796
|
+
from xg.cli.commands import execute_provider_command
|
|
797
|
+
|
|
798
|
+
message, ok = execute_provider_command(manager, settings, raw)
|
|
799
|
+
if ok:
|
|
800
|
+
_reapply_active(agent, settings, manager)
|
|
801
|
+
return message, False
|
|
802
|
+
if cmd == "/hitl":
|
|
803
|
+
return _cmd_hitl(agent, arg), False
|
|
804
|
+
if cmd == "/save":
|
|
805
|
+
return _cmd_memory_sync(agent, cmd, arg), False
|
|
806
|
+
if cmd == "/memory":
|
|
807
|
+
return _cmd_memory_sync(agent, cmd, arg), False
|
|
808
|
+
if cmd == "/mcp":
|
|
809
|
+
mcp = getattr(agent, "mcp_manager", None)
|
|
810
|
+
return (mcp.format_status() if mcp is not None else "MCP 未初始化。"), False
|
|
811
|
+
from xg.cli.commands import SLASH_COMMANDS
|
|
812
|
+
|
|
813
|
+
names = " ".join(spec.name for spec in SLASH_COMMANDS)
|
|
814
|
+
return f"未知命令: {cmd}。可用: {names}。输入 /help 查看详情。", False
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _memory_manager(agent: ReActAgent) -> MemoryManager | None:
|
|
818
|
+
return getattr(agent, "memory_manager", None)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _format_memory_entries(entries) -> str:
|
|
822
|
+
if not entries:
|
|
823
|
+
return "没有找到长期记忆。"
|
|
824
|
+
lines = []
|
|
825
|
+
for entry in entries:
|
|
826
|
+
timestamp = entry.updated_at.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
827
|
+
preview = " ".join(entry.content.split())
|
|
828
|
+
if len(preview) > 120:
|
|
829
|
+
preview = preview[:120] + " ..."
|
|
830
|
+
lines.append(f"#{entry.id} {timestamp} {preview}")
|
|
831
|
+
return "\n".join(lines)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _cmd_memory_sync(agent: ReActAgent, cmd: str, arg: str) -> str:
|
|
835
|
+
memory = _memory_manager(agent)
|
|
836
|
+
if memory is None:
|
|
837
|
+
return "记忆功能未初始化。"
|
|
838
|
+
try:
|
|
839
|
+
if cmd == "/save":
|
|
840
|
+
if not arg:
|
|
841
|
+
return "用法: /save <要保存的项目记忆>"
|
|
842
|
+
entry, created, redacted = memory.save(arg)
|
|
843
|
+
action = "已保存" if created else "已存在,已刷新时间"
|
|
844
|
+
suffix = "(敏感片段已脱敏)" if redacted else ""
|
|
845
|
+
return f"{action}长期记忆 #{entry.id}{suffix}。"
|
|
846
|
+
|
|
847
|
+
parts = arg.split(maxsplit=1)
|
|
848
|
+
sub = parts[0].lower() if parts else "list"
|
|
849
|
+
rest = parts[1].strip() if len(parts) > 1 else ""
|
|
850
|
+
if sub == "list":
|
|
851
|
+
limit = 20
|
|
852
|
+
if rest:
|
|
853
|
+
try:
|
|
854
|
+
limit = int(rest)
|
|
855
|
+
except ValueError:
|
|
856
|
+
return "用法: /memory list [limit]"
|
|
857
|
+
return _format_memory_entries(memory.list(limit))
|
|
858
|
+
if sub == "search":
|
|
859
|
+
if not rest:
|
|
860
|
+
return "用法: /memory search <关键词>"
|
|
861
|
+
return _format_memory_entries(memory.search(rest))
|
|
862
|
+
if sub == "delete":
|
|
863
|
+
try:
|
|
864
|
+
memory_id = int(rest)
|
|
865
|
+
except ValueError:
|
|
866
|
+
return "用法: /memory delete <id>"
|
|
867
|
+
return f"已删除长期记忆 #{memory_id}。" if memory.delete(memory_id) else f"不存在长期记忆 #{memory_id}。"
|
|
868
|
+
if sub == "clear":
|
|
869
|
+
return "清空长期记忆需要交互确认。"
|
|
870
|
+
return "用法: /memory list|search|delete|clear"
|
|
871
|
+
except (MemoryUnavailableError, OSError, ValueError) as exc:
|
|
872
|
+
return f"记忆操作失败:{exc}"
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
async def _handle_memory_command(
|
|
876
|
+
agent: ReActAgent, raw: str, session: PromptSession[str]
|
|
877
|
+
) -> str:
|
|
878
|
+
"""处理需要 prompt 的第五期命令;普通 list/search/save 仍走同步逻辑。"""
|
|
879
|
+
parts = raw.split(maxsplit=1)
|
|
880
|
+
cmd = parts[0].lower()
|
|
881
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
882
|
+
memory = _memory_manager(agent)
|
|
883
|
+
if memory is None:
|
|
884
|
+
return "记忆功能未初始化。"
|
|
885
|
+
|
|
886
|
+
if cmd == "/init":
|
|
887
|
+
try:
|
|
888
|
+
draft = await memory.generate_init_draft(agent.llm)
|
|
889
|
+
except FileExistsError as exc:
|
|
890
|
+
return str(exc)
|
|
891
|
+
except (LlmError, OSError, ValueError) as exc:
|
|
892
|
+
return f"生成 XG.md 失败:{exc}"
|
|
893
|
+
console.print(Panel(Markdown(draft), title="XG.md 草稿", border_style="cyan"))
|
|
894
|
+
answer = await session.prompt_async(
|
|
895
|
+
HTML("<ansiyellow>写入 XG.md?输入 y 确认,其他内容取消 ></ansiyellow> ")
|
|
896
|
+
)
|
|
897
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
898
|
+
return "已取消,未写入 XG.md。"
|
|
899
|
+
try:
|
|
900
|
+
path = memory.write_init_draft(draft)
|
|
901
|
+
except (FileExistsError, OSError) as exc:
|
|
902
|
+
return f"写入 XG.md 失败:{exc}"
|
|
903
|
+
return f"已生成项目记忆:{path.name}。"
|
|
904
|
+
|
|
905
|
+
if cmd == "/memory" and arg.lower() == "clear":
|
|
906
|
+
try:
|
|
907
|
+
count = memory.count()
|
|
908
|
+
except (MemoryUnavailableError, OSError) as exc:
|
|
909
|
+
return f"记忆操作失败:{exc}"
|
|
910
|
+
if count == 0:
|
|
911
|
+
return "当前项目没有长期记忆。"
|
|
912
|
+
answer = await session.prompt_async(
|
|
913
|
+
HTML(f"<ansiyellow>将清空当前项目 {count} 条长期记忆?输入 clear 确认 ></ansiyellow> ")
|
|
914
|
+
)
|
|
915
|
+
if answer.strip().lower() != "clear":
|
|
916
|
+
return "已取消,长期记忆未改变。"
|
|
917
|
+
try:
|
|
918
|
+
removed = memory.clear()
|
|
919
|
+
except (MemoryUnavailableError, OSError) as exc:
|
|
920
|
+
return f"记忆操作失败:{exc}"
|
|
921
|
+
return f"已清空当前项目的 {removed} 条长期记忆。"
|
|
922
|
+
|
|
923
|
+
return _cmd_memory_sync(agent, cmd, arg)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _cmd_hitl(agent: ReActAgent, arg: str) -> str:
|
|
927
|
+
policy = getattr(agent, "approval_policy", None)
|
|
928
|
+
if policy is None:
|
|
929
|
+
return "HITL 未启用(未注入审批策略)"
|
|
930
|
+
sub = arg.split()[0].lower() if arg.split() else ""
|
|
931
|
+
if sub == "on":
|
|
932
|
+
policy.set_enabled(True)
|
|
933
|
+
return "HITL 已开启。"
|
|
934
|
+
if sub == "off":
|
|
935
|
+
policy.set_enabled(False)
|
|
936
|
+
policy.reset_session()
|
|
937
|
+
return "HITL 已关闭(危险模式,工具不再弹审批)。"
|
|
938
|
+
if sub == "reset":
|
|
939
|
+
policy.reset_session()
|
|
940
|
+
return "已清除「本会话全部放行」状态。"
|
|
941
|
+
status = "开启" if policy.enabled else "关闭"
|
|
942
|
+
allow_all = "是" if policy.session_allow_all else "否"
|
|
943
|
+
return f"HITL: {status}(本会话全部放行: {allow_all})。用法: /hitl on|off|reset"
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _cmd_model(
|
|
947
|
+
agent: ReActAgent, settings: Settings, manager: ConfigManager, arg: str
|
|
948
|
+
) -> str:
|
|
949
|
+
"""/model:list 查看;否则在当前 base provider 内切模型(限模型列表内)。"""
|
|
950
|
+
if not arg or arg.strip().lower() == "list":
|
|
951
|
+
return _model_catalog(manager)
|
|
952
|
+
|
|
953
|
+
model = arg.strip()
|
|
954
|
+
provider = manager.resolve_provider(settings.provider) if settings.provider else None
|
|
955
|
+
if provider is None:
|
|
956
|
+
return "当前无可用 base provider,无法切换模型。\n" + _model_catalog(manager)
|
|
957
|
+
|
|
958
|
+
allowed = [provider.default_model, *provider.models]
|
|
959
|
+
if model not in allowed:
|
|
960
|
+
available = "、".join(allowed) if allowed else "(无)"
|
|
961
|
+
return (
|
|
962
|
+
f"模型 {model} 不在 {provider.name} 的可用列表中。可用: {available}。"
|
|
963
|
+
f"如需使用,可运行 /provider {provider.name} model {model} 添加。"
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
result = _switch(agent, settings, manager, provider.name, model)
|
|
967
|
+
|
|
968
|
+
# 手动优先接管:/model 切换成功即持久化并关闭 SmartRouter 清除快照
|
|
969
|
+
if result.startswith("已切换:"):
|
|
970
|
+
manager.set_config_value("active_model", model)
|
|
971
|
+
_disable_smart_router(settings, manager)
|
|
972
|
+
return result + "\n→ SmartRouter 已自动关闭(手动 /model 优先)"
|
|
973
|
+
return result
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _cmd_smart_router(
|
|
977
|
+
agent: ReActAgent, settings: Settings, manager: ConfigManager, arg: str
|
|
978
|
+
) -> str:
|
|
979
|
+
"""处理 /smartRouter on|off|status。default status。"""
|
|
980
|
+
parts = arg.split(maxsplit=1)
|
|
981
|
+
sub = parts[0].lower() if parts else "status"
|
|
982
|
+
|
|
983
|
+
if sub in ("on", "enable"):
|
|
984
|
+
if settings.smart_router_enabled:
|
|
985
|
+
return "SmartRouter 已开启。"
|
|
986
|
+
settings.smart_router_saved = (settings.provider, settings.model)
|
|
987
|
+
settings.smart_router_enabled = True
|
|
988
|
+
manager.set_smart_router_enabled(True)
|
|
989
|
+
return (
|
|
990
|
+
f"SmartRouter 已开启:每轮输入自动按档位选模型。"
|
|
991
|
+
f"(当前模型 {settings.provider}/{settings.model},关闭时恢复)"
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
if sub in ("off", "disable"):
|
|
995
|
+
if not settings.smart_router_enabled:
|
|
996
|
+
return "SmartRouter 已关闭。"
|
|
997
|
+
settings.smart_router_enabled = False
|
|
998
|
+
manager.set_smart_router_enabled(False)
|
|
999
|
+
saved = settings.smart_router_saved
|
|
1000
|
+
settings.smart_router_saved = None
|
|
1001
|
+
if saved:
|
|
1002
|
+
_switch(agent, settings, manager, saved[0], saved[1])
|
|
1003
|
+
return "SmartRouter 已关闭,已恢复开启前的手动模型。"
|
|
1004
|
+
|
|
1005
|
+
if sub in ("reset", "clear"):
|
|
1006
|
+
from xg.adaptive.store import reset_adaptive_data
|
|
1007
|
+
from xg.adaptive.calibrate import recalibrate
|
|
1008
|
+
from xg.adaptive.learned_rules import re_learn
|
|
1009
|
+
from xg.router.postprocess import Hysteresis
|
|
1010
|
+
from xg.router.ml_router import MLRouter
|
|
1011
|
+
|
|
1012
|
+
removed = reset_adaptive_data()
|
|
1013
|
+
# 重建内存共享状态,使 reset 立即生效(不再用旧校准/规则)
|
|
1014
|
+
agent._smart_calibration = recalibrate()
|
|
1015
|
+
agent._smart_learned = re_learn()
|
|
1016
|
+
agent._smart_hysteresis = Hysteresis()
|
|
1017
|
+
from xg.router.semantic import load_semantic_encoder
|
|
1018
|
+
agent._smart_ml = MLRouter(semantic=load_semantic_encoder()) # 与主循环一致
|
|
1019
|
+
detail = "、".join(removed) if removed else "(本轮无可清除项)"
|
|
1020
|
+
return (
|
|
1021
|
+
f"已清除校准与自学习规则:{detail}。feedback.log 保留为历史;"
|
|
1022
|
+
f"之后会按新的 feedback 重新学习。"
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
if sub == "status" or arg.strip() in ("status", ""):
|
|
1026
|
+
lines = [f"SmartRouter: {'开启' if settings.smart_router_enabled else '关闭'}"]
|
|
1027
|
+
cfg = manager.smart_router_config()
|
|
1028
|
+
tiers = cfg.get("tiers") or {}
|
|
1029
|
+
for idx, name in enumerate(TIER_NAMES):
|
|
1030
|
+
target = resolve_tier(idx, settings.provider, settings.model, tiers, manager)
|
|
1031
|
+
raw_entry = tiers.get(name)
|
|
1032
|
+
if target.configured:
|
|
1033
|
+
mark = "OK"
|
|
1034
|
+
else:
|
|
1035
|
+
mark = "(x)" if raw_entry else "-"
|
|
1036
|
+
lines.append(f" {name:<9}→ {target.provider}/{target.model} {mark}")
|
|
1037
|
+
lines.append(" (OK=显式配置可用 (x)=配置但校验失败 -=未配回落 active)")
|
|
1038
|
+
# 校准状态(phase-03 步骤 C):直接聚合 feedback.log 展示实时样本
|
|
1039
|
+
from xg.adaptive.calibrate import aggregate
|
|
1040
|
+
from xg.adaptive.feedback import read_feedback
|
|
1041
|
+
from xg.adaptive.learned_rules import load_learned_rules, rule_hit_stats
|
|
1042
|
+
|
|
1043
|
+
records = read_feedback()
|
|
1044
|
+
cal = aggregate(records)
|
|
1045
|
+
parts = [f"{name}={cal.samples[i]:g}" for i, name in enumerate(TIER_NAMES)]
|
|
1046
|
+
lines.append(f" 校准样本: {' '.join(parts)}(单档满 20 才生效)")
|
|
1047
|
+
bias_parts = [
|
|
1048
|
+
f"{name}={cal.bias[i]:+.2f}" for i, name in enumerate(TIER_NAMES)
|
|
1049
|
+
]
|
|
1050
|
+
lines.append(f" 档位偏置: {' '.join(bias_parts)} 阈值调整 {cal.threshold_adjust:+.2f}")
|
|
1051
|
+
# 自学习规则(phase-04 A1/A3):规则数量与在 feedback.log 上的命中情况
|
|
1052
|
+
rules = getattr(agent, "_smart_learned", load_learned_rules())
|
|
1053
|
+
stats = rule_hit_stats(records, rules)
|
|
1054
|
+
lines.append(
|
|
1055
|
+
f" 自学习规则: {stats['rule_count']} 条,"
|
|
1056
|
+
f"命中样本 {stats['hit_records']} / 可命中样本 {stats['sample_records']}"
|
|
1057
|
+
)
|
|
1058
|
+
for pr in stats["per_rule"]:
|
|
1059
|
+
pred = "、".join(f"{k}{v:g}" for k, v in pr["predicate"].items())
|
|
1060
|
+
lines.append(
|
|
1061
|
+
f" [{pred}] action={pr['action']:+d} conf={pr['confidence']:.2f} "
|
|
1062
|
+
f"support={pr['support']:g} 命中{pr['hits']}次"
|
|
1063
|
+
)
|
|
1064
|
+
# ML 精判(phase-05 B2):产物可用性观测;缺失/缺依赖时显示离线
|
|
1065
|
+
ml = getattr(agent, "_smart_ml", None)
|
|
1066
|
+
if ml is not None:
|
|
1067
|
+
if ml.available:
|
|
1068
|
+
n = ml.n_samples
|
|
1069
|
+
suffix = f",样本 {n}" if n is not None else ""
|
|
1070
|
+
lines.append(f" ML 精判: 可用{suffix},sem_dim={ml.sem_dim}")
|
|
1071
|
+
else:
|
|
1072
|
+
lines.append(" ML 精判: 离线(未训练 / 未装依赖,回落规则路由)")
|
|
1073
|
+
# 语义通道(phase-06 C3):可用性/产物/耗时/有效样本 观测
|
|
1074
|
+
sem = ml.semantic
|
|
1075
|
+
if sem is not None:
|
|
1076
|
+
if sem.available:
|
|
1077
|
+
lines.append(
|
|
1078
|
+
f" 语义通道: 可用({sem.dim}维,已编码 {sem.calls} 次,"
|
|
1079
|
+
f"平均 {sem.avg_ms:.1f}ms/次,最近 {sem.last_ms:.1f}ms)"
|
|
1080
|
+
)
|
|
1081
|
+
elif sem.artifact_exists:
|
|
1082
|
+
lines.append(" 语义通道: 不可用(产物存在但加载失败 / 缺依赖,回落纯 TF-IDF)")
|
|
1083
|
+
else:
|
|
1084
|
+
lines.append(" 语义通道: 未安装(无产物,回落纯 TF-IDF 精判)")
|
|
1085
|
+
return "\n".join(lines)
|
|
1086
|
+
|
|
1087
|
+
return "用法: /smartRouter on|off|status|reset"
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _disable_smart_router(settings: Settings, manager: ConfigManager) -> None:
|
|
1091
|
+
"""手动 /model 切换后关闭 SmartRouter 并清除快照(手动优先)。"""
|
|
1092
|
+
if not settings.smart_router_enabled:
|
|
1093
|
+
return
|
|
1094
|
+
settings.smart_router_enabled = False
|
|
1095
|
+
settings.smart_router_saved = None
|
|
1096
|
+
manager.set_smart_router_enabled(False)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _attach_model(
|
|
1100
|
+
settings: Settings, manager: ConfigManager, agent: ReActAgent,
|
|
1101
|
+
provider_name: str, model: str,
|
|
1102
|
+
) -> str | None:
|
|
1103
|
+
"""仅重建 agent.llm 与内存配置(provider/model/base),不写回持久化。
|
|
1104
|
+
|
|
1105
|
+
SmartRouter 路由用——避免把自动路由到的模型写进 active_provider/active_model。
|
|
1106
|
+
返回错误消息;成功返回 None。
|
|
1107
|
+
"""
|
|
1108
|
+
provider = manager.resolve_provider(provider_name)
|
|
1109
|
+
if provider is None:
|
|
1110
|
+
return f"未知 provider: {provider_name}"
|
|
1111
|
+
key = manager.resolve_api_key(provider)
|
|
1112
|
+
if not key:
|
|
1113
|
+
return f"缺少 {provider.name} 的 api_key 配置,无法使用。请用 /provider key {provider.name} <KEY> 写入 config.json。"
|
|
1114
|
+
agent.llm = create_client(
|
|
1115
|
+
manager.resolve_api_base(provider), key, model,
|
|
1116
|
+
retry_enabled=settings.llm_retry_enabled,
|
|
1117
|
+
max_retries=settings.llm_max_retries,
|
|
1118
|
+
retry_base_delay=settings.llm_retry_base_delay,
|
|
1119
|
+
retry_max_delay=settings.llm_retry_max_delay,
|
|
1120
|
+
retry_jitter=settings.llm_retry_jitter,
|
|
1121
|
+
retry_total_timeout=settings.llm_retry_total_timeout,
|
|
1122
|
+
respect_retry_after=settings.llm_respect_retry_after,
|
|
1123
|
+
)
|
|
1124
|
+
settings.provider = provider.name
|
|
1125
|
+
settings.model = model
|
|
1126
|
+
settings.api_base = manager.resolve_api_base(provider)
|
|
1127
|
+
settings.api_key = key
|
|
1128
|
+
settings.context_window = manager.resolve_window(provider)
|
|
1129
|
+
return None
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _route_user_turn(
|
|
1133
|
+
agent: ReActAgent, settings: Settings, manager: ConfigManager,
|
|
1134
|
+
user_input: str, prev_tier: str | None, prev_ts: float | None,
|
|
1135
|
+
feedback: FeedbackRecorder | None = None,
|
|
1136
|
+
calibration=None,
|
|
1137
|
+
learned_rules=None,
|
|
1138
|
+
hysteresis=None,
|
|
1139
|
+
ml_router=None,
|
|
1140
|
+
) -> tuple[str, float]:
|
|
1141
|
+
"""对一轮普通输入做路由并切换到目标模型(不持久化),打出行内日志。
|
|
1142
|
+
|
|
1143
|
+
返回 (final_tier, ts),供下一轮作为防降级上下文。
|
|
1144
|
+
传入 ``feedback`` 时采集 clarify / cmd_retry / short_high_tier 反馈(phase-03 步骤 B)。
|
|
1145
|
+
传入 ``calibration`` 时在规则打分后应用档位偏置与置信门(phase-03 步骤 C)。
|
|
1146
|
+
传入 ``learned_rules`` / ``hysteresis`` 分别启用局部规则微调与会话内迟滞
|
|
1147
|
+
(phase-04 A1/A2,均由 /smartRouter reset 重建的共享状态提供)。
|
|
1148
|
+
传入 ``ml_router``(phase-05 B2)时软规则决策参与 ML 精判,不可用/信心
|
|
1149
|
+
不足静默回落规则档位。
|
|
1150
|
+
"""
|
|
1151
|
+
tiers = (manager.smart_router_config().get("tiers") or {})
|
|
1152
|
+
now = time.time()
|
|
1153
|
+
result = route_turn(
|
|
1154
|
+
user_input,
|
|
1155
|
+
prev_tier=prev_tier, prev_ts=prev_ts, ts=now,
|
|
1156
|
+
fallback_provider=settings.provider, fallback_model=settings.model,
|
|
1157
|
+
tiers_config=tiers, manager=manager, calibration=calibration,
|
|
1158
|
+
learned_rules=learned_rules, hysteresis=hysteresis, ml_router=ml_router,
|
|
1159
|
+
)
|
|
1160
|
+
if feedback is not None:
|
|
1161
|
+
capture_turn_signals(
|
|
1162
|
+
feedback, user_input, result.features, prev_tier, result.tier, TIER_NAMES,
|
|
1163
|
+
)
|
|
1164
|
+
feedback.flush()
|
|
1165
|
+
if (result.provider, result.model) != (settings.provider, settings.model):
|
|
1166
|
+
err = _attach_model(settings, manager, agent, result.provider, result.model)
|
|
1167
|
+
if err:
|
|
1168
|
+
console.print(Text(f"→ SmartRouter 路由失败: {err}", style="dim"))
|
|
1169
|
+
return prev_tier or result.tier, now
|
|
1170
|
+
console.print(
|
|
1171
|
+
Text(
|
|
1172
|
+
f"→ SmartRouter: {result.tier} → {result.provider}/{result.model}",
|
|
1173
|
+
style="dim",
|
|
1174
|
+
)
|
|
1175
|
+
)
|
|
1176
|
+
return result.tier, now
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _model_catalog(manager: ConfigManager) -> str:
|
|
1180
|
+
"""Render the current model and the available providers catalog."""
|
|
1181
|
+
active = manager.active()
|
|
1182
|
+
lines = [
|
|
1183
|
+
f"当前: {active.provider_name} / {active.model}(窗口 {active.context_window})",
|
|
1184
|
+
"可用 providers:",
|
|
1185
|
+
]
|
|
1186
|
+
for p in manager.list_providers():
|
|
1187
|
+
models = "、".join(p.models) if p.models else "(无)"
|
|
1188
|
+
cache = "cache" if p.supports_cache else "-"
|
|
1189
|
+
vision = "vision" if p.supports_vision else "-"
|
|
1190
|
+
lines.append(
|
|
1191
|
+
f" {p.name:<10} default={p.default_model} models=[{models}] "
|
|
1192
|
+
f"窗口 {p.context_window:<6} {cache:<5} {vision}"
|
|
1193
|
+
)
|
|
1194
|
+
return "\n".join(lines)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _switch(
|
|
1198
|
+
agent: ReActAgent,
|
|
1199
|
+
settings: Settings,
|
|
1200
|
+
manager: ConfigManager,
|
|
1201
|
+
provider_name: str,
|
|
1202
|
+
model: str | None,
|
|
1203
|
+
) -> str:
|
|
1204
|
+
"""切换到指定 provider(可选指定模型)。失败返回错误消息,不改变现状。"""
|
|
1205
|
+
provider = manager.resolve_provider(provider_name)
|
|
1206
|
+
if provider is None:
|
|
1207
|
+
return f"未知 provider: {provider_name},可用: {', '.join(manager.provider_names())}"
|
|
1208
|
+
key = manager.resolve_api_key(provider)
|
|
1209
|
+
if not key:
|
|
1210
|
+
return (
|
|
1211
|
+
f"缺少 {provider.name} 的 api_key 配置,无法切换到 {provider.name}。"
|
|
1212
|
+
f"请用 /provider key {provider.name} <KEY> 写入 config.json。"
|
|
1213
|
+
)
|
|
1214
|
+
model = model or provider.default_model
|
|
1215
|
+
|
|
1216
|
+
api_base = manager.resolve_api_base(provider)
|
|
1217
|
+
settings.provider = provider.name
|
|
1218
|
+
settings.model = model
|
|
1219
|
+
settings.api_base = api_base
|
|
1220
|
+
settings.api_key = key
|
|
1221
|
+
settings.context_window = manager.resolve_window(provider)
|
|
1222
|
+
agent.llm = create_client(
|
|
1223
|
+
api_base, key, model,
|
|
1224
|
+
retry_enabled=settings.llm_retry_enabled,
|
|
1225
|
+
max_retries=settings.llm_max_retries,
|
|
1226
|
+
retry_base_delay=settings.llm_retry_base_delay,
|
|
1227
|
+
retry_max_delay=settings.llm_retry_max_delay,
|
|
1228
|
+
retry_jitter=settings.llm_retry_jitter,
|
|
1229
|
+
retry_total_timeout=settings.llm_retry_total_timeout,
|
|
1230
|
+
respect_retry_after=settings.llm_respect_retry_after,
|
|
1231
|
+
)
|
|
1232
|
+
manager.set_active(provider.name, model)
|
|
1233
|
+
return f"已切换: {provider.display_name} / {model}"
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _reapply_active(
|
|
1237
|
+
agent: ReActAgent, settings: Settings, manager: ConfigManager
|
|
1238
|
+
) -> None:
|
|
1239
|
+
"""/provider 变更后把运行中 client 同步到当前 base,配好即用、无需重启。
|
|
1240
|
+
|
|
1241
|
+
仅同步到能解析的 base;key/api_base 仍缺时保持原样,由调用期引导兜底。
|
|
1242
|
+
"""
|
|
1243
|
+
try:
|
|
1244
|
+
active = manager.active()
|
|
1245
|
+
except ProviderNotConfigured:
|
|
1246
|
+
return
|
|
1247
|
+
msg = _switch(agent, settings, manager, active.provider_name, active.model)
|
|
1248
|
+
if msg.startswith("已切换"):
|
|
1249
|
+
console.print(Text(msg, style="dim"))
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _cmd_config(
|
|
1253
|
+
agent: ReActAgent, settings: Settings, manager: ConfigManager, arg: str
|
|
1254
|
+
) -> str:
|
|
1255
|
+
parts = arg.split()
|
|
1256
|
+
sub = parts[0].lower() if parts else ""
|
|
1257
|
+
|
|
1258
|
+
if sub == "list":
|
|
1259
|
+
header = f"{'provider':<14}{'默认模型':<20}{'窗口':<8}cache vision"
|
|
1260
|
+
lines = [header]
|
|
1261
|
+
for p in manager.list_providers():
|
|
1262
|
+
lines.append(
|
|
1263
|
+
f"{p.name:<14}{p.default_model:<20}{p.context_window:<8}"
|
|
1264
|
+
f"{'✓' if p.supports_cache else '-'} "
|
|
1265
|
+
f"{'✓' if p.supports_vision else '-'}"
|
|
1266
|
+
)
|
|
1267
|
+
return "\n".join(lines)
|
|
1268
|
+
|
|
1269
|
+
if sub == "get":
|
|
1270
|
+
if len(parts) < 2:
|
|
1271
|
+
return "用法: /config get <key>(如 active_provider / providers.deepseek.default_model)"
|
|
1272
|
+
value = manager.get_config_value(parts[1])
|
|
1273
|
+
return f"{parts[1]} = {value if value is not None else '(未设置)'}"
|
|
1274
|
+
|
|
1275
|
+
if sub == "set":
|
|
1276
|
+
if len(parts) < 3:
|
|
1277
|
+
return "用法: /config set <key> <value>"
|
|
1278
|
+
key, value = parts[1], parts[2]
|
|
1279
|
+
if key == "active_provider":
|
|
1280
|
+
return _switch(agent, settings, manager, value, None)
|
|
1281
|
+
if key == "active_model":
|
|
1282
|
+
return _switch(agent, settings, manager, settings.provider, value)
|
|
1283
|
+
manager.set_config_value(key, value)
|
|
1284
|
+
return f"已设置 {key} = {value}(持久化到 {manager.user_config_path})"
|
|
1285
|
+
|
|
1286
|
+
active = manager.active()
|
|
1287
|
+
return "\n".join(
|
|
1288
|
+
[
|
|
1289
|
+
f"provider: {active.provider_name}",
|
|
1290
|
+
f"model: {active.model}",
|
|
1291
|
+
f"api_base: {active.api_base}",
|
|
1292
|
+
f"api_key: {mask_key(active.api_key)}",
|
|
1293
|
+
f"窗口: {active.context_window} token",
|
|
1294
|
+
f"cache: {'✓' if active.supports_cache else '-'} vision: {'✓' if active.supports_vision else '-'}",
|
|
1295
|
+
]
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
1300
|
+
parser = argparse.ArgumentParser(prog="xg", description="XG Agent CLI")
|
|
1301
|
+
parser.add_argument("--tui", action="store_true", help="强制启动 Textual 全屏界面")
|
|
1302
|
+
parser.add_argument("--inline", action="store_true", help="使用兼容的 inline CLI")
|
|
1303
|
+
parser.add_argument("--no-tui", action="store_true", help="--inline 的兼容别名")
|
|
1304
|
+
parser.add_argument("--version", action="store_true", help="显示版本")
|
|
1305
|
+
return parser.parse_args(argv)
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def _tui_mode(args: argparse.Namespace) -> str:
|
|
1309
|
+
if args.inline or args.no_tui:
|
|
1310
|
+
return "inline"
|
|
1311
|
+
if args.tui:
|
|
1312
|
+
return "tui"
|
|
1313
|
+
configured = os.environ.get("XG_TUI_MODE", "auto").lower()
|
|
1314
|
+
return configured if configured in {"inline", "tui"} else "auto"
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _run_tui_or_inline(agent: ReActAgent, settings: Settings, manager: ConfigManager, args: argparse.Namespace) -> None:
|
|
1318
|
+
mode = _tui_mode(args)
|
|
1319
|
+
if mode == "inline" or (mode == "auto" and not (sys.stdin.isatty() and sys.stdout.isatty())):
|
|
1320
|
+
asyncio.run(run_loop(agent, settings, manager))
|
|
1321
|
+
return
|
|
1322
|
+
try:
|
|
1323
|
+
from xg.tui.app import run_tui
|
|
1324
|
+
run_tui(agent, settings, manager)
|
|
1325
|
+
except Exception as exc:
|
|
1326
|
+
if mode == "tui":
|
|
1327
|
+
console.print(Panel(Text(f"Textual TUI 启动失败:{exc}\n请改用 xg --inline。"), style="red"))
|
|
1328
|
+
raise SystemExit(1) from exc
|
|
1329
|
+
console.print(Text(f"TUI 不可用,已降级到 inline:{exc}", style="yellow"))
|
|
1330
|
+
asyncio.run(run_loop(agent, settings, manager))
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def main(argv: list[str] | None = None) -> None:
|
|
1334
|
+
args = _parse_args(argv)
|
|
1335
|
+
if args.version:
|
|
1336
|
+
print("xg-cli 0.1.0")
|
|
1337
|
+
return
|
|
1338
|
+
manager = ConfigManager()
|
|
1339
|
+
settings = load_settings(manager)
|
|
1340
|
+
if settings.provider_missing:
|
|
1341
|
+
console.print(
|
|
1342
|
+
Panel(
|
|
1343
|
+
Text(
|
|
1344
|
+
"尚未配置 base provider。启动不阻断,可正常进入会话:"
|
|
1345
|
+
"输入 /provider(TUI 或按 Ctrl+P)配置服务商与 API Key 后即可使用;"
|
|
1346
|
+
"仅在真正调用模型时才会提示缺失。"
|
|
1347
|
+
),
|
|
1348
|
+
style="yellow",
|
|
1349
|
+
)
|
|
1350
|
+
)
|
|
1351
|
+
elif not settings.api_base or not settings.api_key:
|
|
1352
|
+
console.print(
|
|
1353
|
+
Panel(
|
|
1354
|
+
Text(
|
|
1355
|
+
"当前 base provider 缺少 API Key 配置。进入后可用 /provider show <name> 查看、"
|
|
1356
|
+
"/provider key <name> <KEY> 写入;仅在真正调用模型时才会提示缺失。"
|
|
1357
|
+
),
|
|
1358
|
+
style="yellow",
|
|
1359
|
+
)
|
|
1360
|
+
)
|
|
1361
|
+
elif not settings.model:
|
|
1362
|
+
console.print(Panel(Text("当前 base provider 缺少模型配置(provider 需指定 default_model)。"), style="yellow"))
|
|
1363
|
+
|
|
1364
|
+
agent = build_agent(settings, config_manager=manager)
|
|
1365
|
+
try:
|
|
1366
|
+
_run_tui_or_inline(agent, settings, manager, args)
|
|
1367
|
+
except KeyboardInterrupt:
|
|
1368
|
+
console.print("再见。")
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
if __name__ == "__main__":
|
|
1372
|
+
main()
|