approval.py 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """Persistent contracts for human approval of tool-call batches."""
  2. from __future__ import annotations
  3. import json
  4. import uuid
  5. from datetime import datetime
  6. from hashlib import sha256
  7. from typing import Any, Literal
  8. from pydantic import BaseModel, ConfigDict, Field, model_validator
  9. def canonical_arguments(arguments: dict[str, Any]) -> str:
  10. return json.dumps(
  11. arguments,
  12. ensure_ascii=False,
  13. sort_keys=True,
  14. separators=(",", ":"),
  15. allow_nan=False,
  16. )
  17. def tool_argument_hash(
  18. *,
  19. tool_call_id: str,
  20. tool_name: str,
  21. arguments: dict[str, Any],
  22. ) -> str:
  23. payload = f"{tool_call_id}\n{tool_name}\n{canonical_arguments(arguments)}"
  24. return sha256(payload.encode("utf-8")).hexdigest()
  25. class _StrictModel(BaseModel):
  26. model_config = ConfigDict(extra="forbid")
  27. class ToolApprovalCallV1(_StrictModel):
  28. tool_call_id: str = Field(min_length=1)
  29. tool_name: str = Field(min_length=1)
  30. original_arguments: dict[str, Any]
  31. effective_arguments: dict[str, Any]
  32. argument_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  33. editable_params: list[str]
  34. requires_confirmation: bool
  35. decision: Literal["pending", "approved", "rejected", "auto_approved"]
  36. execution_status: Literal[
  37. "not_started",
  38. "executing",
  39. "executed",
  40. "rejected",
  41. "execution_unknown",
  42. ] = "not_started"
  43. result_message_id: str | None = None
  44. decided_at: str | None = None
  45. @model_validator(mode="after")
  46. def validate_hash(self) -> "ToolApprovalCallV1":
  47. expected = tool_argument_hash(
  48. tool_call_id=self.tool_call_id,
  49. tool_name=self.tool_name,
  50. arguments=self.effective_arguments,
  51. )
  52. if self.argument_hash != expected:
  53. raise ValueError("tool approval argument hash mismatch")
  54. return self
  55. class ToolApprovalBatchV1(_StrictModel):
  56. schema_version: Literal[1] = 1
  57. batch_id: str = Field(min_length=1)
  58. trace_id: str = Field(min_length=1)
  59. assistant_message_id: str = Field(min_length=1)
  60. assistant_sequence: int = Field(gt=0)
  61. status: Literal[
  62. "pending",
  63. "decided",
  64. "executing",
  65. "recoverable_candidate_command",
  66. "completed",
  67. "cancelled",
  68. "execution_unknown",
  69. ] = "pending"
  70. calls: list[ToolApprovalCallV1] = Field(min_length=1)
  71. created_at: str
  72. updated_at: str
  73. @classmethod
  74. def create(
  75. cls,
  76. *,
  77. trace_id: str,
  78. assistant_message_id: str,
  79. assistant_sequence: int,
  80. calls: list[ToolApprovalCallV1],
  81. ) -> "ToolApprovalBatchV1":
  82. now = datetime.now().isoformat()
  83. return cls(
  84. batch_id=f"approval_{uuid.uuid4().hex}",
  85. trace_id=trace_id,
  86. assistant_message_id=assistant_message_id,
  87. assistant_sequence=assistant_sequence,
  88. calls=calls,
  89. created_at=now,
  90. updated_at=now,
  91. )
  92. @property
  93. def pending_calls(self) -> list[ToolApprovalCallV1]:
  94. return [call for call in self.calls if call.decision == "pending"]
  95. def approval_grant(batch: ToolApprovalBatchV1, call: ToolApprovalCallV1) -> dict[str, Any]:
  96. """Build the internal, hash-bound grant consumed by ToolRegistry.execute."""
  97. return {
  98. "batch_id": batch.batch_id,
  99. "trace_id": batch.trace_id,
  100. "tool_call_id": call.tool_call_id,
  101. "tool_name": call.tool_name,
  102. "argument_hash": call.argument_hash,
  103. "decision": call.decision,
  104. }