280 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""可移植的 Agent 任务包合同:角色 + 冻结输入 + 输出 Schema + 工具白名单。
任务包不含任何框架字段(provider/model/二进制路径都属于派发方 ExecutionPolicy),
因此同一个任务包可以被 pi / codex / opencode 等任意框架适配器执行。
系统提示词 = 角色身份文件 + 中央角色合同 + 结构化输出合同;
用户消息 = 功能合同(taskPrompt)+ 冻结输入 JSON。输出按 Draft 2020-12 校验。
"""
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from muse_role import canonical_json, format_schema_contract, sha256_json, sha256_text
from muse_role_contract import (
ROLE_CONTRACT_RELATIVE_PATH,
ROLE_CONTRACT_VERSION,
ROLE_NAMES,
RoleContract,
RoleContractError,
load_role_contract_catalog,
)
SPEC_VERSION = "agent-task-v1"
SUPPORTED_AGENT_ROLES = ROLE_NAMES
AGENT_TASK_SEPARATOR = "\n\n--- 冻结输入 ---\n"
DEFAULT_MAX_DURATION_SECONDS = 600.0
TOOL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
_SPEC_REQUIRED_KEYS = frozenset(
{"specVersion", "role", "taskPrompt", "input", "outputSchema", "outputSchemaId"}
)
_SPEC_OPTIONAL_KEYS = frozenset(
{"toolAllowlist", "workId", "targetChapter", "maxDurationSeconds", "inputSha256"}
)
_SPEC_ALLOWED_KEYS = _SPEC_REQUIRED_KEYS | _SPEC_OPTIONAL_KEYS
def _reject_nonstandard_json_constant(value: str) -> None:
raise ValueError(f"JSON 不允许常量: {value}")
class TaskSpecError(ValueError):
"""任务包不合法:字段缺失、schema 非法、角色不受支持或哈希不符。"""
@dataclass(frozen=True)
class AgentTaskSpec:
"""一次框架派发的可移植任务定义(无框架、无模型字段)。"""
role: str
task_prompt: str
input: Mapping[str, Any]
output_schema: Mapping[str, Any]
output_schema_id: str
tool_allowlist: tuple[str, ...]
work_id: int | None = None
target_chapter: int | None = None
max_duration_seconds: float = DEFAULT_MAX_DURATION_SECONDS
input_sha256: str | None = None
def __post_init__(self) -> None:
if not isinstance(self.role, str) or self.role not in SUPPORTED_AGENT_ROLES:
raise TaskSpecError(
f"role 不受支持: {self.role!r}(可选 {sorted(SUPPORTED_AGENT_ROLES)})"
)
if not isinstance(self.task_prompt, str) or not self.task_prompt.strip():
raise TaskSpecError("taskPrompt 必须是非空字符串")
if not isinstance(self.input, Mapping):
raise TaskSpecError("input 必须是 JSON 对象")
if not isinstance(self.output_schema_id, str) or not self.output_schema_id.strip():
raise TaskSpecError("outputSchemaId 必须是非空字符串")
if len(self.output_schema_id) > 128 or any(ord(char) < 32 for char in self.output_schema_id):
raise TaskSpecError("outputSchemaId 超过 128 字符或含控制字符")
if not isinstance(self.output_schema, Mapping):
raise TaskSpecError("outputSchema 必须是 JSON 对象")
try:
Draft202012Validator.check_schema(self.output_schema)
except SchemaError as exc:
raise TaskSpecError(f"outputSchema 不符合 Draft 2020-12: {exc.message}") from exc
if not isinstance(self.tool_allowlist, tuple):
raise TaskSpecError("toolAllowlist 必须是字符串数组")
for tool in self.tool_allowlist:
if not isinstance(tool, str) or TOOL_NAME_PATTERN.fullmatch(tool) is None:
raise TaskSpecError(f"toolAllowlist 含非法工具名: {tool!r}")
if len(set(self.tool_allowlist)) != len(self.tool_allowlist):
raise TaskSpecError("toolAllowlist 不得含重复工具名")
if (
isinstance(self.max_duration_seconds, bool)
or not isinstance(self.max_duration_seconds, (int, float))
or not math.isfinite(float(self.max_duration_seconds))
or self.max_duration_seconds <= 0
):
raise TaskSpecError("maxDurationSeconds 必须是正的有限数")
if self.work_id is not None and (
isinstance(self.work_id, bool) or not isinstance(self.work_id, int) or self.work_id <= 0
):
raise TaskSpecError("workId 必须是正整数")
if self.target_chapter is not None and (
isinstance(self.target_chapter, bool)
or not isinstance(self.target_chapter, int)
or self.target_chapter <= 0
):
raise TaskSpecError("targetChapter 必须是正整数")
@property
def canonical_input_sha256(self) -> str:
return sha256_json(self.input)
def load_spec(path: str | Path) -> AgentTaskSpec:
"""从 JSON 文件加载并校验任务包;解析、字段或哈希异常统一失败关闭。"""
try:
raw = json.loads(
Path(path).read_text(encoding="utf-8"),
parse_constant=_reject_nonstandard_json_constant,
)
except (OSError, UnicodeError, ValueError) as exc:
raise TaskSpecError(f"spec 文件不可读或不是合法 JSON: {type(exc).__name__}") from exc
if not isinstance(raw, Mapping):
raise TaskSpecError("spec 文件必须是 JSON 对象")
missing = sorted(_SPEC_REQUIRED_KEYS - set(raw))
if missing:
raise TaskSpecError(f"spec 缺少必填字段: {', '.join(missing)}")
unknown = sorted(set(raw) - _SPEC_ALLOWED_KEYS)
if unknown:
raise TaskSpecError(f"spec 含未知字段: {', '.join(unknown)}")
if raw.get("specVersion") != SPEC_VERSION:
raise TaskSpecError(f"specVersion 必须是 {SPEC_VERSION}")
tools = raw.get("toolAllowlist", [])
if not isinstance(tools, list):
raise TaskSpecError("toolAllowlist 必须是字符串数组")
duration = raw.get("maxDurationSeconds", DEFAULT_MAX_DURATION_SECONDS)
spec = AgentTaskSpec(
role=raw["role"],
task_prompt=raw["taskPrompt"],
input=raw["input"],
output_schema=raw["outputSchema"],
output_schema_id=raw["outputSchemaId"],
tool_allowlist=tuple(tools),
work_id=raw.get("workId"),
target_chapter=raw.get("targetChapter"),
max_duration_seconds=duration,
input_sha256=raw.get("inputSha256"),
)
if spec.input_sha256 is not None and spec.input_sha256 != spec.canonical_input_sha256:
raise TaskSpecError("inputSha256 与 input 内容不一致")
return spec
@dataclass(frozen=True)
class TaskPackage:
"""框架适配器实际消费的执行材料(与框架无关)。"""
spec: AgentTaskSpec
role_prompt: str
role_contract: RoleContract
system_prompt: str
system_prompt_sha256: str
user_message: str
user_message_sha256: str
input_sha256: str
spec_sha256: str
def as_identity(self) -> dict[str, Any]:
"""给回执/事件用的身份摘要(不含正文)。"""
return {
"role": self.spec.role,
"roleContractVersion": ROLE_CONTRACT_VERSION,
"roleContractSha256": self.role_contract.contract_sha256,
"roleContractSource": ROLE_CONTRACT_RELATIVE_PATH.as_posix(),
"outputSchemaId": self.spec.output_schema_id,
"outputSchemaSha256": sha256_json(self.spec.output_schema),
"systemPromptSha256": self.system_prompt_sha256,
"userMessageSha256": self.user_message_sha256,
"inputSha256": self.input_sha256,
"specSha256": self.spec_sha256,
"toolAllowlist": list(self.spec.tool_allowlist),
}
def role_prompt_path(repo_root: str | Path, role: str) -> Path:
"""角色文件路径由角色名单一决定,杜绝任意路径注入。"""
return Path(repo_root) / ".agent" / "agents" / f"{role}.md"
def build_task_package(spec: AgentTaskSpec, repo_root: str | Path) -> TaskPackage:
"""装配身份提示、中心角色合同、功能合同与冻结输入。"""
root = Path(repo_root)
try:
catalog = load_role_contract_catalog(root)
role_contract = catalog.for_role(spec.role)
except RoleContractError as exc:
raise TaskSpecError(f"角色合同不可用: {type(exc).__name__}") from exc
path = role_prompt_path(root, spec.role)
if not path.is_file():
raise TaskSpecError(f"角色文件不存在: {path}")
role_prompt = path.read_text(encoding="utf-8")
if not role_prompt.strip():
raise TaskSpecError(f"角色文件为空: {path}")
system_prompt = (
role_prompt.rstrip()
+ "\n\n--- 角色合同(唯一事实源) ---\n"
+ role_contract.contract_prompt
+ format_schema_contract(spec.output_schema)
)
user_message = spec.task_prompt.strip() + AGENT_TASK_SEPARATOR + canonical_json(spec.input)
return TaskPackage(
spec=spec,
role_prompt=role_prompt,
role_contract=role_contract,
system_prompt=system_prompt,
system_prompt_sha256=sha256_text(system_prompt),
user_message=user_message,
user_message_sha256=sha256_text(user_message),
input_sha256=spec.canonical_input_sha256,
spec_sha256=sha256_json(
{
"specVersion": SPEC_VERSION,
"role": spec.role,
"roleContractVersion": catalog.version,
"roleContractSha256": role_contract.contract_sha256,
"taskPrompt": spec.task_prompt.strip(),
"input": spec.input,
"outputSchema": spec.output_schema,
"outputSchemaId": spec.output_schema_id,
"toolAllowlist": list(spec.tool_allowlist),
"workId": spec.work_id,
"targetChapter": spec.target_chapter,
"maxDurationSeconds": float(spec.max_duration_seconds),
}
),
)
class OutputInvalidError(ValueError):
"""框架最终输出未通过结构化合同。"""
def validate_structured_output(final_text: str, spec: AgentTaskSpec) -> dict[str, Any]:
"""抽取 JSON 并按冻结 schema 校验;失败抛 OutputInvalidError(失败关闭)。"""
from muse_llm import extract_json
try:
extracted = extract_json(final_text)
Draft202012Validator(spec.output_schema).validate(extracted)
except Exception as exc: # noqa: BLE001 - 任何解析/校验失败都统一失败关闭
raise OutputInvalidError(f"结构化输出不满足 {spec.output_schema_id}: {type(exc).__name__}") from exc
if not isinstance(extracted, Mapping):
raise OutputInvalidError("结构化输出必须是 JSON 对象")
return dict(extracted)
__all__ = [
"AGENT_TASK_SEPARATOR",
"AgentTaskSpec",
"DEFAULT_MAX_DURATION_SECONDS",
"OutputInvalidError",
"SPEC_VERSION",
"SUPPORTED_AGENT_ROLES",
"TaskPackage",
"TaskSpecError",
"TOOL_NAME_PATTERN",
"build_task_package",
"load_spec",
"role_prompt_path",
"validate_structured_output",
]