Why: Goal-sensitive workflow verification persisted model prompts and prior output text in trusted receipt rows, while cross-step model inputs could reuse stale machine output. Evidence: RED receipt privacy test; full Go suite, targeted race, isolated PostgreSQL store/runmgr/storage-migrate tests, vet, and static build passed locally before commit. A known unrelated TempDir cleanup flake failed once and passed on targeted and serial full replay. Impact: Add optional same-provider source recheck, verify every included item and evidence body before reuse, gate cross-step outputs, reject unsettled attempts, remove raw scope columns, scrub isolated candidate v34 SQLite/PG schemas, and reject unsafe backups. Refs: Approved RDM stage6 W6 source-recheck design adjustment r1; W6 independent review.
622 lines
21 KiB
Go
622 lines
21 KiB
Go
package wfcompile
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"agent-native/runtime/codeorch"
|
||
"agent-native/runtime/gates"
|
||
agentllm "agent-native/runtime/llm"
|
||
"agent-squad/squad"
|
||
"agent-system-backend/internal/tools"
|
||
"agent-system-backend/internal/workflow"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// ProjectStepProvenance is implemented by the trusted Backend run host. Prompt
|
||
// output is bound after materialization; a critic must verify the exact bytes
|
||
// and current project authorization before it includes output in a model call.
|
||
type ProjectStepProvenance interface {
|
||
InvalidateProjectStepOutput(context.Context, string) error
|
||
BindProjectStepOutput(context.Context, string, string, string, time.Time, []byte) error
|
||
VerifyProjectStepOutput(context.Context, string, string, string, []byte) error
|
||
}
|
||
|
||
// 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/<runID>)。
|
||
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_<id> 字段的截断上限。
|
||
outFieldCap = 2000
|
||
)
|
||
|
||
// hostRunner 宿主 intent 步执行器(squad.IntentRunner 实现)。
|
||
type hostRunner struct {
|
||
rc Host
|
||
deps Deps
|
||
scripts map[string]string
|
||
subOf map[string]string
|
||
stepKinds 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) {
|
||
if r.rc.Run().ProjectID != "" {
|
||
guard, ok := r.rc.(ProjectStepProvenance)
|
||
if !ok {
|
||
return &squad.ExecResult{Success: false, ErrorReason: "project workflow source guard unavailable"}, nil
|
||
}
|
||
wsDir := filepath.Dir(filepath.Dir(step.WorkDir))
|
||
strict := step.IntentID == IntentTool || step.IntentID == IntentArtifact || step.IntentID == IntentScript || step.IntentID == IntentCritic
|
||
if err := verifyPriorWorkflowOutputs(ctx, guard, wsDir, r.subOf, r.stepKinds, strict); err != nil {
|
||
return &squad.ExecResult{Success: false, ErrorReason: err.Error()}, nil
|
||
}
|
||
}
|
||
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_<step_id>),供后续步骤 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)
|
||
if r.rc.Run().ProjectID != "" {
|
||
guard, ok := r.rc.(ProjectStepProvenance)
|
||
if !ok {
|
||
return "", nil, fmt.Errorf("项目工作流产物缺少可信来源校验")
|
||
}
|
||
wsDir := filepath.Dir(filepath.Dir(step.WorkDir))
|
||
stepAttemptID, err := completedStepAttempt(wsDir, target)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("项目工作流步骤 %s 状态不可核验: %w", target, err)
|
||
}
|
||
if err := guard.VerifyProjectStepOutput(ctx, target, stepAttemptID, r.stepKinds[target], []byte(output)); err != nil {
|
||
return "", nil, fmt.Errorf("项目工作流步骤 %s 产物依据不可用: %w", target, err)
|
||
}
|
||
}
|
||
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] + "…(截断)"
|
||
}
|