Files
beaver_project/app-instance/backend/beaver/services/agent_service.py
steven_li 8a12c30141 feat(beaver): 完成Task Team功能v1实现,重构后端架构支持统一内核
新增内部Task系统,包括验证、反馈门控机制,实现自动质量验证
(通过率>=0.75)和用户反馈闭环(satisfied/revise/abandon)。

实现Agent Team v1协调器,支持sequence/parallel/dag执行策略,
sub-agent复用主AgentLoop,每个run使用独立memory snapshot。

建立Skill学习pipeline,包含draft/审核/发布/回滚完整生命周期,
通过Task验证通过且用户满意才生成学习候选。

重构目录结构,移除third_party依赖,建立统一engine内核,
所有agent共享运行时基础组件。

更新ContextBuilder清理provider消息字段,增强SkillContext版本管理,
集成TaskExecutionPlanner和TaskSkillResolver实现技能解析机制。
2026-05-08 17:14:14 +08:00

791 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Application service for agent entry.
这层的职责是把“接口层如何调用 AgentLoop”统一收口。
接口层以后不应该各自做这些事情:
1. 自己 new `AgentLoop`
2. 自己决定何时 `boot()`
3. 自己处理 direct run 的同步/异步包装
统一放在 `AgentService` 后CLI / Web / Gateway 才能共享同一条运行主链。
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from uuid import uuid4
from beaver.coordinator.models import ExecutionNode, TeamRunResult
from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader
from beaver.engine.providers import make_provider_bundle
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.tasks import MainAgentRouter, TaskExecutionPlan, TaskRecord, ValidationResult
class AgentService:
"""面向 interfaces 的统一 agent 运行入口。
这里明确区分两种调用模式:
1. direct mode
- 不启动后台运行循环
- 直接调用 `process_direct()` / `run_direct()`
2. running mode
- 先 `await start()`
- 之后所有外部任务都必须走 `submit_direct()`
- 不允许再直接调用 `process_direct()`
"""
def __init__(
self,
*,
workspace: str | Path | None = None,
config_path: str | Path | None = None,
profile: AgentProfile | None = None,
loader: EngineLoader | None = None,
) -> None:
self.profile = profile or AgentProfile()
self.loader = loader or EngineLoader(workspace=workspace, config_path=config_path)
self._loop: AgentLoop | None = None
self._run_task: asyncio.Task[None] | None = None
self._main_agent_router = MainAgentRouter()
def create_loop(self) -> AgentLoop:
"""创建并缓存当前 service 使用的 AgentLoop。"""
if self._loop is None:
self._loop = AgentLoop(profile=self.profile, loader=self.loader)
self._loop.boot()
return self._loop
@property
def has_loop(self) -> bool:
"""当前 service 是否已经创建过 loop。"""
return self._loop is not None
@property
def is_running(self) -> bool:
"""当前 service 是否处于 running mode。"""
return self._run_task is not None and not self._run_task.done()
def close(self) -> None:
"""关闭当前 service 持有的 runtime。"""
if self._run_task is not None and not self._run_task.done():
raise RuntimeError("AgentService.close() requires stop() before closing a running loop")
self._run_task = None
if self._loop is None:
return
try:
self._loop.close()
finally:
self._loop = None
async def start(self) -> None:
"""启动后台运行循环,进入 running mode。
进入 running mode 后:
- 外部任务必须通过 `submit_direct()` 提交
- `process_direct()` 不再允许直接调用
"""
if self._run_task is not None and not self._run_task.done():
return
loop = self.create_loop()
self._run_task = asyncio.create_task(loop.run())
while not loop.is_running:
if self._run_task.done():
await self._run_task
break
await asyncio.sleep(0)
async def _stop_impl(
self,
*,
timeout_seconds: float | None = None,
force: bool = False,
) -> None:
"""内部停止实现,支持 graceful timeout 和可选 force cancel。"""
if self._run_task is None:
return
run_task = self._run_task
loop = self.create_loop()
try:
await loop.stop()
if timeout_seconds is None:
await run_task
else:
try:
await asyncio.wait_for(asyncio.shield(run_task), timeout=timeout_seconds)
except asyncio.TimeoutError as exc:
if force:
run_task.cancel()
try:
await run_task
except asyncio.CancelledError:
pass
else:
raise TimeoutError(
f"AgentService.stop() timed out after {timeout_seconds} seconds while draining queued tasks"
) from exc
finally:
if run_task.done():
self._run_task = None
async def stop(
self,
*,
timeout_seconds: float | None = None,
force: bool = False,
) -> None:
"""停止后台运行循环并等待退出。
参数:
- `timeout_seconds`: graceful drain 的最长等待时间;`None` 表示一直等
- `force`: 超时后是否 cancel 掉运行循环 task
"""
await self._stop_impl(timeout_seconds=timeout_seconds, force=force)
async def shutdown(
self,
*,
timeout_seconds: float | None = None,
force: bool = False,
) -> None:
"""先停运行循环,再释放 runtime。"""
await self._stop_impl(timeout_seconds=timeout_seconds, force=force)
self.close()
async def process_direct(
self,
message: str,
**kwargs: Any,
) -> AgentRunResult:
"""异步 direct run 入口。
仅在 direct mode 下可用。
如果 service 已经 `start()` 进入 running mode
调用方必须改用 `submit_direct()`,不能绕过运行队列直接执行。
"""
if self._run_task is not None and not self._run_task.done():
raise RuntimeError(
"AgentService.process_direct() is unavailable while the service is running; "
"use 'await AgentService.submit_direct(...)' after start()."
)
loop = self.create_loop()
return await self._process_with_main_agent(message, runner=loop.process_direct, kwargs=kwargs)
async def submit_direct(
self,
message: str,
**kwargs: Any,
) -> AgentRunResult:
"""向 running mode 下的 loop 提交 direct task。
这是 `start()` 之后唯一合法的外部任务入口。
"""
loop = self.create_loop()
return await self._process_with_main_agent(message, runner=loop.submit_direct, kwargs=kwargs)
async def submit_feedback(
self,
*,
session_id: str,
run_id: str,
feedback_type: str,
comment: str | None = None,
) -> dict[str, Any]:
"""Record chat feedback for the internal task linked to a run."""
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
task = task_service.get_task_by_run_id(run_id)
if task is None or task.session_id != session_id:
raise ValueError(f"No internal task found for run_id={run_id!r}")
normalized = feedback_type.strip().lower()
if normalized not in {"satisfied", "revise", "abandon"}:
raise ValueError("feedback_type must be one of: satisfied, revise, abandon")
already_recorded = any(
item.get("run_id") == run_id and item.get("feedback_type") == normalized
for item in task.feedback
)
conflicting_feedback = next(
(
item
for item in task.feedback
if item.get("run_id") == run_id and item.get("feedback_type") != normalized
),
None,
)
if conflicting_feedback is not None:
raise ValueError(
f"Feedback for run_id={run_id!r} was already recorded as "
f"{conflicting_feedback.get('feedback_type')!r}"
)
if task.status in {"closed", "abandoned"} and not already_recorded:
raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
updated = task if already_recorded else task_service.add_feedback(
task.task_id,
feedback_type=normalized,
comment=comment,
run_id=run_id,
)
session_manager = self._require_loaded(loaded, "session_manager")
session_manager.update_latest_assistant_event_payload(
session_id,
run_id,
{
"task_id": updated.task_id,
"task_status": updated.status,
"feedback_state": normalized,
},
)
if not already_recorded:
session_manager.append_message(
session_id,
run_id=run_id,
role="system",
event_type="task_feedback_recorded",
event_payload={
"task_id": task.task_id,
"feedback_type": normalized,
"comment": comment,
"task_status": updated.status,
},
content=comment,
context_visible=False,
)
generated_candidates = []
validation = ValidationResult.from_dict(updated.validation_result)
if already_recorded:
generated_candidates = []
elif normalized == "satisfied" and validation is not None and validation.accepted:
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
generated_candidates = [item.to_dict() for item in skill_learning_service.build_learning_candidates()]
elif normalized == "abandon":
memory_service = self._require_loaded(loaded, "memory_service")
memory_service.get_store().add(
"memory",
(
f"Failure memory: task {task.task_id} in session {session_id} was abandoned. "
f"Reason: {(comment or 'not specified').strip()}"
),
)
return {
"session_id": session_id,
"run_id": run_id,
"task_id": updated.task_id,
"task_status": updated.status,
"feedback_type": normalized,
"learning_candidates": generated_candidates,
}
async def _process_with_main_agent(
self,
message: str,
*,
runner: Any,
kwargs: dict[str, Any],
) -> AgentRunResult:
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
session_id = kwargs.get("session_id") or uuid4().hex
kwargs = dict(kwargs)
kwargs["session_id"] = session_id
active_task = task_service.get_latest_open_task(session_id)
decision = self._main_agent_router.classify(message, active_task=active_task)
if not decision.is_task:
return await runner(message, **kwargs)
task = (
task_service.create_task(
session_id=session_id,
description=message,
metadata={"router_reason": decision.reason},
)
if active_task is None or decision.starts_new_task
else active_task
)
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
async def _run_task_mode(
self,
message: str,
*,
runner: Any,
kwargs: dict[str, Any],
task: TaskRecord,
) -> AgentRunResult:
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
validation_service = self._require_loaded(loaded, "validation_service")
task_execution_planner = self._require_loaded(loaded, "task_execution_planner")
session_manager = self._require_loaded(loaded, "session_manager")
run_memory_store = self._require_loaded(loaded, "run_memory_store")
last_result: AgentRunResult | None = None
latest_validation: ValidationResult | None = None
base_execution_context = kwargs.get("execution_context")
provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
kwargs = dict(kwargs)
team_provider_bundle_factory = kwargs.pop("team_provider_bundle_factory", None)
kwargs["provider_bundle"] = provider_bundle
for attempt_index in (1, 2):
task_service.start_run(task.task_id, user_message=message, attempt_index=attempt_index)
plan = await task_execution_planner.plan(
task=task,
user_message=message,
attempt_index=attempt_index,
latest_validation=latest_validation,
provider_bundle=provider_bundle,
)
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(),
},
)
team_summaries: list[str] = []
team_execution_context = ""
if plan.is_team:
team_result, team_error = await self._run_team_for_task(
plan,
task=task,
parent_session_id=kwargs["session_id"],
provider_bundle_factory=team_provider_bundle_factory
or self._build_team_provider_bundle_factory(loaded, kwargs),
)
if team_result is not None:
team_summaries = [self._team_summary_for_validation(team_result)]
team_execution_context = self._team_execution_context(plan, team_result)
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_team_run_completed" if team_result.success else "task_team_run_failed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
"team_run_ids": team_result.run_ids,
"team_success": team_result.success,
"node_results": self._team_node_results_for_event(plan, team_result),
"reason": plan.reason,
"error": None if team_result.success else "one or more team nodes failed",
},
)
else:
team_summaries = [f"Team execution failed: {team_error}"]
team_execution_context = self._failed_team_execution_context(plan, team_error or "unknown error")
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_team_run_failed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
"team_run_ids": [],
"team_success": False,
"reason": plan.reason,
"error": team_error,
},
)
attempt_kwargs = dict(kwargs)
attempt_kwargs.update(
{
"task_id": task.task_id,
"task_mode": True,
"attempt_index": attempt_index,
"learning_candidate_enabled": False,
}
)
if attempt_index == 2 and latest_validation is not None:
revision_context = latest_validation.recommended_revision_prompt.strip()
if revision_context:
attempt_kwargs["execution_context"] = self._join_context(
base_execution_context,
f"Task validation revision request:\n{revision_context}",
team_execution_context,
)
elif team_execution_context:
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
result = await runner(message, **attempt_kwargs)
last_result = result
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,
},
)
task = task_service.append_run(
task.task_id,
result.run_id,
skill_names=self._skill_names_for_run(loaded, result.run_id),
)
validation = await validation_service.validate_task_result(
task=task,
user_message=message,
final_output=result.output_text,
transcript_excerpt=self._run_excerpt(session_manager, result.session_id, result.run_id),
tool_summaries=self._tool_summaries(session_manager, result.session_id, result.run_id),
team_summaries=team_summaries,
provider_bundle=provider_bundle,
)
latest_validation = validation
task = task_service.record_validation(task.task_id, result.run_id, validation)
run_memory_store.update_run_record(result.run_id, validation_result=validation.to_dict())
session_manager.update_latest_assistant_event_payload(
result.session_id,
result.run_id,
{
"task_id": task.task_id,
"task_status": task.status,
"validation_status": "passed" if validation.accepted else "failed",
},
)
session_manager.append_message(
result.session_id,
run_id=result.run_id,
role="system",
event_type="task_validation_snapshotted",
event_payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"validation_result": validation.to_dict(),
"retry_scheduled": not validation.accepted and attempt_index == 1,
},
content=validation.recommended_revision_prompt or None,
context_visible=False,
)
if not validation.accepted and attempt_index == 1:
session_manager.set_run_context_visible(result.session_id, result.run_id, False)
result.task_id = task.task_id
result.task_status = task.status
result.validation_result = validation.to_dict()
if validation.accepted or attempt_index == 2:
return result
if last_result is None: # pragma: no cover - defensive
raise RuntimeError("Task mode did not produce a run result")
return last_result
async def _run_team_for_task(
self,
plan: TaskExecutionPlan,
*,
task: TaskRecord,
parent_session_id: str,
provider_bundle_factory: Any,
) -> tuple[TeamRunResult | None, str | None]:
if plan.graph is None:
return None, "team plan did not include an execution graph"
try:
from beaver.services.team_service import TeamService
result = await TeamService(self.create_loop()).run_team(
plan.graph,
parent_task_id=task.task_id,
parent_session_id=parent_session_id,
parent_run_id=None,
provider_bundle_factory=provider_bundle_factory,
learning_candidate_enabled=False,
)
return result, None
except Exception as exc:
return None, str(exc)
@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 _skill_names_for_run(loaded: Any, run_id: str) -> list[str]:
store = getattr(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 _run_excerpt(session_manager: Any, session_id: str, run_id: str) -> str:
lines = []
for event in session_manager.get_run_event_records(session_id, run_id):
if event.context_visible and event.content:
lines.append(f"{event.role}: {event.content.strip()}")
return "\n".join(lines[:12])[:2400]
@staticmethod
def _tool_summaries(session_manager: Any, session_id: str, run_id: str) -> list[str]:
summaries = []
for event in session_manager.get_run_event_records(session_id, run_id):
if event.event_type != "tool_result_recorded":
continue
text = (event.content or "").strip()
if text:
summaries.append(f"{event.tool_name or 'tool'}: {text[:500]}")
return summaries[:12]
@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())
@staticmethod
def _team_summary_for_validation(result: TeamRunResult) -> str:
lines = [
f"success={result.success}",
f"task_id={result.task_id or ''}",
"summary:",
result.summary,
"nodes:",
]
for node in result.node_results:
lines.append(
f"- {node.node_id}: success={node.success} finish_reason={node.finish_reason} "
f"error={node.error or ''} output={node.output_text[:500]}"
)
return "\n".join(lines)
@staticmethod
def _team_node_results_for_event(plan: TaskExecutionPlan, result: TeamRunResult) -> list[dict[str, Any]]:
nodes = {node.node_id: node for node in plan.graph.nodes} if plan.graph else {}
payloads: list[dict[str, Any]] = []
for item in result.node_results:
payload = item.to_dict()
node = nodes.get(item.node_id)
if node is not None:
payload["selected_skill_names"] = list(node.inherited_pinned_skills)
payload["ephemeral_skill_names"] = [
skill.name for skill in node.inherited_pinned_skill_contexts
]
payload["skill_query"] = node.agent.metadata.get("skill_query")
payload["generated_skill_draft_id"] = node.agent.metadata.get("generated_skill_draft_id")
payload["generated_skill_name"] = node.agent.metadata.get("generated_skill_name")
payload["ephemeral_used"] = bool(node.inherited_pinned_skill_contexts)
payloads.append(payload)
return payloads
@staticmethod
def _team_execution_context(plan: TaskExecutionPlan, result: TeamRunResult) -> str:
node_lines = [
(
f"- {node.node_id}: success={node.success}, finish_reason={node.finish_reason}, "
f"run_id={node.run_id or ''}, error={node.error or ''}\n{node.output_text}"
)
for node in result.node_results
]
return "\n\n".join(
item
for item in [
"Task team execution result:",
f"Planner reason: {plan.reason}",
f"Strategy: {plan.graph.strategy if plan.graph else ''}",
f"Team success: {result.success}",
f"Team summary:\n{result.summary}",
"Node results:\n" + "\n\n".join(node_lines),
(
"Final synthesis instruction:\n" + plan.final_synthesis_instruction
if plan.final_synthesis_instruction
else None
),
"Use the team outputs as internal evidence. Produce the final user-facing answer yourself.",
]
if item
)
@staticmethod
def _failed_team_execution_context(plan: TaskExecutionPlan, error: str) -> str:
return "\n\n".join(
[
"Task team execution failed before final synthesis.",
f"Planner reason: {plan.reason}",
f"Strategy: {plan.graph.strategy if plan.graph else ''}",
f"Error: {error}",
"Proceed as the main agent and produce the best possible final answer.",
]
)
def _build_team_provider_bundle_factory(self, loaded: Any, kwargs: dict[str, Any]) -> Any:
def factory(node: ExecutionNode) -> Any:
node_kwargs = dict(kwargs)
node_kwargs.pop("provider_bundle", None)
if node.agent.model:
node_kwargs["model"] = node.agent.model
if node.agent.provider_name:
node_kwargs["provider_name"] = node.agent.provider_name
return self._make_provider_bundle_for_task(loaded, node_kwargs)
return factory
def _make_provider_bundle_for_task(self, loaded: Any, kwargs: dict[str, Any]) -> Any:
config = loaded.config
configured_provider = config.resolve_provider_target(
model=kwargs.get("model"),
provider_name=kwargs.get("provider_name"),
)
resolved_model = configured_provider.get("model") or self.profile.default_model
resolved_provider_name = configured_provider.get("provider_name") or kwargs.get("provider_name")
return make_provider_bundle(
model=resolved_model,
provider_name=resolved_provider_name,
api_key=kwargs.get("api_key") or configured_provider.get("api_key"),
api_base=kwargs.get("api_base") or configured_provider.get("api_base"),
request_timeout_seconds=configured_provider.get("request_timeout_seconds"),
extra_headers=kwargs.get("extra_headers") or configured_provider.get("extra_headers"),
routing=kwargs.get("routing"),
fallback_target=kwargs.get("fallback_target"),
auxiliary_target=kwargs.get("auxiliary_target"),
embedding_target=kwargs.get("embedding_target") or config.resolve_embedding_target(),
embedding_model=kwargs.get("embedding_model") or config.default_embedding_model,
)
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
try:
result = await self.submit_direct(
inbound.content,
session_id=inbound.session_id,
source=f"gateway:{inbound.channel}",
user_id=inbound.user_id,
title=inbound.title,
execution_context=inbound.execution_context,
model=inbound.model,
provider_name=inbound.provider_name,
embedding_model=inbound.embedding_model,
)
except Exception as exc:
return self.build_outbound_error(
inbound,
detail=str(exc),
finish_reason=self._classify_inbound_failure(exc),
)
return self.build_outbound_message(inbound, result)
@staticmethod
def _classify_inbound_failure(exc: Exception) -> str:
"""把 runtime 异常收口为更稳定的 bus finish reason。"""
if isinstance(exc, RuntimeError):
detail = str(exc)
if (
"requires an active run() loop" in detail
or "not accepting new tasks after stop()" in detail
):
return "stopped"
return "error"
@staticmethod
def build_outbound_message(inbound: InboundMessage, result: AgentRunResult) -> OutboundMessage:
"""把一次 runtime 正常结果转成 bus outbound。"""
return OutboundMessage(
message_id=inbound.message_id,
channel=inbound.channel,
session_id=result.session_id,
run_id=result.run_id,
content=result.output_text,
finish_reason=result.finish_reason,
provider_name=result.provider_name,
model=result.model,
usage=dict(result.usage),
metadata={
"inbound_metadata": dict(inbound.metadata),
"task_id": getattr(result, "task_id", None),
"task_status": getattr(result, "task_status", None),
"validation_result": getattr(result, "validation_result", None),
},
)
@staticmethod
def build_outbound_error(
inbound: InboundMessage,
*,
detail: str,
finish_reason: str = "error",
) -> OutboundMessage:
"""把 inbound 处理失败转换成结构化 outbound 错误消息。"""
return OutboundMessage(
message_id=inbound.message_id,
channel=inbound.channel,
session_id=inbound.session_id,
content=detail,
finish_reason=finish_reason,
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
)
def run_direct(
self,
message: str,
**kwargs: Any,
) -> AgentRunResult:
"""同步 direct run 包装。
主要给当前 CLI 或简单脚本使用。真正的长期方向仍然是让 interfaces
在 direct mode 下直接走 `await process_direct(...)`。
"""
try:
asyncio.get_running_loop()
except RuntimeError:
pass
else:
raise RuntimeError(
"AgentService.run_direct() cannot be used inside an active event loop; "
"use 'await AgentService.process_direct(...)' instead."
)
return asyncio.run(self.process_direct(message, **kwargs))