Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements. Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate. Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable. Refs: Versioned dependencies U1/U3; feature/dependency-workspace
390 lines
15 KiB
Go
390 lines
15 KiB
Go
// 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"
|
||
|
||
"git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git/squad"
|
||
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/workflow"
|
||
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
|
||
)
|
||
|
||
// 宿主 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
|
||
}
|