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/tui/controller.py ADDED
@@ -0,0 +1,1296 @@
1
+ """UI-independent orchestration for one interactive XG session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import itertools
7
+ import json
8
+ import re
9
+ import shlex
10
+ import time
11
+ from collections import deque
12
+ from dataclasses import dataclass, replace
13
+ from typing import Awaitable, Callable, Literal
14
+
15
+ from xg.agent.plan import Plan, PlanEvent, PlanExecutor, ReviewDecision
16
+ from xg.agent.react import AgentEvent, ReActAgent
17
+ from xg.agent.team import ResourceClaim, TeamEvent, TeamExecutor, TeamPlan
18
+ from xg.adaptive.feedback import FeedbackRecorder
19
+ from xg.adaptive.signals import capture_turn_signals
20
+ from xg.cli.commands import CommandContext, CommandResult, CommandService
21
+ from xg.cli.help import parse_help_command
22
+ from xg.config.manager import ConfigManager
23
+ from xg.config.settings import Settings
24
+ from xg.llm.types import Message
25
+ from xg.router import TIER_NAMES, resolve as resolve_tier, route as route_turn
26
+ from xg.safety.hitl import ApprovalDecision
27
+ from xg.tui.reducer import (
28
+ finalize_trace,
29
+ reduce_agent_event,
30
+ reduce_plan_event,
31
+ reduce_team_event,
32
+ set_smart_router_snapshot,
33
+ )
34
+ from xg.tui.state import (
35
+ ApprovalRequest,
36
+ ConfirmationRequest,
37
+ InspectorView,
38
+ QueueItem,
39
+ QueueItemKind,
40
+ MemoryInspectorSnapshot,
41
+ SafetyInspectorSnapshot,
42
+ SessionInspectorSnapshot,
43
+ SmartRouterSnapshot,
44
+ SmartRouterTierSnapshot,
45
+ TuiState,
46
+ TranscriptItem,
47
+ UsageSnapshot,
48
+ )
49
+
50
+
51
+ StateListener = Callable[[TuiState], None]
52
+ MAX_QUEUE_SIZE = 20
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class QueuedSubmission:
57
+ id: str
58
+ text: str
59
+ kind: QueueItemKind
60
+
61
+
62
+ SYSTEM_RESUME_INTENT_PROMPT = (
63
+ "你负责判断用户输入是针对「未完成任务」的继续指令,还是全新的对话指令。\n"
64
+ "规则:\n"
65
+ "1. 如果用户表达继续/接着/完成刚才那个任务/恢复任务 → 输出 {\"intent\": \"resume_task\"}\n"
66
+ "2. 如果用户在回应任务等待的信息(例如补充允许修改的路径、回答任务提出的问题)→ 输出 {\"intent\": \"provide_input\"}\n"
67
+ "3. 如果用户提出与待办任务无关的新问题或新指令 → 输出 {\"intent\": \"new_chat\"}\n"
68
+ "无法判断时优先选 new_chat。只输出一行 JSON,不要解释。"
69
+ )
70
+
71
+
72
+ @dataclass
73
+ class ResumableTask:
74
+ """一个可被「继续」自然语言恢复的任务执行器。同一时刻只保留最近一个。"""
75
+
76
+ kind: Literal["plan", "team"]
77
+ executor: PlanExecutor | TeamExecutor
78
+ goal: str
79
+ turn_id: str
80
+ user_cancelled: bool = False
81
+ resume_count: int = 0
82
+
83
+
84
+ class SessionController:
85
+ """Own the active turn and expose actions to any UI adapter.
86
+
87
+ The controller deliberately has no Textual imports. ``submit`` can be
88
+ awaited by tests or run as an asyncio task by the TUI, while all event
89
+ handling follows the same reducer path.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ agent: ReActAgent,
95
+ settings: Settings,
96
+ manager: ConfigManager,
97
+ on_state_change: StateListener | None = None,
98
+ ) -> None:
99
+ self.agent = agent
100
+ self.settings = settings
101
+ self.manager = manager
102
+ self.on_state_change = on_state_change
103
+ policy = agent.approval_policy
104
+ memory_snapshot = self._read_memory_snapshot(agent.memory_manager)
105
+ self.state = TuiState(
106
+ ui_language=settings.ui_language,
107
+ inspector=replace(
108
+ TuiState().inspector,
109
+ session=SessionInspectorSnapshot(
110
+ provider=settings.provider,
111
+ model=settings.model,
112
+ status="Idle",
113
+ ),
114
+ memory=memory_snapshot,
115
+ safety=SafetyInspectorSnapshot(
116
+ hitl_enabled=bool(policy and policy.enabled),
117
+ session_allow_all=bool(policy and policy.session_allow_all),
118
+ ),
119
+ provider=settings.provider,
120
+ model=settings.model,
121
+ usage=UsageSnapshot(context_window=settings.context_window),
122
+ context_window=settings.context_window,
123
+ hitl_enabled=bool(agent.approval_policy and agent.approval_policy.enabled),
124
+ )
125
+ )
126
+ self.command_service = CommandService(CommandContext(agent, settings, manager), log_sink=self._append_system)
127
+ self._counter = itertools.count(1)
128
+ self._queue_counter = itertools.count(1)
129
+ self._queue: deque[QueuedSubmission] = deque()
130
+ self._queue_worker: asyncio.Task | None = None
131
+ self._shutting_down = False
132
+ self._active_task: asyncio.Task | None = None
133
+ self._active_turn_id = ""
134
+ self._approval_future: asyncio.Future[ApprovalDecision] | None = None
135
+ self._review_future: asyncio.Future[ReviewDecision] | None = None
136
+ self._team_executor: TeamExecutor | None = None
137
+ self._resumable: ResumableTask | None = None
138
+ self._confirmation: ConfirmationRequest | None = None
139
+ self._confirmation_future: asyncio.Future[bool] | None = None
140
+ mcp_manager = getattr(agent, "mcp_manager", None)
141
+ if mcp_manager is not None:
142
+ mcp_manager.add_listener(self._on_mcp_event)
143
+ if agent.approval_policy is not None:
144
+ agent.approval_policy.requester = self._request_approval
145
+ # SmartRouter 防降级上下文(600s 内最多降一档),与 inline 主循环一致
146
+ self._router_prev_tier: str | None = None
147
+ self._router_prev_ts: float | None = None
148
+ # SmartRouter 反馈采集(phase-03 步骤 B)
149
+ self._feedback = FeedbackRecorder(
150
+ session=str(getattr(manager, "project_dir", "") or "")
151
+ )
152
+ # SmartRouter 校准(phase-03 步骤 C):启动时聚合 feedback.log 并落盘
153
+ from xg.adaptive.calibrate import recalibrate
154
+ self._calibration = recalibrate()
155
+ # SmartRouter 自学习/稳定/ML(phase-04 A1/A2 + phase-05 B2):挂到 agent 上,
156
+ # 与 inline 主循环自洽;learned_rules 供路由局部微调、hysteresis 供稳定层、
157
+ # ml 供 status 显示与精判。产物存在可用则参与精判,否则静默回落。
158
+ from xg.adaptive.learned_rules import re_learn
159
+ from xg.router.ml_router import MLRouter
160
+ from xg.router.postprocess import Hysteresis
161
+ from xg.router.semantic import load_semantic_encoder
162
+ self.agent._smart_calibration = self._calibration
163
+ self.agent._smart_learned = re_learn()
164
+ self.agent._smart_hysteresis = Hysteresis()
165
+ self.agent._smart_ml = MLRouter(semantic=load_semantic_encoder())
166
+ self._sync_smart_router_snapshot()
167
+
168
+ @staticmethod
169
+ def _read_memory_snapshot(memory, *, last_operation: str = "") -> MemoryInspectorSnapshot:
170
+ if memory is None:
171
+ return MemoryInspectorSnapshot(
172
+ store_available=False,
173
+ last_operation=last_operation or "unavailable",
174
+ )
175
+ try:
176
+ project = memory.project_loader.load()
177
+ sources = {section.source for section in project.sections}
178
+ warnings = len(memory.warnings())
179
+ try:
180
+ count = memory.count()
181
+ except Exception:
182
+ count = 0
183
+ return MemoryInspectorSnapshot(
184
+ project_root=str(memory.project_root),
185
+ xg_loaded="XG.md" in sources,
186
+ xg_local_loaded="XG.local.md" in sources,
187
+ warning_count=warnings,
188
+ memory_count=count,
189
+ store_available=memory.store is not None,
190
+ last_operation=last_operation or "ready",
191
+ )
192
+ except Exception:
193
+ return MemoryInspectorSnapshot(
194
+ project_root=str(getattr(memory, "project_root", "")),
195
+ store_available=False,
196
+ last_operation=last_operation or "unavailable",
197
+ )
198
+
199
+ def set_inspector_view(self, view: InspectorView) -> bool:
200
+ """Select an Inspector view without creating a transcript event."""
201
+ if view not in ("session", "plan", "memory", "safety"):
202
+ return False
203
+ if self.state.inspector.active_view == view:
204
+ return True
205
+ self._set_state(replace(
206
+ self.state,
207
+ inspector=replace(self.state.inspector, active_view=view),
208
+ ))
209
+ return True
210
+
211
+ def cycle_inspector_view(self, step: int = 1) -> None:
212
+ views: tuple[InspectorView, ...] = ("session", "plan", "memory", "safety")
213
+ try:
214
+ index = views.index(self.state.inspector.active_view)
215
+ except ValueError:
216
+ index = 0
217
+ self.set_inspector_view(views[(index + step) % len(views)])
218
+
219
+ def snapshot(self) -> TuiState:
220
+ return self.state
221
+
222
+ @property
223
+ def busy(self) -> bool:
224
+ active = self._active_task is not None and not self._active_task.done()
225
+ worker = self._queue_worker is not None and not self._queue_worker.done()
226
+ return active or worker or self._confirmation is not None
227
+
228
+ def _publish(self) -> None:
229
+ if self.on_state_change is not None:
230
+ self.on_state_change(self.state)
231
+
232
+ def _sync_smart_router_snapshot(self, active_tier: str = "") -> None:
233
+ """按当前开关与档位配置重建 Header 快照(phase-02 步骤 C)。
234
+
235
+ off 态写入空快照(Header 回到单行渲染);已同步为 off 时不重复写。
236
+ """
237
+ current = self.state.inspector.smart_router
238
+ if not self.settings.smart_router_enabled:
239
+ if current.enabled or current.tiers:
240
+ self._set_state(set_smart_router_snapshot(self.state, SmartRouterSnapshot()))
241
+ return
242
+ tiers_cfg = self.manager.smart_router_config().get("tiers") or {}
243
+ entries: list[SmartRouterTierSnapshot] = []
244
+ for idx, name in enumerate(TIER_NAMES):
245
+ target = resolve_tier(idx, self.settings.provider, self.settings.model, tiers_cfg, self.manager)
246
+ entries.append(SmartRouterTierSnapshot(
247
+ tier=name,
248
+ provider=target.provider,
249
+ model=target.model,
250
+ is_active=(name == active_tier),
251
+ configured=target.configured,
252
+ ))
253
+ # 路由可能已切换模型:同步 Header 主行与上下文窗口,避免显示滞后
254
+ state = replace(
255
+ self.state,
256
+ inspector=replace(
257
+ self.state.inspector,
258
+ provider=self.settings.provider,
259
+ model=self.settings.model,
260
+ usage=replace(self.state.inspector.usage, context_window=self.settings.context_window),
261
+ context_window=self.settings.context_window,
262
+ ),
263
+ )
264
+ self._set_state(set_smart_router_snapshot(
265
+ state,
266
+ SmartRouterSnapshot(enabled=True, tiers=tuple(entries), active_tier=active_tier),
267
+ ))
268
+
269
+ def _route_user_turn(self, text: str) -> None:
270
+ """SmartRouter:普通轮在执行前路由并切换模型(与 inline 主循环行为一致)。
271
+
272
+ 只在开关开启时执行;切换失败保持上一档防降级上下文并仍按结果档展示快照。
273
+ 同时接入反馈信号采集(phase-03 步骤 B:clarify / cmd_retry / short_high_tier)。
274
+ """
275
+ if not self.settings.smart_router_enabled:
276
+ return
277
+ # 延迟导入避免环:xg.cli.app → xg.tui.app → 本模块
278
+ from xg.cli.app import _attach_model
279
+
280
+ tiers_cfg = self.manager.smart_router_config().get("tiers") or {}
281
+ now = time.time()
282
+ result = route_turn(
283
+ text,
284
+ prev_tier=self._router_prev_tier, prev_ts=self._router_prev_ts, ts=now,
285
+ fallback_provider=self.settings.provider, fallback_model=self.settings.model,
286
+ tiers_config=tiers_cfg, manager=self.manager, calibration=self._calibration,
287
+ learned_rules=getattr(self.agent, "_smart_learned", None),
288
+ ml_router=getattr(self.agent, "_smart_ml", None),
289
+ )
290
+ err: str | None = None
291
+ # 先按上一轮档位采集 clarify/cmd_retry/short_high_tier,并立即落盘
292
+ # (与 inline _route_user_turn 保持一致,不依赖本轮切换是否成功)
293
+ capture_turn_signals(
294
+ self._feedback, text, getattr(result, "features", None) or {},
295
+ self._router_prev_tier, result.tier, TIER_NAMES,
296
+ )
297
+ self._feedback.flush()
298
+ # 再执行模型切换;切换失败仅影响"当轮是否真的用了该档 + 快照展示",
299
+ # 不阻断信号采集,也不阻断防降级上下文 prev_tier 的推进
300
+ if (result.provider, result.model) != (self.settings.provider, self.settings.model):
301
+ err = _attach_model(self.settings, self.manager, self.agent, result.provider, result.model)
302
+ if err is None:
303
+ self._router_prev_tier, self._router_prev_ts = result.tier, now
304
+ self._sync_smart_router_snapshot(result.tier)
305
+
306
+ def _set_state(self, state: TuiState) -> None:
307
+ self.state = state
308
+ self._publish()
309
+
310
+ async def _on_mcp_event(self, event) -> None:
311
+ """Expose MCP lifecycle/resource events without coupling MCP to Textual."""
312
+ if self._shutting_down:
313
+ return
314
+ if event.kind == "mcp_server_unavailable":
315
+ self._append_item(TranscriptItem(
316
+ id=f"mcp-{event.server}-{len(self.state.transcript)}",
317
+ kind="error",
318
+ text=f"MCP Server {event.server} 不可用:{event.text}",
319
+ ))
320
+ elif event.kind == "mcp_resource_read":
321
+ self._append_item(TranscriptItem(
322
+ id=f"mcp-resource-{len(self.state.transcript)}",
323
+ kind="context",
324
+ text=f"已读取 MCP resource:{event.server}:{event.text}",
325
+ collapsible=True,
326
+ collapsed=True,
327
+ ))
328
+ elif event.kind == "mcp_server_ready":
329
+ self._set_state(replace(
330
+ self.state,
331
+ notification=(
332
+ f"MCP Server {event.server} 已连接"
333
+ f"({event.tool_count or 0} tools / {event.resource_count or 0} resources)"
334
+ ),
335
+ notification_level="info",
336
+ ))
337
+
338
+ def _begin_turn(self) -> str | None:
339
+ # The queue worker may be alive while it is starting the next turn;
340
+ # only an active turn itself prevents a new turn from beginning.
341
+ if self._active_task is not None and not self._active_task.done():
342
+ self._set_state(replace(self.state, notification="当前任务仍在运行,请先取消", notification_level="warning"))
343
+ return None
344
+ turn_id = f"turn-{next(self._counter)}"
345
+ self._active_turn_id = turn_id
346
+ self._set_state(replace(self.state, phase="running", active_turn_id=turn_id, notification=""))
347
+ return turn_id
348
+
349
+ @staticmethod
350
+ def _submission_kind(text: str) -> QueueItemKind:
351
+ lowered = text.lower()
352
+ if lowered.startswith("/plan"):
353
+ return "plan"
354
+ if lowered.startswith("/team"):
355
+ return "plan"
356
+ if lowered.startswith("/"):
357
+ return "command"
358
+ return "task"
359
+
360
+ def _publish_queue(self, *, notification: str | None = None, level: str = "info") -> None:
361
+ queue_items = [
362
+ QueueItem(id=item.id, text=item.text, kind=item.kind)
363
+ for item in self._queue
364
+ ]
365
+ updates = {"queue": queue_items}
366
+ if notification is not None:
367
+ updates.update(notification=notification, notification_level=level)
368
+ self._set_state(replace(self.state, **updates))
369
+
370
+ def _enqueue(self, text: str) -> bool:
371
+ if len(self._queue) >= MAX_QUEUE_SIZE:
372
+ self._set_state(replace(
373
+ self.state,
374
+ notification=f"任务队列已满(最多 {MAX_QUEUE_SIZE} 项),请稍后再试",
375
+ notification_level="warning",
376
+ ))
377
+ return False
378
+ item = QueuedSubmission(
379
+ id=f"queue-{next(self._queue_counter)}",
380
+ text=text,
381
+ kind=self._submission_kind(text),
382
+ )
383
+ self._queue.append(item)
384
+ self._publish_queue(notification=f"已加入队列 #{item.id.removeprefix('queue-')}")
385
+ return True
386
+
387
+ def _ensure_queue_worker(self) -> None:
388
+ if self._shutting_down or not self._queue:
389
+ return
390
+ if self._queue_worker is None or self._queue_worker.done():
391
+ self._queue_worker = asyncio.create_task(self._drain_queue())
392
+
393
+ async def _drain_queue(self) -> None:
394
+ current = asyncio.current_task()
395
+ try:
396
+ while self._queue and not self._shutting_down:
397
+ submission = self._queue.popleft()
398
+ self._publish_queue(notification=f"开始执行队列 #{submission.id.removeprefix('queue-')}")
399
+ turn_task = asyncio.create_task(self._execute_one(submission.text))
400
+ try:
401
+ await turn_task
402
+ if self._confirmation_future is not None:
403
+ await self._confirmation_future
404
+ except asyncio.CancelledError:
405
+ if self._shutting_down:
406
+ break
407
+ except Exception as exc:
408
+ self._set_state(replace(
409
+ self.state,
410
+ notification=f"队列任务失败:{exc}",
411
+ notification_level="error",
412
+ ))
413
+ finally:
414
+ if self._queue_worker is current:
415
+ self._queue_worker = None
416
+ if self._shutting_down:
417
+ self._queue.clear()
418
+ self._publish_queue()
419
+
420
+ def completion_registry(self):
421
+ """Build a runtime provider registry for dynamic command completion.
422
+
423
+ Each provider reads only local, in-memory state from the live agent —
424
+ never LLM calls, MCP starts/restarts, network I/O or tool execution.
425
+ Polling happens on every keys/refresh; the engine caps and degrades
426
+ gracefully, and the Agent's data is already kept in memory.
427
+ """
428
+ from xg.cli.completion import (
429
+ CompletionCandidate,
430
+ CompletionContext,
431
+ CompletionProviderRegistry,
432
+ path_completion_candidates,
433
+ )
434
+
435
+ reg = CompletionProviderRegistry()
436
+ mgr = self.manager
437
+
438
+ def _providers():
439
+ return mgr.provider_names()
440
+
441
+ def _models():
442
+ # A provider is not advertised with a full model list, so only the
443
+ # default model (plus any configured active model) is offered.
444
+ providers = mgr.provider_names()
445
+ seen = set(mgr.active().model)
446
+ for name in providers:
447
+ p = mgr.resolve_provider(name)
448
+ if p is not None:
449
+ seen.add(p.default_model)
450
+ return sorted(seen)
451
+
452
+ reg.register(
453
+ "provider",
454
+ lambda ctx: [
455
+ CompletionCandidate(name, name, detail="provider", kind="value")
456
+ for name in _providers()
457
+ ],
458
+ )
459
+ reg.register(
460
+ "model",
461
+ lambda ctx: [
462
+ CompletionCandidate(m, m, detail="model", kind="value") for m in _models()
463
+ ],
464
+ )
465
+
466
+ mcp = getattr(self.agent, "mcp_manager", None)
467
+ reg.register(
468
+ "mcp",
469
+ lambda ctx: [
470
+ CompletionCandidate(
471
+ snap.name, snap.name, detail="MCP server", kind="value"
472
+ )
473
+ for snap in (mcp.snapshots() if mcp is not None else [])
474
+ ],
475
+ )
476
+
477
+ skills = getattr(self.agent, "skill_registry", None)
478
+ reg.register(
479
+ "skill",
480
+ lambda ctx: [
481
+ CompletionCandidate(info.name, info.name, detail="skill", kind="value")
482
+ for info in (
483
+ skills.list() if skills is not None and skills.config.enabled else ()
484
+ )
485
+ if info.enabled
486
+ ],
487
+ )
488
+
489
+ memory = getattr(self.agent, "memory_manager", None)
490
+ reg.register(
491
+ "memory_id",
492
+ lambda ctx: [
493
+ CompletionCandidate(str(entry.id), str(entry.id), detail="记忆", kind="value")
494
+ for entry in (memory.list(20) if memory is not None else [])
495
+ ],
496
+ )
497
+
498
+ executor = self._team_executor
499
+ project_root = getattr(self.agent.memory_manager, "project_root", None)
500
+
501
+ def _write_claim_patterns():
502
+ if executor is None or executor.plan is None:
503
+ return []
504
+ patterns: set[str] = set()
505
+ for task in executor.plan.tasks:
506
+ if task.status != "needs_input":
507
+ continue
508
+ for claim in getattr(task, "pending_repair_scope", []):
509
+ if claim.access == "write":
510
+ patterns.add(ResourceClaim(claim.pattern, "write").normalized())
511
+ return sorted(patterns)
512
+
513
+ def _team_team_scope_candidates(ctx: CompletionContext):
514
+ declared: list[CompletionCandidate] = []
515
+ if executor is not None and executor.plan is not None:
516
+ for task in executor.plan.tasks:
517
+ if task.status != "needs_input":
518
+ continue
519
+ for claim in getattr(task, "pending_repair_scope", []):
520
+ path = getattr(claim, "pattern", "") or ""
521
+ if path:
522
+ declared.append(
523
+ CompletionCandidate(path, path, detail="已声明范围", kind="scope")
524
+ )
525
+ if project_root is None:
526
+ return declared
527
+ path_cands = path_completion_candidates(
528
+ ctx.raw,
529
+ ctx.cursor_position,
530
+ project_root,
531
+ allow_patterns=_write_claim_patterns(),
532
+ )
533
+ merged = list(declared)
534
+ seen = {c.insert_text for c in merged}
535
+ for cand in path_cands:
536
+ if cand.insert_text not in seen:
537
+ merged.append(cand)
538
+ seen.add(cand.insert_text)
539
+ return merged
540
+
541
+ reg.register("team_scope", lambda ctx: _team_team_scope_candidates(ctx))
542
+ reg.register(
543
+ "team_task",
544
+ lambda ctx: [
545
+ CompletionCandidate(task.id, task.id, detail=task.title, kind="value")
546
+ for task in (
547
+ executor.plan.tasks if executor is not None and executor.plan is not None
548
+ else []
549
+ )
550
+ if task.status == "needs_input"
551
+ ],
552
+ )
553
+ return reg
554
+
555
+ async def submit(self, text: str) -> bool:
556
+ text = text.strip()
557
+ if not text:
558
+ return False
559
+ lowered = text.lower()
560
+ if parse_help_command(text) is not None:
561
+ result = await self.execute_command(text)
562
+ if result.message:
563
+ self._append_help(result.message)
564
+ return result.ok
565
+ if lowered in ("/cancel", "/c"):
566
+ await self.cancel()
567
+ return True
568
+ if lowered in ("/exit", "/quit"):
569
+ self._shutting_down = True
570
+ await self.cancel()
571
+ self._queue.clear()
572
+ self._publish_queue(notification="正在退出,已清理等待队列")
573
+ return True
574
+ if self.state.phase == "awaiting_plan_review" and not self._is_language_command(text):
575
+ self._set_state(replace(
576
+ self.state,
577
+ notification="当前处于计划审阅,请按 Enter 执行、r 重规划或 Esc 取消",
578
+ notification_level="info",
579
+ ))
580
+ return False
581
+ if self.state.phase == "awaiting_team_input":
582
+ if lowered.startswith("/team resume"):
583
+ return await self._resume_team_command(text)
584
+ self._set_state(replace(
585
+ self.state,
586
+ notification="当前 Team 任务等待输入,请使用 /team resume <任务ID> --write-scope <范围>,或 /cancel",
587
+ notification_level="info",
588
+ ))
589
+ return False
590
+ if self.busy and (self._is_readonly_mcp_command(text) or self._is_language_command(text)):
591
+ result = await self.execute_command(text)
592
+ if result.message:
593
+ self._append_system(result.message)
594
+ return result.ok
595
+ if self.busy or self._queue:
596
+ return self._enqueue(text)
597
+ try:
598
+ return await self._execute_one(text)
599
+ finally:
600
+ self._ensure_queue_worker()
601
+
602
+ async def _execute_one(self, text: str) -> bool:
603
+ lowered = text.lower()
604
+ if text.startswith("/") and not lowered.startswith(("/plan", "/team")):
605
+ current = asyncio.current_task()
606
+ self._active_task = current
607
+ try:
608
+ result = await self.execute_command(text)
609
+ if result.message and not result.open_modal:
610
+ self._append_system(result.message)
611
+ # /smartRouter、/model 等命令可能改变开关或档位配置,同步 Header 快照
612
+ self._sync_smart_router_snapshot()
613
+ return True
614
+ finally:
615
+ if self._active_task is current:
616
+ self._active_task = None
617
+
618
+ # V3:存在未完成(且未被用户取消)的任务时,先尝试自然语言断点续跑
619
+ if await self._maybe_resume(text):
620
+ return True
621
+
622
+ turn_id = self._begin_turn()
623
+ if turn_id is None:
624
+ return False
625
+ self._append_item(TranscriptItem(id=f"user-{len(self.state.transcript)}", kind="user", text=text, turn_id=turn_id))
626
+ is_plan = lowered.startswith("/plan")
627
+ is_team = lowered.startswith("/team")
628
+ goal = text[5:].strip() if (is_plan or is_team) else ""
629
+ if (is_plan or is_team) and not goal:
630
+ self._append_system(f"用法: {'/team' if is_team else '/plan'} <任务描述>")
631
+ return True
632
+ if is_plan or is_team:
633
+ progress_text = "正在生成执行计划"
634
+ else:
635
+ progress_text = "正在准备响应"
636
+ self._append_item(TranscriptItem(
637
+ id=f"progress-{turn_id}",
638
+ kind="progress",
639
+ progress_kind="plan" if is_plan else "response",
640
+ text=progress_text,
641
+ turn_id=turn_id,
642
+ trace_id=turn_id,
643
+ status="running",
644
+ ))
645
+ current = asyncio.current_task()
646
+ self._active_task = current
647
+ try:
648
+ if is_plan:
649
+ await self._run_plan(goal, turn_id)
650
+ elif is_team:
651
+ await self._run_team(goal, turn_id)
652
+ else:
653
+ # 普通轮执行前路由(与 inline _run_loop_body 一致;/plan、/team 不路由)
654
+ self._route_user_turn(text)
655
+ await self._run_agent(text, turn_id)
656
+ except asyncio.CancelledError:
657
+ cancelled = finalize_trace(self.state, turn_id, status="cancelled")
658
+ self._set_state(replace(cancelled, phase="idle", pending_approval=None, pending_plan=None, notification="当前任务已取消"))
659
+ raise
660
+ finally:
661
+ self._remove_progress(turn_id)
662
+ if self._active_task is current:
663
+ self._active_task = None
664
+ self._approval_future = None
665
+ self._review_future = None
666
+ if self.state.active_turn_id == turn_id and self.state.phase == "running":
667
+ self._set_state(replace(self.state, phase="idle", pending_approval=None, pending_plan=None))
668
+ return True
669
+
670
+ async def _run_agent(self, text: str, turn_id: str) -> None:
671
+ async for event in self.agent.run(text):
672
+ self._set_state(reduce_agent_event(self.state, event, turn_id))
673
+
674
+ async def _run_plan(self, goal: str, turn_id: str) -> None:
675
+ executor = PlanExecutor(
676
+ llm=self.agent.llm,
677
+ tools=self.agent.tools,
678
+ settings=self.settings,
679
+ reviewer=self._review_plan,
680
+ approval_policy=self.agent.approval_policy,
681
+ audit=self.agent.audit,
682
+ memory_manager=self.agent.memory_manager,
683
+ mcp_manager=getattr(self.agent, "mcp_manager", None),
684
+ )
685
+ self._resumable = ResumableTask(kind="plan", executor=executor, goal=goal, turn_id=turn_id)
686
+ async for event in executor.run(goal):
687
+ self._set_state(reduce_plan_event(self.state, event, turn_id))
688
+ self._finalize_task_registry(event, turn_id)
689
+
690
+ async def _run_team(self, goal: str, turn_id: str) -> None:
691
+ executor = TeamExecutor(
692
+ llm=self.agent.llm,
693
+ tools=self.agent.tools,
694
+ settings=self.settings,
695
+ reviewer=self._review_plan,
696
+ approval_policy=self.agent.approval_policy,
697
+ audit=self.agent.audit,
698
+ memory_manager=self.agent.memory_manager,
699
+ mcp_manager=getattr(self.agent, "mcp_manager", None),
700
+ project_root=getattr(self.agent.memory_manager, "project_root", None),
701
+ )
702
+ self._team_executor = executor
703
+ self._resumable = ResumableTask(kind="team", executor=executor, goal=goal, turn_id=turn_id)
704
+ async for event in executor.run(goal):
705
+ self._set_state(reduce_team_event(self.state, event, turn_id))
706
+ self._finalize_task_registry(event, turn_id)
707
+
708
+ async def _resume_team_command(self, text: str) -> bool:
709
+ executor = self._team_executor
710
+ if executor is None:
711
+ self._set_state(replace(self.state, notification="没有可恢复的 Team 任务", notification_level="warning"))
712
+ return False
713
+ try:
714
+ parts = shlex.split(text)
715
+ except ValueError as exc:
716
+ self._set_state(replace(self.state, notification=f"恢复命令格式错误:{exc}", notification_level="error"))
717
+ return False
718
+ if len(parts) < 5 or parts[0].lower() != "/team" or parts[1].lower() != "resume":
719
+ self._set_state(replace(self.state, notification="用法:/team resume <任务ID> --write-scope <项目内路径模式>", notification_level="info"))
720
+ return False
721
+ task_id = parts[2]
722
+ claims: list[ResourceClaim] = []
723
+ index = 3
724
+ while index < len(parts):
725
+ if parts[index] != "--write-scope" or index + 1 >= len(parts):
726
+ self._set_state(replace(self.state, notification="用法:/team resume <任务ID> --write-scope <项目内路径模式>", notification_level="info"))
727
+ return False
728
+ claims.append(ResourceClaim(parts[index + 1], "write"))
729
+ index += 2
730
+ current = asyncio.current_task()
731
+ self._active_task = current
732
+ self._set_state(replace(self.state, phase="running", notification="正在按确认范围恢复 Team 任务"))
733
+ try:
734
+ async for event in executor.resume_task_with_repair_scope(task_id, claims):
735
+ self._set_state(reduce_team_event(self.state, event, self._active_turn_id))
736
+ finally:
737
+ if self._active_task is current:
738
+ self._active_task = None
739
+ if self.state.phase == "running":
740
+ self._set_state(replace(self.state, phase="idle"))
741
+ return True
742
+
743
+ # ---- V3:任务自然语言断点续跑 ----
744
+
745
+ _RESUME_KEYWORDS = ("继续", "接着", "继续执行", "continue", "go on", "做完", "把它完成", "恢复任务")
746
+
747
+ async def _maybe_resume(self, text: str) -> bool:
748
+ """存在可恢复任务时尝试路由;返回 True 表示输入已被消费为恢复动作。"""
749
+ task = self._resumable
750
+ if task is None or task.user_cancelled:
751
+ return False
752
+ if task.resume_count >= self.settings.task_max_resumes:
753
+ self._set_state(replace(
754
+ self.state, notification=f"该任务已恢复 {task.resume_count} 次,超过上限,请重新发起任务",
755
+ notification_level="warning",
756
+ ))
757
+ return True
758
+ intent = await self._classify_resume_intent(text, task)
759
+ if intent == "new_chat":
760
+ # 与任务无关的新指令,走主 agent 普通对话
761
+ return False
762
+ if intent == "provide_input":
763
+ return await self._resume_with_scope(task, text)
764
+ return await self._do_resume(task, text)
765
+
766
+ async def _classify_resume_intent(self, text: str, task: ResumableTask) -> Literal["resume_task", "new_chat", "provide_input"]:
767
+ """LLM 意图识别;关闭或失败时回退关键词白名单。"""
768
+ if self.settings.resume_intent_llm and task.executor.llm is not None:
769
+ try:
770
+ summary = self._summarize_task(task)
771
+ messages = [
772
+ Message(role="system", content=SYSTEM_RESUME_INTENT_PROMPT),
773
+ Message(role="user", content=f"任务:{summary}\n用户输入:{text}\n\n只输出 JSON。"),
774
+ ]
775
+ parts: list[str] = []
776
+ async for event in task.executor.llm.stream_chat(messages, tools=None):
777
+ if event.kind == "content" and event.text:
778
+ parts.append(event.text)
779
+ parsed = json.loads("".join(parts))
780
+ kind = parsed.get("intent")
781
+ if kind in ("resume_task", "new_chat", "provide_input"):
782
+ return kind
783
+ except Exception:
784
+ pass
785
+ return "resume_task" if self._is_resume_keyword(text) else ("provide_input" if self._is_provide_input_keyword(text) else "new_chat")
786
+
787
+ @staticmethod
788
+ def _is_resume_keyword(text: str) -> bool:
789
+ lowered = text.lower()
790
+ for kw in ("继续", "接着", "continue", "go on", "做完", "把它完成", "恢复任务", "继续执行"):
791
+ if kw in lowered:
792
+ return True
793
+ return False
794
+
795
+ @staticmethod
796
+ def _is_provide_input_keyword(text: str) -> bool:
797
+ lowered = text.lower()
798
+ patterns = ("允许修改", "允许写", "修改范围", "范围", "write-scope", "write scope", "可以改")
799
+ return any(pattern in lowered for pattern in patterns)
800
+
801
+ async def _do_resume(self, task: ResumableTask, instruction: str) -> bool:
802
+ """执行断点续跑:追加轮次、跑 executor.resume()。"""
803
+ current = asyncio.current_task()
804
+ turn_id = self._begin_turn()
805
+ if turn_id is None:
806
+ return False
807
+ self._append_item(TranscriptItem(id=f"user-{len(self.state.transcript)}", kind="user", text=instruction, turn_id=turn_id))
808
+ self._append_item(TranscriptItem(
809
+ id=f"progress-{turn_id}", kind="progress",
810
+ progress_kind="plan", text="正在恢复执行", turn_id=turn_id,
811
+ trace_id=turn_id, status="running",
812
+ ))
813
+ task.resume_count += 1
814
+ task.turn_id = turn_id
815
+ self._active_task = current
816
+ try:
817
+ if task.kind == "team":
818
+ async for event in task.executor.resume(instruction): # type: ignore[union-attr]
819
+ self._set_state(reduce_team_event(self.state, event, turn_id))
820
+ self._finalize_task_registry(event, turn_id, task=task)
821
+ else:
822
+ async for event in task.executor.resume(instruction): # type: ignore[union-attr]
823
+ self._set_state(reduce_plan_event(self.state, event, turn_id))
824
+ self._finalize_task_registry(event, turn_id, task=task)
825
+ except asyncio.CancelledError:
826
+ self._set_state(replace(self.state, phase="idle", notification="任务已取消"))
827
+ raise
828
+ finally:
829
+ self._remove_progress(turn_id)
830
+ if self._active_task is current:
831
+ self._active_task = None
832
+ if self.state.active_turn_id == turn_id and self.state.phase == "running":
833
+ self._set_state(replace(self.state, phase="idle"))
834
+ return True
835
+
836
+ async def _resume_with_scope(self, task: ResumableTask, text: str) -> bool:
837
+ """needs_input 场景:从用户自然语言里提取写入范围,过安全校验后恢复 Repairer(fail-closed)。"""
838
+ if task.kind != "team":
839
+ self._set_state(replace(self.state, notification="仅 Team 任务需要补充写入范围", notification_level="info"))
840
+ return False
841
+ claims = self._extract_scope_claims(text)
842
+ if not claims:
843
+ self._set_state(replace(
844
+ self.state,
845
+ notification="请回复允许修改的项目内路径,例如「继续,允许修改 xg/auth/」;或使用 /team resume <ID> --write-scope <路径>",
846
+ notification_level="warning",
847
+ ))
848
+ return True
849
+ # 先定位 needs_input 任务
850
+ plan = getattr(task.executor, "_last_plan", None)
851
+ task_id = ""
852
+ if plan is not None:
853
+ for t in plan.tasks:
854
+ if t.status == "needs_input":
855
+ task_id = t.id
856
+ break
857
+ if not task_id:
858
+ self._set_state(replace(self.state, notification="当前没有等待写入范围的 Team 任务", notification_level="warning"))
859
+ return True
860
+ current = asyncio.current_task()
861
+ self._active_task = current
862
+ self._set_state(replace(self.state, phase="running", notification="正在校验写入范围并恢复 Team 任务"))
863
+ try:
864
+ async for event in task.executor.resume_task_with_repair_scope(task_id, claims): # type: ignore[union-attr]
865
+ self._set_state(reduce_team_event(self.state, event, self._active_turn_id))
866
+ # 校验失败会回到 needs_input;校验通过后继续跑剩余批次
867
+ if event.kind in {"team_done", "team_failed", "cancelled"}:
868
+ self._finalize_task_registry(event, self._active_turn_id, task=task)
869
+ finally:
870
+ if self._active_task is current:
871
+ self._active_task = None
872
+ if self.state.phase == "running":
873
+ self._set_state(replace(self.state, phase="idle"))
874
+ return True
875
+
876
+ @staticmethod
877
+ def _extract_scope_claims(text: str) -> list[ResourceClaim]:
878
+ # 提取 `允许修改 X` / `修改范围 X` / `--write-scope X` 后面的项目内路径片段
879
+ claims: list[ResourceClaim] = []
880
+ import re as _re
881
+ for m in _re.finditer(r"(?:允许修改|修改范围|可以改|--write-scope)\s*[::]?\s*([A-Za-z0-9_./\\*-]+)", text):
882
+ pattern = m.group(1).strip('"\',。;::')
883
+ if pattern and " " not in pattern:
884
+ claims.append(ResourceClaim(pattern, "write"))
885
+ return claims
886
+
887
+ def _finalize_task_registry(self, event, turn_id: str, *, task: ResumableTask | None = None) -> None:
888
+ """根据终态事件分类维护可恢复任务注册表 + 摘要回填。"""
889
+ kind = getattr(event, "kind", "")
890
+ entry = task or self._resumable
891
+ if entry is None:
892
+ return
893
+ if kind in ("plan_done", "team_done"):
894
+ # done 保留摘要(new_chat 可答),但不再开放恢复
895
+ if entry.turn_id == turn_id:
896
+ self._append_task_summary(entry, terminal="完成")
897
+ elif kind in ("plan_failed", "team_failed"):
898
+ if entry.turn_id == turn_id:
899
+ self._append_task_summary(entry, terminal="失败")
900
+ self._set_state(replace(self.state, notification=f"{event.message}(输入「继续」可从失败处恢复)", notification_level="warning"))
901
+ elif kind == "cancelled":
902
+ # 用户取消会经 cancel() 清除;此处兜底(系统 fail-closed 取消保留)
903
+ if entry.turn_id == turn_id:
904
+ self._resumable = None
905
+ elif kind in ("plan_resume_requested", "team_resume_requested"):
906
+ # 恢复流自身不改变注册表可恢复状态
907
+ pass
908
+
909
+ def _summarize_task(self, task: ResumableTask) -> str:
910
+ plan = getattr(task.executor, "_last_plan", None)
911
+ if plan is None:
912
+ return f"目标:{task.goal}"
913
+ lines = [f"类型:{'/team' if task.kind == 'team' else '/plan'}", f"目标:{plan.goal}"]
914
+ for t in plan.tasks:
915
+ status = getattr(t, "status", "?")
916
+ title = getattr(t, "title", getattr(t, "description", ""))
917
+ result = getattr(t, "result", "") or ""
918
+ if result and len(result) > 200:
919
+ result = result[:200] + "…"
920
+ lines.append(f"- {getattr(t, 'id', '?')} [{status}] {title}" + (f":{result}" if result else ""))
921
+ return "\n".join(lines)
922
+
923
+ def _append_task_summary(self, task: ResumableTask, *, terminal: str) -> None:
924
+ """把任务终态摘要回填主 agent 上下文,保证后续普通对话不断裂。"""
925
+ try:
926
+ summary = self._summarize_task(task) + f"\n终态:{terminal}"
927
+ except Exception: # 摘要构造失败不影响主流程
928
+ return
929
+ try:
930
+ self.agent.context.append(Message(role="assistant", content=f"[任务执行摘要]\n{summary}"))
931
+ except Exception:
932
+ pass
933
+
934
+ async def cancel(self) -> bool:
935
+ if self._confirmation is not None:
936
+ await self.confirm_command(False)
937
+ return True
938
+ task = self._active_task
939
+ if task is None or task.done():
940
+ self._set_state(replace(self.state, notification="当前没有运行中的任务", notification_level="info"))
941
+ return False
942
+ if self._approval_future and not self._approval_future.done():
943
+ self._approval_future.set_result(ApprovalDecision(allow=False, reason="user_cancelled"))
944
+ if self._review_future and not self._review_future.done():
945
+ self._review_future.set_result(ReviewDecision(action="cancel"))
946
+ task.cancel()
947
+ try:
948
+ await task
949
+ except asyncio.CancelledError:
950
+ pass
951
+ # V3:用户主动取消任务 → 清除可恢复注册表
952
+ self._resumable = None
953
+ return True
954
+
955
+ async def _request_approval(self, tool_name: str, level: str, args: dict) -> ApprovalDecision:
956
+ loop = asyncio.get_running_loop()
957
+ self._approval_future = loop.create_future()
958
+ request = ApprovalRequest(tool_name=tool_name, level=level, args=args, turn_id=self._active_turn_id)
959
+ policy = self.agent.approval_policy
960
+ self._set_state(replace(
961
+ self.state,
962
+ phase="awaiting_approval",
963
+ pending_approval=request,
964
+ inspector=replace(
965
+ self.state.inspector,
966
+ safety=replace(
967
+ self.state.inspector.safety,
968
+ hitl_enabled=bool(policy and policy.enabled),
969
+ session_allow_all=bool(policy and policy.session_allow_all),
970
+ approval_status="waiting",
971
+ current_tool=tool_name,
972
+ current_level=level,
973
+ ),
974
+ ),
975
+ ))
976
+ try:
977
+ return await self._approval_future
978
+ finally:
979
+ self._approval_future = None
980
+
981
+ async def approve_tool(self, decision: ApprovalDecision) -> None:
982
+ if decision.reason == "user_approved_allow_all" and self.agent.approval_policy:
983
+ self.agent.approval_policy.allow_all()
984
+ policy = self.agent.approval_policy
985
+ self._set_state(replace(
986
+ self.state,
987
+ inspector=replace(
988
+ self.state.inspector,
989
+ safety=replace(
990
+ self.state.inspector.safety,
991
+ hitl_enabled=bool(policy and policy.enabled),
992
+ session_allow_all=bool(policy and policy.session_allow_all),
993
+ ),
994
+ ),
995
+ ))
996
+ future = self._approval_future
997
+ if future is not None and not future.done():
998
+ future.set_result(decision)
999
+
1000
+ async def _review_plan(self, plan: Plan) -> ReviewDecision:
1001
+ loop = asyncio.get_running_loop()
1002
+ self._review_future = loop.create_future()
1003
+ self._set_state(replace(self.state, phase="awaiting_plan_review", pending_plan=plan))
1004
+ try:
1005
+ return await self._review_future
1006
+ finally:
1007
+ self._review_future = None
1008
+
1009
+ async def review_plan(self, decision: ReviewDecision) -> None:
1010
+ future = self._review_future
1011
+ if future is not None and not future.done():
1012
+ future.set_result(decision)
1013
+
1014
+ def toggle_plan_details(self) -> None:
1015
+ """Toggle the latest inline plan card without opening another screen."""
1016
+ items = list(self.state.transcript)
1017
+ for index in range(len(items) - 1, -1, -1):
1018
+ if items[index].kind == "plan":
1019
+ items[index] = replace(items[index], collapsed=not items[index].collapsed)
1020
+ self._set_state(replace(self.state, transcript=items))
1021
+ return
1022
+
1023
+ def toggle_diagram_source(self) -> bool:
1024
+ """Toggle Mermaid source for the latest assistant diagram."""
1025
+ items = list(self.state.transcript)
1026
+ for index in range(len(items) - 1, -1, -1):
1027
+ item = items[index]
1028
+ if item.kind == "assistant" and "```mermaid" in item.text.lower():
1029
+ items[index] = replace(item, diagram_source_visible=not item.diagram_source_visible)
1030
+ self._set_state(replace(self.state, transcript=items))
1031
+ return True
1032
+ return False
1033
+
1034
+ def toggle_trace_item(self, item_id: str) -> None:
1035
+ """Toggle one user-selected thinking/tool trace item."""
1036
+ items = list(self.state.transcript)
1037
+ for index, item in enumerate(items):
1038
+ if item.id == item_id and item.collapsible:
1039
+ expanded = item.collapsed
1040
+ items[index] = replace(
1041
+ item,
1042
+ collapsed=not expanded,
1043
+ user_collapsed=not expanded,
1044
+ )
1045
+ self._set_state(replace(self.state, transcript=items))
1046
+ return
1047
+
1048
+ def toggle_latest_trace(self) -> None:
1049
+ """Toggle the newest collapsible thinking/tool item."""
1050
+ for item in reversed(self.state.transcript):
1051
+ if item.collapsible:
1052
+ self.toggle_trace_item(item.id)
1053
+ return
1054
+
1055
+ def toggle_trace_group(self) -> None:
1056
+ """Toggle all collapsible items belonging to the newest trace."""
1057
+ latest = next((item for item in reversed(self.state.transcript) if item.collapsible), None)
1058
+ if latest is None:
1059
+ return
1060
+ trace_id = latest.trace_id
1061
+ members = [item for item in self.state.transcript if item.collapsible and item.trace_id == trace_id]
1062
+ # Mixed state is treated as "collapse all"; only an entirely
1063
+ # collapsed group expands on the next group toggle.
1064
+ expand = all(item.collapsed for item in members)
1065
+ member_ids = {item.id for item in members}
1066
+ items = [
1067
+ replace(item, collapsed=not expand, user_collapsed=not expand)
1068
+ if item.id in member_ids else item
1069
+ for item in self.state.transcript
1070
+ ]
1071
+ self._set_state(replace(self.state, transcript=items))
1072
+
1073
+ def toggle_agent_group(self, group_id: str) -> None:
1074
+ """Toggle one Team Agent conversation without affecting its siblings."""
1075
+ group = self.state.agent_groups.get(group_id)
1076
+ if group is None:
1077
+ return
1078
+ groups = dict(self.state.agent_groups)
1079
+ groups[group_id] = replace(
1080
+ group,
1081
+ collapsed=not group.collapsed,
1082
+ user_toggled=True,
1083
+ )
1084
+ self._set_state(replace(self.state, agent_groups=groups))
1085
+
1086
+ def refresh_diagrams(self) -> None:
1087
+ """Republish state so renderables recalculate their terminal layout."""
1088
+ self._publish()
1089
+
1090
+ async def execute_command(self, raw: str) -> CommandResult:
1091
+ current = asyncio.current_task()
1092
+ is_help = parse_help_command(raw) is not None
1093
+ is_language = self._is_language_command(raw)
1094
+ if self.busy and self._active_task is not current and not self._is_readonly_mcp_command(raw) and not is_help and not is_language:
1095
+ return CommandResult(ok=False, message="当前任务正在运行,请通过输入提交命令以加入队列")
1096
+ if raw.strip().lower() in ("/cancel", "/c"):
1097
+ await self.cancel()
1098
+ return CommandResult(ok=True, message="已取消当前任务")
1099
+ cleared = raw.strip().lower() == "/clear"
1100
+ if cleared:
1101
+ self.agent.clear()
1102
+ self._resumable = None
1103
+ lowered = raw.strip().lower()
1104
+ memory = getattr(self.agent, "memory_manager", None)
1105
+ if lowered == "/init" and memory is not None:
1106
+ try:
1107
+ draft = await memory.generate_init_draft(self.agent.llm)
1108
+ except Exception as exc:
1109
+ return CommandResult(ok=False, message=f"生成 XG.md 失败:{exc}")
1110
+ request = ConfirmationRequest(
1111
+ "init",
1112
+ "写入项目记忆",
1113
+ f"已生成 XG.md 草稿,将写入项目根目录的 XG.md:\n\n{draft}",
1114
+ draft,
1115
+ )
1116
+ self._confirmation = request
1117
+ self._confirmation_future = asyncio.get_running_loop().create_future()
1118
+ self._set_state(replace(self.state, pending_confirmation=request, notification="请输入 y 确认写入 · n 取消"))
1119
+ return CommandResult(ok=True, open_modal="init")
1120
+ if lowered == "/memory clear" and memory is not None:
1121
+ try:
1122
+ count = memory.count()
1123
+ except Exception as exc:
1124
+ return CommandResult(ok=False, message=f"记忆操作失败:{exc}")
1125
+ if count == 0:
1126
+ return CommandResult(ok=True, message="当前项目没有长期记忆")
1127
+ request = ConfirmationRequest(
1128
+ "memory_clear",
1129
+ "清空长期记忆",
1130
+ f"当前项目有 {count} 条长期记忆。\n清空后不可通过 XG 恢复。",
1131
+ )
1132
+ self._confirmation = request
1133
+ self._confirmation_future = asyncio.get_running_loop().create_future()
1134
+ self._set_state(replace(self.state, pending_confirmation=request, notification="请输入 clear 确认清空长期记忆"))
1135
+ return CommandResult(ok=True, open_modal="memory_clear")
1136
+ result = await self.command_service.execute(raw)
1137
+ current_usage = self.state.inspector.usage
1138
+ if cleared:
1139
+ current_usage = UsageSnapshot(context_window=self.settings.context_window)
1140
+ elif self.settings.context_window != current_usage.context_window:
1141
+ current_usage = replace(
1142
+ current_usage,
1143
+ context_window=self.settings.context_window,
1144
+ window_ratio=(
1145
+ current_usage.estimated_prompt_tokens / self.settings.context_window
1146
+ if self.settings.context_window > 0 else 0.0
1147
+ ),
1148
+ budget_usage_ratio=(
1149
+ current_usage.estimated_prompt_tokens / self.settings.token_budget
1150
+ if self.settings.token_budget > 0 else 0.0
1151
+ ),
1152
+ )
1153
+ memory_snapshot = self.state.inspector.memory
1154
+ if lowered == "/init" or lowered == "/save" or lowered.startswith("/memory"):
1155
+ memory_snapshot = self._read_memory_snapshot(memory, last_operation=lowered)
1156
+ policy = self.agent.approval_policy
1157
+ self._set_state(replace(
1158
+ self.state,
1159
+ ui_language=self.settings.ui_language,
1160
+ transcript=[] if cleared else self.state.transcript,
1161
+ inspector=replace(
1162
+ self.state.inspector,
1163
+ session=replace(
1164
+ self.state.inspector.session,
1165
+ provider=self.settings.provider,
1166
+ model=self.settings.model,
1167
+ ),
1168
+ memory=memory_snapshot,
1169
+ safety=replace(
1170
+ self.state.inspector.safety,
1171
+ hitl_enabled=bool(policy and policy.enabled),
1172
+ session_allow_all=bool(policy and policy.session_allow_all),
1173
+ ),
1174
+ provider=self.settings.provider,
1175
+ model=self.settings.model,
1176
+ usage=current_usage,
1177
+ context_tokens=0 if cleared else self.state.inspector.context_tokens,
1178
+ context_window=self.settings.context_window,
1179
+ hitl_enabled=bool(self.agent.approval_policy and self.agent.approval_policy.enabled),
1180
+ ),
1181
+ ))
1182
+ return result
1183
+
1184
+ @staticmethod
1185
+ def _is_language_command(raw: str) -> bool:
1186
+ parts = raw.strip().lower().split()
1187
+ return bool(parts and parts[0] in {"/lang", "/language"})
1188
+
1189
+ @staticmethod
1190
+ def _is_readonly_mcp_command(raw: str) -> bool:
1191
+ parts = raw.strip().lower().split()
1192
+ return bool(
1193
+ parts
1194
+ and parts[0] == "/mcp"
1195
+ and (len(parts) == 1 or parts[1] in {"status", "logs", "resources"})
1196
+ )
1197
+
1198
+ async def confirm_command(self, confirmed: bool) -> None:
1199
+ request = self._confirmation
1200
+ self._confirmation = None
1201
+ if request is None:
1202
+ return
1203
+ message = "已取消操作"
1204
+ if confirmed:
1205
+ try:
1206
+ memory = self.agent.memory_manager
1207
+ if request.kind == "init":
1208
+ path = memory.write_init_draft(str(request.payload))
1209
+ message = f"已生成项目记忆:{path.name}"
1210
+ elif request.kind == "memory_clear":
1211
+ removed = memory.clear()
1212
+ message = f"已清空 {removed} 条长期记忆"
1213
+ except Exception as exc:
1214
+ message = f"操作失败:{exc}"
1215
+ memory = getattr(self.agent, "memory_manager", None)
1216
+ operation = "init" if request.kind == "init" else "clear"
1217
+ self._set_state(replace(
1218
+ self.state,
1219
+ pending_confirmation=None,
1220
+ notification=message,
1221
+ notification_level="info",
1222
+ inspector=replace(
1223
+ self.state.inspector,
1224
+ memory=self._read_memory_snapshot(
1225
+ memory,
1226
+ last_operation=operation if confirmed else f"{operation}_cancelled",
1227
+ ),
1228
+ ),
1229
+ ))
1230
+ self._append_system(message)
1231
+ future = self._confirmation_future
1232
+ self._confirmation_future = None
1233
+ if future is not None and not future.done():
1234
+ future.set_result(confirmed)
1235
+ self._ensure_queue_worker()
1236
+
1237
+ async def shutdown(self) -> None:
1238
+ self._shutting_down = True
1239
+ if self._confirmation is not None:
1240
+ await self.confirm_command(False)
1241
+ if self._active_task is not None and not self._active_task.done():
1242
+ await self.cancel()
1243
+ worker = self._queue_worker
1244
+ if worker is not None and not worker.done() and worker is not asyncio.current_task():
1245
+ worker.cancel()
1246
+ try:
1247
+ await worker
1248
+ except asyncio.CancelledError:
1249
+ pass
1250
+ self._queue.clear()
1251
+ self._publish_queue()
1252
+ mcp_manager = getattr(self.agent, "mcp_manager", None)
1253
+ if mcp_manager is not None:
1254
+ await mcp_manager.close()
1255
+ for service_name in ("web_search", "web_fetch"):
1256
+ service = getattr(self.agent, service_name, None)
1257
+ if service is not None:
1258
+ await service.close()
1259
+
1260
+ async def startup(self) -> None:
1261
+ """Start configured MCP servers without blocking TUI construction."""
1262
+ mcp_manager = getattr(self.agent, "mcp_manager", None)
1263
+ if mcp_manager is None:
1264
+ return
1265
+ await mcp_manager.ensure_started()
1266
+ for error in mcp_manager.config_errors:
1267
+ self._append_system(f"MCP 配置提示:{error}")
1268
+ snapshots = mcp_manager.snapshots()
1269
+ if snapshots:
1270
+ ready = sum(item.status == "ready" for item in snapshots)
1271
+ self._set_state(replace(
1272
+ self.state,
1273
+ notification=f"MCP: {ready}/{len(snapshots)} 个 Server 可用",
1274
+ notification_level="info" if ready == len(snapshots) else "warning",
1275
+ ))
1276
+
1277
+ def _append_item(self, item: TranscriptItem) -> None:
1278
+ state = replace(self.state, transcript=[*self.state.transcript, item])
1279
+ self._set_state(state)
1280
+
1281
+ def _append_system(self, message: str) -> None:
1282
+ if message:
1283
+ self._append_item(TranscriptItem(id=f"system-{len(self.state.transcript)}", kind="system", text=message))
1284
+
1285
+ def _append_help(self, message: str) -> None:
1286
+ if message:
1287
+ self._append_item(TranscriptItem(id=f"help-{len(self.state.transcript)}", kind="help", text=message))
1288
+
1289
+ def _remove_progress(self, turn_id: str) -> None:
1290
+ """Remove the local waiting indicator for one active turn."""
1291
+ items = [
1292
+ item for item in self.state.transcript
1293
+ if not (item.kind == "progress" and item.turn_id == turn_id)
1294
+ ]
1295
+ if len(items) != len(self.state.transcript):
1296
+ self._set_state(replace(self.state, transcript=items))