aps-agent/server/agent_core/plan_orchestration.py

148 lines
5.3 KiB
Python
Raw Permalink 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.

# ============================================================
# Agent 自动编排 v1(moduleId: core-plan-orchestration, 可重生 ✅)
# plan.md §3.1 + 矩阵 110 行剩余项:plan 节点驱动 workflow 分支
# P2 动作出确认卡时创建 L2 Plan 节点(DRAFT,记录 action/params/confirmId);
# 批准/驳回时流转状态(APPROVED/FAILED),Plan 历史与门禁决策对齐且可审计。
# 编排尽力而为:任何失败仅记日志,绝不阻断门禁主流程。
# ============================================================
from __future__ import annotations
import json
import logging
from typing import Any
logger = logging.getLogger("aps.plan.orchestration")
def stage_plan_node(
*,
session_id: str,
action: str,
params: dict[str, Any],
confirm_id: str,
power: str = "P2",
actor: str = "planner",
) -> str | None:
"""P2 动作出卡时创建 L2 Plan 节点(DRAFT)。
Returns: plan_id(编排成功)或 None(尽力而为失败)。
"""
try:
from server.agent_core.plan_runtime import get_plan_store, canonical_inputs_hash
plan_store = get_plan_store()
inputs = {"action": action, "confirmId": confirm_id, "power": power}
# 自动补建父链:L0 意图(按 action 幂等)→ L1 策略 → L2 决策节点
l0_id = _ensure_l0_node(plan_store, action)
l1_id = _ensure_l1_node(plan_store, l0_id, action)
node = plan_store.create(
layer="L2",
parent_id=l1_id,
inputs=inputs,
payload={
"kind": "gate-decision",
"action": action,
"params": _stable_params(params),
"confirmId": confirm_id,
"power": power,
"paramsHash": canonical_inputs_hash(params),
},
status="DRAFT",
created_by="USER",
)
logger.info("plan orchestration staged %s plan=%s confirm=%s", action, node.planId, confirm_id)
return node.planId
except Exception:
logger.exception("plan orchestration stage failed for %s", action)
return None
def _ensure_l0_node(plan_store: Any, action: str) -> str:
"""幂等创建/复用 L0 意图节点(按 action 键控)。"""
from server.agent_core.plan_runtime import PlanNotFoundError
l0_id = f"intent-{_slug(action)}"
try:
plan_store.latest(l0_id)
return l0_id
except PlanNotFoundError:
node = plan_store.create(
plan_id=l0_id, layer="L0", parent_id=None,
inputs={"action": action}, payload={"kind": "intent", "action": action},
status="APPROVED", created_by="USER",
)
return node.planId
def _ensure_l1_node(plan_store: Any, l0_id: str, action: str) -> str:
"""幂等创建/复用 L1 策略节点。"""
from server.agent_core.plan_runtime import PlanNotFoundError
l1_id = f"strategy-{_slug(action)}"
try:
plan_store.latest(l1_id)
return l1_id
except PlanNotFoundError:
node = plan_store.create(
plan_id=l1_id, layer="L1", parent_id=l0_id,
inputs={"intent": l0_id}, payload={"kind": "strategy", "action": action},
status="APPROVED", created_by="USER",
)
return node.planId
def _slug(action: str) -> str:
import re
return re.sub(r"[^A-Za-z0-9_]", "-", action)[:60] or "gate"
def decide_plan_node(
*,
confirm_id: str,
approve: bool,
note: str | None = None,
actor: str = "planner",
) -> str | None:
"""批准/驳回时流转对应 L2 Plan 节点状态(APPROVED/FAILED)。
按 confirmId 匹配最近 staged 节点(payload.confirmId);找不到则静默跳过。
Returns: plan_id(已流转)或 None。
"""
try:
from server.agent_core.plan_runtime import get_plan_store
plan_store = get_plan_store()
# 查找 payload.confirmId == confirm_id 的 L2 节点(仅 DRAFT)
for plan_id in _list_plan_ids(plan_store):
try:
node = plan_store.latest(plan_id)
except Exception:
continue
if node.layer != "L2" or node.status != "DRAFT":
continue
if (node.payload or {}).get("confirmId") != confirm_id:
continue
new_status = "APPROVED" if approve else "FAILED"
plan_store.transition_status(
plan_id, new_status=new_status, created_by="USER",
note=note or ("批准" if approve else "驳回"),
)
logger.info("plan orchestration %s plan=%s confirm=%s", new_status, plan_id, confirm_id)
return plan_id
return None
except Exception:
logger.exception("plan orchestration decide failed confirm=%s", confirm_id)
return None
def _stable_params(params: dict[str, Any]) -> dict[str, Any]:
"""参数稳定投影(去除非 JSON 值,排序键),供 Plan payload 审计。"""
try:
return json.loads(json.dumps(params, ensure_ascii=False, sort_keys=True, default=str))
except Exception:
return {"_unserializable": True}
def _list_plan_ids(plan_store: Any) -> list[str]:
"""读取所有 plan id(PlanStore 内部 _plans;尽力而为)。"""
try:
return list(getattr(plan_store, "_plans", {}) or {})
except Exception:
return []