280 lines
11 KiB
Python
280 lines
11 KiB
Python
#!/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",
|
||
]
|