Why: Flattened "[调用工具 …]" history taught the model to imitate calls and invent artifact ids. A single regex correction then accepted the second false claim. Evidence: ctxasm replay tests cover native tool messages, missing-result pairing, argument compaction, demoted text, and forged-marker stripping. Runtime tests cover forged markers, extension-less claims, two corrections, and the fallback notice. Impact: Assistant history sent to the model no longer contains flattened tool calls. The direct runner corrects a false deliverable twice, then appends a notice instead of accepting the claim. The squad entry still fails closed on the same predicate. base.deliverables is now v5. Refs: feature/agent-harness-p0 Co-authored-by: Cursor <cursoragent@cursor.com>
465 lines
16 KiB
Go
465 lines
16 KiB
Go
// 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"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// 历史窗口(不含当前轮——当前轮单独注入附件与检索)。
|
||
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()})
|
||
|
||
for _, m := range msgs {
|
||
rep.EstChars += len(m.Content)
|
||
}
|
||
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
|
||
}
|