// Package ctxasm 实现上下文组装与预算管理(4.10):分层管线 + 可观测裁剪记录。 package ctxasm import ( "encoding/json" "fmt" "regexp" "strings" "time" "unicode/utf8" agentllm "git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/llm" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) // 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 消息的思考部件以 块原样回传 //(交错思考模型如 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 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]) + "…(附件超长截断)" 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()} } // 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 原样带 ;只进请求,不落盘。 content = "\n" + thinkText + "\n\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 "" } 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 } // 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() }