Files
Agent-System-Backend/internal/wfcompile/host.go
T
agent-exec-transport 4c2db4c89b
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m27s
agent-exec transport baad876f1320016310be2522c373a71b/9d3ed5593bfb
2026-10-22 03:03:31 +00:00

629 lines
21 KiB
Go
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.
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 为本步子工作区,经其上两级回到运行根再定位目标步产出)。
// 信任不变式(本函数不校验任何东西,只依赖调用顺序):直接调用方只有 doTool
// 与 doArtifact,二者所在的宿主步在 Run 里以 strict=true 先跑
// verifyPriorWorkflowOutputs,逐个 prompt 步调用 VerifyProjectStepOutput
// 重校验来源之后才会走到这里;strict 覆盖 wf.tool/wf.artifact/wf.script/
// wf.critic,即所有把上文送模型或落盘的目的步。因此 renderRefs 不会读到
// 未重校验的机器产出。新增不以 strict 运行的调用方(如 wf.card/wf.hitl
// 直读他步产出)会打破该不变式,必须在新增调用点自行校验,不得依赖本函数。
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] + "…(截断)"
}