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/agent/plan.py
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""Plan-and-Execute:任务拆解 → DAG 批次 → 审阅 → 批次执行(第 4 期)。
|
|
2
|
+
|
|
3
|
+
ReAct 之外的第二条执行路径:
|
|
4
|
+
- LLM 独立调用拆解任务为「子任务 + 依赖」,结构化 JSON 输出 + 校验/修复/重试
|
|
5
|
+
- Kahn 拓扑排序生成依赖批次,无依赖子任务按批并行
|
|
6
|
+
- 计划审阅回调(fail closed:无回调自动取消)
|
|
7
|
+
- 子任务以迷你 ReAct 循环执行(复用并行工具 / HITL / 策略层 / 审计)
|
|
8
|
+
- 失败传播:子任务失败以错误信息注入依赖方上下文,失败超限终止剩余批次
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
17
|
+
from typing import TYPE_CHECKING, AsyncIterator, Awaitable, Callable, Literal
|
|
18
|
+
|
|
19
|
+
from xg.agent.react import AgentEvent, DEFAULT_SYSTEM_PROMPT, ReActAgent
|
|
20
|
+
from xg.config.settings import Settings
|
|
21
|
+
from xg.llm.client import LlmClient, LlmError
|
|
22
|
+
from xg.llm.types import Message, Usage
|
|
23
|
+
from xg.memory.context import ConversationContext
|
|
24
|
+
from xg.memory.manager import MemoryManager
|
|
25
|
+
from xg.safety.hitl import HITLPolicy
|
|
26
|
+
from xg.tool.registry import ToolRegistry
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from xg.mcp.manager import McpManager
|
|
30
|
+
|
|
31
|
+
# 依赖结果摘要注入上限(字符)
|
|
32
|
+
DEP_RESULT_LIMIT = 2000
|
|
33
|
+
# 子任务结果摘要上限(字符)
|
|
34
|
+
TASK_RESULT_LIMIT = 2000
|
|
35
|
+
# 拆解重试上限(带错误信息重试次数)
|
|
36
|
+
PLAN_MAX_RETRIES = 2
|
|
37
|
+
|
|
38
|
+
PLANNER_SYSTEM_PROMPT = (
|
|
39
|
+
"你是任务规划器。将用户任务拆解为可执行的子任务列表并识别依赖关系,"
|
|
40
|
+
"只输出一个 JSON 对象,不要输出任何其他文本或 markdown 代码块,格式:\n"
|
|
41
|
+
'{"tasks": [{"id": "t1", "title": "一句话标题", '
|
|
42
|
+
'"description": "执行说明,写明要调用的工具", "deps": []}]}\n'
|
|
43
|
+
"规则:\n"
|
|
44
|
+
"- id 形如 t1/t2/t3,全局唯一\n"
|
|
45
|
+
"- deps 只能引用其他子任务的 id,无依赖用空数组\n"
|
|
46
|
+
"- 依赖关系必须是无环的 DAG\n"
|
|
47
|
+
"- description 写清楚执行步骤与需要调用的工具(write_file / execute_command 等)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PlanError(Exception):
|
|
52
|
+
"""计划生成 / 校验失败(消息面向用户可直接展示)。"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------- 数据模型 ----------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class PlanTask:
|
|
60
|
+
"""计划中的一个子任务。"""
|
|
61
|
+
|
|
62
|
+
id: str # "t1" / "t2" ...
|
|
63
|
+
title: str # 一句话标题
|
|
64
|
+
description: str # 执行说明(含要调用的工具)
|
|
65
|
+
deps: list[str] # 依赖的 task id(可为空)
|
|
66
|
+
status: str = "pending" # pending / running / done / failed
|
|
67
|
+
result: str = "" # 执行结果摘要
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Plan:
|
|
72
|
+
"""一份可执行的计划:目标 + 子任务 + 拓扑批次。"""
|
|
73
|
+
|
|
74
|
+
goal: str
|
|
75
|
+
tasks: list[PlanTask]
|
|
76
|
+
batches: list[list[str]] # 拓扑排序后的批次(按 id)
|
|
77
|
+
|
|
78
|
+
def task_by_id(self, tid: str) -> PlanTask | None:
|
|
79
|
+
for t in self.tasks:
|
|
80
|
+
if t.id == tid:
|
|
81
|
+
return t
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class PlanEvent:
|
|
87
|
+
"""计划执行事件流单元。kind 含义:
|
|
88
|
+
|
|
89
|
+
- plan_generated: 拆解完成(plan 携带完整计划)
|
|
90
|
+
- review: 即将进入审阅(等待回调决策)
|
|
91
|
+
- approved / cancelled / replanned: 审阅决策
|
|
92
|
+
- batch_started: 一个依赖批次开始(batch 为该批 task id)
|
|
93
|
+
- subtask_started / subtask_done / subtask_failed: 子任务生命周期
|
|
94
|
+
- subtask_event: 子任务内部转发的 AgentEvent(agent_event 字段)
|
|
95
|
+
- plan_done / plan_failed: 计划结束(汇总)
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
kind: Literal[
|
|
99
|
+
"plan_generated", "review", "approved", "cancelled", "replanned",
|
|
100
|
+
"batch_started", "subtask_started", "subtask_done", "subtask_failed",
|
|
101
|
+
"subtask_event", "planner_usage", "plan_done", "plan_failed",
|
|
102
|
+
"plan_resume_requested",
|
|
103
|
+
]
|
|
104
|
+
plan: Plan | None = None
|
|
105
|
+
batch: list[str] = field(default_factory=list)
|
|
106
|
+
task: PlanTask | None = None
|
|
107
|
+
message: str = ""
|
|
108
|
+
agent_event: AgentEvent | None = None
|
|
109
|
+
usage: Usage | None = None
|
|
110
|
+
estimated_prompt_tokens: int | None = None
|
|
111
|
+
request_token_limit: int | None = None
|
|
112
|
+
context_window: int | None = None
|
|
113
|
+
compaction_before: int | None = None
|
|
114
|
+
compaction_after: int | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class ReviewDecision:
|
|
119
|
+
"""计划审阅决策。"""
|
|
120
|
+
|
|
121
|
+
action: Literal["execute", "cancel", "replan"]
|
|
122
|
+
feedback: str = "" # replan 时的补充要求
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
PlanReviewer = Callable[[Plan], Awaitable[ReviewDecision]]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------- DAG → 依赖批次(Kahn 算法) ----------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_batches(tasks: list[PlanTask]) -> list[list[str]]:
|
|
132
|
+
"""拓扑排序为依赖批次。批次 0 = 无依赖子任务;批次 n = 依赖全部在前 n-1 批完成的子任务。
|
|
133
|
+
|
|
134
|
+
依赖存在环时抛出 PlanError。deps 引用了不存在的 id 视为无法满足,同样按环处理。
|
|
135
|
+
"""
|
|
136
|
+
remaining = {t.id: set(t.deps) for t in tasks}
|
|
137
|
+
batches: list[list[str]] = []
|
|
138
|
+
while remaining:
|
|
139
|
+
ready = [tid for tid, deps in remaining.items() if not deps]
|
|
140
|
+
if not ready:
|
|
141
|
+
raise PlanError("依赖存在环(或引用了不存在的任务 id),无法生成执行轮次")
|
|
142
|
+
batches.append(sorted(ready))
|
|
143
|
+
for tid in ready:
|
|
144
|
+
remaining.pop(tid)
|
|
145
|
+
for deps in remaining.values():
|
|
146
|
+
deps.difference_update(ready)
|
|
147
|
+
return batches
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------- 拆解输出解析:校验 + 自动修复 ----------
|
|
151
|
+
|
|
152
|
+
_FENCE_RE = re.compile(r"^```[a-zA-Z0-9_-]*\s*|\s*```$")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _strip_fences(text: str) -> str:
|
|
156
|
+
text = text.strip()
|
|
157
|
+
if text.startswith("```"):
|
|
158
|
+
text = _FENCE_RE.sub("", text).strip()
|
|
159
|
+
return text
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _extract_json_object(text: str) -> str:
|
|
163
|
+
"""提取最外层 { ... } 片段(容忍前后夹杂解释文本)。"""
|
|
164
|
+
start = text.find("{")
|
|
165
|
+
end = text.rfind("}")
|
|
166
|
+
if start != -1 and end > start:
|
|
167
|
+
return text[start : end + 1]
|
|
168
|
+
return text
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_tasks(raw: str, max_subtasks: int = 12) -> tuple[list[PlanTask], list[str]]:
|
|
172
|
+
"""解析 LLM 拆解输出为 PlanTask 列表。
|
|
173
|
+
|
|
174
|
+
返回 (tasks, warnings);解析失败抛 PlanError(消息带原因,供重试回灌)。
|
|
175
|
+
自动修复:未知 dep / 自依赖移除并告警;超上限截断并告警。
|
|
176
|
+
"""
|
|
177
|
+
warnings: list[str] = []
|
|
178
|
+
text = _extract_json_object(_strip_fences(raw))
|
|
179
|
+
try:
|
|
180
|
+
data = json.loads(text)
|
|
181
|
+
except json.JSONDecodeError as e:
|
|
182
|
+
raise PlanError(f"JSON 解析失败: {e}") from e
|
|
183
|
+
|
|
184
|
+
if not isinstance(data, dict) or not isinstance(data.get("tasks"), list):
|
|
185
|
+
raise PlanError('顶层结构必须是 {"tasks": [...]}')
|
|
186
|
+
raw_tasks = data["tasks"]
|
|
187
|
+
if not raw_tasks:
|
|
188
|
+
raise PlanError("tasks 为空,至少需要一个子任务")
|
|
189
|
+
|
|
190
|
+
tasks: list[PlanTask] = []
|
|
191
|
+
for i, rt in enumerate(raw_tasks):
|
|
192
|
+
if not isinstance(rt, dict):
|
|
193
|
+
raise PlanError(f"tasks[{i}] 必须是对象")
|
|
194
|
+
tid = str(rt.get("id", "")).strip()
|
|
195
|
+
title = str(rt.get("title", "")).strip()
|
|
196
|
+
if not tid or not title:
|
|
197
|
+
raise PlanError(f"tasks[{i}] 缺少 id 或 title 字段")
|
|
198
|
+
description = str(rt.get("description") or "").strip() or title
|
|
199
|
+
deps_raw = rt.get("deps", [])
|
|
200
|
+
if not isinstance(deps_raw, list):
|
|
201
|
+
raise PlanError(f"tasks[{i}].deps 必须是数组")
|
|
202
|
+
# 去重去空,保持顺序
|
|
203
|
+
deps: list[str] = []
|
|
204
|
+
for d in deps_raw:
|
|
205
|
+
ds = str(d).strip()
|
|
206
|
+
if ds and ds not in deps:
|
|
207
|
+
deps.append(ds)
|
|
208
|
+
tasks.append(PlanTask(id=tid, title=title, description=description, deps=deps))
|
|
209
|
+
|
|
210
|
+
ids = [t.id for t in tasks]
|
|
211
|
+
if len(set(ids)) != len(ids):
|
|
212
|
+
raise PlanError("存在重复的子任务 id")
|
|
213
|
+
|
|
214
|
+
if len(tasks) > max_subtasks:
|
|
215
|
+
warnings.append(f"子任务数 {len(tasks)} 超过上限 {max_subtasks},已截断")
|
|
216
|
+
kept = {t.id for t in tasks[:max_subtasks]}
|
|
217
|
+
tasks = tasks[:max_subtasks]
|
|
218
|
+
for t in tasks:
|
|
219
|
+
t.deps = [d for d in t.deps if d in kept]
|
|
220
|
+
|
|
221
|
+
known = {t.id for t in tasks}
|
|
222
|
+
for t in tasks:
|
|
223
|
+
for d in list(t.deps):
|
|
224
|
+
if d == t.id:
|
|
225
|
+
warnings.append(f"子任务 {t.id} 自依赖,已移除")
|
|
226
|
+
t.deps.remove(d)
|
|
227
|
+
elif d not in known:
|
|
228
|
+
warnings.append(f"子任务 {t.id} 引用了不存在的依赖 {d},已移除")
|
|
229
|
+
t.deps.remove(d)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
build_batches(tasks)
|
|
233
|
+
except PlanError as e:
|
|
234
|
+
raise PlanError(f"{e},请把依赖关系调整为 DAG") from e
|
|
235
|
+
return tasks, warnings
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------- 计划执行器 ----------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class PlanExecutor:
|
|
242
|
+
"""拆解 → 审阅 → 按批次执行 的事件流编排。"""
|
|
243
|
+
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
llm: LlmClient,
|
|
247
|
+
tools: ToolRegistry,
|
|
248
|
+
settings: Settings,
|
|
249
|
+
reviewer: PlanReviewer | None = None,
|
|
250
|
+
approval_policy: HITLPolicy | None = None,
|
|
251
|
+
audit=None,
|
|
252
|
+
memory_manager: MemoryManager | None = None,
|
|
253
|
+
mcp_manager: "McpManager | None" = None,
|
|
254
|
+
) -> None:
|
|
255
|
+
self.llm = llm
|
|
256
|
+
self.tools = tools
|
|
257
|
+
self.settings = settings
|
|
258
|
+
self.reviewer = reviewer
|
|
259
|
+
self.approval_policy = approval_policy
|
|
260
|
+
self.audit = audit
|
|
261
|
+
self.memory_manager = memory_manager
|
|
262
|
+
self.mcp_manager = mcp_manager
|
|
263
|
+
self._planner_context_event: AgentEvent | None = None
|
|
264
|
+
self._planner_usage: Usage | None = None
|
|
265
|
+
self._last_plan: Plan | None = None
|
|
266
|
+
|
|
267
|
+
async def run(self, goal: str) -> AsyncIterator[PlanEvent]:
|
|
268
|
+
"""执行完整流程:拆解 → 审阅(可循环重规划)→ 按批次执行 → 汇总。"""
|
|
269
|
+
if self.mcp_manager is not None:
|
|
270
|
+
try:
|
|
271
|
+
await self.mcp_manager.ensure_started()
|
|
272
|
+
goal = await self.mcp_manager.expand_references(goal)
|
|
273
|
+
except Exception as exc:
|
|
274
|
+
yield PlanEvent(kind="plan_failed", message=f"MCP resource 处理失败: {exc}")
|
|
275
|
+
return
|
|
276
|
+
feedback = ""
|
|
277
|
+
previous: Plan | None = None
|
|
278
|
+
|
|
279
|
+
# ---- 拆解 + 审阅(重规划时循环) ----
|
|
280
|
+
while True:
|
|
281
|
+
try:
|
|
282
|
+
plan, warnings = await self._generate_plan(goal, feedback, previous)
|
|
283
|
+
except LlmError as e:
|
|
284
|
+
yield PlanEvent(kind="plan_failed", message=f"计划生成失败: {e}")
|
|
285
|
+
return
|
|
286
|
+
if plan is None:
|
|
287
|
+
yield PlanEvent(
|
|
288
|
+
kind="plan_failed",
|
|
289
|
+
message="计划生成失败(JSON 解析重试已用尽),建议改用 ReAct 模式直接执行任务。",
|
|
290
|
+
)
|
|
291
|
+
return
|
|
292
|
+
planner_context = self._planner_context_event
|
|
293
|
+
yield PlanEvent(
|
|
294
|
+
kind="plan_generated", plan=plan, message=";".join(warnings),
|
|
295
|
+
usage=self._planner_usage,
|
|
296
|
+
estimated_prompt_tokens=(
|
|
297
|
+
planner_context.estimated_prompt_tokens
|
|
298
|
+
if planner_context is not None else None
|
|
299
|
+
),
|
|
300
|
+
request_token_limit=(
|
|
301
|
+
planner_context.request_token_limit
|
|
302
|
+
if planner_context is not None else None
|
|
303
|
+
),
|
|
304
|
+
context_window=(
|
|
305
|
+
planner_context.context_window
|
|
306
|
+
if planner_context is not None else None
|
|
307
|
+
),
|
|
308
|
+
compaction_before=(
|
|
309
|
+
planner_context.compaction_before
|
|
310
|
+
if planner_context is not None else None
|
|
311
|
+
),
|
|
312
|
+
compaction_after=(
|
|
313
|
+
planner_context.compaction_after
|
|
314
|
+
if planner_context is not None else None
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if self.reviewer is None:
|
|
319
|
+
# fail closed:无审阅回调,自动取消,不执行任何工具
|
|
320
|
+
yield PlanEvent(kind="cancelled", plan=plan, message="无审阅回调,计划自动取消(fail closed)")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
yield PlanEvent(kind="review", plan=plan, message="等待用户审阅")
|
|
324
|
+
decision = await self.reviewer(plan)
|
|
325
|
+
|
|
326
|
+
if decision.action == "execute":
|
|
327
|
+
self._last_plan = plan
|
|
328
|
+
yield PlanEvent(kind="approved", plan=plan)
|
|
329
|
+
break
|
|
330
|
+
if decision.action == "cancel":
|
|
331
|
+
yield PlanEvent(kind="cancelled", plan=plan, message="用户取消计划")
|
|
332
|
+
return
|
|
333
|
+
# replan:带 feedback 重新拆解
|
|
334
|
+
yield PlanEvent(kind="replanned", plan=plan, message=decision.feedback)
|
|
335
|
+
feedback = decision.feedback
|
|
336
|
+
previous = plan
|
|
337
|
+
|
|
338
|
+
# ---- 按批次执行 ----
|
|
339
|
+
async for event in self._execute_batches(plan):
|
|
340
|
+
yield event
|
|
341
|
+
|
|
342
|
+
async def _execute_batches(self, plan: Plan, instruction: str = "") -> AsyncIterator[PlanEvent]:
|
|
343
|
+
"""按已排序批次执行子任务;失败超限终止剩余批次。instruction 注入每个被执行子任务。"""
|
|
344
|
+
failures = 0
|
|
345
|
+
for batch_no, batch in enumerate(plan.batches):
|
|
346
|
+
yield PlanEvent(
|
|
347
|
+
kind="batch_started", plan=plan, batch=batch,
|
|
348
|
+
message=f"第 {batch_no + 1} 轮 / 共 {len(plan.batches)} 轮",
|
|
349
|
+
)
|
|
350
|
+
async for event in self._run_batch(plan, batch, instruction=instruction):
|
|
351
|
+
if event.kind == "subtask_failed":
|
|
352
|
+
failures += 1
|
|
353
|
+
yield event
|
|
354
|
+
if failures > self.settings.plan_max_failures:
|
|
355
|
+
remaining = [tid for b in plan.batches[batch_no + 1:] for tid in b]
|
|
356
|
+
yield PlanEvent(
|
|
357
|
+
kind="plan_failed", plan=plan,
|
|
358
|
+
message=(
|
|
359
|
+
f"失败子任务数 {failures} 超过上限 {self.settings.plan_max_failures},"
|
|
360
|
+
f"终止剩余子任务: {', '.join(remaining) if remaining else '无'}"
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
done = sum(1 for t in plan.tasks if t.status == "done")
|
|
366
|
+
yield PlanEvent(
|
|
367
|
+
kind="plan_done", plan=plan,
|
|
368
|
+
message=f"计划完成: {done}/{len(plan.tasks)} 个子任务成功",
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# ---- 断点续跑(V3)----
|
|
372
|
+
|
|
373
|
+
async def resume(self, instruction: str = "") -> AsyncIterator[PlanEvent]:
|
|
374
|
+
"""从失败/未执行子任务断点续跑。计划已审阅批准过,跳过拆解与审阅。"""
|
|
375
|
+
plan = self._last_plan
|
|
376
|
+
if plan is None:
|
|
377
|
+
yield PlanEvent(kind="plan_failed", message="没有可恢复的计划")
|
|
378
|
+
return
|
|
379
|
+
done = sum(1 for t in plan.tasks if t.status == "done")
|
|
380
|
+
if done == len(plan.tasks):
|
|
381
|
+
yield PlanEvent(kind="plan_failed", plan=plan, message="计划已全部完成,无需恢复")
|
|
382
|
+
return
|
|
383
|
+
# 重置失败/运行中/待办子任务为 pending(done 保留 result 供下游复用)
|
|
384
|
+
for task in plan.tasks:
|
|
385
|
+
if task.status in ("failed", "running", "pending"):
|
|
386
|
+
task.status = "pending"
|
|
387
|
+
task.result = ""
|
|
388
|
+
plan.batches = build_batches(plan.tasks)
|
|
389
|
+
skipped = [t.id for t in plan.tasks if t.status == "done"]
|
|
390
|
+
yield PlanEvent(
|
|
391
|
+
kind="plan_resume_requested", plan=plan,
|
|
392
|
+
message=(
|
|
393
|
+
f"恢复执行:跳过 {len(skipped)} 个已完成子任务,"
|
|
394
|
+
f"重跑 {sum(1 for b in plan.batches for _ in b)} 个子任务"
|
|
395
|
+
+ (f";补充指令:{instruction}" if instruction else "")
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
async for event in self._execute_batches(plan, instruction=instruction):
|
|
399
|
+
yield event
|
|
400
|
+
|
|
401
|
+
# ---- 拆解(LLM 结构化输出 + 重试) ----
|
|
402
|
+
|
|
403
|
+
async def _generate_plan(
|
|
404
|
+
self, goal: str, feedback: str, previous: Plan | None
|
|
405
|
+
) -> tuple[Plan | None, list[str]]:
|
|
406
|
+
"""调用 LLM 生成计划。解析失败带错误信息重试(上限 PLAN_MAX_RETRIES 次)。
|
|
407
|
+
|
|
408
|
+
返回 (plan, warnings);重试用尽返回 (None, warnings)。
|
|
409
|
+
"""
|
|
410
|
+
self._planner_context_event = None
|
|
411
|
+
self._planner_usage = None
|
|
412
|
+
planner_context = ConversationContext(
|
|
413
|
+
PLANNER_SYSTEM_PROMPT,
|
|
414
|
+
self.settings,
|
|
415
|
+
shared_provider=self.memory_manager.shared_sections if self.memory_manager else None,
|
|
416
|
+
)
|
|
417
|
+
planner_context.append(
|
|
418
|
+
Message(role="user", content=self._planner_prompt(goal, feedback, previous))
|
|
419
|
+
)
|
|
420
|
+
budget = await planner_context.ensure_budget(self.llm)
|
|
421
|
+
self._planner_context_event = AgentEvent(
|
|
422
|
+
kind="context_usage",
|
|
423
|
+
estimated_prompt_tokens=budget.after_tokens,
|
|
424
|
+
request_token_limit=budget.request_token_limit,
|
|
425
|
+
context_window=self.settings.context_window,
|
|
426
|
+
compaction_before=(
|
|
427
|
+
budget.before_tokens if budget.status == "compacted" else None
|
|
428
|
+
),
|
|
429
|
+
compaction_after=(
|
|
430
|
+
budget.after_tokens if budget.status == "compacted" else None
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
if not budget.proceed:
|
|
434
|
+
raise LlmError(budget.message or "规划上下文超出模型窗口")
|
|
435
|
+
messages = planner_context.build_messages()
|
|
436
|
+
warnings: list[str] = []
|
|
437
|
+
for _attempt in range(1 + PLAN_MAX_RETRIES):
|
|
438
|
+
raw, usage = await self._llm_text(messages)
|
|
439
|
+
self._planner_usage = usage
|
|
440
|
+
try:
|
|
441
|
+
tasks, warns = parse_tasks(raw, self.settings.plan_max_subtasks)
|
|
442
|
+
except PlanError as e:
|
|
443
|
+
messages.append(Message(role="assistant", content=raw))
|
|
444
|
+
messages.append(Message(
|
|
445
|
+
role="user",
|
|
446
|
+
content=f"上面的输出解析失败:{e}。请修复问题并重新输出完整 JSON(不要包含其他文本)。",
|
|
447
|
+
))
|
|
448
|
+
continue
|
|
449
|
+
warnings.extend(warns)
|
|
450
|
+
return Plan(goal=goal, tasks=tasks, batches=build_batches(tasks)), warnings
|
|
451
|
+
return None, warnings
|
|
452
|
+
|
|
453
|
+
def _planner_prompt(self, goal: str, feedback: str, previous: Plan | None) -> str:
|
|
454
|
+
parts = [f"任务:{goal}"]
|
|
455
|
+
if previous is not None:
|
|
456
|
+
titles = "\n".join(f"- {t.id}: {t.title}" for t in previous.tasks)
|
|
457
|
+
parts.append(f"上一版计划(需要调整):\n{titles}")
|
|
458
|
+
if feedback:
|
|
459
|
+
parts.append(f"用户反馈(重新拆解时必须满足):{feedback}")
|
|
460
|
+
parts.append(f"请拆解为不超过 {self.settings.plan_max_subtasks} 个子任务,输出严格 JSON。")
|
|
461
|
+
return "\n\n".join(parts)
|
|
462
|
+
|
|
463
|
+
async def _llm_text(self, messages: list[Message]) -> tuple[str, Usage | None]:
|
|
464
|
+
"""非流式语义:聚合一次 LLM 调用的全部文本。"""
|
|
465
|
+
parts: list[str] = []
|
|
466
|
+
usage: Usage | None = None
|
|
467
|
+
async for event in self.llm.stream_chat(messages, tools=None):
|
|
468
|
+
if event.kind == "content" and event.text:
|
|
469
|
+
parts.append(event.text)
|
|
470
|
+
elif event.kind == "done":
|
|
471
|
+
usage = event.usage
|
|
472
|
+
return "".join(parts), usage
|
|
473
|
+
|
|
474
|
+
# ---- 批次执行(批内并行) ----
|
|
475
|
+
|
|
476
|
+
async def _run_batch(self, plan: Plan, batch: list[str], instruction: str = "") -> AsyncIterator[PlanEvent]:
|
|
477
|
+
"""并发执行本批子任务,事件按到达顺序转发(含子任务内部 AgentEvent)。
|
|
478
|
+
|
|
479
|
+
断点续跑时跳过已完成(done)的子任务,避免重复执行。
|
|
480
|
+
"""
|
|
481
|
+
pending = [tid for tid in batch if plan.task_by_id(tid).status != "done"]
|
|
482
|
+
if not pending:
|
|
483
|
+
return
|
|
484
|
+
queue: asyncio.Queue[PlanEvent | None] = asyncio.Queue()
|
|
485
|
+
|
|
486
|
+
async def runner(tid: str) -> None:
|
|
487
|
+
try:
|
|
488
|
+
await self._run_one(plan, batch, tid, queue, instruction=instruction)
|
|
489
|
+
finally:
|
|
490
|
+
queue.put_nowait(None)
|
|
491
|
+
|
|
492
|
+
gather_task = asyncio.gather(*(runner(tid) for tid in pending), return_exceptions=True)
|
|
493
|
+
completed = 0
|
|
494
|
+
while completed < len(pending):
|
|
495
|
+
item = await queue.get()
|
|
496
|
+
if item is None:
|
|
497
|
+
completed += 1
|
|
498
|
+
continue
|
|
499
|
+
yield item
|
|
500
|
+
# gather 仅用于收集异常(runner 内部已兜底,这里防御性回灌)
|
|
501
|
+
for result in await gather_task:
|
|
502
|
+
if isinstance(result, Exception):
|
|
503
|
+
yield PlanEvent(kind="subtask_failed", plan=plan, message=f"内部错误: {result}")
|
|
504
|
+
|
|
505
|
+
async def _run_one(
|
|
506
|
+
self, plan: Plan, batch: list[str], tid: str, queue: "asyncio.Queue[PlanEvent | None]",
|
|
507
|
+
instruction: str = "",
|
|
508
|
+
) -> None:
|
|
509
|
+
task = plan.task_by_id(tid)
|
|
510
|
+
assert task is not None
|
|
511
|
+
task.status = "running"
|
|
512
|
+
self._audit("subtask_started", task_id=task.id, title=task.title)
|
|
513
|
+
queue.put_nowait(PlanEvent(kind="subtask_started", plan=plan, batch=batch, task=task))
|
|
514
|
+
|
|
515
|
+
result, error = "", ""
|
|
516
|
+
try:
|
|
517
|
+
result, error = await self._execute_subtask(plan, task, queue, instruction=instruction)
|
|
518
|
+
except Exception as e: # 防御:子任务内部异常不拖垮整批
|
|
519
|
+
error = f"{type(e).__name__}: {e}"
|
|
520
|
+
|
|
521
|
+
if error:
|
|
522
|
+
task.status = "failed"
|
|
523
|
+
task.result = error[:TASK_RESULT_LIMIT]
|
|
524
|
+
self._audit("subtask_failed", task_id=task.id, error=task.result)
|
|
525
|
+
queue.put_nowait(PlanEvent(
|
|
526
|
+
kind="subtask_failed", plan=plan, batch=batch, task=task, message=task.result
|
|
527
|
+
))
|
|
528
|
+
else:
|
|
529
|
+
task.status = "done"
|
|
530
|
+
task.result = result[:TASK_RESULT_LIMIT]
|
|
531
|
+
self._audit("subtask_done", task_id=task.id, result=task.result)
|
|
532
|
+
queue.put_nowait(PlanEvent(
|
|
533
|
+
kind="subtask_done", plan=plan, batch=batch, task=task, message=task.result
|
|
534
|
+
))
|
|
535
|
+
|
|
536
|
+
async def _execute_subtask(
|
|
537
|
+
self, plan: Plan, task: PlanTask, queue: "asyncio.Queue[PlanEvent | None]",
|
|
538
|
+
instruction: str = "",
|
|
539
|
+
) -> tuple[str, str]:
|
|
540
|
+
"""迷你 ReAct 循环执行单个子任务。返回 (result, error),error 非空即失败。"""
|
|
541
|
+
sub_settings = replace(self.settings, tool_steps=self.settings.plan_subtask_steps)
|
|
542
|
+
agent = ReActAgent(
|
|
543
|
+
llm=self.llm,
|
|
544
|
+
tools=self.tools,
|
|
545
|
+
settings=sub_settings,
|
|
546
|
+
system_prompt=self._subtask_system_prompt(plan, task),
|
|
547
|
+
approval_policy=self.approval_policy,
|
|
548
|
+
audit=self.audit,
|
|
549
|
+
memory_manager=self.memory_manager,
|
|
550
|
+
mcp_manager=self.mcp_manager,
|
|
551
|
+
)
|
|
552
|
+
async for event in agent.run(self._subtask_user_prompt(task, instruction)):
|
|
553
|
+
if event.kind in (
|
|
554
|
+
"thinking", "content", "tool_call", "approval", "tool_result",
|
|
555
|
+
"context_compacted", "context_warning", "context_usage", "usage",
|
|
556
|
+
):
|
|
557
|
+
queue.put_nowait(PlanEvent(
|
|
558
|
+
kind="subtask_event", plan=plan, task=task, agent_event=event
|
|
559
|
+
))
|
|
560
|
+
elif event.kind == "error":
|
|
561
|
+
return "", f"LLM 请求失败: {event.text}"
|
|
562
|
+
elif event.kind == "done":
|
|
563
|
+
if event.usage is not None:
|
|
564
|
+
queue.put_nowait(PlanEvent(
|
|
565
|
+
kind="subtask_event", plan=plan, task=task,
|
|
566
|
+
agent_event=AgentEvent(
|
|
567
|
+
kind="usage", usage=event.usage,
|
|
568
|
+
estimated_prompt_tokens=event.estimated_prompt_tokens,
|
|
569
|
+
request_token_limit=event.request_token_limit,
|
|
570
|
+
context_window=event.context_window,
|
|
571
|
+
compaction_before=event.compaction_before,
|
|
572
|
+
compaction_after=event.compaction_after,
|
|
573
|
+
),
|
|
574
|
+
))
|
|
575
|
+
elif event.kind == "step_limit":
|
|
576
|
+
return "", f"达到子任务步数上限({self.settings.plan_subtask_steps})"
|
|
577
|
+
elif event.kind in ("budget_exceeded", "context_overflow"):
|
|
578
|
+
return "", event.text or "子任务上下文 token 超限"
|
|
579
|
+
# 成功:取最后一条含内容的 assistant 消息作为结果摘要
|
|
580
|
+
final = ""
|
|
581
|
+
for m in reversed(agent.messages):
|
|
582
|
+
if m.role == "assistant" and m.content.strip():
|
|
583
|
+
final = m.content
|
|
584
|
+
break
|
|
585
|
+
return final, ""
|
|
586
|
+
|
|
587
|
+
# ---- 子任务上下文注入 ----
|
|
588
|
+
|
|
589
|
+
def _subtask_system_prompt(self, plan: Plan, task: PlanTask) -> str:
|
|
590
|
+
parts = [
|
|
591
|
+
DEFAULT_SYSTEM_PROMPT,
|
|
592
|
+
"",
|
|
593
|
+
"# 计划上下文",
|
|
594
|
+
f"总目标:{plan.goal}",
|
|
595
|
+
]
|
|
596
|
+
skill_registry = getattr(self.tools, "skill_registry", None)
|
|
597
|
+
if skill_registry is not None:
|
|
598
|
+
index = skill_registry.index_text()
|
|
599
|
+
if index:
|
|
600
|
+
parts.extend(["", index])
|
|
601
|
+
completed = [t for t in plan.tasks if t.status == "done"]
|
|
602
|
+
if completed:
|
|
603
|
+
parts.append("已完成的子任务:\n" + "\n".join(f"- {t.id}: {t.title}" for t in completed))
|
|
604
|
+
dep_sections: list[str] = []
|
|
605
|
+
for d in task.deps:
|
|
606
|
+
dep = plan.task_by_id(d)
|
|
607
|
+
if dep is None:
|
|
608
|
+
continue
|
|
609
|
+
if dep.status == "failed":
|
|
610
|
+
dep_sections.append(f"[子任务 {dep.id} 失败] {dep.result or '无详细信息'}")
|
|
611
|
+
else:
|
|
612
|
+
dep_sections.append(
|
|
613
|
+
f"[{dep.id} {dep.title}] 结果:\n{dep.result[:DEP_RESULT_LIMIT]}"
|
|
614
|
+
)
|
|
615
|
+
if dep_sections:
|
|
616
|
+
parts.append("依赖子任务的结果:\n" + "\n\n".join(dep_sections))
|
|
617
|
+
return "\n".join(parts)
|
|
618
|
+
|
|
619
|
+
def _subtask_user_prompt(self, task: PlanTask, instruction: str = "") -> str:
|
|
620
|
+
base = (
|
|
621
|
+
f"请执行子任务 {task.id}({task.title}):\n{task.description}\n\n"
|
|
622
|
+
"只执行这一个子任务,不要处理计划中的其他子任务。"
|
|
623
|
+
"完成后用一小段话汇报执行结果。"
|
|
624
|
+
)
|
|
625
|
+
if instruction:
|
|
626
|
+
base = f"{base}\n\n恢复时补充指令:{instruction}"
|
|
627
|
+
return base
|
|
628
|
+
|
|
629
|
+
def _audit(self, action: str, **fields) -> None:
|
|
630
|
+
if self.audit is not None:
|
|
631
|
+
self.audit.record(action, **fields)
|