ci / vet-and-test (pull_request) Successful in 48s
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.
149 lines
4.5 KiB
Go
149 lines
4.5 KiB
Go
package runmgr
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
// WorkspaceFile 是要落到会话工作区 inputs/ 的原件(上传文件或默认模板)。
|
|
type WorkspaceFile struct {
|
|
Name string
|
|
Content []byte
|
|
}
|
|
|
|
// StageWorkspaceInputs 把原件和可选默认模板写到 <root>/<owner>/<conversation>/inputs/。
|
|
// root 为空时不写(工作区未配置)。返回工作区相对路径。同一批里重名的文件加序号。
|
|
func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte) ([]string, error) {
|
|
if strings.TrimSpace(root) == "" {
|
|
return nil, nil
|
|
}
|
|
dir := filepath.Join(root, owner, conversation, "inputs")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
var rels []string
|
|
used := map[string]bool{}
|
|
for _, file := range files {
|
|
name, ok := safeInputName(file.Name)
|
|
if !ok || len(file.Content) == 0 {
|
|
continue
|
|
}
|
|
name = uniqueInputName(name, used)
|
|
if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil {
|
|
return rels, err
|
|
}
|
|
used[name] = true
|
|
rels = append(rels, "inputs/"+name)
|
|
}
|
|
if len(template) > 0 {
|
|
ext := filepath.Ext(templateName)
|
|
base, ok := safeInputName(templateName)
|
|
if ok {
|
|
ext = filepath.Ext(base)
|
|
}
|
|
name := "_default-template" + ext
|
|
if err := os.WriteFile(filepath.Join(dir, name), template, 0o644); err != nil {
|
|
return rels, err
|
|
}
|
|
rels = append(rels, "inputs/"+name)
|
|
}
|
|
return rels, nil
|
|
}
|
|
|
|
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
|
|
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
|
|
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
|
|
// 原本就是文本类(md/txt/csv)的跳过;解析文本过短(<200 字)视为解析失败跳过。
|
|
func PersistInputTexts(root, owner, conversation string, attachments []core.ParsedDocument) {
|
|
if strings.TrimSpace(root) == "" {
|
|
return
|
|
}
|
|
dir := filepath.Join(root, owner, conversation, "inputs")
|
|
for _, doc := range attachments {
|
|
name := strings.TrimSpace(doc.Meta.Filename)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
switch strings.ToLower(filepath.Ext(name)) {
|
|
case ".md", ".txt", ".csv", ".json":
|
|
continue
|
|
}
|
|
text := strings.TrimSpace(doc.PlainText())
|
|
if utf8.RuneCountInString(text) < 200 {
|
|
continue
|
|
}
|
|
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
|
out := filepath.Join(dir, stem+".md")
|
|
if _, err := os.Stat(out); err == nil {
|
|
continue // 已有同名解析文本(同一附件重发)不覆盖
|
|
}
|
|
if err := os.WriteFile(out, []byte(text), 0o644); err == nil {
|
|
_ = out
|
|
}
|
|
}
|
|
}
|
|
|
|
// ListWorkspaceInputs 列出会话工作区 inputs/ 目录的相对路径(原样返回给环境段)。
|
|
// 覆盖「本轮上传」视角:跨轮可见所有原件与解析文本。目录不存在时回落 staging 列表。
|
|
func ListWorkspaceInputs(root, owner, conversation string, staged []string) []string {
|
|
dir := filepath.Join(root, owner, conversation, "inputs")
|
|
ents, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return staged
|
|
}
|
|
var out []string
|
|
for _, e := range ents {
|
|
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
out = append(out, "inputs/"+e.Name())
|
|
}
|
|
if len(out) == 0 {
|
|
return staged
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) {
|
|
var templateName string
|
|
var template []byte
|
|
if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil {
|
|
if art, err := m.st.GetArtifact(conv.Owner, id); err == nil {
|
|
if body, err := m.st.ArtifactContent(conv.Owner, id); err == nil && len(body) > 0 {
|
|
templateName, template = art.Name, body
|
|
}
|
|
}
|
|
}
|
|
return StageWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template)
|
|
}
|
|
|
|
func safeInputName(name string) (string, bool) {
|
|
base := filepath.Base(strings.TrimSpace(name))
|
|
if base == "" || base == "." || base == ".." || strings.HasPrefix(base, ".") {
|
|
return "", false
|
|
}
|
|
if strings.ContainsAny(base, `/\`) {
|
|
return "", false
|
|
}
|
|
return base, true
|
|
}
|
|
|
|
func uniqueInputName(name string, used map[string]bool) string {
|
|
if !used[name] {
|
|
return name
|
|
}
|
|
ext := filepath.Ext(name)
|
|
stem := strings.TrimSuffix(name, ext)
|
|
for n := 2; ; n++ {
|
|
candidate := fmt.Sprintf("%s-%d%s", stem, n, ext)
|
|
if !used[candidate] {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|