Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
- runtime 只负责在结束点调度,不直接执行提取逻辑;实际 debounce、尾随执行与持久化去重由 `internal/memo` 内部处理。
- 调度时会绑定当次 provider/model 快照,后台任务不会重新读取全局当前配置,避免把历史会话消息发送到后续切换后的 provider。
- 自动提取失败只记日志,不额外发出 TUI 事件,也不影响主链路完成。
- `memo` 的最近消息窗口会复用 `internal/context` 的只读投影规则,只保留 provider-safe 的消息序列。
Comment thread
fennoai[bot] marked this conversation as resolved.
- assistant 含 `tool_calls` 时,只有在窗口内能同时保留对应 `tool` 响应时才会注入;缺响应、空内容或已被 micro compact 清空的 assistant/tool 片段会整组丢弃,保留项会先投影为模型可消费的结构化文本。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里的描述少了一个实现条件:tool 消息还需要满足“可投影”(ToolMetadata 非空)才会保留。当前文案只写了“缺响应/空内容/cleared”会丢弃,和 matchedToolCallSpan 的真实过滤条件不完全一致。建议补一句,避免读者误解。

- recent window 的总消息数有硬预算:`min(limit*2, 24)`;超过预算的整段 tool span 会被跳过,避免窗口体积失控。
- 进入 `memo` 提取前,tool 文本会二次收敛为 `content_excerpt`,并按 `600` rune 上限截断。

补充约束:
- 同一 turn 内的 provider retry 只重放冻结后的 turn 快照,不会重新读取配置。
Expand Down Expand Up @@ -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` 缺失。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这句把错误优先级限定为“真正开始流式前直接返回 ProviderError”。但实现更宽:只要 Generate(...) 返回错误,且流侧唯一问题是 message_done 缺失,就会优先返回原始 provider 错误(包括已有部分 text_delta 的情况)。建议把表述改成与 internal/provider/generate.go 当前逻辑一致。


同一套流式累积逻辑同时复用于:
- 普通 `Run()` 的 assistant 回复收敛
Expand Down
52 changes: 52 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,58 @@ func TestNewMemoExtractorAdapterSchedulesExtractorAndGenerates(t *testing.T) {
}
}

func TestNewMemoExtractorAdapterBuildsProviderSafeMemoWindow(t *testing.T) {
t.Setenv(config.OpenAIDefaultAPIKeyEnv, "token")
cfg := config.StaticDefaults().Clone()
cfg.SelectedProvider = config.OpenAIName
manager := config.NewManager(config.NewLoader("", &cfg))

providerStub := &stubMemoProvider{
generate: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error {
if len(req.Messages) != 2 {
t.Fatalf("unexpected memo window: %+v", req.Messages)
}
for _, message := range req.Messages {
if len(message.ToolCalls) > 0 {
t.Fatalf("unexpected incomplete tool call span in memo window: %+v", req.Messages)
}
}
events <- providertypes.NewTextDeltaStreamEvent(`[]`)
events <- providertypes.NewMessageDoneStreamEvent("stop", nil)
return nil
},
}
factory := &stubMemoProviderFactory{provider: providerStub}
scheduler := &stubMemoExtractorScheduler{}
extractor := newMemoExtractorAdapter(factory, manager, scheduler)

inputMessages := []providertypes.Message{
{Role: providertypes.RoleUser, Content: "first"},
{
Role: providertypes.RoleAssistant,
ToolCalls: []providertypes.ToolCall{
{ID: "call_1", Name: "filesystem_read_file", Arguments: `{}`},
},
},
{Role: providertypes.RoleUser, Content: "second"},
}
extractor.Schedule("session-1", inputMessages)
if !scheduler.called || scheduler.extractor == nil {
t.Fatalf("expected scheduler to receive extractor")
}

entries, err := scheduler.extractor.Extract(context.Background(), inputMessages)
if err != nil {
t.Fatalf("extractor.Extract() error = %v", err)
}
if len(entries) != 0 {
t.Fatalf("expected empty extracted entries, got %+v", entries)
}
if !factory.called {
t.Fatalf("expected provider factory Build to be called")
}
}

func TestNewMemoExtractorAdapterKeepsScheduledConfigSnapshot(t *testing.T) {
t.Setenv(config.OpenAIDefaultAPIKeyEnv, "openai-token")
t.Setenv(config.QiniuDefaultAPIKeyEnv, "qiniu-token")
Expand Down
26 changes: 2 additions & 24 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ package context

import (
"context"
"strings"

providertypes "neo-code/internal/provider/types"
agentsession "neo-code/internal/session"
"neo-code/internal/tools"
)

// DefaultBuilder preserves the current runtime context-building behavior.
Expand Down Expand Up @@ -86,7 +84,7 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu
}, nil
}

// applyReadTimeContextProjection 负责在 provider 请求前按开关应用只读上下文投影,避免改写原始会话消息。
// applyReadTimeContextProjection 负责在 provider 读取路径上应用只读上下文投影,避免改写原始会话消息。
func applyReadTimeContextProjection(
messages []providertypes.Message,
taskState agentsession.TaskState,
Expand All @@ -99,25 +97,5 @@ func applyReadTimeContextProjection(
} else {
projected = microCompactMessagesWithPolicies(messages, policies, options.MicroCompactRetainedToolSpans)
}
return projectToolMessagesForModel(projected)
}

// projectToolMessagesForModel 仅在 provider 读取路径上格式化 tool 消息,避免污染持久化会话内容。
func projectToolMessagesForModel(messages []providertypes.Message) []providertypes.Message {
for i := range messages {
message := messages[i]
if message.Role != providertypes.RoleTool {
continue
}
if len(message.ToolMetadata) == 0 {
continue
}
content := strings.TrimSpace(message.Content)
if content == "" || content == microCompactClearedMessage {
continue
}
messages[i].Content = tools.FormatToolMessageForModel(message)
messages[i].ToolMetadata = nil
}
return messages
return ProjectToolMessagesForModel(projected)
}
27 changes: 27 additions & 0 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -743,3 +743,30 @@ func TestNewBuilderWithMemo(t *testing.T) {
}
})
}

func TestProjectToolMessagesForModelKeepsBuilderProjectionBehavior(t *testing.T) {
t.Parallel()

messages := []providertypes.Message{
{
Role: providertypes.RoleTool,
ToolCallID: "call-1",
Content: "tool output",
ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"},
},
}

projected := ProjectToolMessagesForModel(cloneContextMessages(messages))
if len(projected) != 1 {
t.Fatalf("len(projected) = %d, want 1", len(projected))
}
if !strings.Contains(projected[0].Content, "tool result") || !strings.Contains(projected[0].Content, "tool: filesystem_read_file") {
t.Fatalf("unexpected projected content: %q", projected[0].Content)
}
if projected[0].ToolMetadata != nil {
t.Fatalf("expected projected metadata to be cleared, got %#v", projected[0].ToolMetadata)
}
if messages[0].ToolMetadata == nil {
t.Fatal("expected source messages to remain unchanged")
}
}
235 changes: 235 additions & 0 deletions internal/context/projection.go
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-- {
Comment thread
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)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildRecentMessagesForModel 把 tool 消息投影后直接送入 memo 提取链路。当前 FormatToolMessageForModel 会保留 meta.path/root/workdir,并附带 content_excerpt(最多 600 rune)。这会把本地路径和工具输出片段跨边界发送到提取模型,数据最小化仍不够。建议在 memo 场景增加专用投影(仅保留必要字段,如 tool_name/status + 更强摘要),默认去掉 meta.* 路径类字段。

}

// 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
}
Loading
Loading