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