-
Notifications
You must be signed in to change notification settings - Fork 7
fix: 修复 #285 的 memo 非法消息窗口与 provider 错误包装 #287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,10 @@ | |
| - runtime 只负责在结束点调度,不直接执行提取逻辑;实际 debounce、尾随执行与持久化去重由 `internal/memo` 内部处理。 | ||
| - 调度时会绑定当次 provider/model 快照,后台任务不会重新读取全局当前配置,避免把历史会话消息发送到后续切换后的 provider。 | ||
| - 自动提取失败只记日志,不额外发出 TUI 事件,也不影响主链路完成。 | ||
| - `memo` 的最近消息窗口会复用 `internal/context` 的只读投影规则,只保留 provider-safe 的消息序列。 | ||
| - assistant 含 `tool_calls` 时,只有在窗口内能同时保留对应 `tool` 响应时才会注入;缺响应、空内容或已被 micro compact 清空的 assistant/tool 片段会整组丢弃,保留项会先投影为模型可消费的结构化文本。 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 这里的描述少了一个实现条件: |
||
| - recent window 的总消息数有硬预算:`min(limit*2, 24)`;超过预算的整段 tool span 会被跳过,避免窗口体积失控。 | ||
| - 进入 `memo` 提取前,tool 文本会二次收敛为 `content_excerpt`,并按 `600` rune 上限截断。 | ||
|
|
||
| 补充约束: | ||
| - 同一 turn 内的 provider retry 只重放冻结后的 turn 快照,不会重新读取配置。 | ||
|
|
@@ -90,6 +94,8 @@ | |
| - `internal/provider/streaming` 统一累积文本、tool call 增量和 `message_done` | ||
| - runtime 将累积过程映射成 `RuntimeEvent` | ||
| - TUI 使用 Bubble Tea `Cmd` 监听事件,并在处理完成后继续订阅 | ||
| - `provider.GenerateText` 只在上游 `Generate` 成功返回时,才把缺失 `message_done` 视为流式中断。 | ||
| - 如果 provider 在真正开始流式输出前直接返回 HTTP/ProviderError,则优先保留原始错误,不再额外包装成 `message_done` 缺失。 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 这句把错误优先级限定为“真正开始流式前直接返回 ProviderError”。但实现更宽:只要 |
||
|
|
||
| 同一套流式累积逻辑同时复用于: | ||
| - 普通 `Run()` 的 assistant 回复收敛 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| package context | ||
|
|
||
| import ( | ||
| "strings" | ||
| "unicode/utf8" | ||
|
|
||
| providertypes "neo-code/internal/provider/types" | ||
| "neo-code/internal/tools" | ||
| ) | ||
|
|
||
| const ( | ||
| recentWindowAbsoluteMessageLimit = 24 | ||
| recentWindowToolContentCharLimit = 600 | ||
| ) | ||
|
|
||
| // ProjectToolMessagesForModel 原地投影 tool 消息,复用主链路对模型可见的只读格式化规则。 | ||
| func ProjectToolMessagesForModel(messages []providertypes.Message) []providertypes.Message { | ||
| for i := range messages { | ||
| message := messages[i] | ||
| if !isProjectableToolMessage(message) { | ||
| continue | ||
| } | ||
| messages[i].Content = tools.FormatToolMessageForModel(message) | ||
| messages[i].ToolMetadata = nil | ||
| } | ||
| return messages | ||
| } | ||
|
|
||
| // BuildRecentMessagesForModel 从会话尾部构造 provider-safe 的最近消息窗口,避免保留非法 tool call 片段。 | ||
| func BuildRecentMessagesForModel(messages []providertypes.Message, limit int) []providertypes.Message { | ||
| if len(messages) == 0 || limit <= 0 { | ||
| return nil | ||
| } | ||
|
|
||
| keep := make([]bool, len(messages)) | ||
| anchors := 0 | ||
| keptMessages := 0 | ||
| maxMessages := recentWindowMessageBudget(limit) | ||
|
|
||
| markSpan := func(span []int) bool { | ||
| if len(span) == 0 || keptMessages+len(span) > maxMessages { | ||
| return false | ||
| } | ||
| for _, spanIndex := range span { | ||
| keep[spanIndex] = true | ||
| } | ||
| keptMessages += len(span) | ||
| anchors++ | ||
| return true | ||
| } | ||
|
|
||
| for index := len(messages) - 1; index >= 0 && anchors < limit; index-- { | ||
|
fennoai[bot] marked this conversation as resolved.
|
||
| message := messages[index] | ||
| if message.Role == providertypes.RoleTool { | ||
| continue | ||
| } | ||
|
|
||
| if message.Role == providertypes.RoleAssistant && len(message.ToolCalls) > 0 { | ||
| markSpan(matchedToolCallSpan(messages, index)) | ||
| continue | ||
| } | ||
|
|
||
| if !markSpan([]int{index}) { | ||
| break | ||
| } | ||
| } | ||
|
|
||
| selected := make([]providertypes.Message, 0, limit) | ||
| for index, message := range messages { | ||
| if !keep[index] { | ||
| continue | ||
| } | ||
| selected = append(selected, message) | ||
| } | ||
| if len(selected) == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| return sanitizeRecentWindowToolMessages(ProjectToolMessagesForModel(cloneContextMessages(selected))) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| // matchedToolCallSpan 返回 assistant tool call 与其完整 tool 响应组成的合法窗口下标集合。 | ||
| func matchedToolCallSpan(messages []providertypes.Message, assistantIndex int) []int { | ||
| if assistantIndex < 0 || assistantIndex >= len(messages) { | ||
| return nil | ||
| } | ||
|
|
||
| message := messages[assistantIndex] | ||
| if message.Role != providertypes.RoleAssistant || len(message.ToolCalls) == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| required := make(map[string]struct{}, len(message.ToolCalls)) | ||
| for _, call := range message.ToolCalls { | ||
| callID := strings.TrimSpace(call.ID) | ||
| if callID == "" { | ||
| return nil | ||
| } | ||
| required[callID] = struct{}{} | ||
| } | ||
| if len(required) == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| matched := make(map[string]struct{}, len(required)) | ||
| toolIndexes := make([]int, 0, len(required)) | ||
| for index := assistantIndex + 1; index < len(messages); index++ { | ||
| toolMessage := messages[index] | ||
| if toolMessage.Role != providertypes.RoleTool { | ||
| break | ||
| } | ||
| if !isProjectableToolMessage(toolMessage) { | ||
| continue | ||
| } | ||
|
|
||
| callID := strings.TrimSpace(toolMessage.ToolCallID) | ||
| if _, ok := required[callID]; !ok { | ||
| continue | ||
| } | ||
| if _, exists := matched[callID]; exists { | ||
| continue | ||
| } | ||
| matched[callID] = struct{}{} | ||
| toolIndexes = append(toolIndexes, index) | ||
| } | ||
|
|
||
| if len(matched) != len(required) { | ||
| return nil | ||
| } | ||
|
|
||
| span := make([]int, 0, len(toolIndexes)+1) | ||
| span = append(span, assistantIndex) | ||
| span = append(span, toolIndexes...) | ||
| return span | ||
| } | ||
|
|
||
| // isProjectableToolMessage 判断 tool 消息是否满足“可注入且可投影”条件。 | ||
| func isProjectableToolMessage(message providertypes.Message) bool { | ||
| return isInjectableToolMessage(message) && len(message.ToolMetadata) > 0 | ||
| } | ||
|
|
||
| // isInjectableToolMessage 判断 tool 消息是否仍适合作为模型可见上下文继续注入。 | ||
| func isInjectableToolMessage(message providertypes.Message) bool { | ||
| if message.Role != providertypes.RoleTool { | ||
| return false | ||
| } | ||
| content := strings.TrimSpace(message.Content) | ||
| return content != "" && content != microCompactClearedMessage | ||
| } | ||
|
|
||
| // recentWindowMessageBudget 计算 recent window 可保留的消息总数硬上限,避免窗口体积失控。 | ||
| func recentWindowMessageBudget(limit int) int { | ||
| if limit <= 0 { | ||
| return 0 | ||
| } | ||
| budget := limit * 2 | ||
| if budget < limit { | ||
| budget = limit | ||
| } | ||
| if budget > recentWindowAbsoluteMessageLimit { | ||
| budget = recentWindowAbsoluteMessageLimit | ||
| } | ||
| return budget | ||
| } | ||
|
|
||
| // sanitizeRecentWindowToolMessages 缩减 tool 消息内容,降低 memo 提取链路对原始工具输出的暴露面。 | ||
| func sanitizeRecentWindowToolMessages(messages []providertypes.Message) []providertypes.Message { | ||
| for index := range messages { | ||
| message := messages[index] | ||
| if message.Role != providertypes.RoleTool { | ||
| continue | ||
| } | ||
| messages[index].Content = sanitizeProjectedToolContent(message.Content) | ||
| } | ||
| return messages | ||
| } | ||
|
|
||
| // sanitizeProjectedToolContent 将投影后的 tool content 限制为固定长度摘要,避免注入完整原始输出。 | ||
| func sanitizeProjectedToolContent(content string) string { | ||
| const contentMarker = "\ncontent:\n" | ||
|
|
||
| index := strings.Index(content, contentMarker) | ||
| if index < 0 { | ||
| return sanitizeRawToolContent(content) | ||
| } | ||
|
|
||
| prefix := strings.TrimRight(content[:index], "\n") | ||
| body := strings.TrimSpace(content[index+len(contentMarker):]) | ||
| if body == "" { | ||
| return prefix | ||
| } | ||
|
|
||
| limited, truncated := truncateUTF8(body, recentWindowToolContentCharLimit) | ||
| lines := []string{prefix, "content_excerpt:", limited} | ||
| if truncated { | ||
| lines = append(lines, "[content truncated for memo extraction]") | ||
| } | ||
| return strings.Join(lines, "\n") | ||
| } | ||
|
|
||
| // sanitizeRawToolContent 对未命中投影标记的 tool 文本做保底摘要化,避免透传完整原始输出。 | ||
| func sanitizeRawToolContent(content string) string { | ||
| body := strings.TrimSpace(content) | ||
| if body == "" { | ||
| return "" | ||
| } | ||
| limited, truncated := truncateUTF8(body, recentWindowToolContentCharLimit) | ||
| if !truncated { | ||
| return body | ||
| } | ||
| return strings.Join([]string{ | ||
| "content_excerpt:", | ||
| limited, | ||
| "[content truncated for memo extraction]", | ||
| }, "\n") | ||
| } | ||
|
|
||
| // truncateUTF8 按 rune 数量截断字符串,返回截断后的文本及是否发生截断。 | ||
| func truncateUTF8(text string, maxRunes int) (string, bool) { | ||
| if maxRunes <= 0 || text == "" { | ||
| return "", text != "" | ||
| } | ||
| if utf8.RuneCountInString(text) <= maxRunes { | ||
| return text, false | ||
| } | ||
|
|
||
| count := 0 | ||
| for index := range text { | ||
| if count == maxRunes { | ||
| return text[:index], true | ||
| } | ||
| count++ | ||
| } | ||
| return text, false | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.