Files
weichen ef060d4ee5
ci / vet-and-test (pull_request) Successful in 40s
Keep only hard facts in the blocking gate and add fact-consistency checks
Why: User rulings 4/5 plus the P2-P4 live evidence. Keyword scoring dimensions
(readability, actionability) kept failing valid documents - normal Chinese
section headings flagged as column titles, loops of 4 corrections - because
they grade writing preferences, not verifiable facts. Unverified numbers
(~10-13 per run) remained the largest quality leak through every phase.
And the verifier runs on the same model as the worker (lightweight falls back
to chat), so "independent" verification was self-review. Separately, svc
seeding after boot never registered the tavily tools until a restart because
keys were snapshotted at startup.

Changes: quality.HardFacts (lint actually ran + zero errors) replaces the
full ScoreBytes projection in CheckArtifacts - keyword dimensions remain in
ScoreBytes for eval visibility only. New fact-consistency check
(runtime.factCheck, default on): extract percentages/dates/large numbers from
deliverable text, trace each against the material corpus (attachment text +
tool evidence); misses get exactly one correction round (fix, drop, or mark
as target/estimate), never a loop. runVerifier skips same-source model
verification unless runtime.verifierSameModel is set (hard facts + fact check
still run). Search tools now register lazily, reading tavily keys per call
(settings/seeding take effect immediately).

Evidence: SELF - TestHardFactsIgnoresKeywordDimensions,
TestHardFactsKeepsLintUnavailableBlocking, TestUnverifiedFactsAndNotice,
TestFactCheckContinuationOnce (fact note fires, corrected run passes);
go vet + go test ./... pass; live before/after on the golden task set
recorded on the phase05 task card per the policy clause.

Impact: Gate stops rejecting valid documents on stylistic grounds; invented
figures get one forced correction round; same-model verification no longer
pretends to be independent; tavily config is hot. No contract or storage
changes.
2026-10-10 00:04:57 +00:00

390 lines
15 KiB
Go
Raw Permalink 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.
// Package wfcompile 把工作流定义(core.WorkflowDef)编译为 Agent-Squad 计划
// (Q3 WP1):六类步骤映射到 squad 图步/宿主 intent 步,StepCritic 映射为
// evaluator-optimizer 循环对,P4 全部上限与 DeniedTools 经 internal/workflow
// 的 Validate 在编译入口强制(声明式 ≤20 步;模型生成 ≤8 步、禁 script、
// 执行前必须确认;critic 每步 ≤3 缺省 1、整条 ≤6;脚本回调 ≤50、单行 ≤1MB、
// 超时 600/1800s 由宿主脚本执行器持有)。
//
// 步骤映射(方案 Q3-WP1):
// - prompt → 角色步(Brief 携带渲染后指令;agent 字段映射角色键)
// - hitl → 宿主 intent 步 wf.hitl(AskHITL 卡片,答案落 output.md + 黑板 out_<id>)
// - tool → 宿主 intent 步 wf.tool(ExecuteTool;DeniedTools 编译期已拦)
// - artifact → 宿主 intent 步 wf.artifact(WriteArtifact)
// - card → 宿主 intent 步 wf.card(CreateCard)
// - script → 宿主 intent 步 wf.script(codeorch 宿主执行;插件脚本免确认,
// 模型脚本必须确认;runtime.workflowScripts 开关默认关)
// - critic → 追加 wf.critic 评审步 + Loop{Body:[原步]}(evaluator-optimizer
// 形态):verdict 黑板字段驱动再武装,预算 = 步重试上限 + 1;无法解析的
// 判定按不通过处理(fail-closed,与 legacy judgeStep 同款)。
//
// 模板占位符:{{input}}/{{slots.*}} 在编译期渲染(槽位已在启动前收齐);
// {{steps.X.output}} 编译为文件指引(宿主步全文落 tasks/NN_X/output.md,
// 宿主步要点回填黑板 out_X)——语义桥接,非逐字节等价,差异由 WP5 A/B 记录。
package wfcompile
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"agent-squad/squad"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
// 宿主 intent 步的意图 ID(目录由本包合成,处理函数在 host.go)。
const (
IntentHitl = "wf.hitl"
IntentTool = "wf.tool"
IntentArtifact = "wf.artifact"
IntentCard = "wf.card"
IntentScript = "wf.script"
IntentCritic = "wf.critic"
)
// CriticPair 记录一个「原步 ↔ 评审步」映射(收口后校验最终判定用)。
type CriticPair struct {
StepID string // 被评审的原步 ID
StepTitle string
EvalID string // 评审步 ID(<StepID>_critic)
VerdictField string // 黑板判定字段名
}
// Compiled 是一次编译产物。
type Compiled struct {
Plan *squad.SquadPlan
Catalog *squad.IntentCatalog
Critics []CriticPair
// SubOf 步骤 ID → 子工作区相对路径(宿主处理器解析跨步文件引用)。
SubOf map[string]string
Def core.WorkflowDef
}
// CompileOptions 编译口径。
type CompileOptions struct {
// DynamicLimits 模型生成工作流:≤8 步、禁 script(P4)。
DynamicLimits bool
// ModelScript 模型提供的脚本(run_workflow script= 分支):脚本步执行前
// 必须 HITL 确认(P4:模型脚本必须确认;插件脚本免确认)。
ModelScript bool
}
// Compile 把 def 编译为 squad 计划。vars 提供 {{input}}/{{slots.*}} 的编译期
// 实值(缺失槽位须已由调用方收齐)。knownTools 报告工具是否已注册
// (nil = 只查禁用名单)。
func Compile(def core.WorkflowDef, vars workflow.Vars, opts CompileOptions, knownTools func(string) bool) (*Compiled, error) {
lim := workflow.DeclarativeLimits()
if opts.DynamicLimits {
lim = workflow.DynamicLimits()
}
if err := workflow.Validate(def, lim, knownTools); err != nil {
return nil, err
}
c := &Compiled{SubOf: map[string]string{}, Def: def}
var steps []squad.StepSpec
var agents []squad.RoleSpec
agentSeen := map[string]bool{}
prevDef := "" // 上一个 def 步 ID(artifact 空内容的 prev 回退)
prevChain := "" // 图链上的前驱(含评审步)
for i, raw := range def.Steps {
// {{steps.X.output}} 是运行期引用:先用哨兵保护,避免 RenderStep 按
// 空值吞掉;渲染后按字段语义还原(人读字段 → 文件指引文本;机器字段
// → 原 token,由宿主处理器运行期解析)。
raw = protectStepRefs(raw)
rendered, err := workflow.RenderStep(raw, vars)
if err != nil {
return nil, err
}
sub := fmt.Sprintf("tasks/%02d_%s", i+1, raw.ID)
c.SubOf[raw.ID] = sub
var needs []string
if prevChain != "" {
needs = []string{prevChain}
}
var sp squad.StepSpec
switch raw.Kind {
case "prompt":
role := agentRole(raw.Agent)
if !agentSeen[role] {
agentSeen[role] = true
agents = append(agents, squad.RoleSpec{Role: role, Name: role})
}
sp = squad.StepSpec{
ID: raw.ID, Role: role, Title: rendered.Title,
SubWorkspace: sub, Needs: needs, Targets: []string{"output.md"},
Brief: c.refsToText(rendered.Prompt),
}
case "hitl":
question := c.refsToText(rendered.Title)
kind := string(core.HITLKindInput)
choicesJSON := ""
allowFree := "true"
hint := ""
if rendered.Hitl != nil {
if strings.TrimSpace(rendered.Hitl.Question) != "" {
question = c.refsToText(rendered.Hitl.Question)
}
if rendered.Hitl.Kind != "" {
kind = string(rendered.Hitl.Kind)
}
if len(rendered.Hitl.Choices) > 0 {
b, _ := json.Marshal(rendered.Hitl.Choices)
choicesJSON = string(b)
}
allowFree = boolSlot(rendered.Hitl.AllowFreeInput)
hint = rendered.Hitl.FreeInputHint
}
sp = intentStep(raw.ID, rendered.Title, sub, needs, IntentHitl, map[string]string{
"step_id": raw.ID, "question": question, "kind": kind,
"choices": choicesJSON, "allow_free_input": allowFree, "free_input_hint": hint,
}, []string{"output.md", "fields.json"})
case "tool":
sp = intentStep(raw.ID, rendered.Title, sub, needs, IntentTool, map[string]string{
"step_id": raw.ID, "tool": strings.TrimSpace(rendered.Tool), "args": restoreStepRefs(string(rendered.Args)),
}, []string{"output.md", "fields.json"})
case "artifact":
name := rendered.Artifact
if strings.TrimSpace(name) == "" {
name = raw.ID + ".md"
}
content := restoreStepRefs(rendered.Prompt)
slots := map[string]string{"step_id": raw.ID, "name": name, "content": content}
if strings.TrimSpace(content) == "" && prevDef != "" {
// legacy 语义:artifact 无正文时取上一步产出。
slots["content_ref"] = prevDef
}
sp = intentStep(raw.ID, rendered.Title, sub, needs, IntentArtifact, slots,
[]string{"output.md", "fields.json"})
case "card":
tmpl := core.ActivityCardTemplate{Kind: core.CardKindResult, Title: c.refsToText(rendered.Title)}
if rendered.Card != nil {
tmpl = *rendered.Card
tmpl.Title = c.refsToText(tmpl.Title)
if tmpl.Title == "" {
tmpl.Title = c.refsToText(rendered.Title)
}
}
b, _ := json.Marshal(tmpl)
sp = intentStep(raw.ID, rendered.Title, sub, needs, IntentCard, map[string]string{
"step_id": raw.ID, "template": string(b),
}, []string{"output.md", "fields.json"})
case "script":
sp = intentStep(raw.ID, rendered.Title, sub, needs, IntentScript, map[string]string{
"step_id": raw.ID, "path": strings.TrimSpace(raw.Script), "model": boolSlot(opts.ModelScript),
}, []string{"output.md", "fields.json"})
default:
return nil, fmt.Errorf("workflow: 未知步骤类型 %s", raw.Kind)
}
steps = append(steps, sp)
prevDef = raw.ID
prevChain = raw.ID
// critic → evaluator-optimizer 循环对:原步产出后评审步判定,
// verdict != pass 且预算未尽 → 原步与评审步一起再武装。
if raw.Critic != nil && strings.TrimSpace(raw.Critic.Criteria) != "" {
retries := 1
if raw.Critic.MaxRetries > 0 {
retries = raw.Critic.MaxRetries
}
if retries > workflow.MaxStepRetries {
retries = workflow.MaxStepRetries
}
field := "verdict_" + raw.ID
evalID := raw.ID + "_critic"
evalSub := fmt.Sprintf("tasks/%02d_%s_critic", i+1, raw.ID)
c.SubOf[evalID] = evalSub
steps = append(steps, intentStep(evalID, "验收:"+raw.Title, evalSub,
[]string{raw.ID}, IntentCritic, map[string]string{
"step_id": evalID,
"target_step": raw.ID,
"criteria": raw.Critic.Criteria,
"goal": def.Title,
"verdict_field": field,
"max_attempts": fmt.Sprintf("%d", retries+1),
}, []string{"verdict.md", "fields.json"}))
last := &steps[len(steps)-1]
last.Loop = &squad.LoopSpec{
MaxIterations: retries + 1,
UntilField: field,
UntilEquals: "pass",
Body: []string{raw.ID},
}
c.Critics = append(c.Critics, CriticPair{
StepID: raw.ID, StepTitle: raw.Title, EvalID: evalID, VerdictField: field,
})
prevChain = evalID
}
}
agents = append(agents, squad.DefaultIntentRoleSpec())
c.Plan = &squad.SquadPlan{
Squad: squad.SquadConfig{SquadName: "wf_" + def.ID, Version: "1", Agents: agents},
Workflow: squad.WorkflowPlan{
WorkflowID: squad.NewPlanID(),
Title: def.Title,
PlanningMode: squad.PlanModeBlueprint,
Status: "pending",
Steps: steps,
},
}
c.Catalog = buildCatalog()
return c, nil
}
// PlanPreview 动态工作流的确认预览(legacy planPreview 同款文案)。
func PlanPreview(def core.WorkflowDef) string {
var b strings.Builder
fmt.Fprintf(&b, "按这个计划执行「%s」?\n", def.Title)
for i, step := range def.Steps {
fmt.Fprintf(&b, "%d. %s(%s)\n", i+1, step.Title, step.Kind)
}
return strings.TrimRight(b.String(), "\n")
}
// ParseWorkflowDef 宽松解析模型输出的 WorkflowDef(legacy parseWorkflowDef 同款)。
func ParseWorkflowDef(raw string) (core.WorkflowDef, error) {
s := stripFenceBlock(raw)
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end < start {
return core.WorkflowDef{}, fmt.Errorf("workflow: 规划结果无法解析")
}
var def core.WorkflowDef
if err := json.Unmarshal([]byte(s[start:end+1]), &def); err != nil {
return core.WorkflowDef{}, fmt.Errorf("workflow: 规划结果无法解析")
}
if strings.TrimSpace(def.ID) == "" {
def.ID = "dynamic"
}
if strings.TrimSpace(def.Title) == "" {
def.Title = "临时工作流"
}
return def, nil
}
func stripFenceBlock(s string) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "```") {
return s
}
s = strings.TrimPrefix(s, "```")
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```"))
}
// intentStep 组装一个宿主 intent 步。
func intentStep(id, title, sub string, needs []string, intentID string, slots map[string]string, targets []string) squad.StepSpec {
return squad.StepSpec{
ID: id, Role: squad.DefaultIntentRole, Title: title,
SubWorkspace: sub, Needs: needs, Targets: targets,
IntentID: intentID, IntentSlots: slots,
}
}
// agentRole 把 WorkflowStep.Agent 规整为角色键(空 = general)。
func agentRole(agent string) string {
a := strings.TrimSpace(agent)
if a == "" {
return "general"
}
var b strings.Builder
for _, r := range strings.ToLower(a) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return b.String()
}
func boolSlot(v bool) string {
if v {
return "true"
}
return ""
}
// stepTokenRe 匹配 {{steps.<id>.output}};sentinelRe 匹配编译期哨兵。
var (
stepTokenRe = regexp.MustCompile(`\{\{steps\.([A-Za-z_][A-Za-z0-9_]*)\.output\}\}`)
sentinelRe = regexp.MustCompile(`@@stepsref:([A-Za-z_][A-Za-z0-9_]*)@@`)
)
// protectStepRefs 把步骤定义里的运行期引用换成哨兵(RenderStep 只认识
// {{input}}/{{slots.*}}/{{steps.*}},不保护会被空值替换吞掉)。
func protectStepRefs(step core.WorkflowStep) core.WorkflowStep {
step.Prompt = stepTokenRe.ReplaceAllString(step.Prompt, "@@stepsref:$1@@")
step.Artifact = stepTokenRe.ReplaceAllString(step.Artifact, "@@stepsref:$1@@")
if step.Hitl != nil {
q := stepTokenRe.ReplaceAllString(step.Hitl.Question, "@@stepsref:$1@@")
hitl := *step.Hitl
hitl.Question = q
step.Hitl = &hitl
}
if step.Card != nil {
card := *step.Card
card.Title = stepTokenRe.ReplaceAllString(card.Title, "@@stepsref:$1@@")
step.Card = &card
}
if len(step.Args) > 0 {
step.Args = json.RawMessage(stepTokenRe.ReplaceAllString(string(step.Args), "@@stepsref:$1@@"))
}
return step
}
// refsToText 哨兵 → 文件指引文本(人读/LLM 读字段:Brief、问题、卡标题)。
func (c *Compiled) refsToText(s string) string {
return sentinelRe.ReplaceAllStringFunc(s, func(m string) string {
id := sentinelRe.FindStringSubmatch(m)[1]
return fmt.Sprintf("〈步骤 %s 的产出〉(全文见运行根目录 %s/output.md;宿主步要点见黑板字段 out_%s)",
id, c.SubOf[id], id)
})
}
// restoreStepRefs 哨兵 → 原 token(机器字段:工具实参、工件正文,由宿主
// 处理器运行期按文件解析实值)。
func restoreStepRefs(s string) string {
return sentinelRe.ReplaceAllStringFunc(s, func(m string) string {
id := sentinelRe.FindStringSubmatch(m)[1]
return "{{steps." + id + ".output}}"
})
}
// buildCatalog 合成宿主 intent 目录(validateIntentSteps 的确定性校验面)。
func buildCatalog() *squad.IntentCatalog {
spec := func(id, desc string, slots ...squad.IntentSlotView) squad.IntentSpecView {
v := squad.IntentSpecView{ID: id, Description: desc, Handler: "wfcompile", Slots: slots}
for _, sl := range slots {
if sl.Required {
v.RequiredSlots = append(v.RequiredSlots, sl.Key)
}
}
return v
}
req := func(key string) squad.IntentSlotView {
return squad.IntentSlotView{Key: key, Required: true, Question: "缺少必填槽位 " + key}
}
opt := func(key string) squad.IntentSlotView { return squad.IntentSlotView{Key: key} }
catalog := &squad.IntentCatalog{
Revision: 1,
Markdown: "# 工作流宿主能力\n\n- wf.hitl 人工问答\n- wf.tool 宿主工具\n- wf.artifact 工件落库\n- wf.card 收件箱卡\n- wf.script 脚本宿主\n- wf.critic 步骤验收\n",
Specs: map[string]squad.IntentSpecView{
IntentHitl: spec(IntentHitl, "人工问答步", req("question"), opt("step_id"), opt("kind"), opt("choices"), opt("allow_free_input"), opt("free_input_hint")),
IntentTool: spec(IntentTool, "宿主工具步", req("tool"), opt("step_id"), opt("args")),
IntentArtifact: spec(IntentArtifact, "工件步", req("name"), opt("step_id"), opt("content"), opt("content_ref")),
IntentCard: spec(IntentCard, "收件箱卡步", req("template"), opt("step_id")),
IntentScript: spec(IntentScript, "脚本步", req("path"), opt("step_id"), opt("model")),
IntentCritic: spec(IntentCritic, "步骤验收", req("target_step"), req("criteria"), req("verdict_field"),
opt("step_id"), opt("goal"), opt("max_attempts")),
},
}
return catalog
}