150 lines
7.5 KiB
Python
150 lines
7.5 KiB
Python
"""冻结并校验运行配置,避免历史任务被后续 Agent/Provider 编辑污染。"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from ..models import Agent, AgentPromptTemplate, AppUser, ModelCatalog, ModelProvider, now
|
|
from .coordinator import _provider_model, provider_fingerprint
|
|
from .skills import agent_skills
|
|
from .memory import memory_candidates
|
|
from ..digital_persona import freeze_persona
|
|
from .default_composer import resolve_composer_agent
|
|
|
|
|
|
def _digest(value: object) -> str:
|
|
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def freeze_agent_snapshot(session: Session, agent: Agent, visited: set[int] | None = None,
|
|
user: AppUser | None = None, allow_agent_scope: bool = False,
|
|
allowed_agent_ids: set[int] | None = None) -> dict:
|
|
"""返回无密钥的不可变 Agent 配置,嵌套融合 Agent 也一并冻结。"""
|
|
visited = visited or set()
|
|
if agent.id in visited:
|
|
return {"id": agent.id, "name": agent.name, "cycle": True}
|
|
visited.add(agent.id or 0)
|
|
provider: ModelProvider | None = None
|
|
model = agent.model_name
|
|
catalog: ModelCatalog | None = session.get(ModelCatalog, agent.model_catalog_id) if agent.model_catalog_id else None
|
|
try:
|
|
provider, model = _provider_model(session, agent)
|
|
except Exception:
|
|
provider = session.get(ModelProvider, agent.model_provider_id) if agent.model_provider_id else None
|
|
skills = [{"id": skill.id, "name": skill.name, "version": skill.version,
|
|
"instructions": skill.instructions} for skill in agent_skills(session, agent.id or 0)]
|
|
memories = memory_candidates(session, agent, user, allow_agent_scope=allow_agent_scope,
|
|
allowed_agent_ids=allowed_agent_ids)
|
|
# 人物表达统一由已绑定的数字人提供。旧模板仅保留身份元数据用于历史
|
|
# 审计和兼容快照,正文不会注入运行时,避免两套表达规则互相覆盖。
|
|
template = session.get(AgentPromptTemplate, agent.prompt_template_id) if agent.prompt_template_id else None
|
|
template_snapshot = {}
|
|
if template and template.enabled:
|
|
# 保留完整历史快照供审计/旧任务回放;当前 runner 会忽略其表达字段,
|
|
# 新任务的文风统一从数字人快照注入。
|
|
template_snapshot = {
|
|
"id": template.id, "name": template.name, "version": template.version,
|
|
"role_prompt": template.role_prompt, "task_template": template.task_template,
|
|
"constraints": list(template.constraints or []), "examples": list(template.examples or []),
|
|
"output_schema": dict(template.output_schema or {}), "style": template.style,
|
|
}
|
|
fusions = []
|
|
for fusion_id in agent.fusion_agent_ids or []:
|
|
fused = session.get(Agent, fusion_id)
|
|
if fused:
|
|
fusions.append(freeze_agent_snapshot(session, fused, visited.copy(), user=user,
|
|
allow_agent_scope=allow_agent_scope,
|
|
allowed_agent_ids=allowed_agent_ids))
|
|
snapshot = {
|
|
"id": agent.id, "name": agent.name, "role": agent.role,
|
|
"organization_id": agent.organization_id, "owner_user_id": agent.owner_user_id,
|
|
**freeze_persona(session, agent, user),
|
|
"rules": agent.rules, "rules_version": agent.rules_version,
|
|
"rules_hash": hashlib.sha256((agent.rules or "").encode("utf-8")).hexdigest(),
|
|
"kb_ids": list(agent.kb_ids or []),
|
|
"kb_category_ids": dict(getattr(agent, "kb_category_ids", {}) or {}),
|
|
"allowed_tools": list(agent.allowed_tools or []),
|
|
"provider_id": provider.id if provider else agent.model_provider_id,
|
|
"provider_name": provider.name if provider else "",
|
|
"provider_base_url": provider.base_url if provider else "",
|
|
"provider_fingerprint": provider_fingerprint(provider, model) if provider else "",
|
|
"model": model, "model_catalog_id": catalog.id if catalog else agent.model_catalog_id,
|
|
"model_catalog": {
|
|
"id": catalog.id, "name": catalog.model_name,
|
|
"display_name": catalog.display_name, "capabilities": list(catalog.capabilities or []),
|
|
"capability_status": catalog.capability_status, "rate_version": catalog.rate_version,
|
|
} if catalog else {},
|
|
"skills": skills, "memories": memories, "fusions": fusions,
|
|
"prompt_template": template_snapshot,
|
|
}
|
|
snapshot["snapshot_hash"] = _digest(snapshot)
|
|
return snapshot
|
|
|
|
|
|
def freeze_stage_snapshot(session: Session) -> dict:
|
|
"""一次运行中 QC 和组合阶段使用的完整快照。"""
|
|
qc: dict[str, dict] = {}
|
|
for gate, name in {
|
|
"structure": "结构质检", "content": "内容质检",
|
|
"style": "风格尺度质检", "cross": "跨稿质检",
|
|
}.items():
|
|
agent = session.exec(select(Agent).where(Agent.name == name)).first()
|
|
if agent:
|
|
qc[gate] = freeze_agent_snapshot(session, agent)
|
|
composer = resolve_composer_agent(session)
|
|
payload = {
|
|
"schema_version": 1,
|
|
"captured_at": now(),
|
|
"qc": qc,
|
|
"composer": freeze_agent_snapshot(session, composer),
|
|
}
|
|
payload["snapshot_hash"] = _digest(payload)
|
|
return payload
|
|
|
|
|
|
def agent_from_snapshot(snapshot: dict) -> Agent:
|
|
"""把冻结的数据转为运行时 Agent,不再读取当前的规则或技能绑定。"""
|
|
agent = Agent(
|
|
id=int(snapshot.get("id") or 0), name=str(snapshot.get("name") or "冻结 Agent"),
|
|
role=str(snapshot.get("role") or "service"), rules=str(snapshot.get("rules") or ""),
|
|
rules_version=int(snapshot.get("rules_version") or 1),
|
|
organization_id=int(snapshot.get("organization_id") or 1),
|
|
owner_user_id=int(snapshot.get("owner_user_id") or 0),
|
|
digital_human_id=snapshot.get("digital_human_id"),
|
|
persona_json=dict(snapshot.get("persona_json") or {}),
|
|
runtime_snapshot={"primary": snapshot},
|
|
kb_ids=list(snapshot.get("kb_ids") or []),
|
|
kb_category_ids=dict(snapshot.get("kb_category_ids") or {}), status="active",
|
|
allowed_tools=list(snapshot.get("allowed_tools") or []),
|
|
)
|
|
object.__setattr__(agent, "_frozen_persona_snapshot", snapshot)
|
|
return agent
|
|
|
|
|
|
def skills_prompt_from_snapshot(snapshot: dict) -> str:
|
|
skills = list(snapshot.get("skills") or [])
|
|
if not skills:
|
|
return ""
|
|
lines = ["## 已冻结技能(按下列规则执行)"]
|
|
for skill in skills:
|
|
lines.append(f"### {skill.get('name') or '未命名技能'} v{skill.get('version') or 1}")
|
|
lines.append(str(skill.get("instructions") or ""))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def resolve_snapshot_provider(session: Session, snapshot: dict) -> tuple[ModelProvider, str]:
|
|
"""只允许以原 Provider 和模型续跑;配置漂移必须由人工创建新运行。"""
|
|
provider_id = int(snapshot.get("provider_id") or 0)
|
|
model = str(snapshot.get("model") or "")
|
|
provider = session.get(ModelProvider, provider_id)
|
|
if not provider or not provider.enabled or not model:
|
|
raise ValueError("冻结的模型配置已不可用,请创建新运行后重试")
|
|
expected = str(snapshot.get("provider_fingerprint") or "")
|
|
actual = provider_fingerprint(provider, model)
|
|
if expected and expected != actual:
|
|
raise ValueError("Provider 配置已变更,已阻止使用新配置覆盖历史运行")
|
|
return provider, model
|