Skip to content

Commit ed76435

Browse files
authored
Merge branch 'main' into main
2 parents 9105b5a + 6afe196 commit ed76435

22 files changed

Lines changed: 1369 additions & 81 deletions

internal/app/bootstrap.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ var (
3838
setConsoleInputCodePage = platformSetConsoleInputCodePage
3939
buildToolManagerFunc = buildToolManager
4040
newTUIWithMemo = tui.NewWithMemo
41+
cleanupExpiredSessions = func(
42+
ctx context.Context,
43+
store agentsession.Store,
44+
maxAge time.Duration,
45+
) (int, error) {
46+
return store.CleanupExpiredSessions(ctx, maxAge)
47+
}
4148
)
4249

4350
// BootstrapOptions 描述应用启动时可注入的运行时选项。
@@ -150,6 +157,11 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
150157
// 这意味着所有会话都归属到启动时指定的项目目录下,运行时不会因配置变更而迁移存储位置。
151158
sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir)
152159

160+
// 启动时自动清理过期会话,避免数据库无限膨胀。
161+
if _, err := cleanupExpiredSessions(ctx, sessionStore, agentsession.DefaultSessionMaxAge); err != nil {
162+
log.Printf("session cleanup warning: %v", err)
163+
}
164+
153165
// 注册内置工具的内容摘要器,使 micro-compact 在清理旧工具结果时保留关键上下文。
154166
tools.RegisterBuiltinSummarizers(toolRegistry)
155167

internal/app/bootstrap_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"io"
11+
"log"
1112
"os"
1213
"path/filepath"
1314
"reflect"
@@ -723,6 +724,11 @@ func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) {
723724
if bundle.ConfigManager == nil || bundle.Runtime == nil || bundle.ProviderSelection == nil {
724725
t.Fatalf("expected runtime bundle dependencies, got %+v", bundle)
725726
}
727+
if bundle.Close != nil {
728+
t.Cleanup(func() {
729+
_ = bundle.Close()
730+
})
731+
}
726732
}
727733

728734
func TestBuildRuntimeSucceedsWhenSkillsRootMissing(t *testing.T) {
@@ -989,6 +995,43 @@ func TestBuildRuntimeCleansResourcesWhenToolManagerBuildFails(t *testing.T) {
989995
}
990996
}
991997

998+
func TestBuildRuntimeLogsSessionCleanupWarningAndContinues(t *testing.T) {
999+
disableBuiltinProviderAPIKeys(t)
1000+
1001+
home := t.TempDir()
1002+
t.Setenv("HOME", home)
1003+
t.Setenv("USERPROFILE", home)
1004+
1005+
originalCleanupExpiredSessions := cleanupExpiredSessions
1006+
t.Cleanup(func() { cleanupExpiredSessions = originalCleanupExpiredSessions })
1007+
cleanupExpiredSessions = func(
1008+
ctx context.Context,
1009+
store agentsession.Store,
1010+
maxAge time.Duration,
1011+
) (int, error) {
1012+
return 0, errors.New("cleanup failed")
1013+
}
1014+
1015+
var logBuffer bytes.Buffer
1016+
originalLogWriter := log.Writer()
1017+
log.SetOutput(&logBuffer)
1018+
t.Cleanup(func() { log.SetOutput(originalLogWriter) })
1019+
1020+
bundle, err := BuildRuntime(context.Background(), BootstrapOptions{})
1021+
if err != nil {
1022+
t.Fatalf("BuildRuntime() error = %v", err)
1023+
}
1024+
if bundle.Close != nil {
1025+
defer bundle.Close()
1026+
}
1027+
if bundle.Runtime == nil {
1028+
t.Fatalf("expected runtime bundle to be created")
1029+
}
1030+
if !strings.Contains(logBuffer.String(), "session cleanup warning: cleanup failed") {
1031+
t.Fatalf("expected cleanup warning in logs, got %q", logBuffer.String())
1032+
}
1033+
}
1034+
9921035
func TestNewProgramCleansResourcesWhenTUIBuildFails(t *testing.T) {
9931036
disableBuiltinProviderAPIKeys(t)
9941037

internal/context/microcompact.go

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,58 +23,86 @@ func microCompactMessages(messages []providertypes.Message) []providertypes.Mess
2323
}
2424

2525
// microCompactMessagesWithPolicies 按工具策略对裁剪后的消息做只读投影式微压缩。
26+
// 仅对需要压缩的工具消息做深拷贝,其余消息共享原始引用以减少内存分配。
2627
func microCompactMessagesWithPolicies(messages []providertypes.Message, policies MicroCompactPolicySource, retainedToolSpans int, summarizers MicroCompactSummarizerSource) []providertypes.Message {
2728
if retainedToolSpans <= 0 {
2829
retainedToolSpans = defaultMicroCompactRetainedToolSpans
2930
}
3031

31-
cloned := cloneContextMessages(messages)
32-
if len(cloned) == 0 {
33-
return cloned
32+
if len(messages) == 0 {
33+
return nil
3434
}
3535

36-
spans := internalcompact.BuildMessageSpans(cloned)
36+
spans := internalcompact.BuildMessageSpans(messages)
3737
protectedStart, hasProtectedTail := internalcompact.ProtectedTailStart(spans)
3838
retainedCompactableSpans := 0
3939

40+
modifiedIndices := make(map[int]struct{})
41+
var pendingCompactions []compactionPending
42+
4043
for spanIndex := len(spans) - 1; spanIndex >= 0; spanIndex-- {
4144
span := spans[spanIndex]
4245
if hasProtectedTail && span.Start >= protectedStart {
4346
continue
4447
}
45-
if !isToolCallSpan(cloned, span) {
48+
if !isToolCallSpan(messages, span) {
4649
continue
4750
}
4851

49-
compactableIDs, toolNames := compactableToolCallIDs(cloned[span.Start].ToolCalls, policies)
52+
compactableIDs, toolNames := compactableToolCallIDs(messages[span.Start].ToolCalls, policies)
5053
if len(compactableIDs) == 0 {
5154
continue
5255
}
5356
if retainedCompactableSpans < retainedToolSpans {
54-
if hasCompactableToolMessage(cloned, span, compactableIDs) {
57+
if hasCompactableToolMessage(messages, span, compactableIDs) {
5558
retainedCompactableSpans++
5659
}
5760
continue
5861
}
5962

60-
compactableContents := compactableToolMessageContents(cloned, span, compactableIDs)
63+
compactableContents := compactableToolMessageContents(messages, span, compactableIDs)
6164
if len(compactableContents) == 0 {
6265
continue
6366
}
6467

65-
for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ {
66-
content, ok := compactableContents[messageIndex]
67-
if !ok {
68-
continue
69-
}
70-
summary := summarizeOrClear(cloned[messageIndex], content, toolNames, summarizers)
71-
cloned[messageIndex].Parts = []providertypes.ContentPart{providertypes.NewTextPart(summary)}
68+
for messageIndex, content := range compactableContents {
69+
modifiedIndices[messageIndex] = struct{}{}
70+
pendingCompactions = append(pendingCompactions, compactionPending{
71+
index: messageIndex,
72+
content: content,
73+
toolNames: toolNames,
74+
})
75+
}
76+
}
77+
78+
if len(modifiedIndices) == 0 {
79+
return append([]providertypes.Message(nil), messages...)
80+
}
81+
82+
cloned := make([]providertypes.Message, len(messages))
83+
for i, msg := range messages {
84+
if _, needsClone := modifiedIndices[i]; needsClone {
85+
cloned[i] = cloneSingleMessage(msg)
86+
} else {
87+
cloned[i] = msg
7288
}
7389
}
7490

91+
for _, pending := range pendingCompactions {
92+
summary := summarizeOrClear(cloned[pending.index], pending.content, pending.toolNames, summarizers)
93+
cloned[pending.index].Parts = []providertypes.ContentPart{providertypes.NewTextPart(summary)}
94+
}
95+
7596
return cloned
7697
}
7798

99+
// compactionPending 记录待压缩的消息索引和所需上下文。
100+
type compactionPending struct {
101+
index int
102+
content string
103+
toolNames map[string]string
104+
}
105+
78106
// cloneContextMessages 深拷贝消息切片,避免读时投影污染 runtime 持有的原始会话消息。
79107
func cloneContextMessages(messages []providertypes.Message) []providertypes.Message {
80108
if len(messages) == 0 {
@@ -83,19 +111,24 @@ func cloneContextMessages(messages []providertypes.Message) []providertypes.Mess
83111

84112
cloned := make([]providertypes.Message, 0, len(messages))
85113
for _, message := range messages {
86-
next := message
87-
next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...)
88-
if len(message.ToolMetadata) > 0 {
89-
next.ToolMetadata = make(map[string]string, len(message.ToolMetadata))
90-
for key, value := range message.ToolMetadata {
91-
next.ToolMetadata[key] = value
92-
}
93-
}
94-
cloned = append(cloned, next)
114+
cloned = append(cloned, cloneSingleMessage(message))
95115
}
96116
return cloned
97117
}
98118

119+
// cloneSingleMessage 深拷贝单条消息,隔离 ToolCalls 和 ToolMetadata 的底层引用。
120+
func cloneSingleMessage(msg providertypes.Message) providertypes.Message {
121+
next := msg
122+
next.ToolCalls = append([]providertypes.ToolCall(nil), msg.ToolCalls...)
123+
if len(msg.ToolMetadata) > 0 {
124+
next.ToolMetadata = make(map[string]string, len(msg.ToolMetadata))
125+
for key, value := range msg.ToolMetadata {
126+
next.ToolMetadata[key] = value
127+
}
128+
}
129+
return next
130+
}
131+
99132
// isToolCallSpan 判断当前 span 是否是由 assistant tool call 起始的原子工具块。
100133
func isToolCallSpan(messages []providertypes.Message, span internalcompact.MessageSpan) bool {
101134
if span.Start < 0 || span.Start >= len(messages) {

internal/context/source_system.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ import (
66
"fmt"
77
"os/exec"
88
"strings"
9+
"time"
910
)
1011

12+
// gitCommandTimeout 定义 git 命令的最大等待时间,避免网络挂载或损坏仓库阻塞上下文构建。
13+
const gitCommandTimeout = 5 * time.Second
14+
1115
type gitCommandRunner func(ctx context.Context, workdir string, args ...string) (string, error)
1216

1317
// collectSystemState 汇总运行时上下文,并通过一次 git status 调用获取分支与脏状态。
@@ -104,8 +108,11 @@ func renderSystemStateSection(state SystemState) promptSection {
104108
}
105109
}
106110

111+
// runGitCommand 执行 git 命令并在超时后自动取消,避免阻塞上下文构建主链路。
107112
func runGitCommand(ctx context.Context, workdir string, args ...string) (string, error) {
108-
command := exec.CommandContext(ctx, "git", append([]string{"-C", workdir}, args...)...)
113+
timeoutCtx, cancel := context.WithTimeout(ctx, gitCommandTimeout)
114+
defer cancel()
115+
command := exec.CommandContext(timeoutCtx, "git", append([]string{"-C", workdir}, args...)...)
109116
output, err := command.Output()
110117
if err != nil {
111118
return "", err

0 commit comments

Comments
 (0)