Files
Agent-System-Backend/internal/runmgr/subagent_transcript.go
T
weichen 43297795f5
ci / vet-and-test (pull_request) Successful in 48s
Keep original materials and cleared results reachable across turns
Why: Real-run before/after evidence (phase01 card) showed later turns never
re-read the uploaded source (0 original reads in plan-followup) because the
attachment only existed in the current turn's injection and the context window
hard default was 32000 chars - a 25k-char transcript tripped the clear-on-first
request, replacing old tool results with a pointer the model has no tool to
follow. MiniMax is an interleaved-thinking model whose docs require <think>
blocks in assistant history; dropping them degrades later turns.

Evidence: SELF - new tests TestThinkReplayWrapsAssistantHistory,
TestContextWindowScalesFromTokens, TestThinkReplayEnabled,
TestPersistInputTextsAndListWorkspaceInputs; go test ./internal/... passes;
live before/after comparison on the golden task set recorded on the phase02
task card per the new policy clause.

Impact: models.chat.contextWindow/outputReserve/contextBuffer (tokens,
defaults 128k/16k/4k) drive the in-run clear threshold, converted to chars;
cleared tool results spill to <sandbox>/.agent/results with readable paths;
attachment parse text persists as inputs/<name>.md and the env section lists
all workspace inputs every turn; context.thinkReplay (auto|on|off, auto =
MiniMax) replays thinking blocks into request-side assistant history only.
2026-10-09 14:28:39 +00:00

175 lines
6.1 KiB
Go

package runmgr
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
core "agent-system-core"
)
// subagent_transcript.go — 子代理独立转录与中断续跑(N2 WP5)。
// 每个子 run 一个目录:<workspaceRoot>/<owner>/<conv>/subagents/<runID>/
// request.json 派生请求(agentID/name/task/hint/detach),续跑的重建凭据
// transcript.jsonl 按 part 追加的转录(完成/中断时落定)
// done.json 终态标记(resume 幂等:有它不再续跑)
// resumed.json 已续跑标记(指向新 run)
// subagentDir 返回子 run 的转录目录(未配置 workspaceRoot 时为空,写盘静默跳过)。
func (m *Manager) subagentDir(owner, convID, runID string) string {
if m.workspaceRoot == "" {
return ""
}
return filepath.Join(m.workspaceRoot, filepath.Clean(owner), filepath.Clean(convID), "subagents", filepath.Clean(runID))
}
// subagentRequest 是 request.json 的内容。
type subagentRequest struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
Task string `json:"task"`
Hint string `json:"hint,omitempty"`
Detach bool `json:"detach"`
CreatedAt string `json:"createdAt"`
}
// writeSubagentRequest 在派生时落 request.json 与空转录(失败只记日志,
// 不阻断派生——转录是辅助面)。
func (m *Manager) writeSubagentRequest(run core.Run, agentID, name, task, hint string, detach bool) {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Printf("[runmgr] subagent dir %s: %v", run.ID, err)
return
}
body, _ := json.MarshalIndent(subagentRequest{
AgentID: agentID, Name: name, Task: task, Hint: hint, Detach: detach,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}, "", " ")
if err := os.WriteFile(filepath.Join(dir, "request.json"), body, 0o644); err != nil {
log.Printf("[runmgr] subagent request %s: %v", run.ID, err)
}
// 转录文件先建(append 语义清晰可见)。
if _, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644); err != nil {
log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err)
}
}
// writeSubagentTranscript 把本次收集到的 parts 追加进转录并落 done 标记。
// status 是终态(completed/failed/cancelled/interrupted)。
func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, status string) {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
log.Printf("[runmgr] transcript open %s: %v", run.ID, err)
return
}
enc := json.NewEncoder(f)
for i, p := range parts {
_ = enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)})
}
_ = f.Close()
done, _ := json.Marshal(map[string]string{"status": status, "endedAt": time.Now().UTC().Format(time.RFC3339)})
if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil {
log.Printf("[runmgr] done marker %s: %v", run.ID, err)
}
}
// doneStatus 读 done.json 的 status(无文件或不可读按 "interrupted" 处理——
// 缺标记本身就是中断现场)。
func doneStatus(dir string) string {
body, err := os.ReadFile(filepath.Join(dir, "done.json"))
if err != nil {
return "interrupted"
}
var done struct {
Status string `json:"status"`
}
if json.Unmarshal(body, &done) != nil || done.Status == "" {
return "interrupted"
}
return done.Status
}
// readSubagentRequest 读 request.json(无文件返回 nil, nil)。
func readSubagentRequest(dir string) (*subagentRequest, error) {
body, err := os.ReadFile(filepath.Join(dir, "request.json"))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var req subagentRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("request.json: %w", err)
}
return &req, nil
}
// ResumeInterruptedChildren 重启后扫描被中断的子代理 run:有派生请求且
// 未完成未续跑的,用原请求重新派生(后台),并把前次转录路径写进 hint
// 供模型读取后继续(N2 WP5 的「可恢复」)。幂等:done/resumed 标记跳过。
func (m *Manager) ResumeInterruptedChildren() {
runs, err := m.st.ListInterruptedSubagentRuns()
if err != nil {
log.Printf("[runmgr] resume scan: %v", err)
return
}
for _, run := range runs {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
continue
}
// done.json 语义分流(N3 WP1):终态(completed/failed/cancelled)挡续跑;
// interrupted 不挡——优雅停机与崩溃都是可续跑现场。
if doneStatus(dir) != "interrupted" || fileExists(filepath.Join(dir, "resumed.json")) {
continue
}
req, err := readSubagentRequest(dir)
if err != nil {
log.Printf("[runmgr] resume read %s: %v", run.ID, err)
continue
}
if req == nil {
continue // WP5 之前的旧 run 没有重建凭据,保持 interrupted 终态
}
parent, err := m.st.GetRunByID(run.ParentRunID)
if err != nil {
log.Printf("[runmgr] resume parent %s: %v", run.ParentRunID, err)
continue
}
hint := req.Hint + "\n(本任务是前次中断后的自动续跑;前次转录在 " +
filepath.Join(dir, "transcript.jsonl") + ",可读取后继续。)"
newID, err := m.spawnBackground(parent, req.AgentID, req.Name, req.Task, hint)
if err != nil {
log.Printf("[runmgr] resume spawn %s: %v", run.ID, err)
continue
}
marker, _ := json.Marshal(map[string]string{
"newRun": newID,
"resumedAt": time.Now().UTC().Format(time.RFC3339),
})
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
log.Printf("[runmgr] resumed marker %s: %v", run.ID, err)
}
log.Printf("[runmgr] resumed subagent %s as %s", run.ID, newID)
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}