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/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)