155 lines
5.1 KiB
Python
155 lines
5.1 KiB
Python
"""Shared, stable boundary used by all Project Context tool work packages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any, Protocol
|
|
|
|
from pydantic import AnyUrl, BaseModel, ConfigDict, Field
|
|
|
|
|
|
class ContractModel(BaseModel):
|
|
"""Strict immutable model so provider-specific fields cannot leak to the Agent."""
|
|
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
|
|
class IdentityContext(ContractModel):
|
|
"""Danh tính người gọi kèm phạm vi đã được cấp.
|
|
|
|
``granted_scopes`` là ``frozenset`` để bản ghi danh tính không bị sửa
|
|
trên đường đi giữa các tầng.
|
|
"""
|
|
actor_id: str = Field(min_length=1, max_length=256)
|
|
org_unit: str = Field(min_length=1, max_length=128)
|
|
customer: str = Field(min_length=1, max_length=128)
|
|
project: str = Field(min_length=1, max_length=128)
|
|
granted_scopes: frozenset[str]
|
|
|
|
|
|
class SourceCitation(ContractModel):
|
|
"""Trích dẫn nguồn cho một mẩu thông tin: hệ thống nào, URL nào, bản nào, lấy lúc nào.
|
|
|
|
Bắt buộc có để Agent trả lời kèm nguồn kiểm chứng được, không bịa.
|
|
"""
|
|
system: str = Field(min_length=1, max_length=64)
|
|
url: AnyUrl
|
|
revision: str = Field(min_length=1, max_length=256)
|
|
retrieved_at: datetime
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DispatchResult:
|
|
"""Kết quả một lượt gọi tool: thành công hay không, kèm payload trả cho Agent."""
|
|
ok: bool
|
|
payload: dict[str, Any]
|
|
|
|
|
|
class PolicyDecisionPoint(Protocol):
|
|
"""Nơi quyết định một danh tính có được gọi một tool trên một project hay không."""
|
|
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool:
|
|
"""``True`` nếu cho phép lượt gọi này."""
|
|
...
|
|
|
|
|
|
class CredentialResolver(Protocol):
|
|
"""Nơi cấp thông tin xác thực cho tool, tách khỏi chỗ dùng nó."""
|
|
def resolve(self, identity: IdentityContext, tool_name: str) -> Any:
|
|
"""Trả về thông tin xác thực cho danh tính và tool tương ứng."""
|
|
...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProjectContextRuntime:
|
|
"""Bộ ba mà mọi lượt gọi tool cần: danh tính, cổng chính sách, nơi cấp thông tin xác thực.
|
|
|
|
Bất biến (``frozen``) — một lượt gọi không được đổi bối cảnh của lượt khác.
|
|
"""
|
|
identity: IdentityContext
|
|
policy: PolicyDecisionPoint
|
|
credential_resolver: CredentialResolver
|
|
|
|
|
|
class ProviderError(RuntimeError):
|
|
"""A provider failure with a caller-safe message and retry classification."""
|
|
|
|
def __init__(self, code: str, message: str, *, retryable: bool) -> None:
|
|
"""``safe_message`` là phần được phép hiện cho người dùng; ``retryable`` cho bên
|
|
gọi biết thử lại có ích không, thay vì bắt họ đoán từ chuỗi lỗi.
|
|
"""
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.safe_message = message
|
|
self.retryable = retryable
|
|
|
|
|
|
def decode_offset_cursor(cursor: str | None) -> int:
|
|
"""Shared opaque-cursor decoding for every paginated provider.
|
|
|
|
Rejected before any backend call so an invalid cursor never costs an
|
|
upstream request.
|
|
"""
|
|
if cursor is None:
|
|
return 0
|
|
try:
|
|
offset = int(cursor)
|
|
except ValueError as exc:
|
|
raise ProviderError("INVALID_INPUT", "cursor is not valid.", retryable=False) from exc
|
|
if offset < 0:
|
|
raise ProviderError("INVALID_INPUT", "cursor is not valid.", retryable=False)
|
|
return offset
|
|
|
|
|
|
ToolHandler = Callable[[ContractModel, Any], dict[str, Any]]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ToolTemplate:
|
|
"""Khai báo một tool MCP: tên, mô tả, kiểu vào/ra, và hàm xử lý."""
|
|
name: str
|
|
description: str
|
|
input_model: type[ContractModel]
|
|
output_model: type[ContractModel]
|
|
handler: ToolHandler
|
|
|
|
def declaration(self) -> dict[str, Any]:
|
|
"""Bản khai báo theo đúng định dạng MCP, schema sinh thẳng từ model Pydantic."""
|
|
return {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"inputSchema": self.input_model.model_json_schema(),
|
|
"outputSchema": self.output_model.model_json_schema(),
|
|
}
|
|
|
|
|
|
def error_result(
|
|
code: str,
|
|
*,
|
|
category: str,
|
|
retryable: bool,
|
|
message: str,
|
|
suggested_action: str,
|
|
correlation_id: str,
|
|
) -> DispatchResult:
|
|
"""Dựng một ``DispatchResult`` lỗi theo đúng khuôn chung.
|
|
|
|
Mọi lỗi trả cho Agent đều phải có ``correlation_id`` để dò lại trong
|
|
nhật ký, và ``suggested_action`` để Agent biết nên làm gì tiếp thay vì
|
|
chỉ dừng.
|
|
"""
|
|
return DispatchResult(
|
|
ok=False,
|
|
payload={
|
|
"error": {
|
|
"code": code,
|
|
"category": category,
|
|
"retryable": retryable,
|
|
"message": message,
|
|
"suggested_action": suggested_action,
|
|
"correlation_id": correlation_id,
|
|
}
|
|
},
|
|
)
|