package wfcompile import ( "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "sync" "time" "git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/codeorch" "git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/gates" agentllm "git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/llm" "git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git/squad" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/tools" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/workflow" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) // Host 是编译图执行所需的宿主窄端口(结构性接口;runtime.RunCtx 天然满足, // 避免 wfcompile 反向依赖 runtime 形成环)。 type Host interface { Context() context.Context Run() core.Run Settings() core.EffectiveSettings Emit(typ core.StreamEventType, payload any) error AskHITL(card core.HITLCard) (core.HITLResponse, error) UpdatePlan(p core.PlanSnapshot) error WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult CreateCard(card core.ActivityCard) (core.ActivityCard, error) ConversationSandbox() string Tools() []core.ToolDesc } // Spawner 可选子代理派发能力(脚本回调 agent.spawn 用)。 type Spawner interface { SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) SpawnSubagent(name, task string, hint string) (core.AgentResultPayload, error) } // PlanGetter 可选当前计划读取(脚本回调 step 用)。 type PlanGetter interface { Plan() (core.PlanSnapshot, bool) } // Deps 是编译图执行的装配依赖。 type Deps struct { // DataDir 小队运行数据根(运行目录 = DataDir/wf/)。 DataDir string // Backends 步骤后端解析器(WF1 期由调用方装配 agent-system;WP2 切 agent-native)。 Backends *squad.BackendResolver // DefaultBackend 角色未声明后端时的缺省(空 = agent-system)。 DefaultBackend string // JudgeStream 验收模型流(nil = 验证器不可用,判定 fail-closed 不通过)。 JudgeStream agentllm.StreamFunc // ScriptTimeout 覆盖脚本墙钟超时(0 = 600s;上限 1800s 由 codeorch 持有)。 ScriptTimeout time.Duration } // 脚本宿主限额(P4:回调 ≤50、单行 ≤1MB、超时 600/1800s)。 const ( scriptDefaultTimeout = 600 * time.Second scriptMaxTimeout = 1800 * time.Second scriptMaxCalls = 50 scriptMaxLine = 1 << 20 scriptModelPreview = 4000 // outFieldCap 宿主步产出回填黑板 out_ 字段的截断上限。 outFieldCap = 2000 ) // hostRunner 宿主 intent 步执行器(squad.IntentRunner 实现)。 type hostRunner struct { rc Host deps Deps scripts map[string]string subOf map[string]string mu sync.Mutex verdicts map[string]string // verdict 字段 → 最近一次判定 fails map[string]int // verdict 字段 → 累计不通过次数(触顶即败) lastWhy map[string]string // 原步 ID → 最近一次不通过原因 drops []string // 丢弃记录(legacy drops 同款 "stepID:why") arts []*core.ArtifactRef // 收集到的工件引用 } func (r *hostRunner) Run(ctx context.Context, step squad.IntentStep) (*squad.ExecResult, error) { var content string var fields map[string]string var err error switch step.IntentID { case IntentHitl: content, fields, err = r.doHitl(step) case IntentTool: content, fields, err = r.doTool(ctx, step) case IntentArtifact: content, fields, err = r.doArtifact(step) case IntentCard: content, fields, err = r.doCard(step) case IntentScript: content, fields, err = r.doScript(ctx, step) case IntentCritic: content, fields, err = r.doCritic(ctx, step) default: err = fmt.Errorf("workflow: 未知宿主意图 %s", step.IntentID) } if err != nil { return &squad.ExecResult{Success: false, ErrorReason: err.Error()}, nil } patches := map[string][]byte{} main := "output.md" if len(step.Targets) > 0 { main = step.Targets[0] } // squad 交付物契约拒收空白文件(MaterializeTargets);legacy 允许空产出, // 编译图用占位文本保持流转(out_ 字段仍按原始内容判空)。 body := content if strings.TrimSpace(body) == "" { body = "(本步无文本产出)" } patches[main] = []byte(body + "\n") // 宿主步产出要点回填黑板(out_),供后续步骤 TASK.md 快照引用。 if fields == nil { fields = map[string]string{} } if sid := step.Slots["step_id"]; sid != "" && strings.TrimSpace(content) != "" { fields["out_"+sid] = truncate(content, outFieldCap) } // fields.json 是声明目标之一,恒写(空对象也写,避免交付物校验失败)。 b, _ := json.Marshal(fields) patches["fields.json"] = b return &squad.ExecResult{Success: true, Patches: patches}, nil } // doHitl 人工问答步(legacy runWorkflowStep case "hitl" 同款)。 func (r *hostRunner) doHitl(step squad.IntentStep) (string, map[string]string, error) { kind := core.HITLKind(step.Slots["kind"]) if kind == "" { kind = core.HITLKindInput } card := core.HITLCard{ Question: step.Slots["question"], Kind: kind, AllowFreeInput: step.Slots["allow_free_input"] == "true", FreeInputHint: step.Slots["free_input_hint"], } if raw := step.Slots["choices"]; strings.TrimSpace(raw) != "" { _ = json.Unmarshal([]byte(raw), &card.Choices) } resp, err := r.rc.AskHITL(card) if err != nil { return "", nil, err } if strings.TrimSpace(resp.FreeText) != "" { return strings.TrimSpace(resp.FreeText), nil, nil } return resp.ChoiceID, nil, nil } // doTool 宿主工具步(DeniedTools 已在编译期 Validate 拦截)。 func (r *hostRunner) doTool(_ context.Context, step squad.IntentStep) (string, map[string]string, error) { name := step.Slots["tool"] args := r.renderRefs(step.Slots["args"], step.WorkDir) res := r.rc.ExecuteTool(core.ToolCallRequest{Name: name, Arguments: json.RawMessage(args)}) if res.Status == "error" { return "", nil, fmt.Errorf("workflow: 工具 %s 失败:%s", name, res.Output) } if res.Artifact != nil { r.track(&core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}) } return res.Output, nil, nil } // doArtifact 工件步(空正文回退 content_ref 指向步骤的产出,legacy prev 语义)。 func (r *hostRunner) doArtifact(step squad.IntentStep) (string, map[string]string, error) { content := r.renderRefs(step.Slots["content"], step.WorkDir) if strings.TrimSpace(content) == "" { if ref := step.Slots["content_ref"]; ref != "" { content = r.readStepOutput(step.WorkDir, ref) } } name := step.Slots["name"] if strings.TrimSpace(name) == "" { name = step.Slots["step_id"] + ".md" } art, err := r.rc.WriteArtifact(name, "document", "text/markdown", []byte(content)) if err != nil { return "", nil, err } r.track(&core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}) return string(art.ID), nil, nil } // doCard 收件箱卡步。 func (r *hostRunner) doCard(step squad.IntentStep) (string, map[string]string, error) { var tmpl core.ActivityCardTemplate if err := json.Unmarshal([]byte(step.Slots["template"]), &tmpl); err != nil { return "", nil, fmt.Errorf("workflow: 卡片模板不合法: %v", err) } card := core.ActivityCard{Title: tmpl.Title, Kind: tmpl.Kind, Tone: tmpl.Tone, Body: tmpl.Body, Actions: tmpl.Actions} if card.Kind == "" { card.Kind = core.CardKindResult } created, err := r.rc.CreateCard(card) if err != nil { return "", nil, err } return string(created.ID), nil, nil } // doScript 脚本步:插件脚本免确认;模型脚本(model=true)必须先过 HITL 确认; // runtime.workflowScripts 开关默认关(P4)。执行走 codeorch 宿主(限额同款)。 func (r *hostRunner) doScript(ctx context.Context, step squad.IntentStep) (string, map[string]string, error) { path := step.Slots["path"] body := r.scripts[path] if strings.TrimSpace(body) == "" { return "", nil, fmt.Errorf("workflow: 脚本 %s 不在启动副本里", path) } if step.Slots["model"] == "true" { if !r.scriptsEnabled() { return "", nil, fmt.Errorf("workflow: 工作流脚本未开启") } preview := body if len(preview) > scriptModelPreview { preview = preview[:scriptModelPreview] + "\n…(脚本超长,已截断展示)" } resp, err := r.rc.AskHITL(core.HITLCard{ Question: "运行这段脚本?\n" + preview, Kind: core.HITLKindConfirm, AllowFreeInput: false, Choices: []core.HITLChoice{ {ID: "yes", Label: "运行", Description: "在会话沙箱里执行", Impact: "会调用工具并可能写入工件"}, {ID: "no", Label: "取消", Description: "不执行", Impact: "不会改动任何东西"}, }, }) if err != nil { return "", nil, err } if resp.ChoiceID != "yes" { return "未确认,未执行", nil, nil } } sandbox := strings.TrimSpace(r.rc.ConversationSandbox()) if sandbox == "" { return "", nil, fmt.Errorf("workflow: 没有会话沙箱") } file, err := r.writeScriptFile(sandbox, "step-"+step.Slots["step_id"], body) if err != nil { return "", nil, err } return r.runScriptFile(ctx, sandbox, file) } func (r *hostRunner) scriptsEnabled() bool { return r.rc.Settings().GetBool("runtime.workflowScripts", false) } func (r *hostRunner) writeScriptFile(sandbox, prefix, body string) (string, error) { dir := filepath.Join(sandbox, ".agent") if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } path := filepath.Join(dir, fmt.Sprintf("wf-%s-%d.py", prefix, time.Now().UnixNano())) if err := os.WriteFile(path, []byte(body), 0o600); err != nil { return "", err } return path, nil } // runScriptFile codeorch 宿主执行(legacy runScriptFile 同款限额与回调分发)。 func (r *hostRunner) runScriptFile(ctx context.Context, sandbox, path string) (string, map[string]string, error) { if !r.scriptsEnabled() { return "", nil, fmt.Errorf("workflow: 工作流脚本未开启") } timeout := r.deps.ScriptTimeout if timeout <= 0 { timeout = scriptDefaultTimeout } out := codeorch.RunFile(ctx, sandbox, path, codeorch.Limits{ Timeout: timeout, MaxTimeout: scriptMaxTimeout, MaxCalls: scriptMaxCalls, MaxResultBytes: scriptMaxLine, MaxModelChars: 16000, FullDir: sandbox, }, func(c context.Context, dir, scriptPath string) (*exec.Cmd, error) { return tools.ScriptCommand(c, dir, scriptPath) }, func(_ context.Context, call codeorch.Call) (string, error) { output, got, err := r.dispatchScript(call) r.trackAll(got) return output, err }) return out.ModelText(), nil, out.Err } // dispatchScript 脚本回调分发(legacy dispatchScript 同款语义与文案)。 func (r *hostRunner) dispatchScript(req codeorch.Call) (string, []*core.ArtifactRef, error) { switch req.Op { case "tool.call": if _, denied := workflow.DeniedTools[req.Name]; denied { return "", nil, fmt.Errorf("不能调用 %s", req.Name) } res := r.rc.ExecuteTool(core.ToolCallRequest{Name: req.Name, Arguments: req.Args}) if res.Status == "error" { return "", nil, fmt.Errorf("%s", res.Output) } var arts []*core.ArtifactRef if res.Artifact != nil { arts = append(arts, &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}) } return res.Output, arts, nil case "hitl.ask": var choices []core.HITLChoice if len(req.Choices) > 0 { _ = json.Unmarshal(req.Choices, &choices) } card := core.HITLCard{Question: req.Question, Kind: core.HITLKindInput, AllowFreeInput: true} if len(choices) > 0 { card.Kind = core.HITLKindChoice card.AllowFreeInput = false card.Choices = choices for i := range card.Choices { if card.Choices[i].Impact == "" { card.Choices[i].Impact = "会按这个选择继续" } } } resp, err := r.rc.AskHITL(card) if err != nil { return "", nil, err } if strings.TrimSpace(resp.FreeText) != "" { return strings.TrimSpace(resp.FreeText), nil, nil } return resp.ChoiceID, nil, nil case "agent.spawn": sp, ok := r.rc.(Spawner) if !ok { return "", nil, fmt.Errorf("当前运行不能派发子代理") } agent := req.Agent if strings.TrimSpace(agent) == "" { agent = "general" } res, err := sp.SpawnAgent("agent.sub."+agent, req.Title, req.Task, "") if err != nil { return "", nil, err } text := res.Text if text == "" { text = res.Summary } return text, nil, nil case "artifact.register": name := req.Name if name == "" { name = "script.md" } art, err := r.rc.WriteArtifact(name, "document", "text/markdown", []byte(req.Content)) if err != nil { return "", nil, err } return string(art.ID), []*core.ArtifactRef{{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}}, nil case "card.create": kind := core.CardKind(req.Kind) if kind == "" { kind = core.CardKindResult } title := req.Title if title == "" { title = req.Name } created, err := r.rc.CreateCard(core.ActivityCard{Title: title, Kind: kind}) if err != nil { return "", nil, err } return string(created.ID), nil, nil case "step": r.applyScriptPlan(req.StepID, req.Title, req.Status) return req.Status, nil, nil default: return "", nil, fmt.Errorf("未知回调 %s", req.Op) } } // applyScriptPlan 脚本回调 step:更新右栏计划步态(legacy 同款)。 func (r *hostRunner) applyScriptPlan(id, title, status string) { switch status { case "pending", "in_progress", "done", "skipped": default: status = "done" } snap := core.PlanSnapshot{} if src, ok := r.rc.(PlanGetter); ok { if cur, found := src.Plan(); found { snap = cur snap.Steps = append([]core.PlanStep(nil), cur.Steps...) } } found := false for i := range snap.Steps { if snap.Steps[i].ID == id { snap.Steps[i].Status = status if title != "" { snap.Steps[i].Text = title } found = true break } } if !found { snap.Steps = append(snap.Steps, core.PlanStep{ID: id, Text: title, Status: status}) } _ = r.rc.UpdatePlan(snap) } // doCritic 步骤验收(legacy judgeStep 同款提示词与 fail-closed 判定): // verdict 字段写 pass/fail 驱动 Loop 再武装;不通过记 drops 与原因。 func (r *hostRunner) doCritic(ctx context.Context, step squad.IntentStep) (string, map[string]string, error) { target := step.Slots["target_step"] field := step.Slots["verdict_field"] output := r.readStepOutput(step.WorkDir, target) verdict := r.judge(ctx, step.Slots["goal"], step.Slots["criteria"], output) fields := map[string]string{} if verdict.Passed { fields[field] = "pass" r.recordVerdict(field, target, "pass", "") return "验收通过。" + verdict.Reason, fields, nil } why := verdict.Reason if verdict.NextAction != "" { why += "。下一步:" + verdict.NextAction } fields[field] = "fail" fields["reason_"+target] = truncate(why, outFieldCap) fails := r.recordVerdict(field, target, "fail", why) // 评审预算耗尽仍不通过 = 步骤失败(legacy runStepWithCritic 同款语义; // Loop 触顶只停循环不失败,须由宿主判定 fail-closed)。 if maxAtt := parseIntSlot(step.Slots["max_attempts"], 1); fails >= maxAtt { return "", nil, fmt.Errorf("步骤 %s 未通过验收:%s", target, why) } return "验收不通过:" + why, fields, nil } func parseIntSlot(s string, def int) int { n := 0 if strings.TrimSpace(s) == "" { return def } for _, c := range s { if c < '0' || c > '9' { return def } n = n*10 + int(c-'0') } if n <= 0 { return def } return n } // recordVerdict 记录判定;返回该字段的累计不通过次数。 func (r *hostRunner) recordVerdict(field, target, verdict, why string) int { r.mu.Lock() defer r.mu.Unlock() if r.verdicts == nil { r.verdicts = map[string]string{} r.lastWhy = map[string]string{} r.fails = map[string]int{} } r.verdicts[field] = verdict if verdict != "pass" { r.lastWhy[target] = why r.drops = append(r.drops, target+":"+why) r.fails[field]++ return r.fails[field] } return r.fails[field] } // judge 验收判定(legacy judgeStep 移植;JudgeStream 缺席 = 验证器不可用, // 无法解析 = 不通过,均 fail-closed)。 func (r *hostRunner) judge(ctx context.Context, goal, criteria, output string) gates.Verdict { stream := r.deps.JudgeStream if stream == nil { return gates.Verdict{Passed: false, Reason: "验证器不可用", NextAction: "检查轻量模型配置"} } prompt := fmt.Sprintf("你是验收员。目标:%s\n验收标准:%s\n这一步的产出:\n%s\n标准里要求出现的句子,产出里必须逐字包含,否则 passed 必须为 false,nextAction 写出要补上的原句。只输出一个 JSON 对象,字段为 passed、reason、nextAction。", goal, criteria, output) var b strings.Builder for ev := range stream(ctx, "verify", []agentllm.Message{{Role: agentllm.RoleUser, Content: prompt}}, nil, agentllm.StreamOptions{}) { switch ev.Kind { case agentllm.KindTextDelta: b.WriteString(ev.Text) case agentllm.KindError: return gates.Verdict{Passed: false, Reason: "验证器不可用", NextAction: "稍后再试"} case agentllm.KindMessageEnd: if b.Len() == 0 { b.WriteString(ev.Message.Content) } } } verdict, err := gates.ParseVerdict(b.String()) if err != nil { return gates.Verdict{Passed: false, Reason: "无法解析", NextAction: "按验收标准重做"} } return verdict } // readStepOutput 读取某步的主产出(相对本步子工作区回到运行根)。 func (r *hostRunner) readStepOutput(workDir, stepID string) string { sub, ok := r.subOf[stepID] if !ok { return "" } wsDir := filepath.Dir(filepath.Dir(workDir)) data, err := os.ReadFile(filepath.Join(wsDir, filepath.FromSlash(sub), "output.md")) if err != nil { return "" } return string(data) } // renderRefs 运行期解析残留的 {{steps.X.output}} 占位符(宿主步实参/正文; // workDir 为本步子工作区,经其上两级回到运行根再定位目标步产出)。 func (r *hostRunner) renderRefs(s, workDir string) string { if !strings.Contains(s, "{{steps.") { return s } var b strings.Builder for { i := strings.Index(s, "{{steps.") if i < 0 { b.WriteString(s) return b.String() } j := strings.Index(s[i:], "}}") if j < 0 { b.WriteString(s) return b.String() } token := s[i : i+j+2] id := strings.TrimSuffix(strings.TrimPrefix(token, "{{steps."), ".output}}") b.WriteString(s[:i]) b.WriteString(r.readStepOutput(workDir, id)) s = s[i+j+2:] } } func (r *hostRunner) track(art *core.ArtifactRef) { r.mu.Lock() r.arts = append(r.arts, art) r.mu.Unlock() } func (r *hostRunner) trackAll(arts []*core.ArtifactRef) { if len(arts) == 0 { return } r.mu.Lock() r.arts = append(r.arts, arts...) r.mu.Unlock() } func (r *hostRunner) snapshot() (arts []*core.ArtifactRef, drops []string, verdicts map[string]string, why map[string]string) { r.mu.Lock() defer r.mu.Unlock() arts = append([]*core.ArtifactRef(nil), r.arts...) drops = append([]string(nil), r.drops...) verdicts = map[string]string{} for k, v := range r.verdicts { verdicts[k] = v } why = map[string]string{} for k, v := range r.lastWhy { why[k] = v } return } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…(截断)" }