## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -17,6 +17,11 @@ class ContractModel(BaseModel):
|
||||
|
||||
|
||||
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)
|
||||
@@ -25,6 +30,10 @@ class IdentityContext(ContractModel):
|
||||
|
||||
|
||||
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)
|
||||
@@ -33,20 +42,31 @@ class SourceCitation(ContractModel):
|
||||
|
||||
@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):
|
||||
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool: ...
|
||||
"""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):
|
||||
def resolve(self, identity: IdentityContext, tool_name: str) -> Any: ...
|
||||
"""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
|
||||
@@ -56,6 +76,9 @@ 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
|
||||
@@ -67,6 +90,7 @@ 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]
|
||||
@@ -74,6 +98,7 @@ class ToolTemplate:
|
||||
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,
|
||||
@@ -91,6 +116,12 @@ def error_result(
|
||||
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={
|
||||
|
||||
@@ -8,11 +8,24 @@ from ..foundation import IdentityContext, ProviderError
|
||||
|
||||
|
||||
class ChangeProvider(Protocol):
|
||||
def get_change_context(self, **arguments: Any) -> dict[str, Any]: ...
|
||||
"""Nguồn cấp bối cảnh thay đổi (commit, PR) cho một project.
|
||||
|
||||
Chỉ là hợp đồng: bản cài đặt thật được nối vào qua ``build_provider``.
|
||||
"""
|
||||
def get_change_context(self, **arguments: Any) -> dict[str, Any]:
|
||||
"""Trả về bối cảnh thay đổi (commit, PR) theo tham số của tool."""
|
||||
...
|
||||
|
||||
|
||||
class UnconfiguredChangeProvider:
|
||||
"""Bản thay thế khi chưa cấu hình nguồn thay đổi (commit, PR) thật.
|
||||
|
||||
Luôn ném lỗi ``UNAVAILABLE`` thay vì trả dữ liệu rỗng — rỗng sẽ bị Agent
|
||||
hiểu nhầm là "tra rồi, không có gì", còn lỗi thì nói đúng sự thật là
|
||||
chưa có nguồn nào được nối.
|
||||
"""
|
||||
def get_change_context(self, **arguments: Any) -> dict[str, Any]:
|
||||
"""Luôn báo chưa cấu hình; không cho thử lại."""
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"The change provider is not configured for this environment.",
|
||||
|
||||
@@ -8,11 +8,24 @@ from ..foundation import IdentityContext, ProviderError
|
||||
|
||||
|
||||
class IssueProvider(Protocol):
|
||||
def get_issue_context(self, **arguments: Any) -> dict[str, Any]: ...
|
||||
"""Nguồn cấp bối cảnh issue/ticket cho một project.
|
||||
|
||||
Chỉ là hợp đồng: bản cài đặt thật được nối vào qua ``build_provider``.
|
||||
"""
|
||||
def get_issue_context(self, **arguments: Any) -> dict[str, Any]:
|
||||
"""Trả về bối cảnh issue/ticket theo tham số của tool."""
|
||||
...
|
||||
|
||||
|
||||
class UnconfiguredIssueProvider:
|
||||
"""Bản thay thế khi chưa cấu hình nguồn issue/ticket thật.
|
||||
|
||||
Luôn ném lỗi ``UNAVAILABLE`` thay vì trả dữ liệu rỗng — rỗng sẽ bị Agent
|
||||
hiểu nhầm là "tra rồi, không có gì", còn lỗi thì nói đúng sự thật là
|
||||
chưa có nguồn nào được nối.
|
||||
"""
|
||||
def get_issue_context(self, **arguments: Any) -> dict[str, Any]:
|
||||
"""Luôn báo chưa cấu hình; không cho thử lại."""
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"The issue provider is not configured for this environment.",
|
||||
|
||||
@@ -8,11 +8,24 @@ from ..foundation import IdentityContext, ProviderError
|
||||
|
||||
|
||||
class KnowledgeProvider(Protocol):
|
||||
def search_knowledge(self, **arguments: Any) -> dict[str, Any]: ...
|
||||
"""Nguồn cấp bối cảnh tri thức nội bộ cho một project.
|
||||
|
||||
Chỉ là hợp đồng: bản cài đặt thật được nối vào qua ``build_provider``.
|
||||
"""
|
||||
def search_knowledge(self, **arguments: Any) -> dict[str, Any]:
|
||||
"""Trả về bối cảnh tri thức nội bộ theo tham số của tool."""
|
||||
...
|
||||
|
||||
|
||||
class UnconfiguredKnowledgeProvider:
|
||||
"""Bản thay thế khi chưa cấu hình nguồn tri thức nội bộ thật.
|
||||
|
||||
Luôn ném lỗi ``UNAVAILABLE`` thay vì trả dữ liệu rỗng — rỗng sẽ bị Agent
|
||||
hiểu nhầm là "tra rồi, không có gì", còn lỗi thì nói đúng sự thật là
|
||||
chưa có nguồn nào được nối.
|
||||
"""
|
||||
def search_knowledge(self, **arguments: Any) -> dict[str, Any]:
|
||||
"""Luôn báo chưa cấu hình; không cho thử lại."""
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"The knowledge provider is not configured for this environment.",
|
||||
|
||||
@@ -20,4 +20,5 @@ TOOL_NAMES = tuple(tool.name for tool in TOOLS)
|
||||
|
||||
|
||||
def tool_declarations() -> list[dict[str, Any]]:
|
||||
"""Danh sách khai báo của mọi tool đã đăng ký, để trả về cho ``list_tools``."""
|
||||
return [tool.declaration() for tool in TOOLS]
|
||||
|
||||
@@ -31,6 +31,9 @@ class ProjectScopePolicy:
|
||||
"""Pilot policy: read scope and exact identity-bound project are both mandatory."""
|
||||
|
||||
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool:
|
||||
"""Chỉ cho phép khi danh tính có phạm vi ``read`` VÀ project khớp đúng
|
||||
project gắn với danh tính đó — không cho đọc chéo project.
|
||||
"""
|
||||
return "read" in identity.granted_scopes and project_id == identity.project
|
||||
|
||||
|
||||
@@ -43,7 +46,11 @@ PROVIDER_FACTORIES: dict[str, Callable[[IdentityContext], Any]] = {
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectProviderResolver:
|
||||
"""Tra provider thật cho từng tool theo bảng ``PROVIDER_FACTORIES``."""
|
||||
def resolve(self, identity: IdentityContext, tool_name: str) -> Any:
|
||||
"""Dựng provider cho một tool; tool chưa đăng ký thì báo ``NOT_FOUND`` và
|
||||
không cho thử lại.
|
||||
"""
|
||||
factory = PROVIDER_FACTORIES.get(tool_name)
|
||||
if factory is None:
|
||||
raise ProviderError("NOT_FOUND", "The requested tool is not registered.", retryable=False)
|
||||
@@ -51,6 +58,11 @@ class ProjectProviderResolver:
|
||||
|
||||
|
||||
def _required_environment(name: str) -> str:
|
||||
"""Đọc một biến môi trường bắt buộc; thiếu thì dừng ngay lúc khởi động.
|
||||
|
||||
Thà không chạy còn hơn chạy với cấu hình khuyết rồi lỗi giữa chừng ở một
|
||||
lượt gọi tool nào đó.
|
||||
"""
|
||||
value = os.environ.get(name, "").strip()
|
||||
if not value:
|
||||
raise RuntimeError(f"Project Context MCP cannot start: required setting {name} is missing")
|
||||
|
||||
@@ -99,6 +99,11 @@ def dispatch(
|
||||
|
||||
|
||||
def build_server(runtime: Optional[ProjectContextRuntime] = None):
|
||||
"""Dựng máy chủ MCP Project Context.
|
||||
|
||||
``runtime`` để trống thì lấy bộ mặc định đọc từ biến môi trường; test
|
||||
truyền vào bộ giả để không cần cấu hình thật.
|
||||
"""
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
|
||||
@@ -108,10 +113,16 @@ def build_server(runtime: Optional[ProjectContextRuntime] = None):
|
||||
|
||||
@app.list_tools()
|
||||
async def list_tools() -> list[types.Tool]:
|
||||
"""Trả về khai báo của mọi tool đã đăng ký."""
|
||||
return [types.Tool(**declaration) for declaration in tool_declarations()]
|
||||
|
||||
@app.call_tool()
|
||||
async def call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult:
|
||||
"""Chạy một tool và trả kết quả.
|
||||
|
||||
Luôn kèm payload dạng văn bản JSON; chỉ khi thành công mới đính thêm
|
||||
``structuredContent``, còn lỗi thì bật ``isError``.
|
||||
"""
|
||||
result = dispatch(name, arguments or {}, app_runtime)
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(
|
||||
@@ -126,12 +137,14 @@ def build_server(runtime: Optional[ProjectContextRuntime] = None):
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Điểm vào khi chạy như tiến trình con: phục vụ MCP qua stdio."""
|
||||
import anyio
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
app = build_server()
|
||||
|
||||
async def _run() -> None:
|
||||
"""Vòng lặp phục vụ, đọc/ghi trên stdio cho tới khi tiến trình cha đóng."""
|
||||
async with stdio_server() as (read, write):
|
||||
await app.run(read, write, app.create_initialization_options())
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ from ..foundation import ContractModel, SourceCitation, ToolTemplate
|
||||
|
||||
|
||||
class ChangeContextInput(ContractModel):
|
||||
"""Tham số vào của ``get_project_change_context``: project và mã thay đổi.
|
||||
|
||||
``detail`` chọn độ dài kết quả, ``cursor`` để lấy trang tiếp khi bị cắt bớt.
|
||||
"""
|
||||
project_id: str = Field(min_length=1, max_length=128)
|
||||
change_id: str = Field(min_length=1, max_length=128)
|
||||
detail: Literal["summary", "standard", "full"] = "standard"
|
||||
@@ -19,6 +23,11 @@ class ChangeContextInput(ContractModel):
|
||||
|
||||
|
||||
class ChangeContextOutput(ContractModel):
|
||||
"""Kết quả trả về: mô tả một commit/PR/MR kèm file, commit và issue liên quan.
|
||||
|
||||
``source`` là trích dẫn nguồn bắt buộc; ``truncated``/``next_cursor`` cho
|
||||
Agent biết còn dữ liệu chưa lấy hết.
|
||||
"""
|
||||
correlation_id: str
|
||||
project_id: str
|
||||
change_id: str
|
||||
@@ -38,6 +47,7 @@ class ChangeContextOutput(ContractModel):
|
||||
|
||||
|
||||
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
|
||||
"""Kiểm tham số theo schema rồi chuyển sang provider đã được cấp cho lượt gọi này."""
|
||||
request = ChangeContextInput.model_validate(arguments)
|
||||
return provider.get_change_context(**request.model_dump())
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ from ..foundation import ContractModel, SourceCitation, ToolTemplate
|
||||
|
||||
|
||||
class IssueContextInput(ContractModel):
|
||||
"""Tham số vào của ``get_project_issue_context``: project và mã issue.
|
||||
|
||||
``detail`` chọn độ dài kết quả, ``cursor`` để lấy trang tiếp khi bị cắt bớt.
|
||||
"""
|
||||
project_id: str = Field(min_length=1, max_length=128)
|
||||
issue_key: str = Field(min_length=1, max_length=128)
|
||||
detail: Literal["summary", "standard", "full"] = "standard"
|
||||
@@ -19,6 +23,7 @@ class IssueContextInput(ContractModel):
|
||||
|
||||
|
||||
class RelatedItem(ContractModel):
|
||||
"""Một mục liên quan tới issue (issue khác, PR, tài liệu) kèm loại quan hệ."""
|
||||
item_id: str
|
||||
relation: str
|
||||
title: str
|
||||
@@ -26,6 +31,11 @@ class RelatedItem(ContractModel):
|
||||
|
||||
|
||||
class IssueContextOutput(ContractModel):
|
||||
"""Kết quả trả về: tiêu đề, trạng thái, mô tả, tiêu chí nghiệm thu và các mục liên quan.
|
||||
|
||||
``source`` là trích dẫn nguồn bắt buộc; ``truncated``/``next_cursor`` cho
|
||||
Agent biết còn dữ liệu chưa lấy hết.
|
||||
"""
|
||||
correlation_id: str
|
||||
project_id: str
|
||||
issue_key: str
|
||||
@@ -42,6 +52,7 @@ class IssueContextOutput(ContractModel):
|
||||
|
||||
|
||||
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
|
||||
"""Kiểm tham số theo schema rồi chuyển sang provider đã được cấp cho lượt gọi này."""
|
||||
request = IssueContextInput.model_validate(arguments)
|
||||
return provider.get_issue_context(**request.model_dump())
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ from ..foundation import ContractModel, SourceCitation, ToolTemplate
|
||||
|
||||
|
||||
class KnowledgeSearchInput(ContractModel):
|
||||
"""Tham số vào của ``search_project_knowledge``: project và câu truy vấn.
|
||||
|
||||
``top_k`` chặn trong 1..20 để một lượt tra không nuốt hết cửa sổ ngữ cảnh.
|
||||
"""
|
||||
project_id: str = Field(min_length=1, max_length=128)
|
||||
query: str = Field(min_length=2, max_length=1000)
|
||||
detail: Literal["summary", "standard", "full"] = "standard"
|
||||
@@ -21,6 +25,7 @@ class KnowledgeSearchInput(ContractModel):
|
||||
|
||||
|
||||
class KnowledgeItem(ContractModel):
|
||||
"""Một đoạn tài liệu khớp truy vấn: trích đoạn, điểm số 0..1 và nguồn."""
|
||||
document_id: str
|
||||
chunk_id: str
|
||||
title: str
|
||||
@@ -30,6 +35,7 @@ class KnowledgeItem(ContractModel):
|
||||
|
||||
|
||||
class KnowledgeSearchOutput(ContractModel):
|
||||
"""Kết quả trả về: danh sách đoạn khớp đã xếp hạng, mỗi đoạn kèm trích dẫn nguồn."""
|
||||
correlation_id: str
|
||||
project_id: str
|
||||
query: str
|
||||
@@ -41,6 +47,7 @@ class KnowledgeSearchOutput(ContractModel):
|
||||
|
||||
|
||||
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
|
||||
"""Kiểm tham số theo schema rồi chuyển sang provider đã được cấp cho lượt gọi này."""
|
||||
request = KnowledgeSearchInput.model_validate(arguments)
|
||||
return provider.search_knowledge(**request.model_dump())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user