xg-cli 1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. xg_cli-1.0.dist-info/entry_points.txt +2 -0
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()