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
12 changes: 12 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ var (
setConsoleInputCodePage = platformSetConsoleInputCodePage
buildToolManagerFunc = buildToolManager
newTUIWithMemo = tui.NewWithMemo
cleanupExpiredSessions = func(
ctx context.Context,
store agentsession.Store,
maxAge time.Duration,
) (int, error) {
return store.CleanupExpiredSessions(ctx, maxAge)
}
)

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

// 启动时自动清理过期会话,避免数据库无限膨胀。
if _, err := cleanupExpiredSessions(ctx, sessionStore, agentsession.DefaultSessionMaxAge); err != nil {
log.Printf("session cleanup warning: %v", err)
}

// 注册内置工具的内容摘要器,使 micro-compact 在清理旧工具结果时保留关键上下文。
tools.RegisterBuiltinSummarizers(toolRegistry)

Expand Down
43 changes: 43 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -721,6 +722,11 @@ func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) {
if bundle.ConfigManager == nil || bundle.Runtime == nil || bundle.ProviderSelection == nil {
t.Fatalf("expected runtime bundle dependencies, got %+v", bundle)
}
if bundle.Close != nil {
t.Cleanup(func() {
_ = bundle.Close()
})
}
}

func TestBuildRuntimeSucceedsWhenSkillsRootMissing(t *testing.T) {
Expand Down Expand Up @@ -987,6 +993,43 @@ func TestBuildRuntimeCleansResourcesWhenToolManagerBuildFails(t *testing.T) {
}
}

func TestBuildRuntimeLogsSessionCleanupWarningAndContinues(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

originalCleanupExpiredSessions := cleanupExpiredSessions
t.Cleanup(func() { cleanupExpiredSessions = originalCleanupExpiredSessions })
cleanupExpiredSessions = func(
ctx context.Context,
store agentsession.Store,
maxAge time.Duration,
) (int, error) {
return 0, errors.New("cleanup failed")
}

var logBuffer bytes.Buffer
originalLogWriter := log.Writer()
log.SetOutput(&logBuffer)
t.Cleanup(func() { log.SetOutput(originalLogWriter) })

bundle, err := BuildRuntime(context.Background(), BootstrapOptions{})
if err != nil {
t.Fatalf("BuildRuntime() error = %v", err)
}
if bundle.Close != nil {
defer bundle.Close()
}
if bundle.Runtime == nil {
t.Fatalf("expected runtime bundle to be created")
}
if !strings.Contains(logBuffer.String(), "session cleanup warning: cleanup failed") {
t.Fatalf("expected cleanup warning in logs, got %q", logBuffer.String())
}
}

func TestNewProgramCleansResourcesWhenTUIBuildFails(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

Expand Down
81 changes: 57 additions & 24 deletions internal/context/microcompact.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,58 +23,86 @@ func microCompactMessages(messages []providertypes.Message) []providertypes.Mess
}

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

cloned := cloneContextMessages(messages)
if len(cloned) == 0 {
return cloned
if len(messages) == 0 {
return nil
}

spans := internalcompact.BuildMessageSpans(cloned)
spans := internalcompact.BuildMessageSpans(messages)
protectedStart, hasProtectedTail := internalcompact.ProtectedTailStart(spans)
retainedCompactableSpans := 0

modifiedIndices := make(map[int]struct{})
var pendingCompactions []compactionPending

for spanIndex := len(spans) - 1; spanIndex >= 0; spanIndex-- {
span := spans[spanIndex]
if hasProtectedTail && span.Start >= protectedStart {
continue
}
if !isToolCallSpan(cloned, span) {
if !isToolCallSpan(messages, span) {
continue
}

compactableIDs, toolNames := compactableToolCallIDs(cloned[span.Start].ToolCalls, policies)
compactableIDs, toolNames := compactableToolCallIDs(messages[span.Start].ToolCalls, policies)
if len(compactableIDs) == 0 {
continue
}
if retainedCompactableSpans < retainedToolSpans {
if hasCompactableToolMessage(cloned, span, compactableIDs) {
if hasCompactableToolMessage(messages, span, compactableIDs) {
retainedCompactableSpans++
}
continue
}

compactableContents := compactableToolMessageContents(cloned, span, compactableIDs)
compactableContents := compactableToolMessageContents(messages, span, compactableIDs)
if len(compactableContents) == 0 {
continue
}

for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ {
content, ok := compactableContents[messageIndex]
if !ok {
continue
}
summary := summarizeOrClear(cloned[messageIndex], content, toolNames, summarizers)
cloned[messageIndex].Parts = []providertypes.ContentPart{providertypes.NewTextPart(summary)}
for messageIndex, content := range compactableContents {
modifiedIndices[messageIndex] = struct{}{}
pendingCompactions = append(pendingCompactions, compactionPending{
index: messageIndex,
content: content,
toolNames: toolNames,
})
}
}

if len(modifiedIndices) == 0 {
return append([]providertypes.Message(nil), messages...)
}

cloned := make([]providertypes.Message, len(messages))
for i, msg := range messages {
if _, needsClone := modifiedIndices[i]; needsClone {
cloned[i] = cloneSingleMessage(msg)
} else {
cloned[i] = msg
}
}

for _, pending := range pendingCompactions {
summary := summarizeOrClear(cloned[pending.index], pending.content, pending.toolNames, summarizers)
cloned[pending.index].Parts = []providertypes.ContentPart{providertypes.NewTextPart(summary)}
}

return cloned
}

// compactionPending 记录待压缩的消息索引和所需上下文。
type compactionPending struct {
index int
content string
toolNames map[string]string
}

// cloneContextMessages 深拷贝消息切片,避免读时投影污染 runtime 持有的原始会话消息。
func cloneContextMessages(messages []providertypes.Message) []providertypes.Message {
if len(messages) == 0 {
Expand All @@ -83,19 +111,24 @@ func cloneContextMessages(messages []providertypes.Message) []providertypes.Mess

cloned := make([]providertypes.Message, 0, len(messages))
for _, message := range messages {
next := message
next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...)
if len(message.ToolMetadata) > 0 {
next.ToolMetadata = make(map[string]string, len(message.ToolMetadata))
for key, value := range message.ToolMetadata {
next.ToolMetadata[key] = value
}
}
cloned = append(cloned, next)
cloned = append(cloned, cloneSingleMessage(message))
}
return cloned
}

// cloneSingleMessage 深拷贝单条消息,隔离 ToolCalls 和 ToolMetadata 的底层引用。
func cloneSingleMessage(msg providertypes.Message) providertypes.Message {
next := msg
next.ToolCalls = append([]providertypes.ToolCall(nil), msg.ToolCalls...)
if len(msg.ToolMetadata) > 0 {
next.ToolMetadata = make(map[string]string, len(msg.ToolMetadata))
for key, value := range msg.ToolMetadata {
next.ToolMetadata[key] = value
}
}
return next
}

// isToolCallSpan 判断当前 span 是否是由 assistant tool call 起始的原子工具块。
func isToolCallSpan(messages []providertypes.Message, span internalcompact.MessageSpan) bool {
if span.Start < 0 || span.Start >= len(messages) {
Expand Down
9 changes: 8 additions & 1 deletion internal/context/source_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import (
"fmt"
"os/exec"
"strings"
"time"
)

// gitCommandTimeout 定义 git 命令的最大等待时间,避免网络挂载或损坏仓库阻塞上下文构建。
const gitCommandTimeout = 5 * time.Second

type gitCommandRunner func(ctx context.Context, workdir string, args ...string) (string, error)

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

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