Files
beaver_project/app-instance/backend/beaver/tasks/attempt_orchestrator.py
steven_li 520a21a027 feat(coordinator): 添加团队节点默认最大工具迭代次数配置
添加 DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS 配置项以控制团队节点的最大工具迭代次数,
并修改 LocalAgentRunner 中的逻辑来使用此默认值当 envelope 中未指定时。

fix(runtime): 修复团队节点运行成功判断逻辑

更新运行成功判断条件,将 finish_reason 为 "max_tool_iterations_finalized" 的情况
视为运行失败,并添加对原始工具调用输出的检测,避免将其误判为成功完成。

feat(mcp): 添加团队工作流MCP工具类别支持

增加新的本地MCP工具类别 "team_workflow" 及其对应的工具创建功能,
为团队工作流提供本地工具支持。

refactor(engine): 调整AgentLoop最大工具迭代次数设置

将 AgentProfile 中的默认 max_tool_iterations 从 30 增加到 100,
同时移除 TaskExecutionPlanner 构造函数中的重复参数传递。

perf(mcp): 优化MCP连接管理避免重复连接

添加 mcp_connected 标志来跟踪MCP连接状态,确保 connect_all 只执行一次,
提高性能并避免不必要的重复连接。

refactor(skills): 移除技能团队模板相关功能

移除与技能团队模板相关的代码,包括解析、存储和处理逻辑,
简化技能记录结构和加载流程。

feat(process): 增强会话过程投影器功能

添加技能激活快照事件处理,改进团队运行完成消息显示,
并增强技能激活事件的时间戳记录功能。

refactor(tasks): 简化任务尝试编排器团队执行逻辑

移除团队执行相关代码,将所有任务统一按单步执行处理,
简化任务编排器的复杂度并提升执行效率。

fix(evidence): 修复节点证据评估中需求验证逻辑

更新节点证据评估逻辑,跳过自然语言证据需求的确定性验证,
只执行机器可读的需求验证,避免因自然语言需求导致的节点失败。
2026-06-26 16:36:29 +08:00

406 lines
16 KiB
Python

"""Task attempt orchestration for Beaver Task mode."""
from __future__ import annotations
from time import perf_counter
from typing import Any, Callable
from beaver.engine import AgentRunResult
from beaver.engine.context import SkillContext
from beaver.prompts.main_agent import normalize_main_agent_prompt_locale
from .evidence import EvidenceBuilder, TaskEvidencePacket, render_task_evidence
from .models import TaskRecord
from .planner import TaskExecutionPlan
class TaskAttemptOrchestrator:
"""Own the execution order inside one Task attempt."""
def __init__(
self,
*,
loaded: Any,
create_loop: Callable[[], Any],
make_provider_bundle_for_task: Callable[[Any, dict[str, Any]], Any],
) -> None:
self.loaded = loaded
self.create_loop = create_loop
self.make_provider_bundle_for_task = make_provider_bundle_for_task
async def run(
self,
*,
message: str,
runner: Any,
kwargs: dict[str, Any],
task: TaskRecord,
) -> AgentRunResult:
task_service = self._require_loaded(self.loaded, "task_service")
task_execution_planner = self._require_loaded(self.loaded, "task_execution_planner")
session_manager = self._require_loaded(self.loaded, "session_manager")
base_execution_context = kwargs.get("execution_context")
prompt_locale = kwargs.get("prompt_locale") or task.metadata.get("prompt_locale")
output_language_instruction = self._output_language_instruction(prompt_locale)
provider_bundle = kwargs.get("provider_bundle") or self.make_provider_bundle_for_task(self.loaded, kwargs)
kwargs = dict(kwargs)
kwargs.pop("team_provider_bundle_factory", None)
kwargs["provider_bundle"] = provider_bundle
attempt_index = int(task.metadata.get("latest_attempt_index") or 0) + 1
task_service.start_run(task.task_id, user_message=message, attempt_index=attempt_index)
pre_skill_context = self._build_skill_selection_context(
task=task,
user_message=message,
attempt_index=attempt_index,
)
preselected_skills, pre_skill_latency_ms = await self._assemble_task_attempt_skills(
task_description=pre_skill_context,
provider_bundle=provider_bundle,
thinking_enabled=kwargs.get("thinking_enabled"),
include_skill_assembly=bool(kwargs.get("include_skill_assembly", True)),
pinned_skill_contexts=kwargs.get("pinned_skill_contexts"),
)
if pre_skill_latency_ms:
kwargs["pre_run_latency_ms"] = self._merge_latency_ms(
kwargs.get("pre_run_latency_ms"),
{"pre_skill_assembly_ms": pre_skill_latency_ms},
)
plan = await task_execution_planner.plan(
task=task,
user_message=message,
attempt_index=attempt_index,
provider_bundle=provider_bundle,
skill_summaries=self._skill_summaries_for_planner(preselected_skills),
tool_hints=self._tool_hints_for_skills(preselected_skills),
activated_skills=preselected_skills,
)
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_execution_planned",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
**plan.to_event_payload(),
},
)
if plan.is_team:
plan = TaskExecutionPlan.single(
"legacy_planner_team_ignored",
planner_adaptation=plan.planner_adaptation,
)
outcome_metadata = {
"task_outcome": "single",
"incomplete_node_ids": [],
"node_statuses": {},
"evidence_gaps": {},
}
attempt_kwargs = dict(kwargs)
attempt_kwargs.update(
{
"task_id": task.task_id,
"task_mode": True,
"attempt_index": attempt_index,
"allow_candidate_generation": False,
"pinned_skill_contexts": preselected_skills,
"include_skill_assembly": False,
}
)
attempt_kwargs["execution_context"] = self._join_context(
base_execution_context,
output_language_instruction,
)
attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
task=task,
user_message=message,
attempt_index=attempt_index,
plan=plan,
)
result = await runner(message, **attempt_kwargs)
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_synthesis_completed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"main_run_id": result.run_id,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
**outcome_metadata,
},
)
task = task_service.append_run(
task.task_id,
result.run_id,
skill_names=self._skill_names_for_run(result.run_id),
)
evidence_packet = self._build_task_evidence_packet(
session_manager=session_manager,
task=task,
attempt_index=attempt_index,
result=result,
)
evidence_text = render_task_evidence(evidence_packet)
evidence_debug = {
"evidence_run_ids": [
item.run_id for item in [evidence_packet.main_run, *evidence_packet.team_runs] if item is not None
],
"evidence_session_ids": [
item.session_id
for item in [evidence_packet.main_run, *evidence_packet.team_runs]
if item is not None
],
"tool_result_count": sum(
len(item.tool_results)
for item in [evidence_packet.main_run, *evidence_packet.team_runs]
if item is not None
),
"evidence_length": len(evidence_text),
}
session_manager.update_latest_assistant_event_payload(
result.session_id,
result.run_id,
{
"task_id": task.task_id,
"task_status": task.status,
"evidence_status": "recorded",
},
)
session_manager.append_message(
result.session_id,
run_id=result.run_id,
role="system",
event_type="task_evidence_recorded",
event_payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"evidence_debug": evidence_debug,
},
content=None,
context_visible=False,
)
result.task_id = task.task_id
result.task_status = task.status
result.validation_result = None
return result
async def _assemble_task_attempt_skills(
self,
*,
task_description: str,
provider_bundle: Any,
thinking_enabled: bool | None,
include_skill_assembly: bool,
pinned_skill_contexts: Any,
) -> tuple[list[SkillContext], float]:
started = perf_counter()
selected = self._coerce_skill_contexts(pinned_skill_contexts)
if include_skill_assembly:
skill_assembler = self._require_loaded(self.loaded, "skill_assembler")
runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
assembled = await skill_assembler.assemble(
task_description=task_description,
provider=provider_bundle.auxiliary_provider or provider_bundle.main_provider,
model=getattr(runtime, "model", None),
embedding_runtime=getattr(provider_bundle, "embedding_runtime", None),
thinking_enabled=thinking_enabled,
)
selected = self._merge_skill_contexts(
selected,
list(getattr(assembled, "activated_skills", []) or []),
)
return selected, (perf_counter() - started) * 1000
@staticmethod
def _coerce_skill_contexts(value: Any) -> list[SkillContext]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, SkillContext)]
@staticmethod
def _merge_skill_contexts(left: list[SkillContext], right: list[SkillContext]) -> list[SkillContext]:
merged: list[SkillContext] = []
seen: set[str] = set()
for skill in [*left, *right]:
if skill.name in seen:
continue
seen.add(skill.name)
merged.append(skill)
return merged
@staticmethod
def _skill_summaries_for_planner(skills: list[SkillContext]) -> list[str]:
summaries: list[str] = []
for skill in skills:
content = " ".join((skill.content or "").split())
if len(content) > 240:
content = content[:237].rstrip() + "..."
summaries.append(f"{skill.name}: {content}" if content else skill.name)
return summaries
@staticmethod
def _tool_hints_for_skills(skills: list[SkillContext]) -> list[str]:
result: list[str] = []
for skill in skills:
for hint in skill.tool_hints:
if hint and hint not in result:
result.append(hint)
return result
@staticmethod
def _require_loaded(loaded: Any, field_name: str) -> Any:
value = getattr(loaded, field_name)
if value is None:
raise RuntimeError(f"Engine loader did not provide required dependency {field_name!r}")
return value
@staticmethod
def _merge_latency_ms(current: Any, updates: dict[str, float]) -> dict[str, float]:
merged: dict[str, float] = {}
if isinstance(current, dict):
for key, value in current.items():
if isinstance(value, (int, float)):
merged[str(key)] = float(value)
for key, value in updates.items():
merged[key] = merged.get(key, 0.0) + float(value)
return merged
@staticmethod
def _output_language_instruction(prompt_locale: str | None) -> str:
locale = normalize_main_agent_prompt_locale(prompt_locale)
if locale == "en":
return (
"Output language: English. Use English for user-facing task titles, summaries, plans, "
"and final answers unless the user explicitly requests another language."
)
if locale == "zh-Hant":
return (
"輸出語言:繁體中文。除非使用者明確要求其他語言,所有面向使用者的任務標題、摘要、"
"計劃與最終回答都使用繁體中文。"
)
return (
"输出语言:简体中文。除非用户明确要求其他语言,所有面向用户的任务标题、摘要、"
"计划与最终回答都使用简体中文。"
)
def _skill_names_for_run(self, run_id: str) -> list[str]:
store = getattr(self.loaded, "run_memory_store", None)
if store is None:
return []
for record in store.list_runs():
if record.run_id == run_id:
return [receipt.skill_name for receipt in record.activated_skills]
return []
@staticmethod
def _build_skill_selection_context(
*,
task: TaskRecord,
user_message: str,
attempt_index: int,
plan: TaskExecutionPlan | None = None,
) -> str:
phase = f"attempt_{attempt_index}"
if task.feedback and task.feedback[-1].get("acceptance_type") == "revise":
phase = f"revision_attempt_{attempt_index}"
elif plan is not None and plan.is_team:
phase = f"team_synthesis_attempt_{attempt_index}"
sections = [
f"Task goal:\n{task.goal or task.description}",
f"Task description:\n{task.description}",
f"Current user request:\n{user_message}",
f"Execution phase:\n{phase}",
f"Task status:\n{task.status}",
]
if task.constraints:
sections.append("Known constraints:\n" + "\n".join(f"- {item}" for item in task.constraints))
if task.skill_names:
sections.append(
"Previously activated skills (reuse bias, not pinned):\n"
+ "\n".join(f"- {item}" for item in task.skill_names)
)
else:
sections.append("Previously activated skills:\nNone")
if task.feedback:
history_lines = []
for item in task.feedback[-5:]:
kind = item.get("acceptance_type") or item.get("feedback_type")
comment = item.get("comment") or ""
run_id = item.get("run_id") or ""
history_lines.append(f"- {kind} run={run_id}: {comment}".strip())
sections.append("Task acceptance history:\n" + "\n".join(history_lines))
if plan is not None:
plan_lines = [
f"mode: {plan.mode}",
f"reason: {plan.reason}",
]
if plan.final_synthesis_instruction:
plan_lines.append(f"final synthesis instruction: {plan.final_synthesis_instruction}")
if plan.graph is not None:
plan_lines.append(f"strategy: {plan.graph.strategy}")
plan_lines.append(
"nodes:\n"
+ "\n".join(
f"- {node.node_id}: {node.task}"
for node in plan.graph.nodes
)
)
sections.append("Execution plan:\n" + "\n".join(plan_lines))
sections.append(
"Skill selection instruction:\n"
"Prefer reusing previously activated skills when they still match the Task. "
"Select new skills only if the current request, revision, or execution plan needs a different capability. "
"If no published skill matches, return [] and let the run continue without skills."
)
return "\n\n".join(section for section in sections if section.strip())
@staticmethod
def _append_task_observation(
session_manager: Any,
session_id: str,
*,
event_type: str,
payload: dict[str, Any],
) -> None:
session_manager.append_message(
session_id,
role="system",
event_type=event_type,
event_payload=payload,
content=payload.get("reason") or payload.get("error"),
context_visible=False,
)
@staticmethod
def _join_context(*parts: str | None) -> str:
return "\n\n".join(part.strip() for part in parts if part and part.strip())
def _build_task_evidence_packet(
self,
*,
session_manager: Any,
task: TaskRecord,
attempt_index: int,
result: AgentRunResult,
) -> TaskEvidencePacket:
main_run = EvidenceBuilder(session_manager).build_run_evidence(
result.session_id,
result.run_id,
result.output_text,
result.finish_reason,
)
return TaskEvidencePacket(
task_id=task.task_id,
attempt_index=attempt_index,
main_run=main_run,
team_runs=[],
team_node_results=[],
final_output=result.output_text,
)