feat(engine): 优化智能体循环中的助手消息处理逻辑

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

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

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

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

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

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

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

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

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

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

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

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

- 添加创建独立智能体循环的工厂方法
- 确保新循环继承运行时服务配置
- 支持技能评估等需要隔离环境的场景
```
This commit is contained in:
2026-06-15 14:48:16 +08:00
parent 8aeb97a5fc
commit 4b0bf65ace
53 changed files with 4328 additions and 292 deletions

View File

@ -331,6 +331,10 @@ class ChannelRuntime:
event_recorder=self.record_event,
heartbeat_seconds=float(cfg.config.get("heartbeat_seconds") or 30),
max_message_chars=int(cfg.config.get("max_message_chars") or 20000),
session_peer_from_device_name=bool(
cfg.config.get("session_peer_from_device_name")
or cfg.config.get("sessionPeerFromDeviceName")
),
)
if cfg.kind == "telegram" and cfg.mode in {"polling", "webhook"}:

View File

@ -51,6 +51,7 @@ class TerminalWebSocketAdapter:
event_recorder: Callable[..., None] | None = None,
heartbeat_seconds: float = 30,
max_message_chars: int = 20000,
session_peer_from_device_name: bool = False,
) -> None:
self.channel_id = channel_id
self.kind = kind
@ -61,6 +62,7 @@ class TerminalWebSocketAdapter:
self.event_recorder = event_recorder
self.heartbeat_seconds = max(1.0, float(heartbeat_seconds))
self.max_message_chars = max(1, int(max_message_chars))
self.session_peer_from_device_name = bool(session_peer_from_device_name)
self.started = False
self._connections_by_session: dict[str, TerminalConnection] = {}
self._session_by_peer: dict[str, str] = {}
@ -131,14 +133,15 @@ class TerminalWebSocketAdapter:
*,
current: TerminalConnection | None,
) -> TerminalConnection | None:
peer_id = _clean(payload.get("peer_id"))
if not peer_id:
raw_peer_id = _clean(payload.get("peer_id"))
if not raw_peer_id:
await websocket.send_json({"type": "error", "error": "peer_id is required"})
return current
thread_id = _clean(payload.get("thread_id")) or None
user_id = _clean(payload.get("user_id")) or None
device_name = _clean(payload.get("device_name"))
peer_id = self._session_peer_id(raw_peer_id, device_name)
capabilities = [str(item) for item in payload.get("capabilities") or [] if item is not None]
identity = ChannelIdentity(
channel_id=self.channel_id,
@ -171,7 +174,12 @@ class TerminalWebSocketAdapter:
self._record(
kind="terminal_connected",
session_id=session_id,
metadata={"peer_id": peer_id, "device_name": device_name, "capabilities": capabilities},
metadata={
"peer_id": peer_id,
"raw_peer_id": raw_peer_id,
"device_name": device_name,
"capabilities": capabilities,
},
)
await websocket.send_json(
{
@ -299,3 +307,13 @@ class TerminalWebSocketAdapter:
error=error,
metadata=metadata,
)
def _session_peer_id(self, peer_id: str, device_name: str) -> str:
if self.session_peer_from_device_name and device_name:
return f"device-{_clean_session_part(device_name)}"
return peer_id
def _clean_session_part(value: str) -> str:
cleaned = "-".join(str(value or "").strip().split())
return cleaned.replace(":", "_") or "unknown"

View File

@ -264,6 +264,25 @@ async def _app_lifespan(
)
app.state.channel_runtime = channel_runtime
await channel_runtime.start()
for candidate in loaded.skill_learning_pipeline.list_candidates(status="review_pending"): # type: ignore[union-attr]
skill_name = candidate.draft_skill_name
draft_id = candidate.draft_id
if not skill_name or not draft_id:
continue
if loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) is not None: # type: ignore[union-attr]
continue
draft = loaded.skill_learning_pipeline.get_draft(skill_name, draft_id) # type: ignore[union-attr]
if draft.status != "in_review":
continue
_schedule_skill_draft_eval(
app,
agent_service=attached_service,
loop=attached_service.create_loop(),
loaded=loaded,
candidate_id=candidate.candidate_id,
skill_name=skill_name,
draft_id=draft_id,
)
except BaseException:
if owns_service and started:
with suppress(BaseException):
@ -280,7 +299,10 @@ async def _app_lifespan(
worker = SkillLearningWorker(
pipeline=loaded.skill_learning_pipeline, # type: ignore[arg-type]
provider_bundle_factory=lambda: attached_service._make_provider_bundle_for_task(loaded, {}), # noqa: SLF001
replay_runner_factory=lambda: ReplayRunner(agent_loop=attached_service.create_loop()),
replay_runner_factory=lambda: ReplayRunner(
agent_loop=attached_service.create_loop(),
isolated_loop_factory=attached_service.create_isolated_loop,
),
config=worker_config,
)
worker_task = asyncio.create_task(worker.run_forever())
@ -289,6 +311,13 @@ async def _app_lifespan(
try:
yield
finally:
skill_eval_tasks = getattr(app.state, "skill_eval_tasks", {})
for task in list(skill_eval_tasks.values()):
task.cancel()
for task in list(skill_eval_tasks.values()):
with suppress(BaseException):
await task
skill_eval_tasks.clear()
runtime = getattr(app.state, "channel_runtime", None)
if isinstance(runtime, ChannelRuntime):
with suppress(BaseException):
@ -587,6 +616,7 @@ def create_app(
)
app.state.auth_tokens = {}
app.state.handoff_codes = {}
app.state.skill_eval_tasks = {}
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "")
max_file_size = 50 * 1024 * 1024
max_user_file_upload_size = _int_env("BEAVER_USER_FILES_MAX_UPLOAD_BYTES", 5 * 1024 * 1024 * 1024)
@ -1250,7 +1280,7 @@ def create_app(
session_manager = loaded.session_manager
rows = session_manager.list_sessions_rich(
limit=100,
exclude_sources=["subagent", "notification"],
exclude_sources=["subagent", "notification", "skill_replay_eval"],
exclude_end_reasons=["archived", "deleted"],
) # type: ignore[union-attr]
return [
@ -1259,6 +1289,9 @@ def create_app(
"created_at": _iso_from_timestamp(row.get("started_at")),
"updated_at": _iso_from_timestamp(row.get("last_active")),
"path": str(row.get("id")),
"source": row.get("source"),
"title": row.get("title"),
"preview": row.get("preview"),
}
for row in rows
]
@ -1337,7 +1370,9 @@ def create_app(
async def get_session(session_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
session_manager = loaded.session_manager
session = session_manager.get_or_create(session_id, source="web") # type: ignore[union-attr]
session = session_manager.get_session(session_id) # type: ignore[union-attr]
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
return _session_detail(session_manager, session_id, session) # type: ignore[arg-type]
@app.delete("/api/sessions/{session_id:path}")
@ -2216,21 +2251,33 @@ def create_app(
try:
safety = loaded.skill_learning_pipeline.check_safety(skill_name, draft_id) # type: ignore[union-attr]
if safety.passed and safety.risk_level != "critical":
loaded.skill_learning_pipeline.submit_review( # type: ignore[union-attr]
skill_name,
draft_id,
requested_by=str((payload or {}).get("requested_by") or "web"),
notes=str((payload or {}).get("notes") or ""),
)
candidate_id = _skill_learning_candidate_id_for_draft(loaded, skill_name, draft_id)
if candidate_id is not None:
provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001
await loaded.skill_learning_pipeline.evaluate_draft( # type: ignore[union-attr]
candidate_id,
draft = loaded.skill_learning_pipeline.get_draft(skill_name, draft_id) # type: ignore[union-attr]
if draft.status == "draft":
loaded.skill_learning_pipeline.submit_review( # type: ignore[union-attr]
skill_name,
draft_id,
provider_bundle=provider_bundle,
replay_runner=ReplayRunner(agent_loop=loop),
requested_by=str((payload or {}).get("requested_by") or "web"),
notes=str((payload or {}).get("notes") or ""),
)
elif draft.status not in {"in_review", "approved"}:
raise ValueError("Draft cannot be submitted from its current status")
candidate_id = _skill_learning_candidate_id_for_draft(loaded, skill_name, draft_id)
eval_report = loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) # type: ignore[union-attr]
if candidate_id is not None and eval_report is None:
loaded.skill_learning_store.transition_learning_candidate( # type: ignore[union-attr]
candidate_id,
"review_pending",
event_type="eval_queued",
last_error=None,
)
_schedule_skill_draft_eval(
app,
agent_service=agent_service,
loop=loop,
loaded=loaded,
candidate_id=candidate_id,
skill_name=skill_name,
draft_id=draft_id,
)
except ValueError as exc:
raise _skill_draft_http_error(exc) from exc
@ -3810,14 +3857,88 @@ def _skill_learning_candidate_task_text(loaded: Any, candidate: Any) -> str:
return str(evidence.get("task_text") or "").strip()
def _schedule_skill_draft_eval(
app: FastAPI,
*,
agent_service: AgentService,
loop: Any,
loaded: Any,
candidate_id: str,
skill_name: str,
draft_id: str,
) -> None:
key = f"{skill_name}:{draft_id}"
tasks: dict[str, asyncio.Task[None]] = app.state.skill_eval_tasks
current = tasks.get(key)
if current is not None and not current.done():
return
loaded.skill_learning_pipeline.mark_eval_progress( # type: ignore[union-attr]
candidate_id,
{
"phase": "preparing",
"completed_arms": 0,
"total_arms": 20,
"completed_cases": 0,
"total_cases": 10,
},
)
async def run_eval() -> None:
try:
provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001
await loaded.skill_learning_pipeline.evaluate_draft( # type: ignore[union-attr]
candidate_id,
skill_name,
draft_id,
provider_bundle=provider_bundle,
replay_runner=ReplayRunner(
agent_loop=loop,
isolated_loop_factory=agent_service.create_isolated_loop,
),
progress_callback=lambda progress: loaded.skill_learning_pipeline.mark_eval_progress( # type: ignore[union-attr]
candidate_id,
progress,
),
)
except asyncio.CancelledError:
raise
except Exception as exc:
loaded.skill_learning_pipeline.mark_eval_failed(candidate_id, str(exc)) # type: ignore[union-attr]
task = asyncio.create_task(run_eval())
tasks[key] = task
def remove_completed(completed: asyncio.Task[None]) -> None:
if tasks.get(key) is completed:
tasks.pop(key, None)
task.add_done_callback(remove_completed)
def _skill_draft_payload(loaded: Any, skill_name: str, draft_id: str, *, include_reviews: bool = False) -> dict[str, Any]:
draft = loaded.skill_learning_pipeline.get_draft(skill_name, draft_id) # type: ignore[union-attr]
safety = loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id) # type: ignore[union-attr]
eval_report = loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) # type: ignore[union-attr]
candidate_id = _skill_learning_candidate_id_for_draft(loaded, skill_name, draft_id)
candidate = loaded.skill_learning_pipeline.get_candidate(candidate_id) if candidate_id is not None else None # type: ignore[union-attr]
if eval_report is not None:
eval_status = eval_report.status
elif candidate is None:
eval_status = "not_applicable"
elif candidate.status == "eval_failed":
eval_status = "failed"
elif draft.status in {"in_review", "approved"}:
eval_status = "pending"
else:
eval_status = "not_started"
payload = {
**draft.to_dict(),
"safety_report": safety.to_dict() if safety is not None else None,
"eval_report": eval_report.to_dict() if eval_report is not None else None,
"eval_status": eval_status,
"eval_error": candidate.last_error if candidate is not None and candidate.status == "eval_failed" else None,
"eval_progress": dict(candidate.eval_progress) if candidate is not None else None,
"target_version": _skill_draft_target_version(loaded, draft.skill_name, draft.proposal_kind),
"base_skill": _skill_draft_base_skill_payload(loaded, draft),
}