| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122 |
- """Persistent contracts for human approval of tool-call batches."""
- from __future__ import annotations
- import json
- import uuid
- from datetime import datetime
- from hashlib import sha256
- from typing import Any, Literal
- from pydantic import BaseModel, ConfigDict, Field, model_validator
- def canonical_arguments(arguments: dict[str, Any]) -> str:
- return json.dumps(
- arguments,
- ensure_ascii=False,
- sort_keys=True,
- separators=(",", ":"),
- allow_nan=False,
- )
- def tool_argument_hash(
- *,
- tool_call_id: str,
- tool_name: str,
- arguments: dict[str, Any],
- ) -> str:
- payload = f"{tool_call_id}\n{tool_name}\n{canonical_arguments(arguments)}"
- return sha256(payload.encode("utf-8")).hexdigest()
- class _StrictModel(BaseModel):
- model_config = ConfigDict(extra="forbid")
- class ToolApprovalCallV1(_StrictModel):
- tool_call_id: str = Field(min_length=1)
- tool_name: str = Field(min_length=1)
- original_arguments: dict[str, Any]
- effective_arguments: dict[str, Any]
- argument_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
- editable_params: list[str]
- requires_confirmation: bool
- decision: Literal["pending", "approved", "rejected", "auto_approved"]
- execution_status: Literal[
- "not_started",
- "executing",
- "executed",
- "rejected",
- "execution_unknown",
- ] = "not_started"
- result_message_id: str | None = None
- decided_at: str | None = None
- @model_validator(mode="after")
- def validate_hash(self) -> "ToolApprovalCallV1":
- expected = tool_argument_hash(
- tool_call_id=self.tool_call_id,
- tool_name=self.tool_name,
- arguments=self.effective_arguments,
- )
- if self.argument_hash != expected:
- raise ValueError("tool approval argument hash mismatch")
- return self
- class ToolApprovalBatchV1(_StrictModel):
- schema_version: Literal[1] = 1
- batch_id: str = Field(min_length=1)
- trace_id: str = Field(min_length=1)
- assistant_message_id: str = Field(min_length=1)
- assistant_sequence: int = Field(gt=0)
- status: Literal[
- "pending",
- "decided",
- "executing",
- "recoverable_candidate_command",
- "completed",
- "cancelled",
- "execution_unknown",
- ] = "pending"
- calls: list[ToolApprovalCallV1] = Field(min_length=1)
- created_at: str
- updated_at: str
- @classmethod
- def create(
- cls,
- *,
- trace_id: str,
- assistant_message_id: str,
- assistant_sequence: int,
- calls: list[ToolApprovalCallV1],
- ) -> "ToolApprovalBatchV1":
- now = datetime.now().isoformat()
- return cls(
- batch_id=f"approval_{uuid.uuid4().hex}",
- trace_id=trace_id,
- assistant_message_id=assistant_message_id,
- assistant_sequence=assistant_sequence,
- calls=calls,
- created_at=now,
- updated_at=now,
- )
- @property
- def pending_calls(self) -> list[ToolApprovalCallV1]:
- return [call for call in self.calls if call.decision == "pending"]
- def approval_grant(batch: ToolApprovalBatchV1, call: ToolApprovalCallV1) -> dict[str, Any]:
- """Build the internal, hash-bound grant consumed by ToolRegistry.execute."""
- return {
- "batch_id": batch.batch_id,
- "trace_id": batch.trace_id,
- "tool_call_id": call.tool_call_id,
- "tool_name": call.tool_name,
- "argument_hash": call.argument_hash,
- "decision": call.decision,
- }
|