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
21 changes: 19 additions & 2 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"neo-code/internal/config"
configstate "neo-code/internal/config/state"
agentcontext "neo-code/internal/context"
"neo-code/internal/memo"
"neo-code/internal/provider/builtin"
providercatalog "neo-code/internal/provider/catalog"
agentruntime "neo-code/internal/runtime"
Expand Down Expand Up @@ -40,6 +41,7 @@ type RuntimeBundle struct {
ConfigManager *config.Manager
Runtime agentruntime.Runtime
ProviderSelection *configstate.Service
MemoService *memo.Service
}

// EnsureConsoleUTF8 负责在 Windows 控制台中尽量启用 UTF-8 编码。
Expand Down Expand Up @@ -87,19 +89,34 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
// Session Store 绑定到启动时的 workdir 哈希分桶,整个应用生命周期内不可变。
// 这意味着所有会话都归属到启动时指定的项目目录下,运行时不会因配置变更而迁移存储位置。
sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir)

var contextBuilder agentcontext.Builder = agentcontext.NewBuilderWithToolPolicies(toolRegistry)
var memoSvc *memo.Service
if cfg.Memo.Enabled {
memoStore := memo.NewFileStore(loader.BaseDir(), cfg.Workdir)
memoSource := memo.NewContextSource(memoStore)

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.

memoContextSource exposes InvalidateCache, but this wiring drops that hook and constructs memo.Service with sourceInvl=nil. After /remember or /forget, the next agent turn can still use the stale memo index until the 5s TTL expires, so the new memo is not reliably available immediately. Please pass the source invalidator into NewService (or otherwise share the cache) so writes invalidate the injected prompt cache synchronously.

var sourceInvl func()
if invalidator, ok := memoSource.(interface{ InvalidateCache() }); ok {
sourceInvl = invalidator.InvalidateCache
}
contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource)
memoSvc = memo.NewService(memoStore, nil, cfg.Memo, sourceInvl)
}

runtimeSvc := agentruntime.NewWithFactory(
manager,
toolManager,
sessionStore,
providerRegistry,
agentcontext.NewBuilderWithToolPolicies(toolRegistry),
contextBuilder,
)

return RuntimeBundle{
Config: cfg,
ConfigManager: manager,
Runtime: runtimeSvc,
ProviderSelection: providerSelection,
MemoService: memoSvc,
}, nil
}

Expand All @@ -110,7 +127,7 @@ func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, error
return nil, err
}

tuiApp, err := tui.New(&bundle.Config, bundle.ConfigManager, bundle.Runtime, bundle.ProviderSelection)
tuiApp, err := tui.NewWithMemo(&bundle.Config, bundle.ConfigManager, bundle.Runtime, bundle.ProviderSelection, bundle.MemoService)
if err != nil {
return nil, err
}
Expand Down
68 changes: 68 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1361,3 +1361,71 @@ func TestAutoCompactConfigContextConfigValidate(t *testing.T) {
t.Fatalf("expected error to contain 'auto_compact', got %v", err)
}
}

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

original := MemoConfig{
Enabled: true,
AutoExtract: false,
MaxIndexLines: 100,
}
cloned := original.Clone()
if cloned != original {
t.Fatalf("Clone() = %+v, want %+v", cloned, original)
}
cloned.MaxIndexLines = 200
if original.MaxIndexLines != 100 {
t.Error("modifying clone should not affect original (value type check)")
}
}

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

t.Run("fills zero MaxIndexLines", func(t *testing.T) {
cfg := MemoConfig{Enabled: true, MaxIndexLines: 0}
cfg.ApplyDefaults(MemoConfig{MaxIndexLines: DefaultMemoMaxIndexLines})
if cfg.MaxIndexLines != DefaultMemoMaxIndexLines {
t.Errorf("MaxIndexLines = %d, want %d", cfg.MaxIndexLines, DefaultMemoMaxIndexLines)
}
})

t.Run("preserves explicit MaxIndexLines", func(t *testing.T) {
cfg := MemoConfig{MaxIndexLines: 50}
cfg.ApplyDefaults(MemoConfig{MaxIndexLines: DefaultMemoMaxIndexLines})
if cfg.MaxIndexLines != 50 {
t.Errorf("MaxIndexLines = %d, want 50", cfg.MaxIndexLines)
}
})

t.Run("nil receiver is no-op", func(t *testing.T) {
var cfg *MemoConfig
cfg.ApplyDefaults(MemoConfig{MaxIndexLines: 200})
})
}

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

t.Run("valid config", func(t *testing.T) {
cfg := MemoConfig{MaxIndexLines: 100}
if err := cfg.Validate(); err != nil {
t.Fatalf("valid config should not error: %v", err)
}
})

t.Run("negative MaxIndexLines", func(t *testing.T) {
cfg := MemoConfig{MaxIndexLines: -1}
if err := cfg.Validate(); err == nil {
t.Fatal("negative MaxIndexLines should fail validation")
}
})

t.Run("zero MaxIndexLines is valid", func(t *testing.T) {
cfg := MemoConfig{MaxIndexLines: 0}
if err := cfg.Validate(); err != nil {
t.Fatalf("zero MaxIndexLines should be valid: %v", err)
}
})
}
54 changes: 49 additions & 5 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type persistedConfig struct {
ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"`
Context persistedContextConfig `yaml:"context,omitempty"`
Tools ToolsConfig `yaml:"tools,omitempty"`
Memo persistedMemoConfig `yaml:"memo,omitempty"`
}

type persistedContextConfig struct {
Expand All @@ -48,6 +49,12 @@ type persistedAutoCompactConfig struct {
InputTokenThreshold int `yaml:"input_token_threshold,omitempty"`
}

type persistedMemoConfig struct {
Enabled *bool `yaml:"enabled,omitempty"`
AutoExtract *bool `yaml:"auto_extract,omitempty"`
MaxIndexLines int `yaml:"max_index_lines,omitempty"`
}

func NewLoader(baseDir string, defaults *Config) *Loader {
if defaults == nil {
panic("config: loader defaults are nil")
Expand Down Expand Up @@ -104,7 +111,7 @@ func (l *Loader) Load(ctx context.Context) (*Config, error) {
return nil, fmt.Errorf("config: read config file: %w", err)
}

cfg, err := parseConfigWithContextDefaults(data, l.defaults.Context)
cfg, err := parseConfigWithContextDefaults(data, l.defaults.Context, l.defaults.Memo)
if err != nil {
return nil, fmt.Errorf("config: parse config file: %w", err)
}
Expand Down Expand Up @@ -162,19 +169,28 @@ func defaultBaseDir() string {
}

func parseConfig(data []byte) (*Config, error) {
return parseConfigWithContextDefaults(data, StaticDefaults().Context)
defaults := StaticDefaults()
return parseConfigWithContextDefaults(data, defaults.Context, defaults.Memo)
}

// parseConfigWithContextDefaults 负责在解析配置时注入上下文压缩相关默认值。
func parseConfigWithContextDefaults(data []byte, contextDefaults ContextConfig) (*Config, error) {
func parseConfigWithContextDefaults(
data []byte,
contextDefaults ContextConfig,
memoDefaults ...MemoConfig,
) (*Config, error) {
if len(bytes.TrimSpace(data)) == 0 {
return &Config{}, nil
}

return parseCurrentConfig(data, contextDefaults)
resolvedMemo := defaultMemoConfig()
if len(memoDefaults) > 0 {
resolvedMemo = memoDefaults[0]
}
return parseCurrentConfig(data, contextDefaults, resolvedMemo)
}

func parseCurrentConfig(data []byte, contextDefaults ContextConfig) (*Config, error) {
func parseCurrentConfig(data []byte, contextDefaults ContextConfig, memoDefaults MemoConfig) (*Config, error) {
var file persistedConfig
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
Expand All @@ -189,6 +205,7 @@ func parseCurrentConfig(data []byte, contextDefaults ContextConfig) (*Config, er
ToolTimeoutSec: file.ToolTimeoutSec,
Context: fromPersistedContextConfig(file.Context, contextDefaults),
Tools: file.Tools,
Memo: fromPersistedMemoConfig(file.Memo, memoDefaults),
}

return cfg, nil
Expand All @@ -203,6 +220,7 @@ func marshalPersistedConfig(snapshot Config) ([]byte, error) {
ToolTimeoutSec: snapshot.ToolTimeoutSec,
Context: newPersistedContextConfig(snapshot.Context),
Tools: snapshot.Tools,
Memo: newPersistedMemoConfig(snapshot.Memo),
}

data, err := yaml.Marshal(&file)
Expand Down Expand Up @@ -290,3 +308,29 @@ func assembleProviders(builtin []ProviderConfig, custom []ProviderConfig) ([]Pro
}
return assembled, nil
}

// newPersistedMemoConfig 将运行时 memo 配置收敛为 YAML 持久化结构。
func newPersistedMemoConfig(cfg MemoConfig) persistedMemoConfig {
enabled := cfg.Enabled
autoExtract := cfg.AutoExtract
return persistedMemoConfig{
Enabled: &enabled,
AutoExtract: &autoExtract,
MaxIndexLines: cfg.MaxIndexLines,
}
}

// fromPersistedMemoConfig 将持久化配置恢复为运行时 memo 配置。
func fromPersistedMemoConfig(file persistedMemoConfig, defaults MemoConfig) MemoConfig {
out := defaults
if file.Enabled != nil {
out.Enabled = *file.Enabled
}
if file.AutoExtract != nil {
out.AutoExtract = *file.AutoExtract
}
if file.MaxIndexLines > 0 {
out.MaxIndexLines = file.MaxIndexLines
}
return out
}
80 changes: 80 additions & 0 deletions internal/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -759,3 +759,83 @@ openai_compatible:
t.Fatalf("expected unknown field rejection, got %v", err)
}
}

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

loader := NewLoader(t.TempDir(), testDefaultConfig())
if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil {
t.Fatalf("mkdir base dir: %v", err)
}

raw := `
selected_provider: openai
current_model: gpt-4.1
shell: powershell
memo:
enabled: false
auto_extract: false
max_index_lines: 123
`
if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(raw)+"\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

cfg, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Memo.Enabled {
t.Fatalf("expected memo.enabled to stay false")
}
if cfg.Memo.AutoExtract {
t.Fatalf("expected memo.auto_extract to stay false")
}
if cfg.Memo.MaxIndexLines != 123 {
t.Fatalf("expected memo.max_index_lines=123, got %d", cfg.Memo.MaxIndexLines)
}

data, err := os.ReadFile(loader.ConfigPath())
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(data)
if !strings.Contains(text, "enabled: false") {
t.Fatalf("expected persisted memo.enabled=false, got:\n%s", text)
}
if !strings.Contains(text, "auto_extract: false") {
t.Fatalf("expected persisted memo.auto_extract=false, got:\n%s", text)
}
}

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

loader := NewLoader(t.TempDir(), testDefaultConfig())
if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil {
t.Fatalf("mkdir base dir: %v", err)
}

raw := `
selected_provider: openai
current_model: gpt-4.1
shell: powershell
`
if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(raw)+"\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

cfg, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.Memo.Enabled {
t.Fatalf("expected memo.enabled default true when memo section missing")
}
if !cfg.Memo.AutoExtract {
t.Fatalf("expected memo.auto_extract default true when memo section missing")
}
if cfg.Memo.MaxIndexLines <= 0 {
t.Fatalf("expected memo.max_index_lines to be defaulted, got %d", cfg.Memo.MaxIndexLines)
}
}
Loading
Loading