Files
weichen d6669cd9c8
ci / vet-and-test (pull_request) Successful in 25s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Add StepSpec.Brief rendered as the task-instruction section of TASK.md
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
2026-10-08 02:10:03 +00:00

348 lines
12 KiB
Go
Raw Permalink 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 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
}