Files
beaver_project/app-instance/backend/beaver/services/process_service.py
steven_li 83d9d8c200 ```
feat(learning): 添加技能学习候选者合成锁定机制

添加了 DraftSynthesisInProgress 和 DraftHasNoChanges 异常来处理并发场景,
确保同一技能学习候选者的合成过程不会重复执行。实现了 claim_learning_candidate_for_synthesis
方法来原子性地锁定候选者进行合成。

fix(web): 为技能草案创建端点添加适当的HTTP状态码

当草案没有变化或正在合成时,现在正确返回409状态码而不是内部错误。

feat(skills): 实现技能修订内容比较以检测无变化情况

添加了 _is_noop_revision 方法来比较基础技能和提议的修订,
如果内容没有实际变化则抛出 NoDraftChanges 异常。

refactor(process): 修复任务证据记录后根运行状态更新逻辑

将任务证据记录事件后的状态从 waiting 更改为 done,并设置 finished_at 时间戳。

feat(tools): 防止在同一运行中重复执行外部写入操作

为邮件发送、日历创建等外部写入工具添加去重机制,避免重复的外部操作。

test: 添加技能学习和工具执行的单元测试

增加测试用例验证并发草案合成、重复外部写入抑制和无变化修订检测等功能。
```
2026-06-16 15:58:42 +08:00

464 lines
22 KiB
Python

"""Projection of hidden Task/team events into frontend process streams."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
class SessionProcessProjector:
def __init__(self, session_manager: Any, run_memory_store: Any) -> None:
self.session_manager = session_manager
self.run_memory_store = run_memory_store
def project(self, session_id: str) -> dict[str, Any]:
records = self.session_manager.get_event_records(session_id)
run_records = {record.run_id: record for record in self.run_memory_store.list_runs()}
runs: dict[str, dict[str, Any]] = {}
events: list[dict[str, Any]] = []
artifacts: list[dict[str, Any]] = []
def add_event(
*,
event_id: str,
run_id: str,
kind: str,
actor_type: str,
actor_id: str,
actor_name: str,
text: str,
created_at: str,
status: str | None = None,
parent_run_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
events.append(
{
"event_id": event_id,
"run_id": run_id,
"parent_run_id": parent_run_id,
"kind": kind,
"actor_type": actor_type,
"actor_id": actor_id,
"actor_name": actor_name,
"text": text,
"status": status,
"metadata": dict(metadata or {}),
"created_at": created_at,
}
)
for record in records:
payload = dict(record.event_payload or {})
run_record_for_event = run_records.get(str(record.run_id)) if record.run_id else None
task_id = payload.get("task_id") or getattr(run_record_for_event, "task_id", None)
if not task_id:
continue
attempt_index = int(payload.get("attempt_index") or getattr(run_record_for_event, "attempt_index", None) or 1)
root_run_id = f"task:{task_id}:attempt:{attempt_index}"
created_at = _timestamp(record.timestamp)
root = runs.setdefault(
root_run_id,
{
"run_id": root_run_id,
"parent_run_id": None,
"session_id": session_id,
"actor_type": "system",
"actor_id": "task",
"actor_name": "Task Planner",
"title": f"Task {task_id[:8]} attempt {attempt_index}",
"source": "task_mode",
"status": "running",
"started_at": created_at,
"metadata": {"task_id": task_id, "attempt_index": attempt_index},
},
)
if record.event_type == "assistant_message_added" and record.tool_calls:
run_id = record.run_id or root_run_id
parent_run_id = root_run_id if run_id != root_run_id else None
for index, tool_call in enumerate(record.tool_calls):
if not isinstance(tool_call, dict):
continue
tool_name = _tool_call_name(tool_call)
add_event(
event_id=f"{_event_id(record, 'tool-call')}:{index}",
run_id=run_id,
parent_run_id=parent_run_id,
kind="tool_call_started",
actor_type="tool",
actor_id=tool_name,
actor_name=tool_name,
text=f"Calling tool: {tool_name}.",
created_at=created_at,
status="running",
metadata={
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "tool_call",
"tool_name": tool_name,
"tool_call_id": tool_call.get("id"),
"arguments": _tool_call_arguments(tool_call),
},
)
elif record.event_type == "tool_result_recorded":
run_id = record.run_id or root_run_id
parent_run_id = root_run_id if run_id != root_run_id else None
tool_name = str(record.tool_name or payload.get("tool_name") or "tool")
add_event(
event_id=_event_id(record, "tool-result"),
run_id=run_id,
parent_run_id=parent_run_id,
kind="tool_call_finished",
actor_type="tool",
actor_id=tool_name,
actor_name=tool_name,
text=_truncate(str(record.content or payload.get("error") or "")),
created_at=created_at,
status="done" if payload.get("success", True) else "error",
metadata={
**dict(payload),
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "tool_result",
"tool_name": tool_name,
"tool_call_id": record.tool_call_id,
"result_summary": _truncate(str(record.content or payload.get("error") or "")),
},
)
elif record.event_type == "task_execution_planned":
plan_mode = payload.get("plan_mode") or "single"
strategy = payload.get("strategy") or "single"
node_ids = payload.get("node_ids") or []
root["title"] = f"{plan_mode} plan: {strategy}"
root["summary"] = payload.get("reason") or ""
root["metadata"] = {
**root.get("metadata", {}),
"plan_mode": plan_mode,
"strategy": strategy,
"node_ids": node_ids,
"skill_queries": payload.get("skill_queries") or [],
"selected_skill_names": payload.get("selected_skill_names") or [],
"ephemeral_guidance_ids": payload.get("ephemeral_guidance_ids") or [],
"skill_resolution_report": payload.get("skill_resolution_report") or [],
"fallback_error": payload.get("fallback_error"),
}
add_event(
event_id=_event_id(record, "planned"),
run_id=root_run_id,
kind="task_planned",
actor_type="system",
actor_id="task",
actor_name="Task Planner",
text=f"Beaver planned {plan_mode} execution via {strategy}. {payload.get('reason') or ''}".strip(),
created_at=created_at,
status="running",
metadata={
**root["metadata"],
"timeline_type": "plan",
"user_summary": f"Beaver will use {plan_mode} execution for this task.",
},
)
selected_skill_names = [
str(item)
for item in payload.get("selected_skill_names") or []
if str(item).strip()
]
if selected_skill_names:
add_event(
event_id=_event_id(record, "skills"),
run_id=root_run_id,
kind="skill_selected",
actor_type="system",
actor_id="skill-selector",
actor_name="Skill Selector",
text=f"Selected skill guidance: {', '.join(selected_skill_names)}.",
created_at=created_at,
status="done",
metadata={
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "skill",
"skill_names": selected_skill_names,
"reason": payload.get("reason") or "Selected from task planning context.",
},
)
elif record.event_type in {"task_team_run_completed", "task_team_run_failed"}:
team_success = bool(payload.get("team_success"))
root["status"] = "running"
team_run_ids = payload.get("team_run_ids") or []
root["metadata"] = {
**root.get("metadata", {}),
"team_success": team_success,
"team_run_ids": team_run_ids,
"team_error": payload.get("error"),
}
add_event(
event_id=_event_id(record, "team"),
run_id=root_run_id,
kind="agent_team_created",
actor_type="system",
actor_id="team",
actor_name="Task Team",
text=payload.get("error") or ("Team completed" if team_success else "Team completed with failed nodes"),
created_at=created_at,
status="done" if team_success else "error",
metadata={**dict(payload), "timeline_type": "agent_team", "team_run_ids": team_run_ids},
)
node_results = payload.get("node_results") or []
for item in node_results:
if not isinstance(item, dict):
continue
node_run_id = item.get("run_id") or f"{root_run_id}:node:{item.get('node_id')}"
status = "done" if item.get("success") else "error"
if item.get("finish_reason") == "blocked":
status = "waiting"
run_record = run_records.get(str(node_run_id))
runs[str(node_run_id)] = {
"run_id": str(node_run_id),
"parent_run_id": root_run_id,
"session_id": run_record.session_id if run_record is not None else session_id,
"actor_type": "agent",
"actor_id": str(item.get("node_id") or "sub-agent"),
"actor_name": str(item.get("node_id") or "Sub-agent"),
"title": str(item.get("node_id") or "Sub-agent"),
"source": "task_team",
"status": status,
"started_at": run_record.started_at if run_record is not None else created_at,
"finished_at": run_record.ended_at if run_record is not None else created_at,
"summary": _truncate(str(item.get("output_text") or item.get("error") or "")),
"metadata": {
"task_id": task_id,
"attempt_index": attempt_index,
"node_id": item.get("node_id"),
"skill_query": item.get("skill_query"),
"selected_skill_names": item.get("selected_skill_names") or [],
"ephemeral_skill_names": item.get("ephemeral_skill_names") or [],
"ephemeral_guidance_id": item.get("ephemeral_guidance_id"),
"ephemeral_guidance_name": item.get("ephemeral_guidance_name"),
"ephemeral_used": bool(item.get("ephemeral_used")),
"finish_reason": item.get("finish_reason"),
"error": item.get("error"),
},
}
guidance_id = item.get("ephemeral_guidance_id")
if guidance_id:
guidance_name = str(item.get("ephemeral_guidance_name") or guidance_id)
artifacts.append(
{
"artifact_id": f"{node_run_id}:ephemeral-guidance:{guidance_id}",
"run_id": str(node_run_id),
"actor_type": "agent",
"actor_id": str(item.get("node_id") or "sub-agent"),
"actor_name": str(item.get("node_id") or "Sub-agent"),
"title": f"Ephemeral guidance: {guidance_name}",
"artifact_type": "markdown",
"content": (
f"# Ephemeral guidance\n\n"
f"- Guidance: {guidance_name}\n"
f"- Guidance ID: {guidance_id}\n"
f"- Scope: current delegated sub-agent run only"
),
"metadata": {
"task_id": task_id,
"attempt_index": attempt_index,
"node_id": item.get("node_id"),
"ephemeral_guidance_id": guidance_id,
"ephemeral_guidance_name": guidance_name,
"ephemeral_skill_names": item.get("ephemeral_skill_names") or [],
},
"created_at": created_at,
}
)
add_event(
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
run_id=str(node_run_id),
parent_run_id=root_run_id,
kind="agent_finished",
actor_type="agent",
actor_id=str(item.get("node_id") or "sub-agent"),
actor_name=str(item.get("node_id") or "Sub-agent"),
text=_truncate(str(item.get("output_text") or item.get("error") or "")),
created_at=created_at,
status=status,
metadata={
**dict(item),
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "agent_progress",
},
)
elif record.event_type == "task_synthesis_completed":
main_run_id = str(payload.get("main_run_id") or "")
if main_run_id:
run_record = run_records.get(main_run_id)
activated_skill_names = _activated_skill_names(run_record)
runs[main_run_id] = {
"run_id": main_run_id,
"parent_run_id": root_run_id,
"session_id": run_record.session_id if run_record is not None else session_id,
"actor_type": "agent",
"actor_id": "main-agent",
"actor_name": "Main Agent",
"title": "Final synthesis",
"source": "task_synthesis",
"status": "done" if (run_record is None or run_record.success) else "error",
"started_at": run_record.started_at if run_record is not None else created_at,
"finished_at": run_record.ended_at if run_record is not None else created_at,
"summary": _truncate(run_record.task_text if run_record is not None else ""),
"metadata": {
"task_id": task_id,
"attempt_index": attempt_index,
"skill_names": activated_skill_names,
},
}
if activated_skill_names:
add_event(
event_id=_event_id(record, "synthesis-skills"),
run_id=main_run_id,
parent_run_id=root_run_id,
kind="skill_selected",
actor_type="system",
actor_id="skill-selector",
actor_name="Skill Selector",
text=f"Selected skill guidance: {', '.join(activated_skill_names)}.",
created_at=created_at,
status="done",
metadata={
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "skill",
"skill_names": activated_skill_names,
"activation_reasons": _activated_skill_reasons(run_record),
},
)
add_event(
event_id=_event_id(record, "synthesis"),
run_id=main_run_id,
parent_run_id=root_run_id,
kind="run_finished",
actor_type="agent",
actor_id="main-agent",
actor_name="Main Agent",
text="Main Agent synthesized the final user-facing answer.",
created_at=created_at,
status="done",
metadata=dict(payload),
)
elif record.event_type == "task_evidence_recorded":
root["status"] = "done"
root["finished_at"] = created_at
add_event(
event_id=_event_id(record, "evidence"),
run_id=record.run_id or root_run_id,
parent_run_id=root_run_id if record.run_id else None,
kind="task_result_ready",
actor_type="system",
actor_id="evidence-recorder",
actor_name="Evidence",
text="The task result is ready for user acceptance.",
created_at=created_at,
status="done",
metadata={**dict(payload), "timeline_type": "result"},
)
elif record.event_type == "task_acceptance_recorded":
acceptance_type = str(payload.get("acceptance_type") or payload.get("feedback_type") or "")
if acceptance_type == "accept":
root["status"] = "done"
root["finished_at"] = created_at
elif acceptance_type == "abandon":
root["status"] = "cancelled"
root["finished_at"] = created_at
else:
root["status"] = "waiting"
root["finished_at"] = None
add_event(
event_id=_event_id(record, "acceptance"),
run_id=record.run_id or root_run_id,
parent_run_id=root_run_id if record.run_id else None,
kind="task_acceptance_recorded",
actor_type="user",
actor_id="user-acceptance",
actor_name="User Acceptance",
text=f"User acceptance recorded: {acceptance_type or 'unknown'}.",
created_at=created_at,
status="done",
metadata={**dict(payload), "timeline_type": "acceptance"},
)
return {
"runs": sorted(runs.values(), key=lambda item: item.get("started_at") or ""),
"events": sorted(events, key=lambda item: item.get("created_at") or ""),
"artifacts": sorted(artifacts, key=lambda item: item.get("created_at") or ""),
"agents": [],
}
def _timestamp(value: float | None) -> str:
if value is None:
return datetime.now(timezone.utc).isoformat()
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
def _event_id(record: Any, suffix: str) -> str:
return f"session-event:{record.message_id or record.timestamp}:{suffix}"
def _truncate(text: str, limit: int = 800) -> str:
cleaned = text.strip()
if len(cleaned) <= limit:
return cleaned
return cleaned[: limit - 1] + "..."
def _activated_skill_names(run_record: Any | None) -> list[str]:
if run_record is None:
return []
names = []
for receipt in getattr(run_record, "activated_skills", []) or []:
skill_name = str(getattr(receipt, "skill_name", "") or "").strip()
if skill_name:
names.append(skill_name)
return list(dict.fromkeys(names))
def _activated_skill_reasons(run_record: Any | None) -> list[str]:
if run_record is None:
return []
reasons = []
for receipt in getattr(run_record, "activated_skills", []) or []:
reason = str(getattr(receipt, "activation_reason", "") or "").strip()
if reason:
reasons.append(reason)
return reasons
def _tool_call_name(tool_call: dict[str, Any]) -> str:
function_payload = tool_call.get("function")
if isinstance(function_payload, dict):
name = function_payload.get("name")
if name:
return str(name)
for key in ("name", "tool_name"):
value = tool_call.get(key)
if value:
return str(value)
return "tool"
def _tool_call_arguments(tool_call: dict[str, Any]) -> Any:
function_payload = tool_call.get("function")
if isinstance(function_payload, dict) and "arguments" in function_payload:
return function_payload.get("arguments")
if "arguments" in tool_call:
return tool_call.get("arguments")
if "args" in tool_call:
return tool_call.get("args")
return None