Files
Agent-System-Backend/internal/ctxasm/ctxasm.go
T
weichenandCursor cfde38de31 Send current-turn images and keep vision fallbacks capable
Why: Uploaded images never reached the model, so a text-only chat model could be asked to read a picture it cannot see.

Evidence: go test ./internal/... passed locally before this commit. Image-link rejection is covered by vision package tests.

Impact: Current-turn attachments and fetched image links become image input. History keeps a text placeholder. Image turns swap to the configured vision model when the chat model is not in the capable list, and fallbacks are filtered the same way.

Refs: Agent-System-Dev feature/four-model-vision
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 17:51:17 +00:00

590 lines
20 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"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/vision"
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)
// AssembleOptions 是组装的可选行为。零值全部关闭(向后兼容)。
type AssembleOptions struct {
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
ThinkReplay bool
// Surfaces 是当前会话最近保存的生成页面(U3/R6):以语义投影并入上下文,
// 使下一轮模型能解释并修改已有结构(修改走 generative-ui.present 重提)。
Surfaces []core.SurfaceSnapshot
}
// 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, opts AssembleOptions) ([]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(画像自定义段)")
}
if len(opts.Surfaces) > 0 {
var b strings.Builder
b.WriteString("[本会话已保存的生成页面(结构摘要;正文以预览区为准)]\n")
for _, snap := range opts.Surfaces {
b.WriteString(projectSurface(snap))
b.WriteString("\n")
}
b.WriteString("用户要求调整页面时,按结构摘要理解现状并用 generative-ui.present 提交修订(带 surfaceId 与 baseRevision)。")
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
rep.Items = append(rep.Items, fmt.Sprintf("surface(%d)", len(opts.Surfaces)))
}
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, opts)...)
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))
}
// 当前轮:文本 + 附件解析结果 + 知识检索注入。
msgs = append(msgs, BuildCurrentUser(eff, currentUser, attachments, knowledge, rep))
fillEstimate(msgs, rep)
return msgs, rep
}
// BuildCurrentUser 组装当前轮的用户消息(文本部件 + 附件解析全文 + 知识检索)。
// 会话转录为权威上下文时,每轮只构建这一条消息,历史由转录承载——
// 此处与整段组装共用同一实现,保证两条路径的当前轮语义一致。
func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, rep *Report) agentllm.Message {
var sb strings.Builder
var images []agentllm.Image
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 {
if pics := vision.ImagesFromDoc(doc); len(pics) > 0 {
kept, notes := vision.Admit(images, pics)
images = append(images, kept...)
if len(kept) > 0 {
fmt.Fprintf(&sb, "### %s\n[图片 %s,图像已随本消息发送]\n", doc.Meta.Filename, doc.Meta.Filename)
} else {
fmt.Fprintf(&sb, "### %s\n[图片 %s,本轮未发送图像]\n", doc.Meta.Filename, doc.Meta.Filename)
}
for _, note := range notes {
fmt.Fprintf(&sb, "%s\n", note)
if rep != nil {
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s:%s", doc.Meta.Filename, note))
}
}
continue
}
text := doc.PlainText()
if runes := []rune(text); len(runes) > limit {
text = string(runes[:limit]) + "…(附件超长截断)"
if rep != nil {
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)
}
if rep != nil {
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)
}
if rep != nil {
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
}
}
return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String(), Images: images}
}
// 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, opts AssembleOptions) []agentllm.Message {
var out []agentllm.Message
var text strings.Builder
var think strings.Builder
var calls []agentllm.ToolCall
results := map[string]core.ToolResultPart{}
seq := 0
flush := func() {
content := strings.TrimSpace(text.String())
thinkText := strings.TrimSpace(think.String())
if opts.ThinkReplay && thinkText != "" {
// 交错思考模型要求历史 assistant 原样带 <think>;只进请求,不落盘。
content = "<think>\n" + thinkText + "\n</think>\n\n" + content
}
if content == "" && len(calls) == 0 {
text.Reset()
think.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()
think.Reset()
calls = nil
results = map[string]core.ToolResultPart{}
}
for i, p := range m.Parts {
switch p.Type {
case core.PartThinking:
if strings.TrimSpace(p.Text) != "" {
think.WriteString(p.Text)
think.WriteString("\n")
}
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 留占位行
// (可见即信号,不静默丢弃)。
// 占位文案不带「[产出工件 …]」方括号形态:那正是 ClaimGate 用来识别伪造调用
// 标记的模式——历史里自己产出这种格式,下一轮模型照抄就会被终轮检查误判为
// 「正文里出现了工具调用标记」。
func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string {
if a == nil {
return ""
}
name := a.Name
if isImageArtifact(a.Kind, name) {
return fmt.Sprintf("此前上传的图片:%s。\n", name)
}
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)
}
func isImageArtifact(kind, name string) bool {
if kind == "image" {
return true
}
switch strings.ToLower(filepath.Ext(name)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg":
return true
default:
return false
}
}
// 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
}
// projectSurface 是 ctxasm 侧的结构摘要投影(与 surfacesvc.ProjectSurface 同义;
// 独立实现避免 ctxasm→surfacesvc 依赖边)。
func projectSurface(snap core.SurfaceSnapshot) string {
var b strings.Builder
fmt.Fprintf(&b, "- 页面 %s(修订 %d)%q", string(snap.Identity.SurfaceID), snap.Revision, snap.Spec.Title)
if snap.SemanticText != "" {
fmt.Fprintf(&b, ":%s", snap.SemanticText)
}
b.WriteString("\n 结构:")
var walk func(n core.UINode)
first := true
walk = func(n core.UINode) {
label := string(n.Type)
if n.Title != "" {
label += "(\"" + n.Title + "\")"
}
if n.ID != "" {
label += "#" + n.ID
}
if len(n.Source) > 0 {
label += "[" + strings.Join(n.Source, ",") + "]"
}
if first {
b.WriteString(label)
first = false
} else {
b.WriteString(" > " + label)
}
for _, c := range n.Children {
walk(c)
}
}
for _, n := range snap.Spec.Nodes {
walk(n)
}
b.WriteString("\n 修订此页:present 带 surfaceId=" + string(snap.Identity.SurfaceID) + " 与 baseRevision=" + fmt.Sprint(snap.Revision) + "。")
return b.String()
}