630 lines
28 KiB
Python
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"]
|