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
7 changes: 5 additions & 2 deletions docs/tools-and-tui-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@
- `webfetch`
- `memo_remember`
- `memo_recall`
- `memo_list`
- `memo_remove`

## Memo 能力集成
- `memo_remember``memo_recall` 作为标准工具暴露给模型,沿 `Runtime -> Tool Manager -> internal/tools/memo` 链路执行。
- `memo_remember``memo_recall`、`memo_list`、`memo_remove` 作为标准工具暴露给模型,沿 `Runtime -> Tool Manager -> internal/tools/memo` 链路执行。
- 自动记忆提取不作为单独工具暴露给模型,也不由 TUI 直接触发;它在 runtime 完成最终回复后由 memo 子系统后台调度。
- TUI 目前只通过 Slash Command 展示和管理 memo(如 `/memo`、`/remember`、`/forget`),不会展示后台自动提取的中间状态。
- TUI 的 `/memo`、`/remember`、`/forget` 等 Slash Command 不再直接依赖 memo service,而是通过 `Runtime.ExecuteSystemTool` 统一入口触发系统工具执行,保证 UI 与 memo 逻辑解耦。
Comment thread
fennoai[bot] marked this conversation as resolved.
- TUI 不会展示后台自动提取的中间状态。

## TUI 集成方式
- 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择
Expand Down
8 changes: 5 additions & 3 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func newMemoExtractorAdapter(
})
})

scheduler.ScheduleWithExtractor(sessionID, messages, memo.NewLLMExtractor(generator))
scheduler.ScheduleWithExtractor(sessionID, messages, memo.NewLLMExtractor(generator, cfg.Memo.ExtractRecentMessages))
})
}

Expand Down Expand Up @@ -159,9 +159,11 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
sourceInvl = invalidator.InvalidateCache
}
contextBuilder = agentcontext.NewBuilderWithMemoAndSummarizers(toolRegistry, toolRegistry, memoSource)
memoSvc = memo.NewService(memoStore, nil, cfg.Memo, sourceInvl)
memoSvc = memo.NewService(memoStore, cfg.Memo, sourceInvl)
toolRegistry.Register(memotool.NewRememberTool(memoSvc))
toolRegistry.Register(memotool.NewRecallTool(memoSvc))
toolRegistry.Register(memotool.NewListTool(memoSvc))
toolRegistry.Register(memotool.NewRemoveTool(memoSvc))
}

runtimeSvc := agentruntime.NewWithFactory(
Expand Down Expand Up @@ -189,7 +191,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
runtimeSvc.SetMemoExtractor(newMemoExtractorAdapter(
providerRegistry,
manager,
memo.NewAutoExtractor(nil, memoSvc),
memo.NewAutoExtractor(nil, memoSvc, time.Duration(cfg.Memo.ExtractTimeoutSec)*time.Second),
))
}
needCleanup = false
Expand Down
101 changes: 77 additions & 24 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1492,66 +1492,119 @@ func TestMemoConfigClone(t *testing.T) {
t.Parallel()

original := MemoConfig{
Enabled: true,
AutoExtract: false,
MaxIndexLines: 100,
Enabled: true,
AutoExtract: false,
MaxEntries: 100,
MaxIndexBytes: 2048,
ExtractTimeoutSec: 9,
ExtractRecentMessages: 3,
}
cloned := original.Clone()
if cloned != original {
t.Fatalf("Clone() = %+v, want %+v", cloned, original)
}
cloned.MaxIndexLines = 200
if original.MaxIndexLines != 100 {
cloned.MaxEntries = 200
if original.MaxEntries != 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("fills zero fields", func(t *testing.T) {
cfg := MemoConfig{}
cfg.ApplyDefaults(MemoConfig{
MaxEntries: DefaultMemoMaxEntries,
MaxIndexBytes: DefaultMemoMaxIndexBytes,
ExtractTimeoutSec: DefaultMemoExtractTimeoutSec,
ExtractRecentMessages: DefaultMemoExtractRecentMessage,
})
if cfg.MaxEntries != DefaultMemoMaxEntries {
t.Errorf("MaxEntries = %d, want %d", cfg.MaxEntries, DefaultMemoMaxEntries)
}
if cfg.MaxIndexBytes != DefaultMemoMaxIndexBytes {
t.Errorf("MaxIndexBytes = %d, want %d", cfg.MaxIndexBytes, DefaultMemoMaxIndexBytes)
}
if cfg.ExtractTimeoutSec != DefaultMemoExtractTimeoutSec {
t.Errorf("ExtractTimeoutSec = %d, want %d", cfg.ExtractTimeoutSec, DefaultMemoExtractTimeoutSec)
}
if cfg.ExtractRecentMessages != DefaultMemoExtractRecentMessage {
t.Errorf("ExtractRecentMessages = %d, want %d", cfg.ExtractRecentMessages, DefaultMemoExtractRecentMessage)
}
})

t.Run("preserves explicit fields", func(t *testing.T) {
cfg := MemoConfig{
MaxEntries: 50,
MaxIndexBytes: 1024,
ExtractTimeoutSec: 30,
ExtractRecentMessages: 5,
}
cfg.ApplyDefaults(defaultMemoConfig())
if cfg.MaxEntries != 50 || cfg.MaxIndexBytes != 1024 || cfg.ExtractTimeoutSec != 30 || cfg.ExtractRecentMessages != 5 {
t.Fatalf("ApplyDefaults() unexpectedly overwrote explicit values: %+v", cfg)
}
})

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("preserves negative fields for validation", func(t *testing.T) {
cfg := MemoConfig{
MaxEntries: -1,
MaxIndexBytes: -2,
ExtractTimeoutSec: -3,
ExtractRecentMessages: -4,
}
cfg.ApplyDefaults(defaultMemoConfig())
if cfg.MaxEntries != -1 || cfg.MaxIndexBytes != -2 || cfg.ExtractTimeoutSec != -3 || cfg.ExtractRecentMessages != -4 {
t.Fatalf("ApplyDefaults() unexpectedly rewrote invalid values: %+v", cfg)
}
})

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

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

t.Run("valid config", func(t *testing.T) {
cfg := MemoConfig{MaxIndexLines: 100}
cfg := defaultMemoConfig()
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}
t.Run("non-positive MaxEntries", func(t *testing.T) {
cfg := defaultMemoConfig()
cfg.MaxEntries = 0
if err := cfg.Validate(); err == nil {
t.Fatal("negative MaxIndexLines should fail validation")
t.Fatal("non-positive MaxEntries 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)
t.Run("non-positive MaxIndexBytes", func(t *testing.T) {
cfg := defaultMemoConfig()
cfg.MaxIndexBytes = -1
if err := cfg.Validate(); err == nil {
t.Fatal("non-positive MaxIndexBytes should fail validation")
}
})

t.Run("non-positive ExtractTimeoutSec", func(t *testing.T) {
cfg := defaultMemoConfig()
cfg.ExtractTimeoutSec = 0
if err := cfg.Validate(); err == nil {
t.Fatal("non-positive ExtractTimeoutSec should fail validation")
}
})

t.Run("non-positive ExtractRecentMessages", func(t *testing.T) {
cfg := defaultMemoConfig()
cfg.ExtractRecentMessages = 0
if err := cfg.Validate(); err == nil {
t.Fatal("non-positive ExtractRecentMessages should fail validation")
}
})
}
Expand Down
38 changes: 30 additions & 8 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,13 @@ type persistedAutoCompactConfig struct {
}

type persistedMemoConfig struct {
Enabled *bool `yaml:"enabled,omitempty"`
AutoExtract *bool `yaml:"auto_extract,omitempty"`
MaxIndexLines int `yaml:"max_index_lines,omitempty"`
Enabled *bool `yaml:"enabled,omitempty"`
AutoExtract *bool `yaml:"auto_extract,omitempty"`
MaxEntries *int `yaml:"max_entries,omitempty"`
MaxIndexBytes *int `yaml:"max_index_bytes,omitempty"`
ExtractTimeoutSec *int `yaml:"extract_timeout_sec,omitempty"`
ExtractRecentMessages *int `yaml:"extract_recent_messages,omitempty"`
MaxIndexLines *int `yaml:"max_index_lines,omitempty"`
}

func NewLoader(baseDir string, defaults *Config) *Loader {
Expand Down Expand Up @@ -333,10 +337,17 @@ func assembleProviders(builtin []ProviderConfig, custom []ProviderConfig) ([]Pro
func newPersistedMemoConfig(cfg MemoConfig) persistedMemoConfig {
enabled := cfg.Enabled
autoExtract := cfg.AutoExtract
maxEntries := cfg.MaxEntries
maxIndexBytes := cfg.MaxIndexBytes
extractTimeoutSec := cfg.ExtractTimeoutSec
extractRecentMessages := cfg.ExtractRecentMessages
return persistedMemoConfig{
Enabled: &enabled,
AutoExtract: &autoExtract,
MaxIndexLines: cfg.MaxIndexLines,
Enabled: &enabled,
AutoExtract: &autoExtract,
MaxEntries: &maxEntries,
MaxIndexBytes: &maxIndexBytes,
ExtractTimeoutSec: &extractTimeoutSec,
ExtractRecentMessages: &extractRecentMessages,
}
}

Expand All @@ -349,8 +360,19 @@ func fromPersistedMemoConfig(file persistedMemoConfig, defaults MemoConfig) Memo
if file.AutoExtract != nil {
out.AutoExtract = *file.AutoExtract
}
if file.MaxIndexLines > 0 {
out.MaxIndexLines = file.MaxIndexLines
if file.MaxEntries != nil {
out.MaxEntries = *file.MaxEntries
} else if file.MaxIndexLines != nil {
out.MaxEntries = *file.MaxIndexLines
}
if file.MaxIndexBytes != nil {
out.MaxIndexBytes = *file.MaxIndexBytes
}
if file.ExtractTimeoutSec != nil {
out.ExtractTimeoutSec = *file.ExtractTimeoutSec
}
if file.ExtractRecentMessages != nil {
out.ExtractRecentMessages = *file.ExtractRecentMessages
}
return out
}
106 changes: 101 additions & 5 deletions internal/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1259,7 +1259,10 @@ shell: powershell
memo:
enabled: false
auto_extract: false
max_index_lines: 123
max_entries: 123
max_index_bytes: 4096
extract_timeout_sec: 9
extract_recent_messages: 4
`
writeLoaderConfig(t, loader, raw)

Expand All @@ -1273,8 +1276,17 @@ memo:
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)
if cfg.Memo.MaxEntries != 123 {
t.Fatalf("expected memo.max_entries=123, got %d", cfg.Memo.MaxEntries)
}
if cfg.Memo.MaxIndexBytes != 4096 {
t.Fatalf("expected memo.max_index_bytes=4096, got %d", cfg.Memo.MaxIndexBytes)
}
if cfg.Memo.ExtractTimeoutSec != 9 {
t.Fatalf("expected memo.extract_timeout_sec=9, got %d", cfg.Memo.ExtractTimeoutSec)
}
if cfg.Memo.ExtractRecentMessages != 4 {
t.Fatalf("expected memo.extract_recent_messages=4, got %d", cfg.Memo.ExtractRecentMessages)
}

data, err := os.ReadFile(loader.ConfigPath())
Expand Down Expand Up @@ -1312,8 +1324,92 @@ shell: powershell
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)
if cfg.Memo.MaxEntries <= 0 {
t.Fatalf("expected memo.max_entries to be defaulted, got %d", cfg.Memo.MaxEntries)
}
if cfg.Memo.MaxIndexBytes <= 0 {
t.Fatalf("expected memo.max_index_bytes to be defaulted, got %d", cfg.Memo.MaxIndexBytes)
}
if cfg.Memo.ExtractTimeoutSec <= 0 {
t.Fatalf("expected memo.extract_timeout_sec to be defaulted, got %d", cfg.Memo.ExtractTimeoutSec)
}
if cfg.Memo.ExtractRecentMessages <= 0 {
t.Fatalf("expected memo.extract_recent_messages to be defaulted, got %d", cfg.Memo.ExtractRecentMessages)
}
}

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

loader := NewLoader(t.TempDir(), testDefaultConfig())
raw := `
selected_provider: openai
current_model: gpt-4.1
shell: powershell
memo:
max_index_lines: 123
`
writeLoaderConfig(t, loader, raw)

cfg, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("expected legacy memo field to be accepted, got %v", err)
}
if cfg.Memo.MaxEntries != 123 {
t.Fatalf("expected legacy max_index_lines mapped to memo.max_entries=123, got %d", cfg.Memo.MaxEntries)
}
}

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

tests := []struct {
name string
fieldYAML string
errContain string
}{
{
name: "negative max_entries",
fieldYAML: "max_entries: -1",
errContain: "config: memo: max_entries must be greater than 0",
},
{
name: "negative max_index_bytes",
fieldYAML: "max_index_bytes: -1",
errContain: "config: memo: max_index_bytes must be greater than 0",
},
{
name: "negative extract_timeout_sec",
fieldYAML: "extract_timeout_sec: -1",
errContain: "config: memo: extract_timeout_sec must be greater than 0",
},
{
name: "negative extract_recent_messages",
fieldYAML: "extract_recent_messages: -1",
errContain: "config: memo: extract_recent_messages must be greater than 0",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

loader := NewLoader(t.TempDir(), testDefaultConfig())
raw := `
selected_provider: openai
current_model: gpt-4.1
shell: powershell
memo:
` + tt.fieldYAML + `
`
writeLoaderConfig(t, loader, raw)

_, err := loader.Load(context.Background())
if err == nil || !strings.Contains(err.Error(), tt.errContain) {
t.Fatalf("expected %q, got %v", tt.errContain, err)
}
})
}
}

Expand Down
Loading
Loading