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/team.py ADDED
@@ -0,0 +1,1793 @@
1
+ """Multi-Agent Team 编排(第 10 期 MVP)。
2
+
3
+ Team 是建立在现有 Plan/ReAct 之上的协作控制面:
4
+ - Planner 生成带角色、资源范围和验收标准的任务 DAG;
5
+ - Supervisor 按依赖和资源冲突调度隔离上下文的 Worker;
6
+ - Reviewer 基于 Artifact 和执行证据做任务级审查;
7
+ - 失败任务生成有边界的 Repair Worker,最多重试两次。
8
+
9
+ Worker 仍然通过 ReActAgent -> ToolRegistry -> Guard/HITL -> Audit 执行,
10
+ Team 层不提供绕过现有安全链路的内部通道。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import fnmatch
17
+ import json
18
+ import re
19
+ import uuid
20
+ from dataclasses import dataclass, field, replace
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, AsyncIterator, Awaitable, Callable, Literal, Protocol
23
+
24
+ from xg.agent.plan import PlanError, ReviewDecision, build_batches
25
+ from xg.agent.react import AgentEvent, DEFAULT_SYSTEM_PROMPT, ReActAgent
26
+ from xg.config.settings import Settings
27
+ from xg.llm.client import LlmClient, LlmError
28
+ from xg.llm.types import Message, ToolCall, ToolResult, Usage
29
+ from xg.memory.context import ConversationContext
30
+ from xg.memory.manager import MemoryManager
31
+ from xg.safety.hitl import HITLPolicy
32
+ from xg.tool.registry import ToolRegistry
33
+
34
+ if TYPE_CHECKING:
35
+ from xg.mcp.manager import McpManager
36
+
37
+
38
+ TEAM_MAX_RETRIES = 2
39
+ TEAM_MAX_RECOVERIES = 1
40
+ TEAM_RECOVERY_STEPS = 10
41
+ TEAM_RESULT_LIMIT = 2000
42
+ TEAM_ARTIFACT_LIMIT = 4000
43
+ TEAM_REVIEW_LIMIT = 4000
44
+ TEAM_PLAN_MAX_RETRIES = 2
45
+ TEAM_REVIEW_OUTPUT_RETRIES = 1
46
+ RESOURCE_SCOPED_TOOLS = {"read_file", "write_file", "list_dir", "glob_files", "grep_code"}
47
+ READ_DISCOVERY_TOOLS = {"read_file", "list_dir", "glob_files", "grep_code"}
48
+ READ_DISCOVERY_ROLES = {"researcher", "reviewer"}
49
+ CANONICAL_TEAM_TOOLS = frozenset({
50
+ "read_file", "write_file", "list_dir", "glob_files", "grep_code",
51
+ "execute_command", "web_search", "web_fetch", "load_skill",
52
+ })
53
+ TEAM_TOOL_ALIASES = {
54
+ "find": "glob_files",
55
+ "glob": "glob_files",
56
+ "grep": "grep_code",
57
+ "read": "read_file",
58
+ "write": "write_file",
59
+ }
60
+ DEFAULT_RESOURCE_DENY_PATTERNS = (
61
+ ".env",
62
+ ".env.*",
63
+ "**/*.pem",
64
+ "**/*.key",
65
+ "**/*secret*",
66
+ "**/*credential*",
67
+ "**/*password*",
68
+ ".xg/memory.db",
69
+ ".xg/audit.log",
70
+ )
71
+
72
+
73
+ TEAM_PLANNER_PROMPT = (
74
+ "你是 XG 的团队任务规划器。将用户任务拆解为可执行的 DAG,"
75
+ "为每个任务指定角色、工具范围、资源范围和可验证的验收标准。"
76
+ "只输出一个 JSON 对象,不要输出其他文本或 markdown,格式:\n"
77
+ "{\"tasks\": [{\"id\": \"t1\", \"title\": \"一句话标题\", "
78
+ "\"description\": \"执行说明\", \"deps\": [], "
79
+ "\"owner_role\": \"coder\", "
80
+ "\"allowed_tools\": [\"read_file\", \"write_file\"], "
81
+ "\"resource_scope_mode\": \"targeted\", "
82
+ "\"resource_claims\": [{\"pattern\": \"src/*\", "
83
+ "\"access\": \"write\", \"exclusive\": false}], "
84
+ "\"acceptance_criteria\": [\"可验证条件\"]}]}\n"
85
+ "规则:\n"
86
+ "- id 全局唯一,形如 t1/t2;deps 只能引用其他任务 id;\n"
87
+ "- 依赖必须是无环 DAG;\n"
88
+ "- owner_role 使用 coder、researcher、tester、reviewer 或 repairer;\n"
89
+ "- allowed_tools 必须使用 XG 注册的精确工具名:read_file、write_file、list_dir、glob_files、grep_code、execute_command、web_search、web_fetch、load_skill;\n"
90
+ "- 不要输出 find、glob、grep、cat、shell、bash、terminal、read 或 write 作为工具名;\n"
91
+ "- 读取文件使用 read_file,查看目录使用 list_dir,按模式查找文件使用 glob_files,搜索代码使用 grep_code,执行测试/命令使用 execute_command;\n"
92
+ "- 只读任务不得声明 write 工具;\n"
93
+ "- researcher/reviewer 需要先探索项目结构时使用 resource_scope_mode=read_discovery;\n"
94
+ "- coder/tester/repairer 使用 resource_scope_mode=targeted,写入范围必须声明;\n"
95
+ "- read_discovery 只允许项目根目录内的只读工具,不得声明 write 资源;\n"
96
+ "- 无法判断命令副作用时使用 exclusive=true;\n"
97
+ "- 每个任务必须有至少一条 acceptance_criteria。"
98
+ )
99
+
100
+ TEAM_REVIEWER_PROMPT = (
101
+ "你是严格的任务审查 Agent。你不能修改文件,只能根据任务验收标准、"
102
+ "实际工具结果和任务产物判断是否通过。只输出 JSON:"
103
+ '{"verdict":"pass|fail|needs_input","findings":["问题"],'
104
+ '"required_fixes":["定向修复要求"],'
105
+ '"repair_scope":[{"pattern":"path/to/file","access":"write"}],'
106
+ '"evidence":["证据"]}。'
107
+ "verdict 为 fail 时,尽量提供最小的 repair_scope;"
108
+ "不要把原任务的只读范围自动升级为写入范围。"
109
+ "不要把 Worker 的主观汇报当成测试通过证据。"
110
+ )
111
+
112
+
113
+ @dataclass
114
+ class ResourceClaim:
115
+ """任务对项目资源的访问声明。"""
116
+
117
+ pattern: str
118
+ access: Literal["read", "write"] = "read"
119
+ exclusive: bool = False
120
+
121
+ def normalized(self) -> str:
122
+ pattern = self.pattern.replace("\\", "/").strip()
123
+ while pattern.startswith("./"):
124
+ pattern = pattern[2:]
125
+ return pattern or "**"
126
+
127
+
128
+ @dataclass
129
+ class AgentProfile:
130
+ """一个可注册的 Agent 角色配置。"""
131
+
132
+ name: str
133
+ system_prompt: str
134
+ allowed_tools: tuple[str, ...] = () # 空 tuple 表示使用所有已注册工具
135
+ default_model: str | None = None
136
+ max_steps: int | None = None
137
+ can_write: bool = False
138
+ is_reviewer: bool = False
139
+
140
+
141
+ @dataclass
142
+ class Artifact:
143
+ """Worker 产生的可传递、可验证任务产物。"""
144
+
145
+ id: str
146
+ task_id: str
147
+ kind: str
148
+ uri: str = ""
149
+ summary: str = ""
150
+ checksum: str = ""
151
+ producer_agent_id: str = ""
152
+ version: int = 1
153
+ attempt: int = 1
154
+ parent_artifacts: list[str] = field(default_factory=list)
155
+ verification_records: list[str] = field(default_factory=list)
156
+
157
+
158
+ @dataclass
159
+ class ReviewResult:
160
+ task_id: str
161
+ verdict: Literal["pass", "fail", "needs_input"]
162
+ findings: list[str] = field(default_factory=list)
163
+ required_fixes: list[str] = field(default_factory=list)
164
+ evidence: list[str] = field(default_factory=list)
165
+ repair_scope: list[ResourceClaim] = field(default_factory=list)
166
+ category: str = ""
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class ReviewOutputError:
171
+ """Reviewer 输出无法安全转换为 ReviewResult 时的结构化错误。"""
172
+
173
+ category: str
174
+ message: str
175
+
176
+
177
+ @dataclass
178
+ class TeamTask:
179
+ id: str
180
+ title: str
181
+ description: str
182
+ deps: list[str]
183
+ owner_role: str = "coder"
184
+ allowed_tools: list[str] = field(default_factory=list)
185
+ allowed_tools_declared: bool = False
186
+ invalid_tools: list[str] = field(default_factory=list)
187
+ tool_warnings: list[str] = field(default_factory=list)
188
+ resource_claims: list[ResourceClaim] = field(default_factory=list)
189
+ resource_scope_mode: Literal["targeted", "read_discovery"] = "targeted"
190
+ resource_deny_patterns: list[str] = field(default_factory=list)
191
+ acceptance_criteria: list[str] = field(default_factory=list)
192
+ input_artifacts: list[str] = field(default_factory=list)
193
+ output_artifacts: list[str] = field(default_factory=list)
194
+ status: str = "pending"
195
+ attempts: int = 0
196
+ result: str = ""
197
+ artifacts: list[str] = field(default_factory=list)
198
+ failure_category: str = ""
199
+ blocked_by: list[str] = field(default_factory=list)
200
+ recovery_attempts: int = 0
201
+ repair_attempts_started: int = 0
202
+ repair_attempts_blocked: int = 0
203
+ pending_input_category: str = ""
204
+ pending_input_message: str = ""
205
+ pending_repair_scope: list[ResourceClaim] = field(default_factory=list)
206
+ pending_review: ReviewResult | None = None
207
+
208
+
209
+ @dataclass
210
+ class TeamPlan:
211
+ goal: str
212
+ tasks: list[TeamTask]
213
+ batches: list[list[str]]
214
+
215
+ def task_by_id(self, task_id: str) -> TeamTask | None:
216
+ return next((task for task in self.tasks if task.id == task_id), None)
217
+
218
+
219
+ @dataclass
220
+ class TeamEvent:
221
+ """Team 层事件;内部 AgentEvent 通过 agent_event 嵌套转发。"""
222
+
223
+ kind: Literal[
224
+ "team_started", "team_plan_generated", "team_review", "approved",
225
+ "replanned", "batch_started", "task_started", "task_done",
226
+ "task_failed", "agent_started", "agent_done", "agent_failed",
227
+ "task_blocked",
228
+ "task_retry_started",
229
+ "subtask_event", "artifact_produced", "task_review_started",
230
+ "task_review_done", "repair_requested", "team_done", "team_failed",
231
+ "review_output_invalid", "review_output_retry", "repair_scope_required",
232
+ "repair_scope_validated", "task_needs_input", "task_resume_requested",
233
+ "cancelled", "team_resume_requested",
234
+ ]
235
+ team_id: str = ""
236
+ plan: TeamPlan | None = None
237
+ batch: list[str] = field(default_factory=list)
238
+ task: TeamTask | None = None
239
+ agent_id: str = ""
240
+ role: str = ""
241
+ artifact: Artifact | None = None
242
+ review: ReviewResult | None = None
243
+ agent_event: AgentEvent | None = None
244
+ message: str = ""
245
+ usage: Usage | None = None
246
+ attempt: int = 0
247
+ effective_steps: int = 0
248
+ failure_category: str = ""
249
+ retryable: bool = False
250
+ previous_steps: int = 0
251
+ retry_steps: int = 0
252
+ preserved_artifacts: list[str] = field(default_factory=list)
253
+ scope_claims: list[ResourceClaim] = field(default_factory=list)
254
+ repair_attempts_started: int = 0
255
+ repair_attempts_blocked: int = 0
256
+
257
+
258
+ class Planner(Protocol):
259
+ async def create_plan(self, goal: str) -> TeamPlan: ...
260
+
261
+
262
+ class AgentFactory(Protocol):
263
+ def create(self, profile: AgentProfile, task: TeamTask) -> ReActAgent: ...
264
+
265
+
266
+ class ArtifactStore(Protocol):
267
+ async def publish(self, artifact: Artifact) -> None: ...
268
+ async def get(self, artifact_id: str) -> Artifact | None: ...
269
+ async def for_task(self, task_id: str) -> list[Artifact]: ...
270
+
271
+
272
+ class Reviewer(Protocol):
273
+ async def review(self, task: TeamTask, artifacts: list[Artifact]) -> ReviewResult: ...
274
+
275
+
276
+ class Scheduler(Protocol):
277
+ async def schedule(self, plan: TeamPlan) -> list[list[str]]: ...
278
+
279
+
280
+ class InMemoryArtifactStore:
281
+ """MVP 的进程内 ArtifactStore;后续可替换为 SQLite 或文件实现。"""
282
+
283
+ def __init__(self) -> None:
284
+ self._items: dict[str, Artifact] = {}
285
+
286
+ async def publish(self, artifact: Artifact) -> None:
287
+ self._items[artifact.id] = artifact
288
+
289
+ async def get(self, artifact_id: str) -> Artifact | None:
290
+ return self._items.get(artifact_id)
291
+
292
+ async def for_task(self, task_id: str) -> list[Artifact]:
293
+ return [item for item in self._items.values() if item.task_id == task_id]
294
+
295
+ async def get_many(self, artifact_ids: list[str]) -> list[Artifact]:
296
+ return [self._items[item_id] for item_id in artifact_ids if item_id in self._items]
297
+
298
+
299
+ class ScopedToolRegistry:
300
+ """给 Worker 暴露工具和资源范围的受限视图。"""
301
+
302
+ def __init__(self, base: ToolRegistry, task: TeamTask, project_root: Path, profile: AgentProfile) -> None:
303
+ self._base = base
304
+ self._task = task
305
+ self._project_root = project_root.resolve()
306
+ self._profile = profile
307
+
308
+ def schemas(self) -> list[dict]:
309
+ schemas = self._base.schemas()
310
+ allowed = self._allowed_tools()
311
+ if self._task.allowed_tools_declared and not allowed:
312
+ return []
313
+ if not allowed:
314
+ return schemas
315
+ return [schema for schema in schemas if schema.get("name") in allowed]
316
+
317
+ async def aexecute_calls(self, calls: list[ToolCall], concurrency: int = 4, timeout: float = 120.0) -> list[ToolResult]:
318
+ allowed = self._allowed_tools()
319
+ executable: list[ToolCall] = []
320
+ rejected: dict[str, ToolResult] = {}
321
+ for call in calls:
322
+ if self._task.allowed_tools_declared and not allowed:
323
+ rejected[call.id] = ToolResult(
324
+ tool_call_id=call.id, name=call.name, ok=False,
325
+ error=f"任务未允许任何工具调用: {call.name}",
326
+ )
327
+ continue
328
+ if allowed and call.name not in allowed:
329
+ rejected[call.id] = ToolResult(
330
+ tool_call_id=call.id, name=call.name, ok=False,
331
+ error=f"角色 {self._profile.name} 不允许调用工具: {call.name}",
332
+ )
333
+ continue
334
+ if not self._resource_allowed(call):
335
+ path = call.parsed_arguments().get("path", "<项目根目录>")
336
+ rejected[call.id] = ToolResult(
337
+ tool_call_id=call.id, name=call.name, ok=False,
338
+ error=(
339
+ f"任务资源范围拒绝工具调用: {call.name} "
340
+ f"(path={path}, mode={self._task.resource_scope_mode})"
341
+ ),
342
+ )
343
+ continue
344
+ executable.append(call)
345
+ results = await self._base.aexecute_calls(executable, concurrency=concurrency, timeout=timeout)
346
+ by_id = {result.tool_call_id: result for result in results}
347
+ by_id.update(rejected)
348
+ output: list[ToolResult] = []
349
+ for call in calls:
350
+ result = by_id.get(call.id) or ToolResult(
351
+ tool_call_id=call.id, name=call.name, ok=False, error="工具未执行"
352
+ )
353
+ if result.ok and self._task.resource_scope_mode == "read_discovery":
354
+ result = self._filter_discovery_result(call, result)
355
+ output.append(result)
356
+ return output
357
+
358
+ def _allowed_tools(self) -> set[str]:
359
+ profile_tools = set(self._profile.allowed_tools)
360
+ task_tools = set(self._task.allowed_tools)
361
+ if self._task.allowed_tools_declared and not task_tools:
362
+ return set()
363
+ if profile_tools and task_tools:
364
+ return profile_tools & task_tools
365
+ return profile_tools or task_tools
366
+
367
+ def _resource_allowed(self, call: ToolCall) -> bool:
368
+ claims = self._task.resource_claims
369
+ if call.name not in RESOURCE_SCOPED_TOOLS:
370
+ return True
371
+ args = call.parsed_arguments()
372
+ relative = self._normalize_target(args.get("path"))
373
+ if relative is None:
374
+ return False
375
+ if self._task.resource_scope_mode == "read_discovery":
376
+ return (
377
+ self._profile.name in READ_DISCOVERY_ROLES
378
+ and not self._profile.can_write
379
+ and call.name in READ_DISCOVERY_TOOLS
380
+ and not self._is_denied(relative)
381
+ )
382
+ if not claims:
383
+ return False
384
+ wants_write = call.name == "write_file"
385
+ for claim in claims:
386
+ pattern = claim.normalized()
387
+ matches = self._claim_matches(relative, pattern)
388
+ if matches and (not wants_write or claim.access == "write"):
389
+ return True
390
+ return False
391
+
392
+ def _normalize_target(self, raw_value: object) -> str | None:
393
+ """Normalize an optional tool path relative to the project root."""
394
+ raw = "" if raw_value is None else str(raw_value).strip()
395
+ if not raw or raw in {".", "./", ".\\"}:
396
+ return ""
397
+ path = Path(raw)
398
+ try:
399
+ resolved = path.resolve() if path.is_absolute() else (self._project_root / path).resolve()
400
+ return resolved.relative_to(self._project_root).as_posix()
401
+ except ValueError:
402
+ return None
403
+
404
+ @staticmethod
405
+ def _claim_matches(relative: str, pattern: str) -> bool:
406
+ if not relative:
407
+ return pattern in {"*", "**"}
408
+ if fnmatch.fnmatch(relative, pattern) or (
409
+ pattern.startswith("**/") and fnmatch.fnmatch(relative, pattern[3:])
410
+ ):
411
+ return True
412
+ prefix = pattern.rstrip("/*").rstrip("/")
413
+ if prefix and (relative == prefix or relative.startswith(prefix + "/")):
414
+ return True
415
+ return fnmatch.fnmatch(relative, pattern.rstrip("/") + "/**")
416
+
417
+ def _is_denied(self, relative: str) -> bool:
418
+ patterns = (*DEFAULT_RESOURCE_DENY_PATTERNS, *self._task.resource_deny_patterns)
419
+ return any(
420
+ self._claim_matches(relative, pattern.replace("\\", "/"))
421
+ for pattern in patterns
422
+ )
423
+
424
+ def _filter_discovery_result(self, call: ToolCall, result: ToolResult) -> ToolResult:
425
+ """Remove sensitive paths from discovery tool output before refeeding it."""
426
+ if call.name == "list_dir":
427
+ root = self._normalize_target(call.parsed_arguments().get("path"))
428
+ lines = []
429
+ for line in result.output.splitlines():
430
+ name = line.removeprefix("[dir] ").strip()
431
+ candidate = f"{root}/{name}" if root else name
432
+ if not self._is_denied(candidate):
433
+ lines.append(line)
434
+ return replace(result, output="\n".join(lines) or "(结果已按安全策略过滤)")
435
+ if call.name in {"glob_files", "grep_code"}:
436
+ lines = []
437
+ for line in result.output.splitlines():
438
+ candidate = line
439
+ if call.name == "grep_code" and ":" in line:
440
+ candidate = line.split(":", 1)[0]
441
+ if not self._is_denied(candidate.replace("\\", "/").strip()):
442
+ lines.append(line)
443
+ return replace(result, output="\n".join(lines) or "(结果已按安全策略过滤)")
444
+ return result
445
+
446
+ def __getattr__(self, name: str):
447
+ return getattr(self._base, name)
448
+
449
+
450
+ def build_repair_scope(
451
+ original_task: TeamTask,
452
+ review: ReviewResult,
453
+ ) -> tuple[list[ResourceClaim], list[str]]:
454
+ """根据审查结果生成 Repairer 的最小写入范围。
455
+
456
+ Reviewer 明确给出的范围优先。为了兼容旧版 Reviewer,原任务已有的
457
+ write claim 可以作为回退;原任务的 read claim 永远不会被升级为 write。
458
+ """
459
+ explicit = [
460
+ ResourceClaim(claim.pattern, "write", claim.exclusive)
461
+ for claim in review.repair_scope
462
+ if claim.access == "write" and claim.pattern.strip()
463
+ ]
464
+ if explicit:
465
+ return explicit, []
466
+
467
+ inherited = [
468
+ ResourceClaim(claim.pattern, "write", claim.exclusive)
469
+ for claim in original_task.resource_claims
470
+ if claim.access == "write" and claim.pattern.strip()
471
+ ]
472
+ if inherited:
473
+ return inherited, ["Reviewer 未提供 repair_scope,已兼容使用原任务的 write claim"]
474
+
475
+ return [], ["Reviewer 未提供可安全写入的 repair_scope"]
476
+
477
+
478
+ def _resource_claims_from_json(raw: object) -> list[ResourceClaim]:
479
+ """Parse the optional structured repair scope from Reviewer JSON."""
480
+ if not isinstance(raw, list):
481
+ return []
482
+ claims: list[ResourceClaim] = []
483
+ for item in raw:
484
+ if not isinstance(item, dict):
485
+ continue
486
+ pattern = item.get("pattern")
487
+ access = item.get("access")
488
+ if isinstance(pattern, str) and isinstance(access, str):
489
+ pattern = pattern.strip()
490
+ access = access.strip().lower()
491
+ if isinstance(pattern, str) and pattern and access in {"read", "write"}:
492
+ claims.append(ResourceClaim(pattern, access, bool(item.get("exclusive", False))))
493
+ return claims
494
+
495
+
496
+ def _safe_claim_pattern(pattern: str) -> bool:
497
+ """Return whether a claim pattern can stay within the project root."""
498
+ normalized = pattern.replace("\\", "/").strip()
499
+ if not normalized or normalized.startswith("/") or re.match(r"^[A-Za-z]:/", normalized):
500
+ return False
501
+ return not any(part == ".." for part in normalized.split("/"))
502
+
503
+
504
+ def _claim_overlaps_pattern(claim: str, protected: str) -> bool:
505
+ """Conservatively detect a claim that may include a protected path."""
506
+ return (
507
+ fnmatch.fnmatch(claim, protected)
508
+ or fnmatch.fnmatch(protected, claim)
509
+ or ScopedToolRegistry._claim_matches(claim, protected)
510
+ or ScopedToolRegistry._claim_matches(protected, claim)
511
+ )
512
+
513
+
514
+ def validate_task_resource_policy(
515
+ task: TeamTask,
516
+ profile: AgentProfile,
517
+ project_root: Path | None = None,
518
+ ) -> list[str]:
519
+ """Validate the role/tool/resource combination before starting a Worker."""
520
+ errors: list[str] = []
521
+ mode = task.resource_scope_mode
522
+ profile_tools = set(profile.allowed_tools)
523
+ task_tools = set(task.allowed_tools)
524
+
525
+ if task.invalid_tools:
526
+ errors.append(f"计划包含无效工具:{', '.join(task.invalid_tools)}")
527
+ if mode not in {"targeted", "read_discovery"}:
528
+ errors.append(f"未知资源模式:{mode}")
529
+ if mode == "read_discovery":
530
+ if profile.name not in READ_DISCOVERY_ROLES or profile.can_write:
531
+ errors.append(f"角色 {profile.name} 不能使用 read_discovery")
532
+ if any(claim.access == "write" for claim in task.resource_claims):
533
+ errors.append("read_discovery 不能包含 write claim")
534
+ if task_tools and any(tool not in READ_DISCOVERY_TOOLS for tool in task_tools):
535
+ errors.append("read_discovery 只能使用只读发现工具")
536
+ elif profile.can_write and task.owner_role in {"coder", "tester", "repairer"}:
537
+ # Writable roles must never be put into the discovery-only policy.
538
+ if mode != "targeted":
539
+ errors.append(f"可写角色 {profile.name} 必须使用 targeted")
540
+
541
+ if profile_tools and task_tools:
542
+ unknown = sorted(task_tools - profile_tools)
543
+ if unknown:
544
+ errors.append(f"任务工具超出角色权限:{', '.join(unknown)}")
545
+
546
+ write_claims = [claim for claim in task.resource_claims if claim.access == "write"]
547
+ if task.owner_role == "repairer" and not write_claims:
548
+ errors.append("repair_scope_missing:Repairer 没有明确的 write claim")
549
+
550
+ protected_patterns = DEFAULT_RESOURCE_DENY_PATTERNS + tuple(task.resource_deny_patterns)
551
+ for claim in task.resource_claims:
552
+ normalized = claim.normalized()
553
+ if not _safe_claim_pattern(normalized):
554
+ errors.append(f"资源声明越出项目根目录:{claim.pattern}")
555
+ if claim.access == "write" and any(
556
+ _claim_overlaps_pattern(normalized, protected.replace("\\", "/"))
557
+ for protected in protected_patterns
558
+ ):
559
+ errors.append(f"write claim 命中受保护资源:{claim.pattern}")
560
+ if task.owner_role == "repairer" and normalized in {"*", "**"}:
561
+ errors.append("Repairer 不允许使用全项目写入范围")
562
+
563
+ # Keep the optional parameter part of the validation contract so callers can
564
+ # pass the project root now and path-specific checks can be extended without
565
+ # changing the Worker startup API.
566
+ _ = project_root
567
+ return list(dict.fromkeys(errors))
568
+
569
+
570
+ def normalize_team_tool_names(
571
+ raw_tools: object,
572
+ profile: AgentProfile,
573
+ ) -> tuple[list[str], list[str]]:
574
+ """Normalize Planner tool names to registered XG names.
575
+
576
+ Only aliases with an unambiguous, non-escalating meaning are accepted.
577
+ Role and resource policy checks remain separate and are applied afterwards.
578
+ """
579
+ if not isinstance(raw_tools, list):
580
+ return [], []
581
+ tools: list[str] = []
582
+ warnings: list[str] = []
583
+ seen: set[str] = set()
584
+ for raw in raw_tools:
585
+ original = str(raw).strip()
586
+ if not original:
587
+ continue
588
+ name = original.lower()
589
+ canonical = TEAM_TOOL_ALIASES.get(name, name)
590
+ if canonical != name:
591
+ warnings.append(f"{original} 已转换为 {canonical}")
592
+ if canonical not in CANONICAL_TEAM_TOOLS:
593
+ warnings.append(f"{original} 不是已注册工具")
594
+ continue
595
+ if profile.allowed_tools and canonical not in profile.allowed_tools:
596
+ warnings.append(f"{canonical} 超出角色 {profile.name} 的工具权限")
597
+ if canonical not in seen:
598
+ tools.append(canonical)
599
+ seen.add(canonical)
600
+ return tools, warnings
601
+
602
+
603
+ def default_profiles() -> dict[str, AgentProfile]:
604
+ all_tools = ()
605
+ readonly = ("read_file", "list_dir", "glob_files", "grep_code", "web_search", "web_fetch", "load_skill")
606
+ writable = ("read_file", "write_file", "list_dir", "glob_files", "grep_code", "execute_command", "web_search", "web_fetch", "load_skill")
607
+ return {
608
+ "coder": AgentProfile(
609
+ name="coder",
610
+ system_prompt="你是一名谨慎的代码实现 Agent。只处理当前任务,先阅读相关代码,再实现并验证;不要处理其他任务。",
611
+ allowed_tools=writable,
612
+ max_steps=12,
613
+ can_write=True,
614
+ ),
615
+ "researcher": AgentProfile(
616
+ name="researcher",
617
+ system_prompt="你是一名研究 Agent。只读取和分析资料,输出有来源的结论,不修改项目文件。",
618
+ allowed_tools=readonly,
619
+ max_steps=20,
620
+ ),
621
+ "tester": AgentProfile(
622
+ name="tester",
623
+ system_prompt="你是一名测试 Agent。负责编写或执行当前任务的测试,并准确报告测试命令和结果。",
624
+ allowed_tools=writable,
625
+ max_steps=12,
626
+ can_write=True,
627
+ ),
628
+ "reviewer": AgentProfile(
629
+ name="reviewer",
630
+ system_prompt=TEAM_REVIEWER_PROMPT,
631
+ allowed_tools=readonly,
632
+ max_steps=10,
633
+ is_reviewer=True,
634
+ ),
635
+ "repairer": AgentProfile(
636
+ name="repairer",
637
+ system_prompt="你是一名定向修复 Agent。只修复 Reviewer 列出的 required_fixes,不扩大任务范围。",
638
+ allowed_tools=writable,
639
+ max_steps=12,
640
+ can_write=True,
641
+ ),
642
+ "synthesizer": AgentProfile(
643
+ name="synthesizer",
644
+ system_prompt="你是一名结果汇总 Agent。只汇总已经验证的任务产物,不修改项目文件。",
645
+ allowed_tools=all_tools,
646
+ max_steps=8,
647
+ ),
648
+ }
649
+
650
+
651
+ def _strip_json(text: str) -> str:
652
+ text = text.strip()
653
+ if text.startswith("```"):
654
+ text = re.sub(r"^```[\w-]*\s*", "", text)
655
+ text = re.sub(r"\s*```$", "", text)
656
+ start, end = text.find("{"), text.rfind("}")
657
+ return text[start:end + 1] if start >= 0 and end > start else text
658
+
659
+
660
+ def _review_string_list(data: dict[str, object], field_name: str) -> list[str]:
661
+ raw = data.get(field_name, [])
662
+ if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
663
+ raise ValueError(f"{field_name} 必须是字符串数组")
664
+ return [item.strip() for item in raw if item.strip()]
665
+
666
+
667
+ def parse_review_output(
668
+ task_id: str,
669
+ raw: str,
670
+ ) -> ReviewResult | ReviewOutputError:
671
+ """严格解析 Reviewer JSON,避免非法输出触发 Repairer。"""
672
+ if not raw.strip():
673
+ return ReviewOutputError("review_output_empty", "Reviewer 返回了空内容")
674
+ try:
675
+ data = json.loads(_strip_json(raw))
676
+ except json.JSONDecodeError as exc:
677
+ return ReviewOutputError("review_output_not_json", f"Reviewer 输出不是合法 JSON:{exc}")
678
+ if not isinstance(data, dict):
679
+ return ReviewOutputError("review_output_wrong_shape", "Reviewer 输出顶层必须是 JSON 对象")
680
+
681
+ verdict = data.get("verdict")
682
+ if verdict not in {"pass", "fail", "needs_input"}:
683
+ return ReviewOutputError("review_verdict_invalid", "verdict 必须是 pass、fail 或 needs_input")
684
+ try:
685
+ findings = _review_string_list(data, "findings")
686
+ required_fixes = _review_string_list(data, "required_fixes")
687
+ evidence = _review_string_list(data, "evidence")
688
+ except ValueError as exc:
689
+ return ReviewOutputError("review_output_wrong_shape", str(exc))
690
+
691
+ raw_scope = data.get("repair_scope", [])
692
+ if not isinstance(raw_scope, list):
693
+ return ReviewOutputError("review_scope_invalid", "repair_scope 必须是对象数组")
694
+ claims: list[ResourceClaim] = []
695
+ for index, item in enumerate(raw_scope):
696
+ if not isinstance(item, dict):
697
+ return ReviewOutputError("review_scope_invalid", f"repair_scope[{index}] 必须是对象")
698
+ pattern = item.get("pattern")
699
+ access = item.get("access")
700
+ exclusive = item.get("exclusive", False)
701
+ if not isinstance(pattern, str) or not pattern.strip():
702
+ return ReviewOutputError("review_scope_invalid", f"repair_scope[{index}].pattern 无效")
703
+ if access not in {"read", "write"}:
704
+ return ReviewOutputError("review_scope_invalid", f"repair_scope[{index}].access 无效")
705
+ if not isinstance(exclusive, bool):
706
+ return ReviewOutputError("review_scope_invalid", f"repair_scope[{index}].exclusive 必须是布尔值")
707
+ claims.append(ResourceClaim(pattern.strip(), access, exclusive))
708
+
709
+ return ReviewResult(
710
+ task_id=task_id,
711
+ verdict=verdict, # type: ignore[arg-type]
712
+ findings=findings,
713
+ required_fixes=required_fixes,
714
+ evidence=evidence,
715
+ repair_scope=claims,
716
+ )
717
+
718
+
719
+ def parse_team_tasks(
720
+ raw: str,
721
+ max_tasks: int = 12,
722
+ profiles: dict[str, AgentProfile] | None = None,
723
+ ) -> tuple[list[TeamTask], list[str]]:
724
+ """解析并校验 Team Planner 输出。"""
725
+ try:
726
+ data = json.loads(_strip_json(raw))
727
+ except json.JSONDecodeError as exc:
728
+ raise PlanError(f"Team 计划 JSON 解析失败: {exc}") from exc
729
+ if not isinstance(data, dict) or not isinstance(data.get("tasks"), list) or not data["tasks"]:
730
+ raise PlanError('Team 计划顶层结构必须是 {"tasks": [...]} 且不能为空')
731
+
732
+ profiles = profiles or default_profiles()
733
+ tasks: list[TeamTask] = []
734
+ warnings: list[str] = []
735
+ for index, item in enumerate(data["tasks"]):
736
+ if not isinstance(item, dict):
737
+ raise PlanError(f"tasks[{index}] 必须是对象")
738
+ task_id = str(item.get("id", "")).strip()
739
+ title = str(item.get("title", "")).strip()
740
+ if not task_id or not title:
741
+ raise PlanError(f"tasks[{index}] 缺少 id 或 title")
742
+ deps_raw = item.get("deps", [])
743
+ if not isinstance(deps_raw, list):
744
+ raise PlanError(f"tasks[{index}].deps 必须是数组")
745
+ deps = list(dict.fromkeys(str(dep).strip() for dep in deps_raw if str(dep).strip()))
746
+ role = str(item.get("owner_role") or "coder").strip().lower()
747
+ if role not in profiles:
748
+ warnings.append(f"任务 {task_id} 使用未知角色 {role},已回退为 coder")
749
+ role = "coder"
750
+ allowed_raw = item.get("allowed_tools", [])
751
+ allowed, tool_warnings = normalize_team_tool_names(allowed_raw, profiles[role])
752
+ warnings.extend(f"任务 {task_id}:{warning}" for warning in tool_warnings)
753
+ invalid_tools: list[str] = []
754
+ if isinstance(allowed_raw, list):
755
+ for raw_name in allowed_raw:
756
+ original = str(raw_name).strip()
757
+ canonical = TEAM_TOOL_ALIASES.get(original.lower(), original.lower())
758
+ if canonical not in CANONICAL_TEAM_TOOLS or (
759
+ profiles[role].allowed_tools and canonical not in profiles[role].allowed_tools
760
+ ):
761
+ if original and original not in invalid_tools:
762
+ invalid_tools.append(original)
763
+ claims_raw = item.get("resource_claims", [])
764
+ claims: list[ResourceClaim] = []
765
+ if isinstance(claims_raw, list):
766
+ for claim in claims_raw:
767
+ if not isinstance(claim, dict):
768
+ continue
769
+ pattern = str(claim.get("pattern", "")).strip()
770
+ access = str(claim.get("access", "read")).strip().lower()
771
+ if pattern and access in {"read", "write"}:
772
+ claims.append(ResourceClaim(pattern, access, bool(claim.get("exclusive", False))))
773
+ mode_raw = str(item.get("resource_scope_mode", "")).strip().lower()
774
+ if mode_raw not in {"targeted", "read_discovery"}:
775
+ if mode_raw:
776
+ warnings.append(f"任务 {task_id} 使用未知资源模式 {mode_raw},已回退为 targeted")
777
+ mode = (
778
+ "read_discovery"
779
+ if role in READ_DISCOVERY_ROLES and not any(claim.access == "write" for claim in claims)
780
+ else "targeted"
781
+ )
782
+ else:
783
+ mode = mode_raw
784
+ if mode == "read_discovery" and (
785
+ role not in READ_DISCOVERY_ROLES
786
+ or any(claim.access == "write" for claim in claims)
787
+ ):
788
+ warnings.append(f"任务 {task_id} 的 read_discovery 与角色或写入声明冲突,已回退为 targeted")
789
+ mode = "targeted"
790
+ deny_raw = item.get("resource_deny_patterns", [])
791
+ deny_patterns = (
792
+ [str(pattern).strip() for pattern in deny_raw if str(pattern).strip()]
793
+ if isinstance(deny_raw, list) else []
794
+ )
795
+ criteria_raw = item.get("acceptance_criteria", [])
796
+ criteria = [str(value).strip() for value in criteria_raw if str(value).strip()] if isinstance(criteria_raw, list) else []
797
+ if not criteria:
798
+ criteria = [f"完成任务:{title}"]
799
+ warnings.append(f"任务 {task_id} 缺少验收标准,已使用标题作为最低验收标准")
800
+ description = str(item.get("description") or title).strip()
801
+ tasks.append(TeamTask(
802
+ id=task_id,
803
+ title=title,
804
+ description=description,
805
+ deps=deps,
806
+ owner_role=role,
807
+ allowed_tools=allowed,
808
+ allowed_tools_declared="allowed_tools" in item,
809
+ invalid_tools=invalid_tools,
810
+ tool_warnings=tool_warnings,
811
+ resource_claims=claims,
812
+ resource_scope_mode=mode, # type: ignore[arg-type]
813
+ resource_deny_patterns=deny_patterns,
814
+ acceptance_criteria=criteria,
815
+ input_artifacts=[str(value) for value in item.get("input_artifacts", []) if str(value)] if isinstance(item.get("input_artifacts", []), list) else [],
816
+ output_artifacts=[str(value) for value in item.get("output_artifacts", []) if str(value)] if isinstance(item.get("output_artifacts", []), list) else [],
817
+ ))
818
+
819
+ if len({task.id for task in tasks}) != len(tasks):
820
+ raise PlanError("Team 计划存在重复的任务 id")
821
+ if len(tasks) > max_tasks:
822
+ warnings.append(f"Team 任务数 {len(tasks)} 超过上限 {max_tasks},已截断")
823
+ kept = {task.id for task in tasks[:max_tasks]}
824
+ tasks = tasks[:max_tasks]
825
+ for task in tasks:
826
+ task.deps = [dep for dep in task.deps if dep in kept]
827
+ known = {task.id for task in tasks}
828
+ for task in tasks:
829
+ for dep in list(task.deps):
830
+ if dep == task.id:
831
+ task.deps.remove(dep)
832
+ warnings.append(f"任务 {task.id} 自依赖,已移除")
833
+ elif dep not in known:
834
+ task.deps.remove(dep)
835
+ warnings.append(f"任务 {task.id} 引用了不存在的依赖 {dep},已移除")
836
+ try:
837
+ build_batches(tasks) # TeamTask 使用同样的 id/deps 接口
838
+ except PlanError as exc:
839
+ raise PlanError(f"Team 依赖无效:{exc}") from exc
840
+ return tasks, warnings
841
+
842
+
843
+ def _resource_conflicts(left: TeamTask, right: TeamTask) -> bool:
844
+ for a in left.resource_claims:
845
+ for b in right.resource_claims:
846
+ if not (a.exclusive or b.exclusive or a.access == "write" or b.access == "write"):
847
+ continue
848
+ pa, pb = a.normalized(), b.normalized()
849
+ if fnmatch.fnmatch(pa, pb) or fnmatch.fnmatch(pb, pa) or pa.startswith(pb.rstrip("*").rstrip("/")) or pb.startswith(pa.rstrip("*").rstrip("/")):
850
+ return True
851
+ # 未声明资源的写入任务保守处理:无法知道它是否修改了相同资源。
852
+ return bool(not left.resource_claims and not right.resource_claims and _task_may_write(left) and _task_may_write(right))
853
+
854
+
855
+ def _task_may_write(task: TeamTask) -> bool:
856
+ if task.owner_role in {"coder", "tester", "repairer"}:
857
+ return True
858
+ return any(claim.access == "write" for claim in task.resource_claims)
859
+
860
+
861
+ def conflict_safe_batches(tasks: list[TeamTask]) -> list[list[str]]:
862
+ """在 DAG 批次上进一步按资源冲突做确定性串行化。"""
863
+ raw_batches = build_batches(tasks)
864
+ by_id = {task.id: task for task in tasks}
865
+ output: list[list[str]] = []
866
+ for batch in raw_batches:
867
+ safe: list[str] = []
868
+ for task_id in batch:
869
+ task = by_id[task_id]
870
+ conflict_index = next(
871
+ (index for index, existing_id in enumerate(safe) if _resource_conflicts(task, by_id[existing_id])),
872
+ None,
873
+ )
874
+ if conflict_index is None:
875
+ safe.append(task_id)
876
+ continue
877
+ # 把冲突任务放入新批次;保持确定性和原有排序。
878
+ output.append(safe)
879
+ safe = [task_id]
880
+ if safe:
881
+ output.append(safe)
882
+ return output
883
+
884
+
885
+ TaskReviewCallback = Callable[[TeamTask, list[Artifact]], Awaitable[ReviewResult]]
886
+
887
+
888
+ class TeamExecutor:
889
+ """Team 计划生成、调度、Worker 执行、审查和修复。"""
890
+
891
+ def __init__(
892
+ self,
893
+ llm: LlmClient,
894
+ tools: ToolRegistry,
895
+ settings: Settings,
896
+ reviewer: Callable[[TeamPlan], Awaitable[ReviewDecision]] | None = None,
897
+ task_reviewer: TaskReviewCallback | None = None,
898
+ approval_policy: HITLPolicy | None = None,
899
+ audit=None,
900
+ memory_manager: MemoryManager | None = None,
901
+ mcp_manager: "McpManager | None" = None,
902
+ profiles: dict[str, AgentProfile] | None = None,
903
+ artifact_store: ArtifactStore | None = None,
904
+ project_root: Path | None = None,
905
+ team_id: str | None = None,
906
+ agent_factory: AgentFactory | None = None,
907
+ ) -> None:
908
+ self.llm = llm
909
+ self.tools = tools
910
+ self.settings = settings
911
+ self.reviewer = reviewer
912
+ self.task_reviewer = task_reviewer
913
+ self.approval_policy = approval_policy
914
+ self.audit = audit
915
+ self.memory_manager = memory_manager
916
+ self.mcp_manager = mcp_manager
917
+ self.profiles = profiles or default_profiles()
918
+ self.artifacts: ArtifactStore = artifact_store or InMemoryArtifactStore()
919
+ self.project_root = (project_root or Path.cwd()).resolve()
920
+ self.team_id = team_id or f"team-{uuid.uuid4().hex[:8]}"
921
+ self.agent_factory = agent_factory
922
+ self._last_plan: TeamPlan | None = None
923
+
924
+ async def run(self, goal: str) -> AsyncIterator[TeamEvent]:
925
+ if self.mcp_manager is not None:
926
+ try:
927
+ await self.mcp_manager.ensure_started()
928
+ goal = await self.mcp_manager.expand_references(goal)
929
+ except Exception as exc:
930
+ yield TeamEvent(kind="team_failed", team_id=self.team_id, message=f"MCP resource 处理失败: {exc}")
931
+ return
932
+
933
+ yield TeamEvent(kind="team_started", team_id=self.team_id, message=goal)
934
+ self._audit("team_started", team_id=self.team_id, goal=goal)
935
+ try:
936
+ plan, warnings = await self._generate_plan(goal)
937
+ except LlmError as exc:
938
+ yield TeamEvent(kind="team_failed", team_id=self.team_id, message=f"团队计划生成失败: {exc}")
939
+ return
940
+ if plan is None:
941
+ yield TeamEvent(kind="team_failed", team_id=self.team_id, message="团队计划生成失败,建议改用 /plan 或普通 ReAct。")
942
+ return
943
+
944
+ yield TeamEvent(kind="team_plan_generated", team_id=self.team_id, plan=plan, message=";".join(warnings))
945
+ if self.reviewer is None:
946
+ yield TeamEvent(kind="cancelled", team_id=self.team_id, plan=plan, message="无计划审阅回调,Team 自动取消(fail closed)")
947
+ return
948
+ yield TeamEvent(kind="team_review", team_id=self.team_id, plan=plan, message="等待用户审阅团队计划")
949
+ decision = await self.reviewer(plan)
950
+ if decision.action == "cancel":
951
+ yield TeamEvent(kind="cancelled", team_id=self.team_id, plan=plan, message="用户取消团队计划")
952
+ return
953
+ if decision.action == "replan":
954
+ # MVP 只在首次审阅阶段支持一次显式重规划循环,保持和 /plan 一致。
955
+ yield TeamEvent(kind="replanned", team_id=self.team_id, plan=plan, message=decision.feedback)
956
+ try:
957
+ plan, warnings = await self._generate_plan(goal, decision.feedback, plan)
958
+ except LlmError as exc:
959
+ yield TeamEvent(kind="team_failed", team_id=self.team_id, message=f"团队重规划失败: {exc}")
960
+ return
961
+ if plan is None:
962
+ yield TeamEvent(kind="team_failed", team_id=self.team_id, message="团队重规划失败,建议改用 /plan 或普通 ReAct。")
963
+ return
964
+ yield TeamEvent(kind="team_plan_generated", team_id=self.team_id, plan=plan, message=";".join(warnings))
965
+ yield TeamEvent(kind="team_review", team_id=self.team_id, plan=plan, message="等待用户审阅重规划")
966
+ decision = await self.reviewer(plan)
967
+ if decision.action != "execute":
968
+ yield TeamEvent(kind="cancelled", team_id=self.team_id, plan=plan, message="团队计划未获批准")
969
+ return
970
+
971
+ yield TeamEvent(kind="approved", team_id=self.team_id, plan=plan, message="团队计划已批准,开始执行")
972
+ self._last_plan = plan
973
+ plan.batches = conflict_safe_batches(plan.tasks)
974
+ async for event in self._execute_plan_batches(plan):
975
+ yield event
976
+
977
+ async def _execute_plan_batches(self, plan: TeamPlan, instruction: str = "") -> AsyncIterator[TeamEvent]:
978
+ """按批次执行团队任务;遇 needs_input/failed/超限终止。instruction 注入每个被执行 worker。"""
979
+ for batch_number, batch in enumerate(plan.batches, 1):
980
+ yield TeamEvent(
981
+ kind="batch_started", team_id=self.team_id, plan=plan, batch=batch,
982
+ message=f"第 {batch_number} 轮 / 共 {len(plan.batches)} 轮",
983
+ )
984
+ async for event in self._run_batch(plan, batch, instruction=instruction):
985
+ yield event
986
+ waiting = [task for task in plan.tasks if task.status == "needs_input"]
987
+ if waiting:
988
+ return
989
+ failed = [task for task in plan.tasks if task.status == "failed"]
990
+ if failed:
991
+ blocked = self._block_dependents(plan, {task.id for task in failed})
992
+ for task in blocked:
993
+ yield TeamEvent(
994
+ kind="task_blocked",
995
+ team_id=self.team_id,
996
+ plan=plan,
997
+ task=task,
998
+ role=task.owner_role,
999
+ message=task.result,
1000
+ )
1001
+ if len(failed) > self.settings.plan_max_failures:
1002
+ message = (
1003
+ f"失败任务数 {len(failed)} 超过上限 "
1004
+ f"{self.settings.plan_max_failures};阻塞任务数 {len(blocked)}"
1005
+ )
1006
+ else:
1007
+ message = (
1008
+ f"Team 因 {len(failed)} 个任务失败而停止;"
1009
+ f"阻塞任务数 {len(blocked)}"
1010
+ )
1011
+ yield TeamEvent(
1012
+ kind="team_failed", team_id=self.team_id, plan=plan,
1013
+ message=message,
1014
+ )
1015
+ return
1016
+
1017
+ done = sum(task.status == "done" for task in plan.tasks)
1018
+ if done != len(plan.tasks):
1019
+ yield TeamEvent(kind="team_failed", team_id=self.team_id, plan=plan, message=f"Team 未完成:{done}/{len(plan.tasks)} 个任务通过")
1020
+ return
1021
+ yield TeamEvent(kind="team_done", team_id=self.team_id, plan=plan, message=f"Team 完成:{done}/{len(plan.tasks)} 个任务通过")
1022
+
1023
+ # ---- 断点续跑(V3)----
1024
+
1025
+ async def resume(self, instruction: str = "") -> AsyncIterator[TeamEvent]:
1026
+ """Team 级断点续跑:从失败/阻塞/待办任务继续。needs_input 任务需用户先补充范围。"""
1027
+ plan = self._last_plan
1028
+ if plan is None:
1029
+ yield TeamEvent(kind="team_failed", team_id=self.team_id, message="没有可恢复的 Team 计划")
1030
+ return
1031
+ waiting = [task for task in plan.tasks if task.status == "needs_input"]
1032
+ if waiting:
1033
+ task = waiting[0]
1034
+ yield TeamEvent(
1035
+ kind="task_needs_input", team_id=self.team_id, plan=plan, task=task,
1036
+ role=task.owner_role, failure_category=task.pending_input_category,
1037
+ message=";".join(
1038
+ filter(None, [task.pending_input_message, "任务等待输入,请先通过 /team resume 或自然语言补充范围后再继续"])
1039
+ ),
1040
+ )
1041
+ return
1042
+ done = sum(task.status == "done" for task in plan.tasks)
1043
+ if done == len(plan.tasks):
1044
+ yield TeamEvent(kind="team_failed", team_id=self.team_id, plan=plan, message="团队计划已全部完成,无需恢复")
1045
+ return
1046
+ # 重置 failed/blocked/pending/running 任务为 pending(done 保留 Artifact/result 供复用)
1047
+ for task in plan.tasks:
1048
+ if task.status in ("failed", "blocked", "pending", "running"):
1049
+ task.status = "pending"
1050
+ task.result = ""
1051
+ task.blocked_by = []
1052
+ task.failure_category = ""
1053
+ plan.batches = conflict_safe_batches(plan.tasks)
1054
+ skipped = [task.id for task in plan.tasks if task.status == "done"]
1055
+ yield TeamEvent(
1056
+ kind="team_resume_requested", team_id=self.team_id, plan=plan,
1057
+ message=(
1058
+ f"Team 恢复执行:跳过 {len(skipped)} 个已完成任务,"
1059
+ f"重跑 {sum(1 for b in plan.batches for _ in b)} 个任务"
1060
+ + (f";补充指令:{instruction}" if instruction else "")
1061
+ ),
1062
+ )
1063
+ async for event in self._execute_plan_batches(plan, instruction=instruction):
1064
+ yield event
1065
+
1066
+ async def _generate_plan(
1067
+ self, goal: str, feedback: str = "", previous: TeamPlan | None = None
1068
+ ) -> tuple[TeamPlan | None, list[str]]:
1069
+ context = ConversationContext(
1070
+ TEAM_PLANNER_PROMPT,
1071
+ self.settings,
1072
+ shared_provider=self.memory_manager.shared_sections if self.memory_manager else None,
1073
+ )
1074
+ parts = [f"任务:{goal}", f"请拆解为不超过 {self.settings.plan_max_subtasks} 个团队任务。"]
1075
+ if previous is not None:
1076
+ parts.append("上一版计划任务:\n" + "\n".join(f"- {task.id}: {task.title}" for task in previous.tasks))
1077
+ if feedback:
1078
+ parts.append(f"用户反馈(必须满足):{feedback}")
1079
+ context.append(Message(role="user", content="\n\n".join(parts)))
1080
+ budget = await context.ensure_budget(self.llm)
1081
+ if not budget.proceed:
1082
+ raise LlmError(budget.message or "团队规划上下文超出模型窗口")
1083
+ messages = context.build_messages()
1084
+ warnings: list[str] = []
1085
+ for _ in range(1 + TEAM_PLAN_MAX_RETRIES):
1086
+ raw = await self._llm_text(messages)
1087
+ try:
1088
+ tasks, task_warnings = parse_team_tasks(
1089
+ raw, self.settings.plan_max_subtasks, profiles=self.profiles
1090
+ )
1091
+ except PlanError as exc:
1092
+ messages.extend([
1093
+ Message(role="assistant", content=raw),
1094
+ Message(role="user", content=f"上面的 Team JSON 无效:{exc}。请修复并重新输出完整 JSON。"),
1095
+ ])
1096
+ continue
1097
+ warnings.extend(task_warnings)
1098
+ return TeamPlan(goal=goal, tasks=tasks, batches=conflict_safe_batches(tasks)), warnings
1099
+ return None, warnings
1100
+
1101
+ async def _run_batch(self, plan: TeamPlan, batch: list[str], instruction: str = "") -> AsyncIterator[TeamEvent]:
1102
+ # 断点续跑时跳过已完成(done)任务,避免重复执行
1103
+ pending = [tid for tid in batch if plan.task_by_id(tid).status != "done"]
1104
+ if not pending:
1105
+ return
1106
+ queue: asyncio.Queue[TeamEvent | None] = asyncio.Queue()
1107
+
1108
+ async def runner(task_id: str) -> None:
1109
+ try:
1110
+ await self._run_task(plan, task_id, queue, instruction=instruction)
1111
+ finally:
1112
+ queue.put_nowait(None)
1113
+
1114
+ semaphore = asyncio.Semaphore(max(1, getattr(self.settings, "team_max_agents", 4)))
1115
+
1116
+ async def limited_runner(task_id: str) -> None:
1117
+ async with semaphore:
1118
+ await runner(task_id)
1119
+
1120
+ jobs = [asyncio.create_task(limited_runner(task_id)) for task_id in pending]
1121
+ completed = 0
1122
+ while completed < len(jobs):
1123
+ item = await queue.get()
1124
+ if item is None:
1125
+ completed += 1
1126
+ else:
1127
+ yield item
1128
+ await asyncio.gather(*jobs, return_exceptions=True)
1129
+
1130
+ @staticmethod
1131
+ def _block_dependents(plan: TeamPlan, failed_ids: set[str]) -> list[TeamTask]:
1132
+ """Mark only downstream tasks as blocked; they were never executed."""
1133
+ blocked: list[TeamTask] = []
1134
+ changed = True
1135
+ while changed:
1136
+ changed = False
1137
+ for task in plan.tasks:
1138
+ if task.status != "pending" or not any(
1139
+ dep in failed_ids or dep in {item.id for item in blocked}
1140
+ for dep in task.deps
1141
+ ):
1142
+ continue
1143
+ blockers = [
1144
+ dep for dep in task.deps
1145
+ if dep in failed_ids or any(item.id == dep for item in blocked)
1146
+ ]
1147
+ task.status = "blocked"
1148
+ task.blocked_by = blockers
1149
+ task.result = f"依赖任务未通过,未执行:{', '.join(blockers)}"
1150
+ task.failure_category = "dependency_blocked"
1151
+ blocked.append(task)
1152
+ changed = True
1153
+ return blocked
1154
+
1155
+ def _pause_task_for_input(
1156
+ self,
1157
+ plan: TeamPlan,
1158
+ task: TeamTask,
1159
+ queue: asyncio.Queue[TeamEvent | None],
1160
+ *,
1161
+ category: str,
1162
+ message: str,
1163
+ review: ReviewResult | None = None,
1164
+ scope_claims: list[ResourceClaim] | None = None,
1165
+ ) -> None:
1166
+ """Pause safely instead of converting an unresolved decision to failure."""
1167
+ task.status = "needs_input"
1168
+ task.failure_category = category
1169
+ task.pending_input_category = category
1170
+ task.pending_input_message = message
1171
+ task.pending_repair_scope = list(scope_claims or [])
1172
+ task.pending_review = review
1173
+ task.result = message[:TEAM_RESULT_LIMIT]
1174
+ queue.put_nowait(TeamEvent(
1175
+ kind="repair_scope_required" if category.startswith("repair_scope") else "task_needs_input",
1176
+ team_id=self.team_id, plan=plan, task=task, role=task.owner_role,
1177
+ failure_category=category, scope_claims=list(task.pending_repair_scope),
1178
+ repair_attempts_started=task.repair_attempts_started,
1179
+ repair_attempts_blocked=task.repair_attempts_blocked,
1180
+ message=message,
1181
+ review=review,
1182
+ ))
1183
+
1184
+ async def resume_task_with_repair_scope(
1185
+ self,
1186
+ task_id: str,
1187
+ claims: list[ResourceClaim],
1188
+ ) -> AsyncIterator[TeamEvent]:
1189
+ """Resume a paused task after revalidating an explicit write scope."""
1190
+ plan = self._last_plan
1191
+ if plan is None:
1192
+ yield TeamEvent(
1193
+ kind="team_failed", team_id=self.team_id,
1194
+ message="没有可恢复的 Team 计划",
1195
+ )
1196
+ return
1197
+ task = plan.task_by_id(task_id)
1198
+ if task is None or task.status != "needs_input" or task.pending_review is None:
1199
+ yield TeamEvent(
1200
+ kind="task_needs_input", team_id=self.team_id, plan=plan, task=task,
1201
+ failure_category="resume_invalid", message="任务不存在或当前不处于等待输入状态",
1202
+ )
1203
+ return
1204
+
1205
+ queue: asyncio.Queue[TeamEvent | None] = asyncio.Queue()
1206
+ review = task.pending_review
1207
+ repair_claims = [
1208
+ ResourceClaim(claim.pattern, "write", claim.exclusive)
1209
+ for claim in claims
1210
+ if claim.access == "write" and claim.pattern.strip()
1211
+ ]
1212
+ repair = replace(
1213
+ task,
1214
+ id=f"{task.id}-repair-{task.repair_attempts_started + 1}",
1215
+ title=f"修复:{task.title}",
1216
+ description="\n".join(review.required_fixes or review.findings) or "根据审查结果修复任务",
1217
+ owner_role="repairer",
1218
+ allowed_tools=[],
1219
+ allowed_tools_declared=False,
1220
+ invalid_tools=[],
1221
+ tool_warnings=[],
1222
+ resource_scope_mode="targeted",
1223
+ resource_claims=repair_claims,
1224
+ resource_deny_patterns=list(task.resource_deny_patterns),
1225
+ deps=[],
1226
+ status="pending",
1227
+ result="",
1228
+ artifacts=[],
1229
+ failure_category="",
1230
+ blocked_by=[],
1231
+ recovery_attempts=0,
1232
+ )
1233
+ repair_profile = self.profiles.get("repairer") or self.profiles["coder"]
1234
+ policy_errors = validate_task_resource_policy(repair, repair_profile, self.project_root)
1235
+ if policy_errors:
1236
+ task.repair_attempts_blocked += 1
1237
+ self._pause_task_for_input(
1238
+ plan, task, queue,
1239
+ category="repair_scope_missing" if not repair_claims else "repair_scope_unsafe",
1240
+ message=";".join(policy_errors), review=review, scope_claims=repair_claims,
1241
+ )
1242
+ while not queue.empty():
1243
+ event = queue.get_nowait()
1244
+ if event is not None:
1245
+ yield event
1246
+ return
1247
+
1248
+ task.status = "running"
1249
+ task.pending_input_category = ""
1250
+ task.pending_input_message = ""
1251
+ task.pending_repair_scope = list(repair_claims)
1252
+ task.repair_attempts_started += 1
1253
+ task.attempts = task.repair_attempts_started
1254
+ queue.put_nowait(TeamEvent(
1255
+ kind="task_resume_requested", team_id=self.team_id, plan=plan, task=task,
1256
+ role="repairer", scope_claims=list(repair_claims),
1257
+ message="已确认修复范围,继续执行 Repairer",
1258
+ ))
1259
+ queue.put_nowait(TeamEvent(
1260
+ kind="repair_scope_validated", team_id=self.team_id, plan=plan, task=task,
1261
+ role="repairer", scope_claims=list(repair_claims),
1262
+ message="Repairer 写入范围校验通过",
1263
+ ))
1264
+ queue.put_nowait(TeamEvent(
1265
+ kind="repair_requested", team_id=self.team_id, plan=plan, task=repair,
1266
+ role="repairer", attempt=task.repair_attempts_started,
1267
+ message=repair.description,
1268
+ ))
1269
+ result, artifacts, agent_id, error, category = await self._execute_worker(
1270
+ plan, repair, queue, attempt=task.repair_attempts_started,
1271
+ )
1272
+ if error:
1273
+ task.status = "failed"
1274
+ task.failure_category = category or "execution_failed"
1275
+ task.result = error[:TEAM_RESULT_LIMIT]
1276
+ queue.put_nowait(TeamEvent(
1277
+ kind="agent_failed", team_id=self.team_id, plan=plan, task=repair,
1278
+ agent_id=agent_id, role="repairer", attempt=task.repair_attempts_started,
1279
+ failure_category=task.failure_category, message=error,
1280
+ ))
1281
+ queue.put_nowait(TeamEvent(
1282
+ kind="task_failed", team_id=self.team_id, plan=plan, task=task,
1283
+ agent_id=agent_id, role=task.owner_role,
1284
+ failure_category=task.failure_category, message=error,
1285
+ ))
1286
+ else:
1287
+ await self._publish_artifacts(
1288
+ plan, repair, artifacts, agent_id, queue,
1289
+ attempt=task.repair_attempts_started,
1290
+ )
1291
+ task.result = result[:TEAM_RESULT_LIMIT]
1292
+ try:
1293
+ next_review = await self._review(plan, task, artifacts, queue)
1294
+ except Exception as exc:
1295
+ next_review = ReviewResult(
1296
+ task.id, "needs_input", [f"Reviewer 执行失败:{exc}"],
1297
+ ["重新执行任务审查"], [], [], "review_execution_failed",
1298
+ )
1299
+ if next_review.verdict == "pass":
1300
+ task.status = "done"
1301
+ task.pending_review = None
1302
+ queue.put_nowait(TeamEvent(
1303
+ kind="task_done", team_id=self.team_id, plan=plan, task=task,
1304
+ message=f"修复后通过:{task.result}",
1305
+ ))
1306
+ if all(item.status == "done" for item in plan.tasks):
1307
+ queue.put_nowait(TeamEvent(
1308
+ kind="team_done", team_id=self.team_id, plan=plan,
1309
+ message=f"Team 完成:{sum(item.status == 'done' for item in plan.tasks)}/{len(plan.tasks)} 个任务通过",
1310
+ ))
1311
+ else:
1312
+ self._pause_task_for_input(
1313
+ plan, task, queue,
1314
+ category=next_review.category or "review_output_invalid",
1315
+ message=";".join(next_review.findings or next_review.required_fixes) or "Reviewer 需要用户处理",
1316
+ review=next_review,
1317
+ )
1318
+
1319
+ while not queue.empty():
1320
+ event = queue.get_nowait()
1321
+ if event is not None:
1322
+ yield event
1323
+
1324
+ async def _run_task(self, plan: TeamPlan, task_id: str, queue: asyncio.Queue[TeamEvent | None], instruction: str = "") -> None:
1325
+ task = plan.task_by_id(task_id)
1326
+ if task is None:
1327
+ return
1328
+ dependencies = [plan.task_by_id(dep) for dep in task.deps]
1329
+ if any(dep is None or dep.status != "done" for dep in dependencies):
1330
+ task.status = "blocked"
1331
+ task.blocked_by = [
1332
+ dep_id for dep_id, dep in zip(task.deps, dependencies)
1333
+ if dep is None or dep.status != "done"
1334
+ ]
1335
+ task.result = f"依赖任务未通过,未执行:{', '.join(task.blocked_by)}"
1336
+ task.failure_category = "dependency_blocked"
1337
+ queue.put_nowait(TeamEvent(
1338
+ kind="task_blocked", team_id=self.team_id, plan=plan,
1339
+ task=task, role=task.owner_role, message=task.result,
1340
+ ))
1341
+ return
1342
+
1343
+ task.status = "running"
1344
+ self._audit("team_task_started", team_id=self.team_id, task_id=task.id, role=task.owner_role)
1345
+ queue.put_nowait(TeamEvent(kind="task_started", team_id=self.team_id, plan=plan, task=task, role=task.owner_role))
1346
+ profile = self.profiles.get(task.owner_role) or self.profiles["coder"]
1347
+ result, artifacts, agent_id, error, failure_category = await self._execute_worker(
1348
+ plan, task, queue, attempt=1, instruction=instruction
1349
+ )
1350
+ first_agent_id = agent_id
1351
+ if error:
1352
+ queue.put_nowait(TeamEvent(
1353
+ kind="agent_failed", team_id=self.team_id, plan=plan, task=task,
1354
+ agent_id=agent_id, role=task.owner_role, attempt=1,
1355
+ failure_category=failure_category, message=error,
1356
+ ))
1357
+ max_recoveries = max(0, getattr(self.settings, "team_max_recoveries", TEAM_MAX_RECOVERIES))
1358
+ if error and task.recovery_attempts < max_recoveries and self._should_recover(task, profile, failure_category):
1359
+ recovery_steps = self._recovery_steps()
1360
+ task.recovery_attempts += 1
1361
+ preserved_ids = [artifact.id for artifact in artifacts]
1362
+ await self._publish_artifacts(plan, task, artifacts, agent_id, queue, attempt=1)
1363
+ queue.put_nowait(TeamEvent(
1364
+ kind="task_retry_started", team_id=self.team_id, plan=plan, task=task,
1365
+ role=task.owner_role, attempt=task.recovery_attempts + 1,
1366
+ failure_category=failure_category, retryable=True,
1367
+ previous_steps=self._effective_steps(profile), retry_steps=recovery_steps,
1368
+ preserved_artifacts=preserved_ids,
1369
+ message="只读任务达到步数上限,使用已有证据进行一次受控恢复",
1370
+ ))
1371
+ recovery_summary = self._recovery_summary(task, artifacts)
1372
+ result, recovery_artifacts, recovery_agent_id, recovery_error, recovery_category = await self._execute_worker(
1373
+ plan, task, queue, attempt=task.recovery_attempts + 1,
1374
+ steps_override=recovery_steps, recovery_summary=recovery_summary,
1375
+ )
1376
+ artifacts.extend(recovery_artifacts)
1377
+ agent_id = recovery_agent_id
1378
+ if recovery_error:
1379
+ error = f"{error};恢复执行仍失败:{recovery_error}"
1380
+ failure_category = recovery_category or failure_category
1381
+ else:
1382
+ error = ""
1383
+ failure_category = ""
1384
+ if error:
1385
+ await self._publish_artifacts(plan, task, artifacts, agent_id, queue, attempt=task.recovery_attempts + 1)
1386
+ task.status = "failed"
1387
+ task.result = error[:TEAM_RESULT_LIMIT]
1388
+ task.failure_category = failure_category or "execution_failed"
1389
+ self._audit("team_task_failed", team_id=self.team_id, task_id=task.id, role=task.owner_role, error=task.result)
1390
+ if agent_id != first_agent_id:
1391
+ queue.put_nowait(TeamEvent(
1392
+ kind="agent_failed", team_id=self.team_id, plan=plan, task=task,
1393
+ agent_id=agent_id, role=task.owner_role,
1394
+ attempt=task.recovery_attempts + 1,
1395
+ failure_category=task.failure_category, message=task.result,
1396
+ ))
1397
+ queue.put_nowait(TeamEvent(
1398
+ kind="task_failed", team_id=self.team_id, plan=plan, task=task,
1399
+ agent_id=agent_id, role=task.owner_role, failure_category=task.failure_category,
1400
+ attempt=task.recovery_attempts + 1, retryable=False, message=task.result,
1401
+ ))
1402
+ return
1403
+ task.result = result[:TEAM_RESULT_LIMIT]
1404
+ await self._publish_artifacts(plan, task, artifacts, agent_id, queue, attempt=task.recovery_attempts + 1)
1405
+ try:
1406
+ review = await self._review(plan, task, artifacts, queue)
1407
+ except Exception as exc:
1408
+ review = ReviewResult(
1409
+ task.id, "needs_input", [f"Reviewer 执行失败:{exc}"],
1410
+ ["重新执行任务审查"], [], [], "review_execution_failed",
1411
+ )
1412
+ if review.verdict == "pass":
1413
+ task.status = "done"
1414
+ self._audit("team_task_done", team_id=self.team_id, task_id=task.id, role=task.owner_role, result=task.result)
1415
+ queue.put_nowait(TeamEvent(kind="task_done", team_id=self.team_id, plan=plan, task=task, message=task.result))
1416
+ return
1417
+ if review.verdict == "needs_input":
1418
+ self._pause_task_for_input(
1419
+ plan, task, queue,
1420
+ category=review.category or "review_output_invalid",
1421
+ message=";".join(review.findings or review.required_fixes) or "Reviewer 需要用户处理",
1422
+ review=review,
1423
+ )
1424
+ return
1425
+
1426
+ max_repairs = max(0, getattr(self.settings, "team_max_repairs", TEAM_MAX_RETRIES))
1427
+ for attempt in range(1, max_repairs + 1):
1428
+ repair_claims, scope_warnings = build_repair_scope(task, review)
1429
+ if not repair_claims:
1430
+ self._pause_task_for_input(
1431
+ plan, task, queue,
1432
+ category="repair_scope_missing",
1433
+ message="Repairer 尚未启动:没有明确的 write claim,请确认允许修改的文件范围",
1434
+ review=review,
1435
+ )
1436
+ return
1437
+ repair_description = "\n".join(review.required_fixes or review.findings) or "根据审查结果修复任务"
1438
+ if scope_warnings:
1439
+ repair_description += "\n\n修复范围提示:" + ";".join(scope_warnings)
1440
+ repair = replace(
1441
+ task,
1442
+ id=f"{task.id}-repair-{task.repair_attempts_started + 1}",
1443
+ title=f"修复:{task.title}",
1444
+ description=repair_description,
1445
+ owner_role="repairer",
1446
+ # A role change must not inherit the original role's tool or
1447
+ # discovery policy. Empty means use the repairer profile tools.
1448
+ allowed_tools=[],
1449
+ allowed_tools_declared=False,
1450
+ invalid_tools=[],
1451
+ tool_warnings=[],
1452
+ resource_scope_mode="targeted",
1453
+ resource_claims=[
1454
+ ResourceClaim(claim.pattern, "write", claim.exclusive)
1455
+ for claim in repair_claims
1456
+ ],
1457
+ resource_deny_patterns=list(task.resource_deny_patterns),
1458
+ deps=[],
1459
+ status="pending",
1460
+ result="",
1461
+ artifacts=[],
1462
+ failure_category="",
1463
+ blocked_by=[],
1464
+ recovery_attempts=0,
1465
+ )
1466
+ repair_profile = self.profiles.get("repairer") or self.profiles["coder"]
1467
+ policy_errors = validate_task_resource_policy(repair, repair_profile, self.project_root)
1468
+ if policy_errors:
1469
+ task.repair_attempts_blocked += 1
1470
+ category = (
1471
+ "repair_scope_missing"
1472
+ if any(error.startswith("repair_scope_missing") for error in policy_errors)
1473
+ else "repair_scope_unsafe"
1474
+ )
1475
+ self._pause_task_for_input(
1476
+ plan, task, queue,
1477
+ category=category,
1478
+ message=";".join(policy_errors),
1479
+ review=review,
1480
+ scope_claims=repair_claims,
1481
+ )
1482
+ return
1483
+ task.attempts = task.repair_attempts_started + 1
1484
+ task.repair_attempts_started += 1
1485
+ queue.put_nowait(TeamEvent(kind="repair_requested", team_id=self.team_id, plan=plan, task=repair, role="repairer", message=repair.description))
1486
+ repair_result, repair_artifacts, repair_agent_id, repair_error, repair_category = await self._execute_worker(
1487
+ plan, repair, queue, attempt=task.repair_attempts_started
1488
+ )
1489
+ if repair_error:
1490
+ queue.put_nowait(TeamEvent(
1491
+ kind="agent_failed", team_id=self.team_id, plan=plan, task=repair,
1492
+ agent_id=repair_agent_id, role="repairer", attempt=task.repair_attempts_started,
1493
+ failure_category=repair_category or "execution_failed", message=repair_error,
1494
+ ))
1495
+ review = ReviewResult(task.id, "fail", [repair_error], [repair_error], [], [], repair_category or "execution_failed")
1496
+ else:
1497
+ await self._publish_artifacts(plan, repair, repair_artifacts, repair_agent_id, queue, attempt=task.repair_attempts_started)
1498
+ task.result = repair_result[:TEAM_RESULT_LIMIT]
1499
+ try:
1500
+ review = await self._review(plan, task, repair_artifacts, queue)
1501
+ except Exception as exc:
1502
+ review = ReviewResult(task.id, "needs_input", [f"Reviewer 执行失败:{exc}"], ["重新执行任务审查"], [], [], "review_execution_failed")
1503
+ if review.verdict == "pass":
1504
+ task.status = "done"
1505
+ queue.put_nowait(TeamEvent(kind="task_done", team_id=self.team_id, plan=plan, task=task, message=f"修复后通过:{task.result}"))
1506
+ return
1507
+ if review.verdict == "needs_input":
1508
+ self._pause_task_for_input(
1509
+ plan, task, queue,
1510
+ category=review.category or "review_output_invalid",
1511
+ message=";".join(review.findings or review.required_fixes) or "Reviewer 需要用户处理",
1512
+ review=review,
1513
+ )
1514
+ return
1515
+ task.status = "failed"
1516
+ task.failure_category = "review_failed"
1517
+ task.result = "; ".join(review.findings or review.required_fixes)[:TEAM_RESULT_LIMIT] or "审查未通过且修复次数已用尽"
1518
+ self._audit("team_task_failed", team_id=self.team_id, task_id=task.id, role=task.owner_role, error=task.result)
1519
+ queue.put_nowait(TeamEvent(kind="task_failed", team_id=self.team_id, plan=plan, task=task, review=review, message=task.result))
1520
+
1521
+ def _effective_steps(self, profile: AgentProfile) -> int:
1522
+ configured = getattr(self.settings, f"team_{profile.name}_steps", None)
1523
+ candidate = configured or profile.max_steps or self.settings.plan_subtask_steps
1524
+ maximum = max(1, getattr(self.settings, "team_max_steps", 40))
1525
+ return max(1, min(int(candidate), maximum))
1526
+
1527
+ def _recovery_steps(self) -> int:
1528
+ maximum = max(1, getattr(self.settings, "team_max_steps", 40))
1529
+ configured = max(1, getattr(self.settings, "team_recovery_steps", TEAM_RECOVERY_STEPS))
1530
+ return min(configured, maximum)
1531
+
1532
+ @staticmethod
1533
+ def _should_recover(task: TeamTask, profile: AgentProfile, failure_category: str) -> bool:
1534
+ if failure_category != "step_limit" or profile.name not in READ_DISCOVERY_ROLES:
1535
+ return False
1536
+ if profile.can_write or "write_file" in profile.allowed_tools or "execute_command" in profile.allowed_tools:
1537
+ return False
1538
+ if any(claim.access == "write" for claim in task.resource_claims):
1539
+ return False
1540
+ if task.resource_scope_mode not in {"targeted", "read_discovery"}:
1541
+ return False
1542
+ if task.allowed_tools and any(tool not in READ_DISCOVERY_TOOLS for tool in task.allowed_tools):
1543
+ return False
1544
+ return True
1545
+
1546
+ @staticmethod
1547
+ def _recovery_summary(task: TeamTask, artifacts: list[Artifact]) -> str:
1548
+ evidence = "\n".join(
1549
+ f"- {artifact.id}: {artifact.uri} — {artifact.summary[:600]}"
1550
+ for artifact in artifacts
1551
+ ) or "- 暂无可复用 Artifact"
1552
+ criteria = "\n".join(f"- {item}" for item in task.acceptance_criteria) or "- 未提供验收标准"
1553
+ return (
1554
+ "上一次只读执行已达到步数上限。请复用以下已有证据,只补齐未完成的验收项,"
1555
+ "不要重复扫描已经确认的内容,也不要修改项目文件。\n"
1556
+ f"任务验收标准:\n{criteria}\n已有证据:\n{evidence}"
1557
+ )
1558
+
1559
+ async def _publish_artifacts(
1560
+ self,
1561
+ plan: TeamPlan,
1562
+ task: TeamTask,
1563
+ artifacts: list[Artifact],
1564
+ agent_id: str,
1565
+ queue: asyncio.Queue[TeamEvent | None],
1566
+ *,
1567
+ attempt: int,
1568
+ ) -> None:
1569
+ existing = set(task.artifacts)
1570
+ for artifact in artifacts:
1571
+ if artifact.id in existing:
1572
+ continue
1573
+ artifact.attempt = attempt
1574
+ await self.artifacts.publish(artifact)
1575
+ task.artifacts.append(artifact.id)
1576
+ existing.add(artifact.id)
1577
+ queue.put_nowait(TeamEvent(
1578
+ kind="artifact_produced", team_id=self.team_id, plan=plan, task=task,
1579
+ agent_id=agent_id, role=task.owner_role, artifact=artifact, attempt=attempt,
1580
+ ))
1581
+
1582
+ async def _execute_worker(
1583
+ self,
1584
+ plan: TeamPlan,
1585
+ task: TeamTask,
1586
+ queue: asyncio.Queue[TeamEvent | None],
1587
+ *,
1588
+ attempt: int = 1,
1589
+ steps_override: int | None = None,
1590
+ recovery_summary: str = "",
1591
+ instruction: str = "",
1592
+ ) -> tuple[str, list[Artifact], str, str, str]:
1593
+ profile = self.profiles.get(task.owner_role) or self.profiles["coder"]
1594
+ agent_id = f"agent-{uuid.uuid4().hex[:8]}"
1595
+ policy_errors = validate_task_resource_policy(task, profile, self.project_root)
1596
+ if policy_errors:
1597
+ category = (
1598
+ "repair_scope_missing"
1599
+ if any(error.startswith("repair_scope_missing") for error in policy_errors)
1600
+ else "resource_policy_invalid"
1601
+ )
1602
+ return "", [], agent_id, ";".join(policy_errors), category
1603
+ effective_steps = steps_override or self._effective_steps(profile)
1604
+ queue.put_nowait(TeamEvent(
1605
+ kind="agent_started", team_id=self.team_id, plan=plan, task=task,
1606
+ agent_id=agent_id, role=profile.name, attempt=attempt,
1607
+ effective_steps=effective_steps,
1608
+ message=f"预算 {effective_steps} 步" if attempt == 1 else f"恢复预算 {effective_steps} 步",
1609
+ ))
1610
+ sub_settings = replace(
1611
+ self.settings,
1612
+ tool_steps=effective_steps,
1613
+ )
1614
+ scoped_tools = ScopedToolRegistry(self.tools, task, self.project_root, profile)
1615
+ prompt = self._worker_system_prompt(plan, task, profile)
1616
+ if self.agent_factory is not None:
1617
+ agent = self.agent_factory.create(profile, task)
1618
+ else:
1619
+ agent = ReActAgent(
1620
+ llm=self.llm,
1621
+ tools=scoped_tools, # type: ignore[arg-type]
1622
+ settings=sub_settings,
1623
+ system_prompt=prompt,
1624
+ approval_policy=self.approval_policy,
1625
+ audit=self.audit,
1626
+ memory_manager=self.memory_manager,
1627
+ mcp_manager=self.mcp_manager,
1628
+ )
1629
+ artifacts: list[Artifact] = []
1630
+ try:
1631
+ async for event in agent.run(self._worker_user_prompt(task, recovery_summary=recovery_summary, instruction=instruction)):
1632
+ if event.kind in {
1633
+ "thinking", "content", "tool_call", "approval", "tool_result", "retrying",
1634
+ "context_compacted", "context_warning", "context_usage", "usage",
1635
+ }:
1636
+ queue.put_nowait(TeamEvent(kind="subtask_event", team_id=self.team_id, plan=plan, task=task, agent_id=agent_id, role=profile.name, agent_event=event))
1637
+ if event.kind == "tool_result" and event.tool_result:
1638
+ result = event.tool_result
1639
+ summary = (result.output or result.error)[:TEAM_ARTIFACT_LIMIT]
1640
+ kind = "test" if "test" in result.name.lower() or "pytest" in summary.lower() else "tool"
1641
+ artifacts.append(Artifact(
1642
+ id=f"artifact-{uuid.uuid4().hex[:10]}", task_id=task.id, kind=kind,
1643
+ uri=str(event.tool_result.name), summary=summary,
1644
+ producer_agent_id=agent_id,
1645
+ verification_records=["tool_result:ok" if result.ok else "tool_result:failed"],
1646
+ ))
1647
+ elif event.kind == "error":
1648
+ category = event.error_category or "execution_failed"
1649
+ if event.retry_attempts:
1650
+ category = "transient_api_error_exhausted"
1651
+ message = event.text or "Worker 执行失败"
1652
+ if event.retry_attempts:
1653
+ message = f"{message}(已重试 {event.retry_attempts} 次)"
1654
+ return "", artifacts, agent_id, message, category
1655
+ return "", artifacts, agent_id, event.text or "Worker 执行失败"
1656
+ elif event.kind == "step_limit":
1657
+ return "", artifacts, agent_id, f"达到 Worker 步数上限({sub_settings.tool_steps})", "step_limit"
1658
+ return "", artifacts, agent_id, f"达到 Worker 步数上限({sub_settings.tool_steps})"
1659
+ elif event.kind in {"budget_exceeded", "context_overflow"}:
1660
+ category = "context_overflow" if event.kind == "context_overflow" else "budget_exceeded"
1661
+ return "", artifacts, agent_id, event.text or "Worker 上下文或预算超限", category
1662
+ return "", artifacts, agent_id, event.text or "Worker 上下文超限"
1663
+ except asyncio.CancelledError:
1664
+ raise
1665
+ except Exception as exc:
1666
+ return "", artifacts, agent_id, f"{type(exc).__name__}: {exc}", "worker_exception"
1667
+ return "", artifacts, agent_id, f"{type(exc).__name__}: {exc}"
1668
+ final = next((message.content for message in reversed(agent.messages) if message.role == "assistant" and message.content.strip()), "")
1669
+ artifacts.append(Artifact(
1670
+ id=f"artifact-{uuid.uuid4().hex[:10]}", task_id=task.id, kind="report",
1671
+ summary=final[:TEAM_ARTIFACT_LIMIT], producer_agent_id=agent_id,
1672
+ ))
1673
+ queue.put_nowait(TeamEvent(kind="agent_done", team_id=self.team_id, plan=plan, task=task, agent_id=agent_id, role=profile.name, message=final[:TEAM_RESULT_LIMIT]))
1674
+ return final, artifacts, agent_id, "", ""
1675
+
1676
+ async def _review(
1677
+ self, plan: TeamPlan, task: TeamTask, artifacts: list[Artifact], queue: asyncio.Queue[TeamEvent | None]
1678
+ ) -> ReviewResult:
1679
+ queue.put_nowait(TeamEvent(kind="task_review_started", team_id=self.team_id, plan=plan, task=task, role="reviewer"))
1680
+ if not getattr(self.settings, "team_review", True):
1681
+ result = ReviewResult(task.id, "pass", [], [], ["Team 任务级审查已关闭"])
1682
+ elif any(record == "tool_result:failed" for artifact in artifacts for record in artifact.verification_records):
1683
+ result = ReviewResult(task.id, "fail", ["存在工具执行失败证据"], ["修复工具失败并重新验证"], [])
1684
+ elif self.task_reviewer is not None:
1685
+ result = await self.task_reviewer(task, artifacts)
1686
+ else:
1687
+ result = await self._llm_review(task, artifacts, queue=queue)
1688
+ queue.put_nowait(TeamEvent(
1689
+ kind="task_review_done", team_id=self.team_id, plan=plan, task=task,
1690
+ role="reviewer", review=result, failure_category=result.category,
1691
+ message=";".join(result.findings or result.required_fixes),
1692
+ ))
1693
+ self._audit("team_task_review", team_id=self.team_id, task_id=task.id, verdict=result.verdict, findings=result.findings)
1694
+ return result
1695
+
1696
+ async def _llm_review(
1697
+ self,
1698
+ task: TeamTask,
1699
+ artifacts: list[Artifact],
1700
+ *,
1701
+ queue: asyncio.Queue[TeamEvent | None] | None = None,
1702
+ ) -> ReviewResult:
1703
+ evidence = "\n".join(
1704
+ f"- [{artifact.kind}] {artifact.uri}: {artifact.summary[:TEAM_ARTIFACT_LIMIT]}"
1705
+ for artifact in artifacts
1706
+ ) or "(没有产物)"
1707
+ prompt = (
1708
+ f"任务:{task.title}\n说明:{task.description}\n"
1709
+ f"验收标准:\n- " + "\n- ".join(task.acceptance_criteria) +
1710
+ f"\n执行产物和证据:\n{evidence}\n"
1711
+ "请严格输出 JSON;verdict 为 fail 时尽量提供最小 repair_scope,"
1712
+ "每项格式为 {\"pattern\": \"项目内路径\", \"access\": \"write\"}。"
1713
+ )
1714
+ context = [Message(role="system", content=TEAM_REVIEWER_PROMPT), Message(role="user", content=prompt)]
1715
+ retry_limit = max(
1716
+ 0,
1717
+ getattr(self.settings, "team_review_output_retries", TEAM_REVIEW_OUTPUT_RETRIES),
1718
+ )
1719
+ last_error = ReviewOutputError("review_output_invalid", "Reviewer 输出无效")
1720
+ for attempt in range(retry_limit + 1):
1721
+ raw = await self._llm_text(context)
1722
+ parsed = parse_review_output(task.id, raw)
1723
+ if isinstance(parsed, ReviewResult):
1724
+ return parsed
1725
+ last_error = parsed
1726
+ if queue is not None:
1727
+ queue.put_nowait(TeamEvent(
1728
+ kind="review_output_invalid", team_id=self.team_id, task=task,
1729
+ role="reviewer", attempt=attempt + 1,
1730
+ failure_category=parsed.category, retryable=attempt < retry_limit,
1731
+ message=parsed.message,
1732
+ ))
1733
+ if attempt >= retry_limit:
1734
+ break
1735
+ if queue is not None:
1736
+ queue.put_nowait(TeamEvent(
1737
+ kind="review_output_retry", team_id=self.team_id, task=task,
1738
+ role="reviewer", attempt=attempt + 1, retryable=True,
1739
+ message=f"Reviewer 输出无法解析,正在重新请求结构化结果({attempt + 1}/{retry_limit})",
1740
+ ))
1741
+ context.extend([
1742
+ Message(role="assistant", content=raw),
1743
+ Message(
1744
+ role="user",
1745
+ content=(
1746
+ f"上一次 Reviewer 输出无效({parsed.category})。"
1747
+ "请不要重新执行任务或调用工具,只输出一个合法 JSON 对象。"
1748
+ "如果无法安全确定修改文件范围,请使用 verdict=needs_input,不要猜测路径。"
1749
+ ),
1750
+ ),
1751
+ ])
1752
+ return ReviewResult(
1753
+ task.id,
1754
+ "needs_input",
1755
+ ["Reviewer 未返回可解析的结构化结果"],
1756
+ ["请选择重新审查,或补充允许 Repairer 修改的文件范围"],
1757
+ [last_error.category],
1758
+ [],
1759
+ last_error.category,
1760
+ )
1761
+
1762
+ def _worker_system_prompt(self, plan: TeamPlan, task: TeamTask, profile: AgentProfile) -> str:
1763
+ parts = [DEFAULT_SYSTEM_PROMPT, "", f"# 你的角色:{profile.name}", profile.system_prompt, "", f"# Team 总目标\n{plan.goal}", f"# 当前任务\n{task.title}\n{task.description}", "# 验收标准\n- " + "\n- ".join(task.acceptance_criteria)]
1764
+ parts.append(f"# 资源策略\n模式:{task.resource_scope_mode}")
1765
+ if task.resource_claims:
1766
+ parts.append("# 资源范围\n" + "\n".join(f"- {claim.access}: {claim.pattern}" for claim in task.resource_claims))
1767
+ if task.resource_scope_mode == "read_discovery":
1768
+ parts.append("# 探索规则\n允许在项目根目录内使用只读发现工具;不要写文件、执行命令或读取敏感文件。")
1769
+ dependencies: list[str] = []
1770
+ for dep_id in task.deps:
1771
+ dep = plan.task_by_id(dep_id)
1772
+ if dep is not None:
1773
+ dependencies.append(f"[{dep.id}] {dep.result[:TEAM_RESULT_LIMIT]}")
1774
+ if dependencies:
1775
+ parts.append("# 已通过审查的依赖结果\n" + "\n".join(dependencies))
1776
+ return "\n".join(parts)
1777
+
1778
+ @staticmethod
1779
+ def _worker_user_prompt(task: TeamTask, *, recovery_summary: str = "", instruction: str = "") -> str:
1780
+ prompt = f"请执行任务 {task.id}({task.title}):\n{task.description}\n完成后简要汇报结果和验证证据,只处理这个任务。"
1781
+ extra = [part for part in (recovery_summary, instruction) if part]
1782
+ return f"{prompt}\n\n「{ ';'.join(extra) }」" if extra else prompt
1783
+
1784
+ async def _llm_text(self, messages: list[Message]) -> str:
1785
+ parts: list[str] = []
1786
+ async for event in self.llm.stream_chat(messages, tools=None):
1787
+ if event.kind == "content" and event.text:
1788
+ parts.append(event.text)
1789
+ return "".join(parts)
1790
+
1791
+ def _audit(self, action: str, **fields) -> None:
1792
+ if self.audit is not None:
1793
+ self.audit.record(action, **fields)