feat(engine): 添加MCP连接管理和工具集成功能

- 集成MCP连接管理器,支持MCP服务器连接
- 添加多种内置工具:ClarifyTool、CronTool、DelegateTool、ExecuteCodeTool、
  PatchFileTool、ProcessTool、SendMessageTool、SpawnTool、TerminalTool、
  TodoTool、WebFetchTool、WebSearchTool、WriteFileTool等
- 实现工具注册和装配功能
- 添加技能选择上下文参数
- 支持思考模式控制参数thinking_enabled

feat(coordinator): 重构任务执行计划器参数命名

- 将learning_candidate_enabled重命名为allow_candidate_generation
- 更新TeamGraphScheduler中的参数传递
- 修改LocalAgentRunner中的相关参数处理
- 更新README文档中的相应描述

refactor(context): 标准化工具调用参数格式

- 添加_json导入用于参数序列化
- 实现_provider_tool_calls方法标准化OpenAI兼容的工具调用载荷
- 修复工具调用中参数非字符串类型的序列化问题

refactor(session): 优化消息历史记录过滤逻辑

- 修改get_messages_as_conversation为基于运行状态过滤消息
- 排除未完成、失败或错误结束的运行记录
- 改进对话历史的可见性控制机制

fix(store): 修复FTS索引重建逻辑

- 添加异常处理防止FTS索引创建失败
- 实现_rebuild_fts_index方法重新构建全文搜索索引
- 优化索引触发器和表的维护流程
This commit is contained in:
2026-05-14 09:43:48 +08:00
parent 8a12c30141
commit 30ab74ffb2
149 changed files with 12293 additions and 2812 deletions

View File

@ -21,9 +21,13 @@ 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.foundation.models import CronJob, CronRunRecord
from beaver.tasks import MainAgentRouter, TaskExecutionPlan, TaskRecord, ValidationResult
NOTIFICATION_SESSION_ID = "notify:default:scheduled"
class AgentService:
"""面向 interfaces 的统一 agent 运行入口。
@ -50,15 +54,24 @@ class AgentService:
self._loop: AgentLoop | None = None
self._run_task: asyncio.Task[None] | None = None
self._main_agent_router = MainAgentRouter()
self._runtime_services: dict[str, Any] = {}
def create_loop(self) -> AgentLoop:
"""创建并缓存当前 service 使用的 AgentLoop。"""
if self._loop is None:
self._loop = AgentLoop(profile=self.profile, loader=self.loader)
self._loop.runtime_services.update(self._runtime_services)
self._loop.boot()
return self._loop
def register_runtime_service(self, name: str, service: Any) -> None:
"""Expose process-level services to tools during agent runs."""
self._runtime_services[name] = service
if self._loop is not None:
self._loop.runtime_services[name] = service
@property
def has_loop(self) -> bool:
"""当前 service 是否已经创建过 loop。"""
@ -196,6 +209,191 @@ class AgentService:
loop = self.create_loop()
return await self._process_with_main_agent(message, runner=loop.submit_direct, kwargs=kwargs)
async def run_scheduled_task(
self,
message: str,
*,
session_id: str,
cron_job_id: str,
cron_job_name: str,
scheduled_run_id: str | None = None,
requires_followup: bool = False,
) -> AgentRunResult:
"""Run a cron trigger as a normal internal Task.
Scheduled jobs are product-level Tasks, not hidden one-off agent turns.
This entry bypasses the main-agent classifier and forces Task mode so
every trigger produces a TaskRecord, validation, feedback state, and a
run_id that the scheduled-task history can link to.
"""
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
loop = self.create_loop()
task = task_service.create_task(
session_id=session_id,
description=message,
creator="cron",
metadata={
"source": "scheduled_cron",
"cron_job_id": cron_job_id,
"cron_job_name": cron_job_name,
"scheduled_run_id": scheduled_run_id,
"user_engaged": False,
"requires_followup": requires_followup,
},
)
execution_context = (
"This turn was triggered automatically by a scheduled task.\n\n"
f"Cron Job ID: {cron_job_id}\n"
f"Cron Job Name: {cron_job_name}\n"
f"Scheduled Run ID: {scheduled_run_id or 'unknown'}\n"
"Run it as a normal Beaver Task. Do not ask the user for confirmation; "
"execute the task and report the concrete outcome."
)
runner = loop.submit_direct if self.is_running else loop.process_direct
result = await self._run_task_mode(
message,
runner=runner,
task=task,
kwargs={
"session_id": session_id,
"source": "cron",
"user_id": "cron",
"title": cron_job_name,
"execution_context": execution_context,
},
)
loaded = self.create_loop().boot()
session_manager = self._require_loaded(loaded, "session_manager")
session_manager.update_latest_assistant_event_payload(
result.session_id,
result.run_id,
{
"message_type": "scheduled_reply",
"scheduled_job_id": job.id,
"scheduled_run_id": run.scheduled_run_id,
"cron_job_name": job.name,
"mode": "notification",
},
)
return result
async def run_scheduled_notification(
self,
message: str,
*,
session_id: str = NOTIFICATION_SESSION_ID,
cron_job_id: str,
cron_job_name: str,
scheduled_run_id: str,
) -> AgentRunResult:
"""Run a cron trigger as a notification result, not as an active Task."""
loop = self.create_loop()
loaded = loop.boot()
session_manager = self._require_loaded(loaded, "session_manager")
runner = loop.submit_direct if self.is_running else loop.process_direct
execution_context = (
"This turn was triggered automatically by a scheduled notification.\n\n"
f"Cron Job ID: {cron_job_id}\n"
f"Cron Job Name: {cron_job_name}\n"
f"Scheduled Run ID: {scheduled_run_id}\n"
"Generate the notification content directly for the user. Do not ask for confirmation."
)
result = await runner(
message,
session_id=session_id,
source="notification",
user_id="cron",
title=cron_job_name,
execution_context=execution_context,
)
session_manager.update_latest_assistant_event_payload(
result.session_id,
result.run_id,
{
"message_type": "scheduled_result",
"scheduled_job_id": cron_job_id,
"scheduled_run_id": scheduled_run_id,
"cron_job_name": cron_job_name,
"mode": "notification",
},
)
return result
def engage_scheduled_run(
self,
*,
job: CronJob,
run: CronRunRecord,
intent: str = "revise_once",
thinking_enabled: bool | None = None,
) -> TaskRecord:
"""Create or mark the Task that lets the user work on a scheduled result."""
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
if run.task_id:
existing = task_service.get_task(run.task_id)
if existing is not None:
existing.metadata["user_engaged"] = True
existing.metadata["engage_intent"] = intent
task_service.store.upsert_task(existing)
return existing
task = task_service.create_task(
session_id=run.notification_session_id or NOTIFICATION_SESSION_ID,
description=f"修改定时通知:{job.name}",
creator="cron",
metadata={
"source": "scheduled_run",
"cron_job_id": job.id,
"cron_job_name": job.name,
"scheduled_run_id": run.scheduled_run_id,
"scheduled_output": run.output,
"user_engaged": True,
"engage_intent": intent,
},
)
return task
async def submit_scheduled_reply(
self,
message: str,
*,
job: CronJob,
run: CronRunRecord,
intent: str = "revise_once",
) -> AgentRunResult:
task = self.engage_scheduled_run(job=job, run=run, intent=intent)
loop = self.create_loop()
runner = loop.submit_direct if self.is_running else loop.process_direct
execution_context = (
"The user is replying to a scheduled notification result.\n\n"
f"Cron Job ID: {job.id}\n"
f"Cron Job Name: {job.name}\n"
f"Scheduled Run ID: {run.scheduled_run_id}\n"
f"Engagement intent: {intent}\n"
f"Original scheduled instruction: {job.payload.message}\n"
f"Original notification output:\n{run.output or ''}\n\n"
"Handle this as a Task continuation. If the intent is update_future, explain the durable change "
"that should apply to future notifications."
)
return await self._run_task_mode(
message,
runner=runner,
task=task,
kwargs={
"session_id": task.session_id,
"source": "notification",
"user_id": "web",
"title": job.name,
"execution_context": execution_context,
"thinking_enabled": thinking_enabled,
},
)
async def submit_feedback(
self,
*,
@ -269,19 +467,51 @@ class AgentService:
generated_candidates = []
validation = ValidationResult.from_dict(updated.validation_result)
if not already_recorded:
run_memory_store = self._require_loaded(loaded, "run_memory_store")
feedback_payload = {
"feedback_type": normalized,
"comment": comment or "",
"task_status": updated.status,
}
run_memory_store.update_run_record(
run_id,
success=normalized == "satisfied",
feedback=feedback_payload,
)
run_memory_store.update_skill_effects_for_run(
run_id,
success=normalized == "satisfied",
feedback_score=self._feedback_score_for_learning(normalized, validation),
notes=(comment or normalized).strip(),
)
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
skill_learning_service.rescore_skill_versions()
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()]
generated_candidates = [
item.to_dict()
for item in skill_learning_service.build_learning_candidates_for_task(
updated.task_id,
trigger_run_id=run_id,
)
]
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()}"
),
session_manager.append_message(
session_id,
run_id=run_id,
role="system",
event_type="task_failure_evidence_recorded",
event_payload={
"task_id": updated.task_id,
"feedback_type": normalized,
"comment": comment or "",
"task_status": updated.status,
"durable_memory_written": False,
},
content=(comment or "Task abandoned; retained as run/session failure evidence."),
context_visible=False,
)
return {
@ -302,20 +532,46 @@ class AgentService:
) -> AgentRunResult:
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
session_manager = self._require_loaded(loaded, "session_manager")
session_id = kwargs.get("session_id") or uuid4().hex
kwargs = dict(kwargs)
kwargs["session_id"] = session_id
provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
kwargs["provider_bundle"] = provider_bundle
router_provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
router_runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
active_task = task_service.get_latest_open_task(session_id)
decision = self._main_agent_router.classify(message, active_task=active_task)
decision = await self._main_agent_router.classify(
message,
active_task=active_task,
provider=router_provider,
model=getattr(router_runtime, "model", None),
recent_messages=session_manager.get_messages_as_conversation(session_id),
thinking_enabled=kwargs.get("thinking_enabled"),
)
if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"):
active_task.metadata["short_title"] = decision.short_title
task_service.store.upsert_task(active_task)
if active_task is not None and decision.closes_task:
task_service.close_task(active_task.task_id, reason=decision.reason)
return await runner(message, **kwargs)
if active_task is not None and decision.abandons_task:
task_service.abandon_task(active_task.task_id, reason=decision.reason)
return await runner(message, **kwargs)
if not decision.is_task:
kwargs["include_skill_assembly"] = False
kwargs["include_tools"] = False
return await runner(message, **kwargs)
task = (
task_service.create_task(
session_id=session_id,
description=message,
metadata={"router_reason": decision.reason},
metadata={
"router_reason": decision.reason,
**({"short_title": decision.short_title} if decision.short_title else {}),
},
)
if active_task is None or decision.starts_new_task
else active_task
@ -420,7 +676,7 @@ class AgentService:
"task_id": task.task_id,
"task_mode": True,
"attempt_index": attempt_index,
"learning_candidate_enabled": False,
"allow_candidate_generation": False,
}
)
if attempt_index == 2 and latest_validation is not None:
@ -433,6 +689,14 @@ class AgentService:
)
elif team_execution_context:
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
task=task,
user_message=message,
attempt_index=attempt_index,
latest_validation=latest_validation,
plan=plan,
team_summaries=team_summaries,
)
result = await runner(message, **attempt_kwargs)
last_result = result
@ -519,7 +783,7 @@ class AgentService:
parent_session_id=parent_session_id,
parent_run_id=None,
provider_bundle_factory=provider_bundle_factory,
learning_candidate_enabled=False,
allow_candidate_generation=False,
)
return result, None
except Exception as exc:
@ -542,6 +806,93 @@ class AgentService:
return [receipt.skill_name for receipt in record.activated_skills]
return []
@staticmethod
def _feedback_score_for_learning(feedback_type: str, validation: ValidationResult | None) -> float:
if feedback_type == "satisfied":
if validation is not None:
return max(0.0, min(1.0, float(validation.score)))
return 1.0
if feedback_type == "revise":
return 0.5
return 0.0
@staticmethod
def _build_skill_selection_context(
*,
task: TaskRecord,
user_message: str,
attempt_index: int,
latest_validation: ValidationResult | None = None,
plan: TaskExecutionPlan | None = None,
team_summaries: list[str] | None = None,
) -> str:
phase = f"attempt_{attempt_index}"
if latest_validation is not None:
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 latest_validation is not None:
validation_lines = [
f"accepted: {latest_validation.accepted}",
f"score: {latest_validation.score}",
]
if latest_validation.issues:
validation_lines.append("issues:\n" + "\n".join(f"- {item}" for item in latest_validation.issues))
if latest_validation.missing_requirements:
validation_lines.append(
"missing requirements:\n"
+ "\n".join(f"- {item}" for item in latest_validation.missing_requirements)
)
if latest_validation.recommended_revision_prompt:
validation_lines.append(
"recommended revision:\n"
+ latest_validation.recommended_revision_prompt
)
sections.append("Validation feedback:\n" + "\n".join(validation_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))
if team_summaries:
sections.append("Team execution summaries:\n" + "\n\n".join(team_summaries)[:2400])
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 _run_excerpt(session_manager: Any, session_id: str, run_id: str) -> str:
lines = []
@ -611,8 +962,8 @@ class AgentService:
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_guidance_id"] = node.agent.metadata.get("ephemeral_guidance_id")
payload["ephemeral_guidance_name"] = node.agent.metadata.get("ephemeral_guidance_name")
payload["ephemeral_used"] = bool(node.inherited_pinned_skill_contexts)
payloads.append(payload)
return payloads