zizi 208a36ee6c feat(agent-loop): v1 全闭环建设波收口 — D1-D5 五件交付 + 双spec + 种子/烟测产物
- D1: Registry 3 prompt(策划/对抗/fix)+eval 骨架+registry 注册
- D2: agent-loop 2 schema + 4 模板 schema(对齐 gen_spike 真源, additionalProperties:false)
- D3: 编排器+裁决引擎(judge D1-D10 决策表/账本幂等重放/预算闸熔断/eval 回流/批报告), 59 单测绿(mini-desktop)
- D5: 玩家 CDP 取证 player_cdp.py(真点击/蛇形→驼峰映射/demo 三信号/三角合成判定), 9001 自测 PASS
- (D4 后端件已在 9bf3d54 先行入库, 三条 mvn 两轮独立实跑全绿)
- spec: 评审版(已拍板)+execution 版(已审定+建设期补充裁决 D4-a~d/D5-a~c/D2-a/D3-a)
- W2 试点/种子 9003-9008/B4 四链路烟测 产物与总账/作战清单同步
- eval 种子: C1 spike 52 条转入 config.clicker-designer(inputs 52/labels 13)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 06:24:43 +08:00

190 lines
8.5 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
# -*- coding: utf-8 -*-
"""
Prompt Registry 加载与变量渲染D3 件)—— specHJ-AGENT-LOOP-EXEC-001 §7.1
铁律:
- prompt 不内嵌代码:一律从 contracts/prompts/D1 交付)按 registry.yaml 读取后渲染,
git 即事实源;不依赖未实现的 Java Loader
- 渲染采用 {{input.xxx}} 占位符替换(与 01-safety 既有范本一致);
- 渲染后若仍残留 {{input.*}} 占位符 → 立即报错(变量缺漏在联调时暴露,禁止把花括号发给 LLM
- frontmatter version 与 registry 注册 version 不一致 → 立即报错Prompt 即契约,两处必须同步,
承接「改 prompt 必升 version」四道闸纪律
D1↔D3 渲染变量约定(写给 D1 对齐run_batch 按此传参):
- config.clicker-designeridea / template_schema / banned_list / findings首轮 findings 传空串,
schema 重出轮传 schema 错误文本——prompt 模板需容忍该段为空);
- quality.adversary-reviewidea / game_designGameDesign 全文 JSON
- fix.design-reviseidea / template_schema / game_design / findingsP1 项 JSON
标准库限定registry.yaml 用最小 YAML 子集解析(仅本注册表的「列表项+键值」形态),
解析异常带行号报错,绝不静默容错。
"""
import json
import os
import re
# 渲染后残留占位符的检测模式(任何 {{input.xxx}} 残留=变量缺漏)
_PLACEHOLDER_RE = re.compile(r"\{\{\s*input\.([A-Za-z0-9_]+)\s*\}\}")
class PromptError(Exception):
"""Prompt 契约违规registry 解析失败/文件缺失/版本不一致/变量缺漏)。"""
def _strip_inline_comment(value):
"""剥行内注释registry 值后的 ` # ...`);引号包裹的值先去引号。本注册表值均为简单标量。"""
out = []
in_quote = None
for ch in value:
if in_quote:
if ch == in_quote:
in_quote = None
out.append(ch)
elif ch in ("'", '"'):
in_quote = ch
out.append(ch)
elif ch == "#":
break # 注释起点(不在引号内)
else:
out.append(ch)
s = "".join(out).strip()
if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
s = s[1:-1]
return s
def load_registry(registry_path):
"""
解析 contracts/prompts/registry.yaml 的最小子集:顶层 `prompts:` 列表,
每项为 `- id: xxx` 起头的键值块。返回 {prompt_id: {key: value}}。
解析不到 prompts 列表或键值形态异常 → 带行号抛 PromptError。
"""
if not os.path.exists(registry_path):
raise PromptError("registry 不存在:%sD1 交付件未就位?)" % registry_path)
entries = {}
current = None
in_prompts = False
with open(registry_path, "r", encoding="utf-8") as fh:
for ln, raw in enumerate(fh, 1):
line = raw.rstrip("\n")
if not line.strip() or line.lstrip().startswith("#"):
continue
if re.match(r"^prompts\s*:\s*(#.*)?$", line):
in_prompts = True
continue
if not in_prompts:
continue
# 新列表项:` - id: xxx`
m = re.match(r"^\s*-\s+id\s*:\s*(.+)$", line)
if m:
pid = _strip_inline_comment(m.group(1))
if not pid:
raise PromptError("registry 第 %d 行:列表项 id 为空" % ln)
current = {}
entries[pid] = current
current["id"] = pid
continue
# 列表项内键值:` key: value`
m = re.match(r"^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
if m and current is not None:
current[m.group(1)] = _strip_inline_comment(m.group(2))
continue
# prompts 段内出现无法识别的行(顶层新键则视为 prompts 段结束)
if re.match(r"^[A-Za-z_]", line):
in_prompts = False
current = None
continue
raise PromptError("registry 第 %d 行无法解析:%r" % (ln, line))
return entries
def _split_frontmatter(text, path):
"""切 frontmatter首行 `---` 到下一个 `---`);返回 (meta_lines, body)。无 frontmatter → 报错§5.2 必填)。"""
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
raise PromptError("prompt 缺 frontmatter首行须为 ---%s" % path)
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
return lines[1:idx], "\n".join(lines[idx + 1:]).strip()
raise PromptError("prompt frontmatter 未闭合(缺第二个 ---%s" % path)
def _meta_value(meta_lines, key):
"""从 frontmatter 行中取顶层标量键id/version 等);不存在返回 None。"""
for line in meta_lines:
m = re.match(r"^%s\s*:\s*(.+)$" % re.escape(key), line)
if m:
return _strip_inline_comment(m.group(1))
return None
class PromptStore(object):
"""Registry 驱动的 prompt 仓库load含版本一致性核验+ render含残留占位符防御"""
def __init__(self, prompts_dir):
""":param prompts_dir: contracts/prompts/ 绝对路径registry.yaml 所在目录)"""
self.prompts_dir = prompts_dir
self.registry = load_registry(os.path.join(prompts_dir, "registry.yaml"))
self._cache = {} # prompt_id → (version, body)
def require(self, prompt_ids):
"""批跑前置核验:所有需要的 prompt id 已注册且文件存在fail-fastD1 未就位即停)。"""
missing = []
for pid in prompt_ids:
entry = self.registry.get(pid)
if entry is None or not os.path.exists(os.path.join(self.prompts_dir, entry.get("file", ""))):
missing.append(pid)
if missing:
raise PromptError("Registry 缺以下 promptD1 交付件未就位或 file 路径不实):%s" % ", ".join(missing))
def load(self, prompt_id):
"""
加载 prompt 正文与版本:
- registry 必须有该 id 且 file 存在;
- frontmatter.version 必须与 registry.version 一致Prompt 即契约,不一致即停)。
返回 (version: str, body: str)。
"""
if prompt_id in self._cache:
return self._cache[prompt_id]
entry = self.registry.get(prompt_id)
if entry is None:
raise PromptError("prompt id 未注册:%sregistry.yaml" % prompt_id)
path = os.path.join(self.prompts_dir, entry.get("file", ""))
if not os.path.exists(path):
raise PromptError("prompt 文件不存在:%sregistry file 字段不实)" % path)
with open(path, "r", encoding="utf-8") as fh:
text = fh.read()
meta_lines, body = _split_frontmatter(text, path)
fm_version = _meta_value(meta_lines, "version")
reg_version = entry.get("version")
if fm_version and reg_version and fm_version != reg_version:
raise PromptError(
"prompt %s 版本不一致frontmatter=%s registry=%s(改 prompt 必升 version 且两处同步)"
% (prompt_id, fm_version, reg_version))
version = reg_version or fm_version or "0.0.0"
self._cache[prompt_id] = (version, body)
return version, body
def render(self, prompt_id, variables):
"""
渲染 prompt替换 {{input.xxx}} 占位符。
- variables 键不带 input. 前缀;非字符串值 JSON 序列化ensure_ascii=False中文原样
- 渲染后残留任何 {{input.*}} → 抛 PromptError 并列出缺失变量(禁止把占位符发给 LLM
返回 (text: str, version: str)。
"""
version, body = self.load(prompt_id)
text = body
for key, value in (variables or {}).items():
if not isinstance(value, str):
value = json.dumps(value, ensure_ascii=False, indent=2)
text = text.replace("{{input.%s}}" % key, value)
# 兼容占位符内有空白的写法 {{ input.key }}
text = re.sub(r"\{\{\s*input\.%s\s*\}\}" % re.escape(key), lambda _m, v=value: v, text)
leftovers = sorted(set(_PLACEHOLDER_RE.findall(text)))
if leftovers:
raise PromptError(
"prompt %s 渲染后仍残留未供给变量:%sD1↔D3 变量约定见本模块头注释)"
% (prompt_id, ", ".join(leftovers)))
return text, version