deerflow-code/offline-backend-20260512/backend/app/report_collaboration/interventions/service.py
2026-09-07 18:24:55 +08:00

630 lines
28 KiB
Python

"""Runtime command orchestration with safe-point application."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from app.report_collaboration.contracts.commands import CollaborationCommand, IntentDecision
from app.report_collaboration.execution.ledger import TaskLedger
from app.report_collaboration.execution.quality_gate import requirement_from_session_record
from app.report_collaboration.interventions.impact_analyzer import CommandImpact, ImpactAnalyzer
from app.report_collaboration.interventions.intent_router import RuntimeIntentContext, RuntimeIntentRouterAgent
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
from deerflow.persistence.report_collaboration.base import (
ReportCollaborationConflictError,
ReportCollaborationNotFoundError,
ReportCollaborationStore,
ReportCollaborationValidationError,
)
from deerflow.persistence.report_collaboration.codec import parse_lease_until
_PROCESSABLE = {"accepted", "accepted_waiting_boundary"}
_CANCELLABLE = {"pending", "classified", "confirmation_required", "accepted", "accepted_waiting_boundary"}
class RuntimeCommandService:
"""Classify, confirm and apply user commands without delegating scheduling."""
def __init__(
self,
store: ReportCollaborationStore,
*,
router: RuntimeIntentRouterAgent | None = None,
analyzer: ImpactAnalyzer | None = None,
config: ReportCollaborationConfig | None = None,
) -> None:
self._store = store
self._router = router or RuntimeIntentRouterAgent()
self._analyzer = analyzer or ImpactAnalyzer(
confirmation_threshold=(config or ReportCollaborationConfig()).intent_confirmation_threshold
)
self._config = config or ReportCollaborationConfig()
self._ledger = TaskLedger(store)
async def submit(
self,
run_id: str,
*,
text: str,
idempotency_key: str,
target_node_id: str | None = None,
agent_run_id: str | None = None,
) -> dict[str, Any]:
run, session, plan, node_runs, agent_runs = await self._context(run_id)
existing = next(
(item for item in await self._store.list_commands(run["session_id"], run_id=run_id) if item["idempotency_key"] == idempotency_key),
None,
)
if existing is not None:
if existing.get("status") != "pending":
return existing
# Resume an interrupted classification from the durable command,
# never from a retry request carrying a different payload.
text = str(existing.get("text") or text)
stored_targets = list(existing.get("target_ids") or [])
target_node_id = stored_targets[0] if stored_targets else None
agent_run_id = None
target_nodes, target_node_run_id = self._resolve_targets(
plan=plan,
node_runs=node_runs,
agent_runs=agent_runs,
target_node_id=target_node_id,
agent_run_id=agent_run_id,
)
command = existing or await self._store.create_command(
run["session_id"],
idempotency_key=idempotency_key,
text=text,
run_id=run_id,
operation="command",
target_type="node" if target_nodes else None,
target_ids=target_nodes,
expected_revision=int(session.get("requirement_revision") or 0),
status="pending",
)
# A concurrent duplicate may have won the unique idempotency key.
if command.get("status") != "pending":
return command
message = await self._store.create_message(
run["session_id"],
role="human",
content=text,
# Command and message use separate uniqueness domains. Reusing the
# client key lets the frontend replace its optimistic bubble.
idempotency_key=idempotency_key,
metadata={
"idempotency_key": idempotency_key,
"target_node_id": target_node_id,
"agent_run_id": agent_run_id,
"runtime_command": True,
},
command_id=command["command_id"],
agent_run_id=agent_run_id,
node_run_id=target_node_run_id,
completed=True,
)
await self._store.append_event(
session_id=run["session_id"],
run_id=run_id,
event_type="message.created",
data={"message": message},
)
requirement = requirement_from_session_record(session)
latest = _latest_nodes(node_runs)
report_versions = await self._store.list_report_versions(run["session_id"])
report_summary = None
if report_versions:
report_summary = str(report_versions[-1].get("markdown") or "")[:1200]
decision = await self._router.route(
RuntimeIntentContext(
text=text,
requirement=requirement.model_dump(mode="json") if requirement else {},
plan_nodes=list(plan.get("nodes") or []),
target_node_ids=target_nodes,
target_agent_run_id=agent_run_id,
awaiting_node_ids=[node_id for node_id, row in latest.items() if row.get("status") == "awaiting_input"],
report_summary=report_summary,
)
)
valid_node_ids = {str(item.get("id")) for item in plan.get("nodes") or [] if item.get("id")}
decision_targets = [node_id for node_id in decision.requested_target_ids if node_id in valid_node_ids]
impact = self._analyzer.analyze(
decision,
plan=plan,
node_runs=node_runs,
agent_runs=agent_runs,
explicit_target_node_ids=target_nodes or decision_targets,
)
if decision.intent == "add_angle" and not impact.target_node_ids:
rejected = await self._reject_classified(
run,
command,
decision,
impact,
error="没有可用的研究支路承接新角度",
)
return rejected
final_status = "confirmation_required" if impact.requires_confirmation else (
"accepted_waiting_boundary" if impact.waiting_boundary_nodes else "accepted"
)
patch = _classification_patch(decision, impact, status=final_status)
classified = {**command, **patch, "status": "classified"}
await self._store.append_event(
session_id=run["session_id"],
run_id=run_id,
event_type="command.classified",
data={"command": CollaborationCommand.model_validate(classified).model_dump(mode="json")},
)
try:
command = await self._store.patch_command(
command["command_id"],
patch=patch,
expected_statuses={"pending"},
)
except ReportCollaborationConflictError:
# Another request carrying the same key classified it first. The
# unique command remains the only source of effects.
return await self._store.get_command(command["command_id"]) or command
if final_status == "confirmation_required":
await self._store.append_event(
session_id=run["session_id"],
run_id=run_id,
event_type="command.confirmation.required",
data={"command": command},
)
return command
await self._emit_accepted(run, command, bool(impact.waiting_boundary_nodes))
if not impact.waiting_boundary_nodes:
await self.process_safe_point(run_id)
command = await self._store.get_command(command["command_id"]) or command
return command
async def confirm(self, run_id: str, command_id: str, *, idempotency_key: str) -> dict[str, Any]:
run, _session, plan, node_runs, agent_runs = await self._context(run_id)
existing_audit = next(
(item for item in await self._store.list_commands(run["session_id"], run_id=run_id) if item["idempotency_key"] == idempotency_key),
None,
)
command = await self._command_for_run(run, command_id)
if existing_audit is not None:
return command
if command.get("status") in {"accepted", "accepted_waiting_boundary", "executing", "completed"}:
return command
if command.get("status") != "confirmation_required":
raise ReportCollaborationConflictError("COMMAND_STATE_CONFLICT", f"命令当前状态为 {command.get('status')},不能确认")
await self._store.create_command(
run["session_id"],
idempotency_key=idempotency_key,
text="",
run_id=run_id,
operation="confirm_command",
target_type="command",
target_ids=[command_id],
status="completed",
)
decision = _decision_from_command(command)
impact = self._analyzer.analyze(
decision,
plan=plan,
node_runs=node_runs,
agent_runs=agent_runs,
explicit_target_node_ids=list(command.get("target_ids") or []),
)
status = "accepted_waiting_boundary" if impact.waiting_boundary_nodes else "accepted"
try:
command = await self._store.patch_command(
command_id,
patch={"status": status, "impact_nodes": impact.impact_nodes, "error": None},
expected_statuses={"confirmation_required"},
)
except ReportCollaborationConflictError:
current = await self._command_for_run(run, command_id)
if current.get("status") in {"accepted", "accepted_waiting_boundary", "executing", "completed"}:
return current
raise
await self._emit_accepted(run, command, bool(impact.waiting_boundary_nodes))
if not impact.waiting_boundary_nodes:
await self.process_safe_point(run_id)
command = await self._store.get_command(command_id) or command
return command
async def cancel(self, run_id: str, command_id: str, *, idempotency_key: str) -> dict[str, Any]:
run, _session, _plan, _nodes, _agents = await self._context(run_id)
command = await self._command_for_run(run, command_id)
audits = await self._store.list_commands(run["session_id"], run_id=run_id)
if any(item["idempotency_key"] == idempotency_key for item in audits):
return command
await self._store.create_command(
run["session_id"],
idempotency_key=idempotency_key,
text="",
run_id=run_id,
operation="cancel_command",
target_type="command",
target_ids=[command_id],
status="completed",
)
if command.get("status") == "cancelled":
return command
try:
cancelled = await self._store.patch_command(
command_id,
patch={"status": "cancelled"},
expected_statuses=_CANCELLABLE,
)
except ReportCollaborationConflictError:
current = await self._command_for_run(run, command_id)
if current.get("status") == "cancelled":
return current
raise
await self._store.append_event(
session_id=run["session_id"],
run_id=run["id"],
event_type="command.cancelled",
data={"command_id": command_id, "command": cancelled},
)
return cancelled
async def process_safe_point(self, run_id: str, *, completed_node_id: str | None = None) -> list[str]:
"""Apply accepted commands in creation order at a model/tool/commit boundary."""
run, _session, _plan, node_runs, agent_runs = await self._context(run_id)
applied: list[str] = []
await self.reclaim_executing(run_id, force=False)
commands = await self._store.list_commands(run["session_id"], run_id=run_id)
# One command may mutate several attempts and artifacts. Never overlap
# two such mutations for the same run, even across Gateway/worker
# processes. CAS on the oldest command elects the processor.
if any(item.get("status") == "executing" for item in commands):
return applied
for command in commands:
if command.get("operation", "command") != "command" and not command.get("intent"):
continue
if command.get("status") not in _PROCESSABLE:
continue
running = _running_impacted_nodes(node_runs, agent_runs, list(command.get("impact_nodes") or []))
if completed_node_id:
running.discard(completed_node_id)
if running:
if command.get("status") == "accepted":
await self._store.patch_command(
command["command_id"],
patch={"status": "accepted_waiting_boundary"},
expected_statuses={"accepted"},
)
continue
try:
claimed = await self._store.patch_command(
command["command_id"],
patch={"status": "executing", "error": None},
expected_statuses=_PROCESSABLE,
)
except ReportCollaborationConflictError:
return applied
try:
result = await self._apply(run, claimed)
await self._store.patch_command(
command["command_id"],
patch={"status": "completed", "reason": result},
expected_statuses={"executing"},
)
await self._store.append_event(
session_id=run["session_id"],
run_id=run_id,
event_type="command.completed",
data={"command_id": command["command_id"], "result_summary": result},
)
applied.append(command["command_id"])
except Exception as exc:
message = str(exc)[:500]
await self._store.patch_command(
command["command_id"],
patch={"status": "failed", "error": message},
expected_statuses={"executing"},
)
await self._store.append_event(
session_id=run["session_id"],
run_id=run_id,
event_type="command.failed",
data={"command_id": command["command_id"], "error": message},
)
return applied
async def _apply(self, run: dict[str, Any], command: dict[str, Any]) -> str:
intent = str(command.get("intent") or "")
instruction = str(command.get("normalized_instruction") or command.get("text") or "")
current_run = await self._store.get_run(run["id"])
if (
current_run
and (current_run.get("cancel_requested") or current_run.get("status") == "cancelled")
and intent not in {"cancel", "ask_report"}
):
raise ReportCollaborationConflictError("RUN_CANCELLED", "运行已取消,不能再应用修改")
if intent == "cancel":
await self._store.request_cancel_run(run["id"], idempotency_key=f"{command['idempotency_key']}:natural-cancel")
return "已提交取消执行请求"
if intent == "ask_report":
versions = await self._store.list_report_versions(run["session_id"])
artifacts = await self._store.list_artifacts(run["id"])
if versions:
answer = f"当前报告为 v{versions[-1]['version']},正文已生成;已有 {len(artifacts)} 项协作成果。"
else:
answer = f"报告仍在生成中,目前已有 {len(artifacts)} 项协作成果。"
message = await self._store.create_message(
run["session_id"],
role="ai",
content=answer,
idempotency_key=f"{command['idempotency_key']}:answer",
name="协作协调者",
command_id=command["command_id"],
completed=True,
)
await self._store.append_event(
session_id=run["session_id"],
run_id=run["id"],
event_type="message.created",
data={"message": message},
)
return "已回答当前报告状态"
if intent == "answer_question":
resumed = await self._ledger.apply_user_answer(
run["id"],
command_id=command["command_id"],
instruction=instruction,
node_ids=list(command.get("impact_nodes") or command.get("target_ids") or []),
)
if not resumed:
raise ReportCollaborationValidationError("NO_AWAITING_NODE", "当前没有可由该回答恢复的等待节点")
return f"已将回答交给 {len(resumed)} 个等待节点"
impact_nodes = list(command.get("impact_nodes") or [])
if not impact_nodes:
raise ReportCollaborationValidationError("NO_IMPACT_NODE", "没有找到可安全返工的节点")
changed = await self._ledger.apply_command_rework(
run["id"],
command_id=command["command_id"],
instruction=instruction,
node_ids=impact_nodes,
)
if not changed:
raise ReportCollaborationValidationError("COMMAND_NO_EFFECT", "目标节点已过期或命令已经应用")
await self._apply_requirement_patch(run, command)
return f"已在安全边界应用修改,重新排队 {len(changed)} 个节点"
async def reclaim_executing(self, run_id: str, *, force: bool = True) -> list[str]:
"""Return stuck ``executing`` commands to ``accepted`` so a takeover can finish them.
``force=True`` is for lease reclaim: the previous worker is gone.
``force=False`` only releases commands whose ``updated_at`` exceeded the lease TTL.
"""
run = await self._store.get_run(run_id)
if run is None:
return []
ttl = max(30, int(self._config.lease_seconds))
now = datetime.now(UTC)
reclaimed: list[str] = []
for command in await self._store.list_commands(run["session_id"], run_id=run_id):
if command.get("status") != "executing":
continue
if not force:
updated = parse_lease_until(command.get("updated_at"))
if updated is not None and now - updated < timedelta(seconds=ttl):
continue
try:
await self._store.patch_command(
command["command_id"],
patch={"status": "accepted", "error": None},
expected_statuses={"executing"},
)
except ReportCollaborationConflictError:
continue
reclaimed.append(command["command_id"])
return reclaimed
async def _reject_classified(
self,
run: dict[str, Any],
command: dict[str, Any],
decision: IntentDecision,
impact: CommandImpact,
*,
error: str,
) -> dict[str, Any]:
patch = _classification_patch(decision, impact, status="rejected")
patch["error"] = error
await self._store.append_event(
session_id=run["session_id"],
run_id=run["id"],
event_type="command.classified",
data={"command": {**command, **patch}},
)
try:
rejected = await self._store.patch_command(
command["command_id"],
patch=patch,
expected_statuses={"pending"},
)
except ReportCollaborationConflictError:
return await self._store.get_command(command["command_id"]) or command
await self._store.append_event(
session_id=run["session_id"],
run_id=run["id"],
event_type="command.failed",
data={"command_id": rejected["command_id"], "error": error},
)
return rejected
async def _apply_requirement_patch(self, run: dict[str, Any], command: dict[str, Any]) -> None:
intent = str(command.get("intent") or "")
if intent not in {"add_angle", "update_requirements"}:
return
session = await self._store.get_session_record(run["session_id"])
if not session:
raise ReportCollaborationNotFoundError("session", run["session_id"])
current = requirement_from_session_record(session)
if current is None:
return
# Recreate the same deterministic patch used by the router. It is not
# persisted on the command row, so confirmed commands cannot trust an
# arbitrary client payload.
context = RuntimeIntentContext(
text=str(command.get("text") or ""),
requirement=current.model_dump(mode="json"),
plan_nodes=[],
)
decision = await RuntimeIntentRouterAgent().route(context)
if decision.requirement_patch is None:
return
state = dict(session.get("requirement_json") or {})
snapshot = decision.requirement_patch.model_dump(mode="json")
if isinstance(state.get("snapshot"), dict):
state["snapshot"] = snapshot
else:
state = snapshot
await self._store.save_requirement_state(
run["session_id"],
requirement_json=state,
requirement_revision=int(snapshot["revision"]),
status="running",
)
await self._store.append_event(
session_id=run["session_id"],
run_id=run["id"],
event_type="requirement.updated",
data={"requirement": snapshot, "revision": snapshot["revision"], "command_id": command["command_id"]},
)
async def _emit_accepted(self, run: dict[str, Any], command: dict[str, Any], waiting: bool) -> None:
await self._store.append_event(
session_id=run["session_id"],
run_id=run["id"],
event_type="command.accepted",
data={
"command_id": command["command_id"],
"waiting_boundary": waiting,
"impact_nodes": list(command.get("impact_nodes") or []),
},
)
async def _command_for_run(self, run: dict[str, Any], command_id: str) -> dict[str, Any]:
command = await self._store.get_command(command_id)
if command is None:
raise ReportCollaborationNotFoundError("command", command_id)
commands = await self._store.list_commands(run["session_id"], run_id=run["id"])
if not any(item["command_id"] == command_id for item in commands):
raise ReportCollaborationNotFoundError("command", command_id)
return command
async def _context(self, run_id: str) -> tuple[dict, dict, dict, list[dict], list[dict]]:
run = await self._store.get_run(run_id)
if run is None:
raise ReportCollaborationNotFoundError("run", run_id)
session = await self._store.get_session_record(run["session_id"])
if session is None:
raise ReportCollaborationNotFoundError("session", run["session_id"])
plan = await self._store.get_plan(run["plan_id"])
if plan is None:
raise ReportCollaborationNotFoundError("plan", run["plan_id"])
return run, session, plan, await self._store.list_node_runs(run_id), await self._store.list_agent_runs(run_id)
@staticmethod
def _resolve_targets(
*,
plan: dict,
node_runs: list[dict],
agent_runs: list[dict],
target_node_id: str | None,
agent_run_id: str | None,
) -> tuple[list[str], str | None]:
valid_nodes = {str(item.get("id")) for item in plan.get("nodes") or [] if item.get("id")}
node_by_run = {str(item.get("node_run_id")): str(item.get("node_id")) for item in node_runs if item.get("node_run_id")}
resolved: list[str] = []
node_run_id: str | None = None
if target_node_id:
if target_node_id not in valid_nodes:
raise ReportCollaborationValidationError("TARGET_NOT_FOUND", f"目标节点不存在:{target_node_id}")
resolved.append(target_node_id)
latest = [item for item in node_runs if item.get("node_id") == target_node_id]
if latest:
node_run_id = max(latest, key=lambda item: int(item.get("attempt") or 0)).get("node_run_id")
if agent_run_id:
agent = next((item for item in agent_runs if item.get("agent_run_id") == agent_run_id), None)
if agent is None:
raise ReportCollaborationValidationError("TARGET_NOT_FOUND", f"智能体运行不存在:{agent_run_id}")
agent_node_run = str(agent.get("node_run_id") or "")
agent_node = node_by_run.get(agent_node_run)
if not agent_node:
raise ReportCollaborationValidationError("TARGET_NOT_FOUND", f"智能体运行没有对应节点:{agent_run_id}")
if resolved and resolved[0] != agent_node:
raise ReportCollaborationValidationError("TARGET_MISMATCH", "目标节点与智能体运行不属于同一节点")
if agent_node not in resolved:
resolved.append(agent_node)
node_run_id = agent_node_run
return resolved, node_run_id
def _classification_patch(decision: IntentDecision, impact: CommandImpact, *, status: str) -> dict[str, Any]:
return {
"intent": decision.intent,
"confidence": decision.confidence,
"target_type": impact.target_type,
"target_ids": impact.target_node_ids,
"normalized_instruction": decision.normalized_instruction,
"requires_confirmation": impact.requires_confirmation,
"cost_level": impact.cost_level,
"reason": impact.reason,
"impact_nodes": impact.impact_nodes,
"status": status,
"error": None,
}
def _decision_from_command(command: dict[str, Any]) -> IntentDecision:
intent = command.get("intent")
if not intent:
raise ReportCollaborationValidationError("COMMAND_NOT_CLASSIFIED", "命令尚未完成意图识别")
return IntentDecision.model_validate(
{
"intent": intent,
"confidence": command.get("confidence") or 0,
"normalized_instruction": command.get("normalized_instruction") or command.get("text") or "",
"scope": command.get("target_type") or "conversation",
"requested_target_ids": command.get("target_ids") or [],
"requirement_patch": None,
"requires_confirmation": False,
"cost_level": command.get("cost_level") or "low",
"reason": command.get("reason") or "用户已确认",
}
)
def _latest_nodes(rows: list[dict]) -> dict[str, dict]:
latest: dict[str, dict] = {}
for row in rows:
node_id = str(row.get("node_id") or "")
if node_id and (node_id not in latest or int(row.get("attempt") or 0) >= int(latest[node_id].get("attempt") or 0)):
latest[node_id] = row
return latest
def _running_impacted_nodes(node_runs: list[dict], agent_runs: list[dict], impact_nodes: list[str]) -> set[str]:
wanted = set(impact_nodes)
latest = _latest_nodes(node_runs)
node_by_run = {str(row.get("node_run_id")): node_id for node_id, row in latest.items()}
running = {
node_id
for node_id, row in latest.items()
if node_id in wanted and row.get("status") in {"running", "validating"}
}
for agent in agent_runs:
if agent.get("status") == "running":
node_id = node_by_run.get(str(agent.get("node_run_id") or ""))
if node_id in wanted:
running.add(node_id)
return running
__all__ = ["RuntimeCommandService"]