Files
beaver_project/app-instance/backend/beaver/engine/loop.py
steven_li 4b0bf65ace ```
feat(engine): 优化智能体循环中的助手消息处理逻辑

- 在没有工具调用时才添加助手消息到上下文
- 确保工具调用响应正确添加到消息上下文中
- 修复了消息构建的条件逻辑

fix(cron): 改进定时任务调度的时间解析功能

- 添加正则表达式导入用于时间显示解析
- 实现从显示文本中提取毫秒间隔的功能
- 增强整数转换的安全性,避免类型错误
- 优化定时任务配置的解析逻辑

feat(outlook): 增强Outlook集成的功能和稳定性

- 将默认超时时间从10秒增加到180秒
- 为状态检查函数添加可选的验证参数
- 串行执行邮件概览获取操作而非并行
- 改进连接状态验证逻辑

feat(channel): 添加设备名称作为会话标识的选项

- 为终端WebSocket适配器添加新的配置选项
- 实现基于设备名称生成会话对等ID的功能
- 记录原始对等ID和设备名称的元数据
- 支持从设备名称创建会话对等ID

feat(skills): 完善技能学习评估系统和进度跟踪

- 在应用启动时自动调度待评估的技能草稿
- 为技能评估工作创建独立的循环工厂
- 实现异步技能评估任务的取消和清理机制
- 添加技能评估进度报告和状态跟踪功能
- 扩展会话列表API以包含更多详细信息
- 防止对不存在的会话进行操作
- 优化技能草稿提交和评估的业务逻辑

perf(skills): 提升技能评估的并发性能

- 实现并行技能案例评估以提高效率
- 添加最大并行案例数的环境变量控制
- 实现实时评估进度更新和回调机制
- 优化评估过程中的资源管理和同步

refactor(services): 创建隔离的智能体循环实例

- 添加创建独立智能体循环的工厂方法
- 确保新循环继承运行时服务配置
- 支持技能评估等需要隔离环境的场景
```
2026-06-15 14:48:16 +08:00

1255 lines
50 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.

"""Unified agent loop used by all Beaver agents."""
from __future__ import annotations
import asyncio
import json
import os
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext
from beaver.foundation.events import ChannelIdentity
from beaver.memory.runs import RunRecord, SkillEffectRecord
from beaver.skills.learning import RunReceiptContext
from beaver.skills.catalog.utils import strip_frontmatter
from beaver.skills.specs import SkillActivationReceipt
from beaver.engine.providers import ProviderBundle, make_provider_bundle
from beaver.tools import ToolContext
from .loader import EngineLoader, EngineLoadResult
TOOL_FAILURE_GUIDANCE_PROMPT = (
"# Tool Failure Guidance\n\n"
"If the same class of tools fails repeatedly in a run, stop retrying with query variants. "
"Use available materials, state uncertainty clearly, and provide partial confirmed results."
)
RAW_TOOL_CALL_FALLBACK = (
"The run reached the configured tool-call limit before producing a reliable final answer. "
"The model attempted another tool call instead of answering, so the raw tool call was suppressed. "
"Please request a revision to continue the task."
)
_RAW_TOOL_CALL_RE = re.compile(
r"^\s*<tool_call\b[\s\S]*?</tool_call>\s*$|^\s*<function=[^>]+>[\s\S]*?</function>\s*$",
re.IGNORECASE,
)
@dataclass(slots=True)
class AgentProfile:
"""Runtime profile for a Beaver agent instance."""
name: str = "default"
system_prompt: str = ""
default_model: str = "gpt-4.1-mini"
max_tokens: int | None = None
max_context_messages: int = 1000
temperature: float = 0.2
max_tool_iterations: int = 30
@dataclass(slots=True)
class AgentRunResult:
"""一次 direct run 的最小结果结构。"""
session_id: str
run_id: str
output_text: str
finish_reason: str
tool_iterations: int
provider_name: str | None = None
model: str | None = None
usage: dict[str, Any] = field(default_factory=dict)
task_id: str | None = None
task_status: str | None = None
validation_result: dict[str, Any] | None = None
@dataclass(slots=True)
class _DirectRunRequest:
"""运行循环中的单个 direct task。"""
task: str
kwargs: dict[str, Any]
future: asyncio.Future[AgentRunResult]
class AgentLoop:
"""Single execution kernel shared by root agents and delegated agents."""
def __init__(self, *, profile: AgentProfile | None = None, loader: EngineLoader | None = None) -> None:
self.profile = profile or AgentProfile()
self.loader = loader or EngineLoader()
self.loaded: EngineLoadResult | None = None
self.runtime_services: dict[str, Any] = {}
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
self._active_direct_task: asyncio.Task[Any] | None = None
self._running = False
self._stop_requested = False
def boot(self) -> EngineLoadResult:
"""Load shared runtime capabilities once for this agent instance."""
if self.loaded is None:
self.loaded = self.loader.load()
return self.loaded
@property
def is_running(self) -> bool:
return self._running
async def run(self) -> None:
"""启动最小运行循环,顺序消费提交进来的 direct tasks。
第一版故意保持克制:
1. 只做单消费者串行消费
2. 真正执行仍复用 `process_direct()`
3. 不引入 bus / worker / priority / retry
"""
if self._running:
raise RuntimeError("AgentLoop.run() is already active")
self.boot()
self._run_queue = asyncio.Queue()
self._running = True
self._stop_requested = False
try:
while True:
item = await self._run_queue.get()
if item is None:
if self._stop_requested:
break
continue
if item.future.cancelled():
continue
previous_direct_task = self._active_direct_task
self._active_direct_task = asyncio.current_task()
try:
result = await self._process_direct_impl(item.task, **item.kwargs)
except asyncio.CancelledError:
if not item.future.done():
item.future.cancel()
raise
except Exception as exc: # pragma: no cover - defensive queue path
if not item.future.done():
item.future.set_exception(exc)
else:
if not item.future.done():
item.future.set_result(result)
finally:
self._active_direct_task = previous_direct_task
finally:
if self._run_queue is not None:
while True:
try:
pending = self._run_queue.get_nowait()
except asyncio.QueueEmpty:
break
if isinstance(pending, _DirectRunRequest) and not pending.future.done():
pending.future.set_exception(
RuntimeError("AgentLoop.run() stopped before processing the queued task")
)
self._running = False
self._stop_requested = False
self._run_queue = None
async def stop(self) -> None:
"""停止运行循环。
第一版语义:
- 不再接收新任务
- 当前已经取出的任务允许收尾
- 不自动 close runtime
"""
if not self._running or self._run_queue is None:
return
self._stop_requested = True
await self._run_queue.put(None)
async def submit_direct(
self,
task: str,
**kwargs: Any,
) -> AgentRunResult:
"""向运行中的 loop 提交一个 direct task并等待结果。"""
if not self._running or self._run_queue is None:
raise RuntimeError("AgentLoop.submit_direct() requires an active run() loop")
if self._stop_requested:
raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()")
if asyncio.current_task() is self._active_direct_task:
return await self._process_direct_impl(task, **kwargs)
future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future()
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
return await future
def close(self) -> None:
"""关闭当前 loop 持有的 runtime。
第 6 阶段先把生命周期最小骨架立住:
- `boot()` 负责建立 runtime
- `close()` 负责释放由 runtime 持有的资源
- 之后再在此基础上扩 `run()/stop()/shutdown hooks`
"""
if self._running:
raise RuntimeError("AgentLoop.close() requires the run loop to be stopped first")
if self.loaded is None:
return
try:
self.loaded.close()
finally:
self.loaded = None
async def process_direct(
self,
task: str,
*,
session_id: str | None = None,
source: str = "direct",
user_id: str | None = None,
title: str | None = None,
execution_context: str | None = None,
skill_selection_context: str | None = None,
prompt_locale: str | None = None,
model: str | None = None,
provider_name: str | None = None,
api_key: str | None = None,
api_base: str | None = None,
extra_headers: dict[str, str] | None = None,
routing: Any = None,
fallback_target: dict[str, Any] | None = None,
auxiliary_target: dict[str, Any] | None = None,
embedding_target: dict[str, Any] | None = None,
embedding_model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
thinking_enabled: bool | None = None,
include_skill_assembly: bool = True,
include_tools: bool = True,
max_tool_iterations: int | None = None,
provider_bundle: ProviderBundle | None = None,
parent_session_id: str | None = None,
task_id: str | None = None,
task_mode: bool = False,
attempt_index: int | None = None,
pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None,
tool_executor_override: Any = None,
allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None,
channel_identity: ChannelIdentity | None = None,
) -> AgentRunResult:
"""跑通最小 direct run 主链。
当前主链刻意保持克制,只解决这些事情:
1. 确保 session 存在
2. 用 frozen memory + history 组 prompt
3. 调 provider
4. 若有 tool calls则进入最小 tool loop
5. 把 user/assistant/tool 消息和 usage 写回 session
"""
if self._running:
raise RuntimeError(
"AgentLoop.process_direct() is disabled while run() is active; "
"submit tasks via submit_direct() instead."
)
return await self._process_direct_impl(
task,
session_id=session_id,
source=source,
user_id=user_id,
title=title,
execution_context=execution_context,
skill_selection_context=skill_selection_context,
prompt_locale=prompt_locale,
model=model,
provider_name=provider_name,
api_key=api_key,
api_base=api_base,
extra_headers=extra_headers,
routing=routing,
fallback_target=fallback_target,
auxiliary_target=auxiliary_target,
embedding_target=embedding_target,
embedding_model=embedding_model,
max_tokens=max_tokens,
temperature=temperature,
thinking_enabled=thinking_enabled,
include_skill_assembly=include_skill_assembly,
include_tools=include_tools,
max_tool_iterations=max_tool_iterations,
provider_bundle=provider_bundle,
parent_session_id=parent_session_id,
task_id=task_id,
task_mode=task_mode,
attempt_index=attempt_index,
pinned_skill_names=pinned_skill_names,
pinned_skill_contexts=pinned_skill_contexts,
tool_executor_override=tool_executor_override,
allow_candidate_generation=allow_candidate_generation,
intent_agent_decision=intent_agent_decision,
channel_identity=channel_identity,
)
async def _process_direct_impl(
self,
task: str,
*,
session_id: str | None = None,
source: str = "direct",
user_id: str | None = None,
title: str | None = None,
execution_context: str | None = None,
skill_selection_context: str | None = None,
prompt_locale: str | None = None,
model: str | None = None,
provider_name: str | None = None,
api_key: str | None = None,
api_base: str | None = None,
extra_headers: dict[str, str] | None = None,
routing: Any = None,
fallback_target: dict[str, Any] | None = None,
auxiliary_target: dict[str, Any] | None = None,
embedding_target: dict[str, Any] | None = None,
embedding_model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
thinking_enabled: bool | None = None,
include_skill_assembly: bool = True,
include_tools: bool = True,
max_tool_iterations: int | None = None,
provider_bundle: ProviderBundle | None = None,
parent_session_id: str | None = None,
task_id: str | None = None,
task_mode: bool = False,
attempt_index: int | None = None,
pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None,
tool_executor_override: Any = None,
allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None,
channel_identity: ChannelIdentity | None = None,
) -> AgentRunResult:
"""真正执行一轮 direct run 的内部实现。
规则:
- 外部直接调用时走 `process_direct()`
- 运行循环内部消费时走 `_process_direct_impl()`
- 这样才能保证 run 模式下外部不能绕过队列直接执行
"""
loaded = self.boot()
session_manager = self._require_loaded("session_manager")
memory_service = self._require_loaded("memory_service")
context_builder = self._require_loaded("context_builder")
tool_registry = self._require_loaded("tool_registry")
tool_assembler = self._require_loaded("tool_assembler")
tool_executor = self._require_loaded("tool_executor")
effective_tool_executor = tool_executor_override or tool_executor
skills_loader = self._require_loaded("skills_loader")
skill_assembler = self._require_loaded("skill_assembler")
skill_learning_service = self._require_loaded("skill_learning_service")
mcp_manager = getattr(loaded, "mcp_manager", None)
if mcp_manager is not None:
loaded.mcp_report = await mcp_manager.connect_all(tool_registry)
loaded.tools = [spec.name for spec in tool_registry.list_specs()]
config = loaded.config
configured_provider = config.resolve_provider_target(model=model, provider_name=provider_name)
resolved_session_id = session_id or uuid4().hex
resolved_run_id = uuid4().hex
resolved_model = configured_provider.get("model") or self.profile.default_model
resolved_provider_name = configured_provider.get("provider_name") or provider_name
resolved_api_key = api_key or configured_provider.get("api_key")
resolved_api_base = api_base or configured_provider.get("api_base")
resolved_extra_headers = extra_headers or configured_provider.get("extra_headers")
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
resolved_embedding_model = embedding_model or config.default_embedding_model
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
resolved_max_tokens = self.profile.max_tokens if max_tokens is None else max_tokens
resolved_temperature = self.profile.temperature if temperature is None else temperature
resolved_max_tool_iterations = (
self.profile.max_tool_iterations if max_tool_iterations is None else max_tool_iterations
)
# 每个 run 都捕获自己的 frozen snapshot不能依赖 MemoryService
# 上的共享 `_snapshot`,否则 parallel team runs 会互相覆盖。
memory_snapshot = memory_service.capture_snapshot_for_run()
if parent_session_id:
session_manager.ensure_session(
parent_session_id,
source="unknown",
model=resolved_model,
user_id=user_id,
)
session_manager.ensure_session(
resolved_session_id,
source=source,
model=resolved_model,
title=title,
user_id=user_id,
parent_session_id=parent_session_id,
)
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="run_started",
event_payload={
"source": source,
"model": resolved_model,
"agent_name": self.profile.name,
"task_id": task_id,
"task_mode": task_mode,
"attempt_index": attempt_index,
"thinking_enabled": thinking_enabled,
"include_skill_assembly": include_skill_assembly,
"skill_selection_context_present": bool(skill_selection_context),
"parent_session_id": parent_session_id,
"pinned_skill_names": list(pinned_skill_names or []),
"pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []],
"intent_agent_decision": intent_agent_decision,
},
content=task,
context_visible=False,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
if intent_agent_decision:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="intent_agent_decision_snapshotted",
event_payload=dict(intent_agent_decision),
content=str(intent_agent_decision.get("choice") or ""),
context_visible=False,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
user_message_recorded = False
iterations = 0
final_usage: dict[str, Any] = {}
final_provider_name: str | None = resolved_provider_name
final_model: str | None = resolved_model
run_started_at = self._utc_now()
activated_receipts: list[SkillActivationReceipt] = []
try:
bundle = provider_bundle or make_provider_bundle(
model=resolved_model,
provider_name=resolved_provider_name,
api_key=resolved_api_key,
api_base=resolved_api_base,
request_timeout_seconds=resolved_request_timeout_seconds,
extra_headers=resolved_extra_headers,
routing=routing,
fallback_target=fallback_target,
auxiliary_target=auxiliary_target,
embedding_target=resolved_embedding_target,
embedding_model=resolved_embedding_model,
)
skill_selector_provider = bundle.auxiliary_provider or bundle.main_provider
skill_selector_model = (
bundle.auxiliary_runtime.model
if bundle.auxiliary_runtime is not None
else bundle.main_runtime.model
)
pinned_skills = [
*(pinned_skill_contexts or []),
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
]
if not include_skill_assembly:
activated_skills = self._merge_skill_contexts(pinned_skills, [])
else:
skill_query = skill_selection_context or task
assembled_skills = await skill_assembler.assemble(
task_description=skill_query,
provider=skill_selector_provider,
model=skill_selector_model,
embedding_runtime=bundle.embedding_runtime,
thinking_enabled=thinking_enabled,
)
for interaction in getattr(assembled_skills, "llm_interactions", []) or []:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="skill_assembler_llm_interaction_snapshotted",
event_payload=interaction,
content=json.dumps(interaction, ensure_ascii=False, default=str),
context_visible=False,
source=source,
title=title,
model=skill_selector_model,
user_id=user_id,
)
activated_skills = self._merge_skill_contexts(
pinned_skills,
assembled_skills.activated_skills,
)
skill_activation_messages = context_builder.build_skill_activation_messages(
activated_skills
)
activated_receipts = [
SkillActivationReceipt(
run_id=resolved_run_id,
session_id=resolved_session_id,
skill_name=skill.name,
skill_version=skill.version,
content_hash=skill.content_hash,
activated_at=self._utc_now(),
activation_reason=skill.activation_reason,
tool_hints=list(skill.tool_hints),
)
for skill in activated_skills
]
if skill_activation_messages or activated_receipts:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="skill_activation_snapshotted",
event_payload={
"receipts": [receipt.to_dict() for receipt in activated_receipts],
"activation_messages": skill_activation_messages,
},
content="\n\n".join(message["content"] for message in skill_activation_messages) or None,
context_visible=False,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
if not include_tools:
selected_tool_specs = []
else:
selected_tool_specs = await tool_assembler.assemble(
task_description=task,
registry=tool_registry,
skills_loader=skills_loader,
activated_skills=activated_skills,
embedding_runtime=bundle.embedding_runtime,
top_k=10,
)
tool_schemas = tool_registry.export_selected_provider_schemas(selected_tool_specs)
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="tool_selection_snapshotted",
event_payload={
"tools": [spec.to_mcp_descriptor() for spec in selected_tool_specs],
"tool_names": [spec.name for spec in selected_tool_specs],
},
content=", ".join(spec.name for spec in selected_tool_specs) or None,
context_visible=False,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
build_input = ContextBuildInput(
base_system_prompt=self.profile.system_prompt,
prompt_locale=prompt_locale,
history=session_manager.get_history(
resolved_session_id,
max_messages=max(1, self.profile.max_context_messages),
),
current_user_input=task,
memory_snapshot=memory_snapshot,
activated_skills=activated_skills,
session_context=SessionContext(
session_id=resolved_session_id,
source=source,
model=resolved_model,
user_id=user_id,
channel=channel_identity.channel_id if channel_identity else None,
channel_kind=channel_identity.kind if channel_identity else None,
account_id=channel_identity.account_id if channel_identity else None,
peer_id=channel_identity.peer_id if channel_identity else None,
peer_type=channel_identity.peer_type if channel_identity else None,
chat_id=channel_identity.peer_id if channel_identity else None,
thread_id=channel_identity.thread_id if channel_identity else None,
parent_session_id=parent_session_id,
),
runtime_context=self._current_runtime_context(),
execution_context=execution_context,
extra_sections=[TOOL_FAILURE_GUIDANCE_PROMPT],
)
context_result = context_builder.build_messages(build_input)
if skill_selection_context:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="skill_selection_context_snapshotted",
event_payload={
"skill_selection_context": skill_selection_context,
"task_id": task_id,
"task_mode": task_mode,
"attempt_index": attempt_index,
},
content=skill_selection_context,
context_visible=False,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
session_manager.update_system_prompt(resolved_session_id, context_result.system_prompt)
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="system_prompt_snapshotted",
event_payload={
"source": source,
"model": resolved_model,
"system_prompt_length": len(context_result.system_prompt),
},
content=context_result.system_prompt,
context_visible=False,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="user",
event_type="user_message_added",
content=task,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
user_message_recorded = True
provider = bundle.main_provider
messages = list(context_result.messages)
tool_context = ToolContext(
workspace=str(loaded.workspace),
session_id=resolved_session_id,
user_id=user_id,
services={
"session_manager": session_manager,
"memory_service": memory_service,
"memory_store": memory_service.get_store(),
"tool_registry": tool_registry,
"skills_loader": skills_loader,
"draft_service": getattr(loaded, "draft_service", None),
"beaver_config": loaded.config,
"task_id": task_id,
"run_id": resolved_run_id,
**self.runtime_services,
},
metadata={
"source": source,
"agent_name": self.profile.name,
"session_id": resolved_session_id,
"task_id": task_id,
"run_id": resolved_run_id,
},
)
final_text = ""
final_finish_reason = "stop"
final_provider_name = bundle.main_runtime.provider_name
final_model = bundle.main_runtime.model
while True:
chat_kwargs: dict[str, Any] = {
"messages": messages,
"tools": tool_schemas if include_tools else None,
"model": final_model,
"max_tokens": resolved_max_tokens,
"temperature": resolved_temperature,
}
if thinking_enabled is not None:
chat_kwargs["thinking_enabled"] = thinking_enabled
message_char_length = len(json.dumps(messages, ensure_ascii=False, default=str))
tool_schema_char_length = len(json.dumps(tool_schemas, ensure_ascii=False, default=str))
tool_names = [
str(tool.get("function", {}).get("name") or tool.get("name") or "tool")
for tool in (tool_schemas or [])
if isinstance(tool, dict)
]
snapshot_payload = {
"iteration": iterations,
"provider_name": final_provider_name,
"model": final_model,
"message_count": len(messages),
"tool_names": tool_names,
"message_char_length": message_char_length,
"tool_schema_char_length": tool_schema_char_length,
"max_tokens": resolved_max_tokens,
"temperature": resolved_temperature,
"thinking_enabled": thinking_enabled,
}
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="llm_request_snapshotted",
event_payload=snapshot_payload,
content=json.dumps(snapshot_payload, ensure_ascii=False, default=str),
context_visible=False,
source=source,
title=title,
model=final_model,
user_id=user_id,
)
response = await provider.chat(**chat_kwargs)
final_provider_name = response.provider_name or final_provider_name
final_model = response.model or final_model
final_usage = self._merge_usage(final_usage, response.usage or {})
self._record_usage(session_manager, resolved_session_id, response.usage or {})
assistant_tool_calls = self._serialize_tool_calls(response.tool_calls)
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="assistant",
event_type="assistant_message_added",
event_payload={"task_id": task_id} if task_id else None,
content=response.content,
tool_calls=assistant_tool_calls or None,
finish_reason=response.finish_reason,
reasoning=response.reasoning_content,
context_visible=not bool(assistant_tool_calls),
source=source,
title=title,
model=final_model,
user_id=user_id,
)
if not response.has_tool_calls:
context_builder.add_assistant_message(
messages,
content=response.content,
reasoning_content=response.reasoning_content,
)
final_text = response.content or ""
if self._looks_like_raw_tool_call(final_text):
final_text = RAW_TOOL_CALL_FALLBACK
final_finish_reason = "invalid_tool_call_text"
else:
final_finish_reason = response.finish_reason or "stop"
break
if iterations >= resolved_max_tool_iterations:
finalized = await self._finalize_after_tool_limit(
provider=provider,
messages=messages,
model=final_model,
max_tokens=resolved_max_tokens,
temperature=resolved_temperature,
thinking_enabled=thinking_enabled,
)
final_text = finalized or RAW_TOOL_CALL_FALLBACK
final_finish_reason = "max_tool_iterations_finalized" if finalized else "max_tool_iterations"
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="assistant",
event_type="assistant_message_added",
event_payload={"task_id": task_id} if task_id else None,
content=final_text,
finish_reason=final_finish_reason,
source=source,
title=title,
model=final_model,
user_id=user_id,
)
context_builder.add_assistant_message(
messages,
content=final_text,
)
break
context_builder.add_assistant_message(
messages,
content=response.content,
tool_calls=assistant_tool_calls or None,
reasoning_content=response.reasoning_content,
)
iterations += 1
for tool_call in response.tool_calls:
result = await effective_tool_executor.execute_tool_call(tool_call, context=tool_context)
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="tool",
event_type="tool_result_recorded",
event_payload={
"success": result.success,
"error": result.error,
},
content=result.content,
tool_name=result.tool_name,
tool_call_id=tool_call.id,
source=source,
title=title,
model=final_model,
user_id=user_id,
)
context_builder.add_tool_result(
messages,
tool_call_id=tool_call.id,
tool_name=result.tool_name,
result=result.content,
)
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="run_completed",
event_payload={
"finish_reason": final_finish_reason,
"tool_iterations": iterations,
"task_id": task_id,
"task_mode": task_mode,
"attempt_index": attempt_index,
},
content=final_text,
finish_reason=final_finish_reason,
context_visible=False,
source=source,
title=title,
model=final_model,
user_id=user_id,
)
self._record_run_receipts(
skill_learning_service=skill_learning_service,
session_manager=session_manager,
session_id=resolved_session_id,
run_id=resolved_run_id,
task=task,
run_started_at=run_started_at,
run_ended_at=self._utc_now(),
finish_reason=final_finish_reason,
activated_receipts=activated_receipts,
success=(final_finish_reason == "stop"),
task_id=task_id,
attempt_index=attempt_index,
allow_candidate_generation=False,
)
return AgentRunResult(
session_id=resolved_session_id,
run_id=resolved_run_id,
output_text=final_text,
finish_reason=final_finish_reason,
tool_iterations=iterations,
provider_name=final_provider_name,
model=final_model,
usage=final_usage,
task_id=task_id,
)
except Exception as exc:
if not user_message_recorded:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="user",
event_type="user_message_added",
content=task,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
result = self._build_error_result(
session_manager=session_manager,
session_id=resolved_session_id,
run_id=resolved_run_id,
source=source,
title=title,
user_id=user_id,
model=final_model or resolved_model,
message=f"Run failed before completion: {exc}",
tool_iterations=iterations,
provider_name=final_provider_name,
usage=final_usage,
task_id=task_id,
)
self._record_run_receipts(
skill_learning_service=skill_learning_service,
session_manager=session_manager,
session_id=resolved_session_id,
run_id=resolved_run_id,
task=task,
run_started_at=run_started_at,
run_ended_at=self._utc_now(),
finish_reason="error",
activated_receipts=activated_receipts,
success=False,
task_id=task_id,
attempt_index=attempt_index,
allow_candidate_generation=False,
)
return result
def _require_loaded(self, field_name: str) -> Any:
loaded = self.boot()
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
async def _finalize_after_tool_limit(
*,
provider: Any,
messages: list[dict[str, Any]],
model: str,
max_tokens: int | None,
temperature: float,
thinking_enabled: bool | None,
) -> str:
final_messages = AgentLoop._with_system_guidance(
messages,
(
"The configured tool iteration budget is exhausted. Do not call tools. "
"Produce the best final answer from the existing conversation and tool results. "
"State uncertainty explicitly."
),
)
kwargs: dict[str, Any] = {
"messages": final_messages,
"tools": None,
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
}
if thinking_enabled is not None:
kwargs["thinking_enabled"] = thinking_enabled
response = await provider.chat(**kwargs)
if response.has_tool_calls:
return ""
content = (response.content or "").strip()
if AgentLoop._looks_like_raw_tool_call(content):
return ""
return content
@staticmethod
def _looks_like_raw_tool_call(content: str | None) -> bool:
if not content:
return False
return bool(_RAW_TOOL_CALL_RE.match(content))
@staticmethod
def _with_system_guidance(messages: list[dict[str, Any]], guidance: str) -> list[dict[str, Any]]:
copied = [dict(message) for message in messages]
if copied and copied[0].get("role") == "system":
existing = str(copied[0].get("content") or "").strip()
copied[0]["content"] = "\n\n".join(part for part in (existing, guidance.strip()) if part)
return copied
return [{"role": "system", "content": guidance.strip()}, *copied]
@staticmethod
def _load_pinned_skill_contexts(skills_loader: Any, skill_names: list[str]) -> list[SkillContext]:
contexts: list[SkillContext] = []
seen: set[str] = set()
for name in skill_names:
normalized = str(name).strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
record = skills_loader.get_skill_record(normalized)
raw_content = skills_loader.load_published_skill(normalized)
content = strip_frontmatter(raw_content).strip() if raw_content else ""
if record is None or not content:
raise ValueError(f"Pinned skill {normalized!r} is not available for delegated execution")
contexts.append(
SkillContext(
name=normalized,
content=content,
version=record.version,
content_hash=record.content_hash or "",
activation_reason="pinned_delegation",
tool_hints=list(record.tool_hints),
)
)
return contexts
@staticmethod
def _merge_skill_contexts(
pinned_skills: list[SkillContext],
open_skills: list[SkillContext],
) -> list[SkillContext]:
result: list[SkillContext] = []
seen: set[str] = set()
for skill in [*pinned_skills, *open_skills]:
if skill.name in seen:
continue
seen.add(skill.name)
result.append(skill)
return result
@staticmethod
def _serialize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
payload: list[dict[str, Any]] = []
for tool_call in tool_calls:
arguments = tool_call.arguments
if not isinstance(arguments, str):
arguments = json.dumps(arguments or {}, ensure_ascii=False, default=str)
payload.append(
{
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": arguments,
},
}
)
return payload
@staticmethod
def _record_usage(session_manager: Any, session_id: str, usage: dict[str, Any]) -> None:
"""把 provider usage 映射到 session usage 字段。
这里先做最常见字段的最小映射:
- prompt_tokens -> input_tokens
- completion_tokens -> output_tokens
后面如果 provider 层补了更细的 cache/reasoning/cost再往这里扩。
"""
if not usage:
return
session_manager.update_usage(
session_id,
input_tokens=int(usage.get("input_tokens", usage.get("prompt_tokens", 0)) or 0),
output_tokens=int(usage.get("output_tokens", usage.get("completion_tokens", 0)) or 0),
reasoning_tokens=int(usage.get("reasoning_tokens", 0) or 0),
)
@staticmethod
def _merge_usage(total: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]:
"""把多轮 provider usage 合并成一次 run 的累计 usage。"""
merged = dict(total)
for key, value in delta.items():
if isinstance(value, (int, float)) and isinstance(merged.get(key, 0), (int, float)):
merged[key] = merged.get(key, 0) + value
else:
merged[key] = value
return merged
@staticmethod
def _build_error_result(
*,
session_manager: Any,
session_id: str,
run_id: str,
source: str,
title: str | None,
user_id: str | None,
model: str | None,
message: str,
tool_iterations: int,
provider_name: str | None,
usage: dict[str, Any],
task_id: str | None = None,
) -> AgentRunResult:
"""把主链中的未处理异常收口成可追踪的 assistant error turn。"""
session_manager.append_message(
session_id,
run_id=run_id,
role="assistant",
event_type="assistant_message_added",
event_payload={"task_id": task_id} if task_id else None,
content=message,
finish_reason="error",
source=source,
title=title,
model=model,
user_id=user_id,
)
session_manager.append_message(
session_id,
run_id=run_id,
role="system",
event_type="run_failed",
event_payload={
"tool_iterations": tool_iterations,
"provider_name": provider_name,
"task_id": task_id,
},
content=message,
finish_reason="error",
context_visible=False,
source=source,
title=title,
model=model,
user_id=user_id,
)
return AgentRunResult(
session_id=session_id,
run_id=run_id,
output_text=message,
finish_reason="error",
tool_iterations=tool_iterations,
provider_name=provider_name,
model=model,
usage=usage,
task_id=task_id,
)
@staticmethod
def _record_run_receipts(
*,
skill_learning_service: Any,
session_manager: Any,
session_id: str,
run_id: str,
task: str,
run_started_at: str,
run_ended_at: str,
finish_reason: str,
activated_receipts: list[SkillActivationReceipt],
success: bool,
task_id: str | None = None,
attempt_index: int | None = None,
allow_candidate_generation: bool = False,
) -> None:
run_record = RunRecord(
run_id=run_id,
session_id=session_id,
task_id=task_id,
attempt_index=attempt_index,
task_text=task,
started_at=run_started_at,
ended_at=run_ended_at,
success=success,
finish_reason=finish_reason,
feedback={},
activated_skills=list(activated_receipts),
)
effect_records = [
SkillEffectRecord(
run_id=run_id,
skill_name=receipt.skill_name,
skill_version=receipt.skill_version,
success=success,
feedback_score=None,
notes=finish_reason,
created_at=run_ended_at,
)
for receipt in activated_receipts
]
try:
candidates = skill_learning_service.collect_run_receipts(
RunReceiptContext(run_record=run_record, effect_records=effect_records),
generate_candidates=allow_candidate_generation,
)
except Exception as exc: # pragma: no cover - defensive hot-path guard
session_manager.append_message(
session_id,
run_id=run_id,
role="system",
event_type="skill_effects_snapshot_failed",
event_payload={
"run_record": run_record.to_dict(),
"skill_effects": [item.to_dict() for item in effect_records],
"error": str(exc),
},
content=f"Skill learning receipt recording failed: {exc}",
context_visible=False,
)
return
session_manager.append_message(
session_id,
run_id=run_id,
role="system",
event_type="skill_effects_snapshotted",
event_payload={
"run_record": run_record.to_dict(),
"skill_effects": [item.to_dict() for item in effect_records],
"learning_candidates": [candidate.to_dict() for candidate in candidates],
"candidate_generation_allowed": allow_candidate_generation,
},
content=f"Recorded {len(effect_records)} skill effect record(s).",
context_visible=False,
)
@staticmethod
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@staticmethod
def _current_runtime_context() -> RuntimeContext:
utc_now = datetime.now(timezone.utc)
timezone_name = AgentLoop._configured_timezone_name()
local_now = datetime.now().astimezone()
rendered_timezone = local_now.tzname()
if timezone_name:
try:
local_now = utc_now.astimezone(ZoneInfo(timezone_name))
rendered_timezone = timezone_name
except ZoneInfoNotFoundError:
rendered_timezone = local_now.tzname() or timezone_name
return RuntimeContext(
utc_datetime=utc_now.isoformat(),
local_datetime=local_now.isoformat(),
timezone=rendered_timezone,
utc_offset=AgentLoop._format_utc_offset(local_now),
)
@staticmethod
def _configured_timezone_name() -> str | None:
for value in (os.getenv("BEAVER_RUNTIME_TIMEZONE"), os.getenv("TZ")):
cleaned = (value or "").strip()
if cleaned:
return cleaned
try:
timezone_file = "/etc/timezone"
if os.path.exists(timezone_file):
with open(timezone_file, encoding="utf-8") as file:
cleaned = file.read().strip()
if cleaned:
return cleaned
except OSError:
return None
return None
@staticmethod
def _format_utc_offset(value: datetime) -> str | None:
raw = value.strftime("%z")
if not raw:
return None
return f"{raw[:3]}:{raw[3:]}"