interrupts.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. """中断展示与恢复共用一个载荷和标识,批准范围等于卡片展示范围。"""
  2. import hashlib
  3. import json
  4. from dataclasses import dataclass
  5. from typing import Any
  6. from obs import get_logger
  7. logger = get_logger(__name__)
  8. @dataclass(frozen=True)
  9. class ResumeDecision:
  10. interrupt_token: str = ""
  11. approved: bool | None = None
  12. status: str | None = None
  13. answers: list[str] | None = None
  14. error: str | None = None
  15. class ResumeError(ValueError):
  16. def __init__(self, code: str, message: str):
  17. self.code = code
  18. super().__init__(message)
  19. def pack(item):
  20. value = getattr(item, "value", item)
  21. payload = dict(value) if isinstance(value, dict) else {"message": str(value)}
  22. if getattr(item, "id", None):
  23. payload["_interrupt_id"] = item.id
  24. return payload
  25. def token_for(payload):
  26. return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()
  27. def interrupt_event(writer, payload):
  28. payload = payload if isinstance(payload, dict) else {"message": str(payload)}
  29. token = token_for(payload)
  30. if payload.get("kind") == "ask_user":
  31. return writer.event("interrupt", interrupt_token=token,
  32. ask_user={"questions": payload["questions"]})
  33. actions = payload.get("action_requests") or []
  34. if actions:
  35. first = actions[0]
  36. return writer.interrupt_tool_approval(
  37. tool=first.get("name", ""), args=first.get("args"),
  38. reason=first.get("description", ""), side_effect="批准后执行卡片中的全部操作",
  39. actions=actions, pending_count=len(actions), interrupt_token=token,
  40. )
  41. return writer.event("interrupt", message=payload.get("message", "智能体暂停,等待输入"),
  42. interrupt_token=token)
  43. def resume_value(request, state):
  44. items = [item for task in getattr(state, "tasks", ()) for item in getattr(task, "interrupts", ())]
  45. if not items:
  46. raise ResumeError(**{"code": "no_pending_interrupt", "message": "没有待确认请求"})
  47. selected = next((item for item in items if token_for(pack(item)) == request.interrupt_token), None)
  48. if selected is None:
  49. raise ResumeError(**{"code": "stale_interrupt", "message": "确认请求已变化,请刷新后重试"})
  50. payload = pack(selected)
  51. if payload.get("kind") == "ask_user":
  52. if request.status == "cancelled":
  53. value = {"cancelled": True}
  54. else:
  55. if not request.answers or len(request.answers) != len(payload["questions"]) or any(not a.strip() for a in request.answers):
  56. raise ResumeError(**{"code": "invalid_answers", "message": "请回答全部问题"})
  57. value = {"answers": request.answers}
  58. else:
  59. if request.approved is None and request.status not in {"cancelled", "error"}:
  60. raise ResumeError(**{"code": "invalid_decision", "message": "请选择批准或拒绝"})
  61. value = official_resume(request, len(payload.get("action_requests") or []))
  62. interrupt_id = getattr(selected, "id", None)
  63. return {interrupt_id: value} if interrupt_id else value
  64. def official_resume(payload: ResumeDecision, pending_count: int) -> dict[str, Any]:
  65. """把领域层的 ResumeDecision 翻译成官方 HITL 的 ``{"decisions": [...]}``。
  66. 官方要求 decisions 与 action_requests **数量严格一一对应**,数量不符会
  67. 抛错,所以这里按 ``pending_count`` 复制同一决策。
  68. 翻译表:
  69. - ``approved=True`` → ``approve``
  70. - ``approved=False`` → ``reject``(带拒绝理由)
  71. - ``status=cancelled`` → ``reject``(用户取消等价于不执行)
  72. - ``status=error`` → ``reject``(前端恢复出错,不执行更安全)
  73. - 有 ``answers`` → ``respond``(把人的回答作为工具结果)
  74. """
  75. count = max(pending_count, 1)
  76. if count > 1 and payload.approved is True and not payload.interrupt_token:
  77. raise ResumeError(**{"code": "approval_refresh_required", "message": "请刷新并确认完整操作列表"})
  78. if payload.approved is True:
  79. decision: dict[str, Any] = {"type": "approve"}
  80. elif payload.approved is False:
  81. decision = {"type": "reject", "message": "用户拒绝了该操作,请勿重试,改为说明情况"}
  82. elif payload.status == "cancelled":
  83. decision = {"type": "reject", "message": "用户取消了该操作"}
  84. elif payload.status == "error":
  85. decision = {
  86. "type": "reject",
  87. "message": f"前端恢复出错,该操作未执行:{payload.error or '未提供详情'}",
  88. }
  89. elif payload.answers:
  90. decision = {"type": "respond", "message": "\n".join(payload.answers)}
  91. else:
  92. decision = {"type": "reject", "message": "未收到明确答复,该操作未执行"}
  93. return {"decisions": [dict(decision) for _ in range(count)]}
  94. async def pending_interrupts(agent: Any, config: dict[str, Any]) -> list[Any]:
  95. """读取当前挂起的中断载荷列表。
  96. 供两处复用:``GET /chat/interrupt``(刷新页面后补渲染卡片)与
  97. ``POST /chat/resume``(在构造 decisions 前先知道有几个待审批动作——
  98. 官方 HITL 要求 decisions 与 action_requests **数量严格一一对应**,
  99. 数量对不上会直接抛错)。
  100. """
  101. get_state = getattr(agent, "aget_state", None)
  102. if get_state is None:
  103. return []
  104. try:
  105. state = await get_state(config)
  106. except Exception as exc:
  107. logger.warning("读取图状态失败:%s: %s", type(exc).__name__, exc)
  108. raise
  109. found: list[Any] = []
  110. for task in getattr(state, "tasks", None) or ():
  111. for item in getattr(task, "interrupts", None) or ():
  112. found.append(pack(item))
  113. return found