Why: the Q3 compiler maps core.WorkflowDef prompt steps onto squad graph steps; the rendered prompt text needs a first-class carrier in the step task brief (until now only leader delegations had a briefing channel). Evidence: TestStepBriefRendersIntoTaskFile asserts the section presence, body, and position (after targets, before the context table); full squad suite green (SELF), CI authoritative (GATE). Impact: additive StepSpec field brief + writeTaskFile section; no behavior change when Brief is empty. Refs: Q3 plan WP1 (2026-10-07-agent-stack-q2q3-plans.md); Dev task card .task/feature-agent-stack-q3.md
348 lines
12 KiB
Go
348 lines
12 KiB
Go
package squad
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"agent-squad/textextract"
|
||
)
|
||
|
||
// WorkspaceEngine 任务级沙箱工作区引擎。布局与产品 squad 工作区兼容:
|
||
//
|
||
// runs/<run_id>/
|
||
// workspace.json squad.json workflow.json blackboard.json template.md
|
||
// context/{goal.md, materials/, materials_text/, index.json}
|
||
// tasks/<NN>_<role>/{TASK.md, AGENTS.md, ...步骤产物}
|
||
// output.md manifest.json
|
||
type WorkspaceEngine struct {
|
||
// DataDir 数据根(runs/ 的父目录,也用于解析角色 InstructionsFile 相对路径)。
|
||
DataDir string
|
||
}
|
||
|
||
// PrepareWorkspace 创建运行工作区并写入初始文件。
|
||
func (e *WorkspaceEngine) PrepareWorkspace(meta *RunMetadata, plan *SquadPlan) (string, error) {
|
||
wsDir := filepath.Join(e.DataDir, "runs", meta.ID)
|
||
for _, d := range []string{wsDir, filepath.Join(wsDir, "context", "materials"),
|
||
filepath.Join(wsDir, "context", "materials_text"), filepath.Join(wsDir, "tasks")} {
|
||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
meta.WorkDir = wsDir
|
||
meta.UpdatedAt = time.Now()
|
||
// 注意:run 级状态 JSON 不在此处写——由 st.save() 经 Store 端口统一落盘
|
||
// (Q1 WP5:SQLite 后端时这些状态进数据库而非文件)。这里只建目录与
|
||
// 上下文/模板等运行无关状态的工作区资产。
|
||
ctxIdx, err := e.writeContextAssets(wsDir, &meta.Goal, plan)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
_ = ctxIdx
|
||
tmpl := renderTemplate(plan.Template, meta.Goal)
|
||
if tmpl == "" {
|
||
tmpl = defaultTemplate(meta.Goal, plan)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(wsDir, "template.md"), []byte(tmpl), 0o644); err != nil {
|
||
return "", err
|
||
}
|
||
return wsDir, nil
|
||
}
|
||
|
||
// writeContextAssets 写入 goal.md、材料原文与抽取文本、index.json。
|
||
func (e *WorkspaceEngine) writeContextAssets(wsDir string, goal *Goal, plan *SquadPlan) (*ContextIndex, error) {
|
||
var b strings.Builder
|
||
b.WriteString("# 运行目标\n\n")
|
||
b.WriteString(goal.Text + "\n")
|
||
if len(goal.Constraints) > 0 {
|
||
b.WriteString("\n## 约束\n")
|
||
for k, v := range goal.Constraints {
|
||
fmt.Fprintf(&b, "- %s: %s\n", k, v)
|
||
}
|
||
}
|
||
if err := os.WriteFile(filepath.Join(wsDir, "context", "goal.md"), []byte(b.String()), 0o644); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
idx := &ContextIndex{Items: []ContextItem{
|
||
{File: "context/goal.md", Description: "运行目标与约束", AuthorityLevel: "authoritative",
|
||
Guidance: "不可推翻;所有产出须与目标一致"},
|
||
}}
|
||
for _, m := range goal.Materials {
|
||
name := filepath.Base(m.Filename)
|
||
data := m.Content
|
||
if len(data) == 0 && m.Path != "" {
|
||
if d, err := os.ReadFile(m.Path); err == nil {
|
||
data = d
|
||
}
|
||
}
|
||
if err := os.WriteFile(filepath.Join(wsDir, "context", "materials", name), data, 0o644); err != nil {
|
||
return nil, err
|
||
}
|
||
text, err := textextract.ExtractTextWithAnchors(name, data)
|
||
if err != nil {
|
||
text = fmt.Sprintf("(材料抽取失败: %v)", err)
|
||
}
|
||
txtName := strings.TrimSuffix(name, filepath.Ext(name)) + ".txt"
|
||
if err := os.WriteFile(filepath.Join(wsDir, "context", "materials_text", txtName), []byte(text), 0o644); err != nil {
|
||
return nil, err
|
||
}
|
||
idx.Items = append(idx.Items, ContextItem{
|
||
File: "context/materials_text/" + txtName, Description: "输入材料文本(" + name + ")",
|
||
AuthorityLevel: "primary_evidence", Guidance: "引用须标注出处与页码锚点",
|
||
})
|
||
}
|
||
if plan.Template != "" {
|
||
idx.Items = append(idx.Items, ContextItem{
|
||
File: "template.md", Description: "交付物骨架模板", AuthorityLevel: "authoritative",
|
||
Guidance: "按模板章节撰写;不得留空或删除章节",
|
||
})
|
||
}
|
||
if err := writeJSON(filepath.Join(wsDir, "context", "index.json"), idx); err != nil {
|
||
return nil, err
|
||
}
|
||
return idx, nil
|
||
}
|
||
|
||
// UpdateGoalFile 重写 context/goal.md(澄清续跑后目标文本已修订时使用)。
|
||
func (e *WorkspaceEngine) UpdateGoalFile(wsDir string, goal *Goal) error {
|
||
var b strings.Builder
|
||
b.WriteString("# 运行目标\n\n")
|
||
b.WriteString(goal.Text + "\n")
|
||
if len(goal.Constraints) > 0 {
|
||
b.WriteString("\n## 约束\n")
|
||
for k, v := range goal.Constraints {
|
||
fmt.Fprintf(&b, "- %s: %s\n", k, v)
|
||
}
|
||
}
|
||
return os.WriteFile(filepath.Join(wsDir, "context", "goal.md"), []byte(b.String()), 0o644)
|
||
}
|
||
|
||
// BuildSubWorkspaces 为每个 pending 步骤构建子工作区(TASK.md + AGENTS.md +
|
||
// 父级上下文快照)。上下文按 InputLayoutSnapshot 语义复制为子目录本地文件:
|
||
// 执行工具的沙箱只允许子目录内路径,TASK.md 只引用本地名(对齐主仓产品设计)。
|
||
func (e *WorkspaceEngine) BuildSubWorkspaces(wsDir string, plan *SquadPlan, bb *BlackboardState) error {
|
||
for i := range plan.Workflow.Steps {
|
||
step := &plan.Workflow.Steps[i]
|
||
if step.Status != "pending" && step.Status != "" {
|
||
continue // 恢复/续跑时不重建已执行子目录
|
||
}
|
||
role, ok := findAgent(plan.Squad.Agents, step.Role)
|
||
if !ok {
|
||
return fmt.Errorf("step %s references role %s not in squad", step.ID, step.Role)
|
||
}
|
||
sub := step.SubWorkspace
|
||
if sub == "" {
|
||
sub = fmt.Sprintf("tasks/%02d_%s", i+1, step.Role)
|
||
step.SubWorkspace = sub
|
||
}
|
||
subDir := filepath.Join(wsDir, filepath.FromSlash(sub))
|
||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
if err := snapshotContextInto(wsDir, subDir); err != nil {
|
||
return err
|
||
}
|
||
if err := e.writeTaskFile(subDir, step, role, bb); err != nil {
|
||
return err
|
||
}
|
||
if err := e.writeAgentsFile(subDir, role); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// snapshotContextInto 把 context/ 与 template.md 快照复制进子工作区。
|
||
func snapshotContextInto(wsDir, subDir string) error {
|
||
src := filepath.Join(wsDir, "context")
|
||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil || info.IsDir() {
|
||
return err
|
||
}
|
||
rel, err := filepath.Rel(wsDir, path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
dst := filepath.Join(subDir, rel)
|
||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||
return err
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(dst, data, 0o644)
|
||
})
|
||
}
|
||
|
||
func (e *WorkspaceEngine) writeTaskFile(subDir string, step *StepSpec, role RoleSpec, bb *BlackboardState) error {
|
||
idxBytes, _ := os.ReadFile(filepath.Join(subDir, "context", "index.json"))
|
||
idx := &ContextIndex{}
|
||
_ = json.Unmarshal(idxBytes, idx)
|
||
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "# 步骤任务:%s\n\n", step.Title)
|
||
fmt.Fprintf(&b, "- 角色:%s(%s)\n", role.Name, role.Role)
|
||
fmt.Fprintf(&b, "- 期望产出:%v\n\n", step.Targets)
|
||
|
||
// 编译期任务指令(Q3):编译器渲染好的富文本任务书,权威仅次于目标。
|
||
if strings.TrimSpace(step.Brief) != "" {
|
||
b.WriteString("## 任务指令\n\n")
|
||
b.WriteString(strings.TrimSpace(step.Brief))
|
||
b.WriteString("\n\n")
|
||
}
|
||
|
||
b.WriteString("## 任务上下文资源与权威等级(已快照到本目录,只引用本地路径)\n\n")
|
||
b.WriteString("| 资源路径 | 权威等级 | 用途说明 | 使用与引用指导 |\n|---|---|---|---|\n")
|
||
b.WriteString("| `context/goal.md` | **authoritative** | 运行目标 | 不可推翻 |\n")
|
||
for _, it := range idx.Items {
|
||
fmt.Fprintf(&b, "| `%s` | **%s** | %s | %s |\n", it.File, it.AuthorityLevel, it.Description, it.Guidance)
|
||
}
|
||
b.WriteString("\n## 黑板快照(共享状态)\n\n")
|
||
if len(bb.Fields) > 0 {
|
||
b.WriteString("字段事实:\n")
|
||
for k, v := range bb.Fields {
|
||
fmt.Fprintf(&b, "- %s = %s\n", k, v)
|
||
}
|
||
}
|
||
if gaps := bb.UnresolvedBlockingGaps(); len(gaps) > 0 {
|
||
b.WriteString("\n未解决缺口(如无法推进,写入 gaps.json 声明 blocking=true):\n")
|
||
for _, g := range gaps {
|
||
fmt.Fprintf(&b, "- [%s] %s\n", g.ID, g.Item)
|
||
}
|
||
}
|
||
b.WriteString("\n## 产出要求\n\n")
|
||
b.WriteString("1. 按期望产出文件名落盘到本子目录;\n2. 结构化事实回填 `fields.json`(字符串键值);\n")
|
||
b.WriteString("3. 发现信息缺口且无法假设时写 `gaps.json`([{\"item\":\"…\",\"blocking\":true}])。\n")
|
||
return os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(b.String()), 0o644)
|
||
}
|
||
|
||
func (e *WorkspaceEngine) writeAgentsFile(subDir string, role RoleSpec) error {
|
||
instr := e.ResolveInstructions(role)
|
||
if instr == "" {
|
||
instr = defaultInstructions(role)
|
||
}
|
||
return os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(instr), 0o644)
|
||
}
|
||
|
||
// ResolveInstructions 解析角色指令:内联优先,其次指令文件(DataDir 相对或绝对)。
|
||
func (e *WorkspaceEngine) ResolveInstructions(role RoleSpec) string {
|
||
if strings.TrimSpace(role.Instructions) != "" {
|
||
return role.Instructions
|
||
}
|
||
if role.InstructionsFile == "" {
|
||
return ""
|
||
}
|
||
path := role.InstructionsFile
|
||
if !filepath.IsAbs(path) {
|
||
path = filepath.Join(e.DataDir, path)
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return fmt.Sprintf("# 角色指令\n\n(指令文件 %s 读取失败:%v;以下为内联兜底)\n\n%s",
|
||
role.InstructionsFile, err, defaultInstructions(role))
|
||
}
|
||
return string(data)
|
||
}
|
||
|
||
func defaultInstructions(role RoleSpec) string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "# 角色:%s(%s)\n\n", role.Name, role.Role)
|
||
b.WriteString("## 通用守则\n\n")
|
||
b.WriteString("- 缺失的信息若会实质改变结果、执行路径、权限或安全,向人求助(声明阻塞缺口);否则自行决定,并说明你决定了什么。\n")
|
||
b.WriteString("- 严禁假装完成:交付受阻时如实报告阻塞与原因;失败不等于目标完成。\n\n")
|
||
if len(role.Responsibilities) > 0 {
|
||
b.WriteString("## 职责\n")
|
||
for _, r := range role.Responsibilities {
|
||
fmt.Fprintf(&b, "- %s\n", r)
|
||
}
|
||
}
|
||
if len(role.ForbiddenActions) > 0 {
|
||
b.WriteString("\n## 禁止事项\n")
|
||
for _, f := range role.ForbiddenActions {
|
||
fmt.Fprintf(&b, "- %s\n", f)
|
||
}
|
||
}
|
||
if len(role.Skills) > 0 {
|
||
b.WriteString("\n## 技能\n")
|
||
for _, s := range role.Skills {
|
||
fmt.Fprintf(&b, "- %s\n", s)
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func defaultTemplate(goal Goal, plan *SquadPlan) string {
|
||
title := plan.Workflow.Title
|
||
if title == "" {
|
||
title = goal.Text
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "# %s\n\n", title)
|
||
b.WriteString("## 概述与背景\n\n(目标、范围与输入材料概述)\n\n")
|
||
b.WriteString("## 核心内容\n\n(按目标拆解的核心分析/结果章节)\n\n")
|
||
b.WriteString("## 风险与注意事项\n\n(风险、限制与依赖)\n\n")
|
||
b.WriteString("## 基准假设与待确认清单\n\n(假设及需人工确认事项)\n")
|
||
return b.String()
|
||
}
|
||
|
||
// renderTemplate 用 Goal 约束渲染模板 {{key}} 占位符({{goal}} 专指目标文本)。
|
||
func renderTemplate(tmpl string, goal Goal) string {
|
||
if tmpl == "" {
|
||
return ""
|
||
}
|
||
out := strings.ReplaceAll(tmpl, "{{goal}}", goal.Text)
|
||
for k, v := range goal.Constraints {
|
||
out = strings.ReplaceAll(out, "{{"+k+"}}", v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func writeJSON(path string, v any) error {
|
||
data, err := json.MarshalIndent(v, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
data = append(data, '\n')
|
||
return AtomicWrite(path, data)
|
||
}
|
||
|
||
// AtomicWrite 在同目录创建临时文件,写入后 Sync、Close,再 rename 到目标路径。
|
||
func AtomicWrite(path string, data []byte) error {
|
||
dir := filepath.Dir(path)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
f, err := os.CreateTemp(dir, ".tmp-*")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmp := f.Name()
|
||
ok := false
|
||
defer func() {
|
||
if !ok {
|
||
_ = os.Remove(tmp)
|
||
}
|
||
}()
|
||
if _, err := f.Write(data); err != nil {
|
||
_ = f.Close()
|
||
return err
|
||
}
|
||
if err := f.Sync(); err != nil {
|
||
_ = f.Close()
|
||
return err
|
||
}
|
||
if err := f.Close(); err != nil {
|
||
return err
|
||
}
|
||
if err := os.Rename(tmp, path); err != nil {
|
||
return err
|
||
}
|
||
ok = true
|
||
return nil
|
||
}
|