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