Files
Agent-System-Backend/internal/ctxasm/ctxasm.go
T
weichen 42cca8fade Store secrets apart from settings and assemble context at the end
Why: Masks must not be the only copy of a key, and personal instructions, lore hits, and candidate replies have to reach the model without rewriting the system prefix or deleting history.
Evidence: go test ./internal/store ./internal/ctxasm ./internal/settings ./internal/runtime ./internal/server. Migration v16 upgrades and rolls back. Startup snapshots settings before moving raw keys.
Impact: One labeled key is active per service. Excluding a message hides its whole run from the model. Regenerating appends another candidate and rewinds files to the user turn. User slash commands expand to prompt text and do not fire on their own.
Refs: docs/records/2026-10-04-sillytavern-review.md
2026-10-04 18:20:10 +00:00

466 lines
16 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 ctxasm 实现上下文组装与预算管理(4.10):分层管线 + 可观测裁剪记录。
package ctxasm
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// Report 记录一次组装的实际构成(可观测:模型看到了什么、裁了什么)。
type Report struct {
Items []string `json:"items"`
Truncations []string `json:"truncations,omitempty"`
EstChars int `json:"estChars"`
Sections []Section `json:"sections,omitempty"`
PrefixHash string `json:"prefixHash,omitempty"`
}
// DefaultMaxAttachmentChars 单个附件注入模型的默认字符上限(settings context.maxAttachmentChars 可调)。
// 16k 时代码值对会议纪要类长文档明显不足(正文大半被静默丢弃);48k ≈ 主流 128k 上下文的安全占比。
const DefaultMaxAttachmentChars = 48000
// DefaultMaxCardChars 单张产物卡回填注入的默认字符上限(settings context.maxCardChars 可调)。
const DefaultMaxCardChars = 12000
// CardResolver 按 ID 取收件箱卡(产物卡全文回填;nil = 无卡服务/无此卡,跳过)。
type CardResolver func(id core.ID) *core.ActivityCard
// ArtifactResolver 按 ID 取工件及其内容(artifact_ref 正文回填;D46 与卡片回填
// 对称)。ok=false = 无工件服务/无此卡/内容不可读,部件留占位行。
type ArtifactResolver func(id core.ID) (art core.Artifact, content []byte, ok bool)
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
// 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。
// prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。
// resolveCard 用于 card 引用部件回填产物卡正文(submit_card;D35:卡片为产物权威
// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。resolveArtifact 同理回填
// artifact_ref 工件正文(D46 统一;仅 UTF-8 文本,超 limit 截断留痕)。
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver) ([]agentllm.Message, *Report) {
rep := &Report{}
msgs := make([]agentllm.Message, 0, len(sys)+1)
msgs = append(msgs, sys...)
rep.Items = append(rep.Items, promptObs...)
if strings.TrimSpace(prof.SystemPrompt) != "" {
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: prof.SystemPrompt})
rep.Items = append(rep.Items, "system(画像自定义段)")
}
maxHist := prof.Context.MaxHistoryMessages
if maxHist <= 0 {
maxHist = int(eff.GetNumber("context.maxHistoryMessages", 40))
}
if maxHist <= 0 {
maxHist = 40
}
history = visibleHistory(history)
// 历史窗口(不含当前轮——当前轮单独注入附件与检索)。
start := 0
if len(history) > maxHist {
start = len(history) - maxHist
if prof.Context.SummarizeBeyond {
summary := extractiveSummary(history[:start])
if summary != "" {
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: "[更早对话摘要]\n" + summary})
rep.Items = append(rep.Items, fmt.Sprintf("summary(%d)", start))
}
}
rep.Truncations = append(rep.Truncations, fmt.Sprintf("历史 %d 条超窗口 %d,仅保留最近部分", len(history), maxHist))
}
limitCard := int(eff.GetNumber("context.maxCardChars", DefaultMaxCardChars))
if limitCard <= 0 {
limitCard = DefaultMaxCardChars
}
cardsBackfilled := 0
artsBackfilled := 0
for _, m := range history[start:] {
if m.ID == currentUser.ID {
continue
}
if m.Role == core.RoleAssistant {
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)...)
continue
}
content := renderMessage(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)
if content == "" {
continue
}
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: content})
}
rep.Items = append(rep.Items, fmt.Sprintf("history(%d)", len(history)-start))
if cardsBackfilled > 0 {
rep.Items = append(rep.Items, fmt.Sprintf("card(%d)", cardsBackfilled))
}
if artsBackfilled > 0 {
rep.Items = append(rep.Items, fmt.Sprintf("artifact(%d)", artsBackfilled))
}
// 当前轮:文本 + 附件解析结果 + 知识检索注入。
var sb strings.Builder
for _, p := range currentUser.Parts {
if p.Type == core.PartText {
sb.WriteString(p.Text)
}
}
if len(attachments) > 0 {
limit := int(eff.GetNumber("context.maxAttachmentChars", DefaultMaxAttachmentChars))
if limit <= 0 {
limit = DefaultMaxAttachmentChars
}
sb.WriteString("\n\n[附件](用户上传文档的解析全文,按原文顺序保留标题/列表/表格;超长时文末标注截断。请依据文档内容完成任务,输出完整不省略)\n")
for _, doc := range attachments {
text := doc.PlainText()
if runes := []rune(text); len(runes) > limit {
text = string(runes[:limit]) + "…(附件超长截断)"
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
}
fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text)
}
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
}
if len(knowledge) > 0 {
sb.WriteString("\n\n[相关知识]\n")
for i, h := range knowledge {
fmt.Fprintf(&sb, "[%d] %s(来源 %s)\n%s\n", i+1, h.Doc.Title, h.Doc.Source, h.Doc.Chunk)
}
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
}
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()})
fillEstimate(msgs, rep)
return msgs, rep
}
// maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。
const maxToolArgRunes = 4000
// forgedMarkerLine 匹配模型在正文里模仿出来的调用/结果/工件行。回放时整行剔除,
// 避免下一轮把这种文本当成「以前就是这样调用工具」的格式。
var forgedMarkerLine = regexp.MustCompile(`(?m)^[^\n]*\[(?:调用工具|工具结果|产出工件) [^\n]*\n?`)
// renderMessage 把用户消息部件渲染为模型可读文本(思考部件不回灌;
// card 引用回填产物卡正文、artifact_ref 回填工件正文——截断记入 rep.Truncations)。
// 助手消息不走这里:工具调用必须保持提供方的原生消息,见 renderAssistantTurns。
func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) string {
var sb strings.Builder
for _, p := range m.Parts {
switch p.Type {
case core.PartText:
sb.WriteString(stripForgedMarkers(p.Text))
sb.WriteString("\n")
case core.PartArtifactRef:
sb.WriteString(renderArtifactRef(p.Artifact, resolveArtifact, limitCard, rep, artsBackfilled))
case core.PartHITL:
if p.Hitl != nil && p.Hitl.Resolved {
fmt.Fprintf(&sb, "[用户确认:%s → %s]\n", p.Hitl.Question, p.Hitl.Answer)
}
case core.PartCard:
sb.WriteString(renderCardPart(p.Card, resolveCard, limitCard, rep, cardsBackfilled))
}
}
return strings.TrimSpace(sb.String())
}
// renderAssistantTurns 把一条助手消息切成提供方可接受的若干段:
// 前置文本与 tool_calls 组成一条 assistant,每个调用紧跟一条 tool 结果。
// 没有结果的调用补一条失败占位,避免模型把中断当成成功。孤立的结果直接跳过。
// 被守卫降级、且后面不是工具调用的过程文本不回放。
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) []agentllm.Message {
var out []agentllm.Message
var text strings.Builder
var calls []agentllm.ToolCall
results := map[string]core.ToolResultPart{}
seq := 0
flush := func() {
content := strings.TrimSpace(text.String())
if content == "" && len(calls) == 0 {
text.Reset()
results = map[string]core.ToolResultPart{}
return
}
out = append(out, agentllm.Message{Role: agentllm.RoleAssistant, Content: content, ToolCalls: calls})
for _, c := range calls {
body := "(无结果:运行中断)"
isErr := true
if res, ok := results[c.ID]; ok {
body = res.Summary
isErr = res.Status == "error"
}
out = append(out, agentllm.Message{
Role: agentllm.RoleTool,
ToolCallID: c.ID,
Name: c.Name,
Content: body,
IsError: isErr,
})
}
text.Reset()
calls = nil
results = map[string]core.ToolResultPart{}
}
for i, p := range m.Parts {
switch p.Type {
case core.PartText:
if demotedProcessText(m.Parts, i) {
continue
}
cleaned := stripForgedMarkers(p.Text)
if strings.TrimSpace(cleaned) == "" {
continue
}
if len(calls) > 0 {
flush()
}
text.WriteString(cleaned)
text.WriteString("\n")
case core.PartToolCall:
if p.ToolCall == nil {
continue
}
id := p.ToolCall.ID
if id == "" {
id = fmt.Sprintf("hist_%s_%d", m.ID, seq)
seq++
}
calls = append(calls, agentllm.ToolCall{
ID: id,
Name: p.ToolCall.Name,
Arguments: compactToolArguments(p.ToolCall.Arguments),
})
case core.PartToolResult:
if p.ToolResult == nil {
continue
}
id := p.ToolResult.CallID
if id == "" {
for _, c := range calls {
if _, ok := results[c.ID]; !ok {
id = c.ID
break
}
}
}
if id == "" || !hasCall(calls, id) {
continue
}
results[id] = *p.ToolResult
case core.PartArtifactRef:
if len(calls) > 0 {
flush()
}
text.WriteString(renderArtifactRef(p.Artifact, resolveArtifact, limitCard, rep, artsBackfilled))
case core.PartHITL:
if p.Hitl == nil || !p.Hitl.Resolved {
continue
}
if len(calls) > 0 {
flush()
}
fmt.Fprintf(&text, "[用户确认:%s → %s]\n", p.Hitl.Question, p.Hitl.Answer)
case core.PartCard:
if len(calls) > 0 {
flush()
}
text.WriteString(renderCardPart(p.Card, resolveCard, limitCard, rep, cardsBackfilled))
}
}
flush()
return out
}
func hasCall(calls []agentllm.ToolCall, id string) bool {
for _, c := range calls {
if c.ID == id {
return true
}
}
return false
}
// demotedProcessText 是守卫降级后的谎称正文:过程文本,且后面紧跟的不是工具调用。
// 工具轮的解说也标了过程,但它后面就是 tool_call,要保留。
func demotedProcessText(parts []core.Part, i int) bool {
if parts[i].Type != core.PartText || !parts[i].Process {
return false
}
return i+1 >= len(parts) || parts[i+1].Type != core.PartToolCall
}
func stripForgedMarkers(text string) string {
return strings.TrimSpace(forgedMarkerLine.ReplaceAllString(text, ""))
}
// compactToolArguments 在参数超限时截断其中的长字符串。解析失败或截断后仍超限时,
// 换成仍可被提供方接受的占位对象,而不是一段被截断的非法 JSON。
func compactToolArguments(raw string) string {
if utf8.RuneCountInString(raw) <= maxToolArgRunes {
return raw
}
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return fmt.Sprintf(`{"_omitted":"参数 %d 字符"}`, utf8.RuneCountInString(raw))
}
truncateLongStrings(&v, 500)
encoded, err := json.Marshal(v)
if err != nil || utf8.RuneCountInString(string(encoded)) > maxToolArgRunes {
return fmt.Sprintf(`{"_omitted":"参数 %d 字符"}`, utf8.RuneCountInString(raw))
}
return string(encoded)
}
func truncateLongStrings(v *any, keep int) {
switch x := (*v).(type) {
case map[string]any:
for k, val := range x {
truncateLongStrings(&val, keep)
x[k] = val
}
case []any:
for i := range x {
truncateLongStrings(&x[i], keep)
}
case string:
n := utf8.RuneCountInString(x)
if n > keep {
*v = string([]rune(x)[:keep]) + fmt.Sprintf("…(已省略 %d 字符)", n-keep)
}
}
}
// renderCardPart 渲染 card 部件:引用部件(cardId)回填产物卡全文(超限截断留痕);
// 无 resolver 或无此卡时留占位(可见即信号,不静默丢弃)。
func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep *Report, cardsBackfilled *int) string {
if cp == nil {
return ""
}
if cp.CardID == "" || resolveCard == nil {
return fmt.Sprintf("[卡片 %s(内容见收件箱)]\n", cp.Kind)
}
card := resolveCard(cp.CardID)
if card == nil {
return fmt.Sprintf("[卡片 %s(内容见收件箱)]\n", cp.Kind)
}
*cardsBackfilled++
var sb strings.Builder
fmt.Fprintf(&sb, "[产物卡片:%s]\n", card.Title)
total := 0
for _, n := range card.Body {
switch n.Type {
case "markdown":
runes := []rune(n.Markdown)
if total+len(runes) > limit {
keep := limit - total
if keep < 0 {
keep = 0
}
rep.Truncations = append(rep.Truncations, fmt.Sprintf("卡片 %s 超 %d 字符截断", card.Title, limit))
sb.WriteString(string(runes[:keep]))
sb.WriteString("…(卡片超长截断)\n")
return sb.String()
}
total += len(runes)
sb.WriteString(n.Markdown)
sb.WriteString("\n")
case "kv":
for _, kv := range n.KV {
fmt.Fprintf(&sb, "%s:%s\n", kv[0], kv[1])
}
}
}
return sb.String()
}
// renderArtifactRef 渲染 artifact_ref 部件:有 resolver 且内容为 UTF-8 文本时
// 回填正文(与卡同 limit,超限截断留痕);二进制/不可读/无 resolver 留占位行
// (可见即信号,不静默丢弃)。
func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string {
if a == nil {
return ""
}
if resolve == nil {
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
}
art, content, ok := resolve(a.ArtifactID)
if !ok {
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
}
if !utf8.Valid(content) {
return fmt.Sprintf("[产出工件 %s(二进制内容,正文见工件查看器)]\n", art.Name)
}
*backfilled++
text := string(content)
if runes := []rune(text); len(runes) > limit {
text = string(runes[:limit]) + "…(工件超长截断)"
rep.Truncations = append(rep.Truncations, fmt.Sprintf("工件 %s 超 %d 字符截断", art.Name, limit))
}
return fmt.Sprintf("[产出工件 %s]\n%s\n", art.Name, text)
}
// extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。
func extractiveSummary(dropped []core.Message) string {
var lines []string
for _, m := range dropped {
var text string
for _, p := range m.Parts {
if p.Type == core.PartText && strings.TrimSpace(p.Text) != "" {
text = strings.TrimSpace(p.Text)
break
}
}
if text == "" {
continue
}
runes := []rune(text)
if len(runes) > 80 {
text = string(runes[:80]) + "…"
}
role := "用户"
if m.Role == core.RoleAssistant {
role = "助手"
}
lines = append(lines, role+":"+text)
if len(lines) >= 12 {
break
}
}
return strings.Join(lines, "\n")
}
// InjectMemory 把未过期记忆作为系统段放到提示词之后。空列表不改。
func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm.Message {
if len(entries) == 0 {
return sys
}
var b strings.Builder
b.WriteString("[记忆]\n")
n := 0
for _, e := range entries {
if e.ExpiresAt != nil && e.ExpiresAt.Before(time.Now()) {
continue
}
if strings.TrimSpace(e.Content) == "" {
continue
}
fmt.Fprintf(&b, "- (%s) %s\n", e.Type, strings.TrimSpace(e.Content))
n++
if n >= 20 {
break
}
}
if n == 0 {
return sys
}
out := make([]agentllm.Message, 0, len(sys)+1)
out = append(out, sys...)
out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
return out
}