Skip to content

Commit 2974bb9

Browse files
authored
Merge pull request #604 from Yumiue/html_progress
feat(memo, runtime, web): 引入 run 边界记忆提取与语义去重,修复 Web 端 slash memo 指令
2 parents e9a4b0c + 0a4e227 commit 2974bb9

25 files changed

Lines changed: 1642 additions & 446 deletions

internal/app/bootstrap.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ func newMemoExtractorAdapter(
115115
})
116116
})
117117

118-
scheduler.ScheduleWithExtractor(sessionID, messages, memo.NewLLMExtractor(generator, cfg.Memo.ExtractRecentMessages))
118+
scheduler.ScheduleWithExtractor(sessionID, messages, memo.NewLLMExtractor(generator))
119119
})
120120
}
121121

internal/app/bootstrap_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1675,6 +1675,47 @@ func TestNewMemoExtractorAdapterBuildsProviderSafeMemoWindow(t *testing.T) {
16751675
}
16761676
}
16771677

1678+
func TestNewMemoExtractorAdapterUsesFullRunMemoWindow(t *testing.T) {
1679+
t.Setenv(config.OpenAIDefaultAPIKeyEnv, "token")
1680+
cfg := config.StaticDefaults().Clone()
1681+
cfg.SelectedProvider = config.OpenAIName
1682+
manager := config.NewManager(config.NewLoader("", &cfg))
1683+
1684+
providerStub := &stubMemoProvider{
1685+
generate: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error {
1686+
if len(req.Messages) != 12 {
1687+
t.Fatalf("unexpected memo window length %d, want full run: %+v", len(req.Messages), req.Messages)
1688+
}
1689+
events <- providertypes.NewTextDeltaStreamEvent(`[]`)
1690+
events <- providertypes.NewMessageDoneStreamEvent("stop", nil)
1691+
return nil
1692+
},
1693+
}
1694+
factory := &stubMemoProviderFactory{provider: providerStub}
1695+
scheduler := &stubMemoExtractorScheduler{}
1696+
extractor := newMemoExtractorAdapter(factory, manager, scheduler)
1697+
1698+
inputMessages := make([]providertypes.Message, 0, 12)
1699+
for index := 0; index < 12; index++ {
1700+
inputMessages = append(inputMessages, providertypes.Message{
1701+
Role: providertypes.RoleUser,
1702+
Parts: []providertypes.ContentPart{providertypes.NewTextPart(fmt.Sprintf("message-%02d", index))},
1703+
})
1704+
}
1705+
extractor.Schedule("session-1", inputMessages)
1706+
if !scheduler.called || scheduler.extractor == nil {
1707+
t.Fatalf("expected scheduler to receive extractor")
1708+
}
1709+
1710+
_, err := scheduler.extractor.Extract(context.Background(), inputMessages)
1711+
if err != nil {
1712+
t.Fatalf("extractor.Extract() error = %v", err)
1713+
}
1714+
if !factory.called {
1715+
t.Fatalf("expected provider factory Build to be called")
1716+
}
1717+
}
1718+
16781719
func TestNewMemoExtractorAdapterKeepsScheduledConfigSnapshot(t *testing.T) {
16791720
t.Setenv(config.OpenAIDefaultAPIKeyEnv, "openai-token")
16801721
t.Setenv(config.QiniuDefaultAPIKeyEnv, "qiniu-token")

internal/config/config_test.go

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,12 +1495,11 @@ func TestMemoConfigClone(t *testing.T) {
14951495
t.Parallel()
14961496

14971497
original := MemoConfig{
1498-
Enabled: true,
1499-
AutoExtract: false,
1500-
MaxEntries: 100,
1501-
MaxIndexBytes: 2048,
1502-
ExtractTimeoutSec: 9,
1503-
ExtractRecentMessages: 3,
1498+
Enabled: true,
1499+
AutoExtract: false,
1500+
MaxEntries: 100,
1501+
MaxIndexBytes: 2048,
1502+
ExtractTimeoutSec: 9,
15041503
}
15051504
cloned := original.Clone()
15061505
if cloned != original {
@@ -1518,10 +1517,9 @@ func TestMemoConfigApplyDefaults(t *testing.T) {
15181517
t.Run("fills zero fields", func(t *testing.T) {
15191518
cfg := MemoConfig{}
15201519
cfg.ApplyDefaults(MemoConfig{
1521-
MaxEntries: DefaultMemoMaxEntries,
1522-
MaxIndexBytes: DefaultMemoMaxIndexBytes,
1523-
ExtractTimeoutSec: DefaultMemoExtractTimeoutSec,
1524-
ExtractRecentMessages: DefaultMemoExtractRecentMessage,
1520+
MaxEntries: DefaultMemoMaxEntries,
1521+
MaxIndexBytes: DefaultMemoMaxIndexBytes,
1522+
ExtractTimeoutSec: DefaultMemoExtractTimeoutSec,
15251523
})
15261524
if cfg.MaxEntries != DefaultMemoMaxEntries {
15271525
t.Errorf("MaxEntries = %d, want %d", cfg.MaxEntries, DefaultMemoMaxEntries)
@@ -1532,33 +1530,28 @@ func TestMemoConfigApplyDefaults(t *testing.T) {
15321530
if cfg.ExtractTimeoutSec != DefaultMemoExtractTimeoutSec {
15331531
t.Errorf("ExtractTimeoutSec = %d, want %d", cfg.ExtractTimeoutSec, DefaultMemoExtractTimeoutSec)
15341532
}
1535-
if cfg.ExtractRecentMessages != DefaultMemoExtractRecentMessage {
1536-
t.Errorf("ExtractRecentMessages = %d, want %d", cfg.ExtractRecentMessages, DefaultMemoExtractRecentMessage)
1537-
}
15381533
})
15391534

15401535
t.Run("preserves explicit fields", func(t *testing.T) {
15411536
cfg := MemoConfig{
1542-
MaxEntries: 50,
1543-
MaxIndexBytes: 1024,
1544-
ExtractTimeoutSec: 30,
1545-
ExtractRecentMessages: 5,
1537+
MaxEntries: 50,
1538+
MaxIndexBytes: 1024,
1539+
ExtractTimeoutSec: 30,
15461540
}
15471541
cfg.ApplyDefaults(defaultMemoConfig())
1548-
if cfg.MaxEntries != 50 || cfg.MaxIndexBytes != 1024 || cfg.ExtractTimeoutSec != 30 || cfg.ExtractRecentMessages != 5 {
1542+
if cfg.MaxEntries != 50 || cfg.MaxIndexBytes != 1024 || cfg.ExtractTimeoutSec != 30 {
15491543
t.Fatalf("ApplyDefaults() unexpectedly overwrote explicit values: %+v", cfg)
15501544
}
15511545
})
15521546

15531547
t.Run("preserves negative fields for validation", func(t *testing.T) {
15541548
cfg := MemoConfig{
1555-
MaxEntries: -1,
1556-
MaxIndexBytes: -2,
1557-
ExtractTimeoutSec: -3,
1558-
ExtractRecentMessages: -4,
1549+
MaxEntries: -1,
1550+
MaxIndexBytes: -2,
1551+
ExtractTimeoutSec: -3,
15591552
}
15601553
cfg.ApplyDefaults(defaultMemoConfig())
1561-
if cfg.MaxEntries != -1 || cfg.MaxIndexBytes != -2 || cfg.ExtractTimeoutSec != -3 || cfg.ExtractRecentMessages != -4 {
1554+
if cfg.MaxEntries != -1 || cfg.MaxIndexBytes != -2 || cfg.ExtractTimeoutSec != -3 {
15621555
t.Fatalf("ApplyDefaults() unexpectedly rewrote invalid values: %+v", cfg)
15631556
}
15641557
})
@@ -1603,13 +1596,6 @@ func TestMemoConfigValidate(t *testing.T) {
16031596
}
16041597
})
16051598

1606-
t.Run("non-positive ExtractRecentMessages", func(t *testing.T) {
1607-
cfg := defaultMemoConfig()
1608-
cfg.ExtractRecentMessages = 0
1609-
if err := cfg.Validate(); err == nil {
1610-
t.Fatal("non-positive ExtractRecentMessages should fail validation")
1611-
}
1612-
})
16131599
}
16141600

16151601
func TestNormalizeWorkdirEdgeCases(t *testing.T) {

internal/config/loader.go

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,11 @@ type persistedAskConfig struct {
6666
}
6767

6868
type persistedMemoConfig struct {
69-
Enabled *bool `yaml:"enabled,omitempty"`
70-
AutoExtract *bool `yaml:"auto_extract,omitempty"`
71-
MaxEntries *int `yaml:"max_entries,omitempty"`
72-
MaxIndexBytes *int `yaml:"max_index_bytes,omitempty"`
73-
ExtractTimeoutSec *int `yaml:"extract_timeout_sec,omitempty"`
74-
ExtractRecentMessages *int `yaml:"extract_recent_messages,omitempty"`
69+
Enabled *bool `yaml:"enabled,omitempty"`
70+
AutoExtract *bool `yaml:"auto_extract,omitempty"`
71+
MaxEntries *int `yaml:"max_entries,omitempty"`
72+
MaxIndexBytes *int `yaml:"max_index_bytes,omitempty"`
73+
ExtractTimeoutSec *int `yaml:"extract_timeout_sec,omitempty"`
7574
}
7675

7776
func NewLoader(baseDir string, defaults *Config) *Loader {
@@ -225,6 +224,9 @@ func parseConfigWithContextDefaults(
225224
}
226225

227226
func parseCurrentConfig(data []byte, contextDefaults ContextConfig, memoDefaults MemoConfig) (*Config, error) {
227+
if err := rejectRemovedMemoFields(data); err != nil {
228+
return nil, err
229+
}
228230
var file persistedConfig
229231
decoder := yaml.NewDecoder(bytes.NewReader(data))
230232
decoder.KnownFields(true)
@@ -384,14 +386,12 @@ func newPersistedMemoConfig(cfg MemoConfig) persistedMemoConfig {
384386
maxEntries := cfg.MaxEntries
385387
maxIndexBytes := cfg.MaxIndexBytes
386388
extractTimeoutSec := cfg.ExtractTimeoutSec
387-
extractRecentMessages := cfg.ExtractRecentMessages
388389
return persistedMemoConfig{
389-
Enabled: &enabled,
390-
AutoExtract: &autoExtract,
391-
MaxEntries: &maxEntries,
392-
MaxIndexBytes: &maxIndexBytes,
393-
ExtractTimeoutSec: &extractTimeoutSec,
394-
ExtractRecentMessages: &extractRecentMessages,
390+
Enabled: &enabled,
391+
AutoExtract: &autoExtract,
392+
MaxEntries: &maxEntries,
393+
MaxIndexBytes: &maxIndexBytes,
394+
ExtractTimeoutSec: &extractTimeoutSec,
395395
}
396396
}
397397

@@ -413,12 +413,43 @@ func fromPersistedMemoConfig(file persistedMemoConfig, defaults MemoConfig) Memo
413413
if file.ExtractTimeoutSec != nil {
414414
out.ExtractTimeoutSec = *file.ExtractTimeoutSec
415415
}
416-
if file.ExtractRecentMessages != nil {
417-
out.ExtractRecentMessages = *file.ExtractRecentMessages
418-
}
419416
return out
420417
}
421418

419+
// rejectRemovedMemoFields 在 strict decode 前拦截已删除的 memo 字段,输出明确迁移提示。
420+
func rejectRemovedMemoFields(data []byte) error {
421+
var root yaml.Node
422+
if err := yaml.Unmarshal(data, &root); err != nil {
423+
return err
424+
}
425+
if len(root.Content) == 0 {
426+
return nil
427+
}
428+
doc := root.Content[0]
429+
if doc.Kind != yaml.MappingNode {
430+
return nil
431+
}
432+
433+
for i := 0; i < len(doc.Content); i += 2 {
434+
if strings.TrimSpace(doc.Content[i].Value) != "memo" {
435+
continue
436+
}
437+
memoNode := doc.Content[i+1]
438+
if memoNode.Kind != yaml.MappingNode {
439+
return nil
440+
}
441+
for j := 0; j < len(memoNode.Content); j += 2 {
442+
if strings.TrimSpace(memoNode.Content[j].Value) == "extract_recent_messages" {
443+
return fmt.Errorf(
444+
"config: memo.extract_recent_messages has been removed; memory extraction now always uses the full run boundary",
445+
)
446+
}
447+
}
448+
return nil
449+
}
450+
return nil
451+
}
452+
422453
// normalizeVerificationSchemaContent 在内存中预处理 verification schema,避免旧字段先于 strict decode 触发硬失败。
423454
func normalizeVerificationSchemaContent(raw []byte) ([]byte, bool, error) {
424455
if len(bytes.TrimSpace(raw)) == 0 {

internal/config/loader_test.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,7 +1930,6 @@ memo:
19301930
max_entries: 123
19311931
max_index_bytes: 4096
19321932
extract_timeout_sec: 9
1933-
extract_recent_messages: 4
19341933
`
19351934
writeLoaderConfig(t, loader, raw)
19361935

@@ -1953,9 +1952,6 @@ memo:
19531952
if cfg.Memo.ExtractTimeoutSec != 9 {
19541953
t.Fatalf("expected memo.extract_timeout_sec=9, got %d", cfg.Memo.ExtractTimeoutSec)
19551954
}
1956-
if cfg.Memo.ExtractRecentMessages != 4 {
1957-
t.Fatalf("expected memo.extract_recent_messages=4, got %d", cfg.Memo.ExtractRecentMessages)
1958-
}
19591955

19601956
data, err := os.ReadFile(loader.ConfigPath())
19611957
if err != nil {
@@ -2001,9 +1997,6 @@ shell: powershell
20011997
if cfg.Memo.ExtractTimeoutSec <= 0 {
20021998
t.Fatalf("expected memo.extract_timeout_sec to be defaulted, got %d", cfg.Memo.ExtractTimeoutSec)
20031999
}
2004-
if cfg.Memo.ExtractRecentMessages <= 0 {
2005-
t.Fatalf("expected memo.extract_recent_messages to be defaulted, got %d", cfg.Memo.ExtractRecentMessages)
2006-
}
20072000
}
20082001

20092002
func TestLoaderRejectsLegacyMemoMaxIndexLinesField(t *testing.T) {
@@ -2028,6 +2021,28 @@ memo:
20282021
}
20292022
}
20302023

2024+
func TestLoaderRejectsRemovedMemoExtractRecentMessagesField(t *testing.T) {
2025+
t.Parallel()
2026+
2027+
loader := NewLoader(t.TempDir(), testDefaultConfig())
2028+
raw := `
2029+
selected_provider: openai
2030+
current_model: gpt-4.1
2031+
shell: powershell
2032+
memo:
2033+
extract_recent_messages: 4
2034+
`
2035+
writeLoaderConfig(t, loader, raw)
2036+
2037+
cfg, err := loader.Load(context.Background())
2038+
if err == nil {
2039+
t.Fatalf("expected removed memo field to be rejected, cfg=%+v", cfg)
2040+
}
2041+
if !strings.Contains(err.Error(), "memo.extract_recent_messages has been removed") {
2042+
t.Fatalf("expected migration hint for extract_recent_messages, got %v", err)
2043+
}
2044+
}
2045+
20312046
func TestLoaderRejectsExplicitInvalidMemoNumbers(t *testing.T) {
20322047
t.Parallel()
20332048

@@ -2051,11 +2066,6 @@ func TestLoaderRejectsExplicitInvalidMemoNumbers(t *testing.T) {
20512066
fieldYAML: "extract_timeout_sec: -1",
20522067
errContain: "config: memo: extract_timeout_sec must be greater than 0",
20532068
},
2054-
{
2055-
name: "negative extract_recent_messages",
2056-
fieldYAML: "extract_recent_messages: -1",
2057-
errContain: "config: memo: extract_recent_messages must be greater than 0",
2058-
},
20592069
}
20602070

20612071
for _, tt := range tests {

internal/config/memo.go

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,28 @@ package config
33
import "errors"
44

55
const (
6-
DefaultMemoMaxEntries = 200
7-
DefaultMemoMaxIndexBytes = 16 * 1024
8-
DefaultMemoExtractTimeoutSec = 15
9-
DefaultMemoExtractRecentMessage = 10
6+
DefaultMemoMaxEntries = 200
7+
DefaultMemoMaxIndexBytes = 16 * 1024
8+
DefaultMemoExtractTimeoutSec = 15
109
)
1110

1211
// MemoConfig 控制跨会话持久记忆的行为配置。
1312
type MemoConfig struct {
14-
Enabled bool `yaml:"enabled,omitempty"`
15-
AutoExtract bool `yaml:"auto_extract,omitempty"`
16-
MaxEntries int `yaml:"max_entries,omitempty"`
17-
MaxIndexBytes int `yaml:"max_index_bytes,omitempty"`
18-
ExtractTimeoutSec int `yaml:"extract_timeout_sec,omitempty"`
19-
ExtractRecentMessages int `yaml:"extract_recent_messages,omitempty"`
13+
Enabled bool `yaml:"enabled,omitempty"`
14+
AutoExtract bool `yaml:"auto_extract,omitempty"`
15+
MaxEntries int `yaml:"max_entries,omitempty"`
16+
MaxIndexBytes int `yaml:"max_index_bytes,omitempty"`
17+
ExtractTimeoutSec int `yaml:"extract_timeout_sec,omitempty"`
2018
}
2119

2220
// defaultMemoConfig 返回跨会话记忆的默认配置。
2321
func defaultMemoConfig() MemoConfig {
2422
return MemoConfig{
25-
Enabled: true,
26-
AutoExtract: true,
27-
MaxEntries: DefaultMemoMaxEntries,
28-
MaxIndexBytes: DefaultMemoMaxIndexBytes,
29-
ExtractTimeoutSec: DefaultMemoExtractTimeoutSec,
30-
ExtractRecentMessages: DefaultMemoExtractRecentMessage,
23+
Enabled: true,
24+
AutoExtract: true,
25+
MaxEntries: DefaultMemoMaxEntries,
26+
MaxIndexBytes: DefaultMemoMaxIndexBytes,
27+
ExtractTimeoutSec: DefaultMemoExtractTimeoutSec,
3128
}
3229
}
3330

@@ -50,9 +47,6 @@ func (c *MemoConfig) ApplyDefaults(defaults MemoConfig) {
5047
if c.ExtractTimeoutSec == 0 {
5148
c.ExtractTimeoutSec = defaults.ExtractTimeoutSec
5249
}
53-
if c.ExtractRecentMessages == 0 {
54-
c.ExtractRecentMessages = defaults.ExtractRecentMessages
55-
}
5650
}
5751

5852
// Validate 校验 memo 配置是否合法。
@@ -66,8 +60,5 @@ func (c MemoConfig) Validate() error {
6660
if c.ExtractTimeoutSec <= 0 {
6761
return errors.New("extract_timeout_sec must be greater than 0")
6862
}
69-
if c.ExtractRecentMessages <= 0 {
70-
return errors.New("extract_recent_messages must be greater than 0")
71-
}
7263
return nil
7364
}

0 commit comments

Comments
 (0)