magic-agent 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CLIENT-CONTRACT.md +51 -12
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +14 -0
  4. package/client-ui/pc/js/93-rail.js +3 -1
  5. package/client-ui/shared/conv/03-chat-message.js +20 -3
  6. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  7. package/client-ui/shared/conv/conv.css +17 -0
  8. package/client-ui/shared/conversation.js +170 -39
  9. package/client-ui/shared/transport-http.js +183 -38
  10. package/client-ui-mobile/index.html +370 -68
  11. package/client-ui-mobile/reqs.js +42 -29
  12. package/internal/agent/capability.go +8 -1
  13. package/internal/agent/claude.go +5 -3
  14. package/internal/agent/codebuddy.go +6 -2
  15. package/internal/agent/codex.go +7 -2
  16. package/internal/agent/dsh_sdk.go +8 -0
  17. package/internal/agent/dsh_sdk_test.go +17 -0
  18. package/internal/agent/engine.go +20 -0
  19. package/internal/agent/engine_test.go +52 -0
  20. package/internal/agent/llmengine.go +14 -0
  21. package/internal/agent/llmengine_test.go +24 -0
  22. package/internal/agent/llmhttp.go +6 -0
  23. package/internal/agent/llmhttp_test.go +27 -0
  24. package/internal/agent/llmmodels.go +48 -0
  25. package/internal/agent/permission.go +5 -0
  26. package/internal/cli/ask.go +32 -17
  27. package/internal/cli/requirements.go +2 -0
  28. package/internal/cli/sessionlog.go +5 -0
  29. package/internal/cli/sessionlog_user_test.go +49 -0
  30. package/internal/client/requirements.go +27 -7
  31. package/internal/client/requirements_test.go +51 -0
  32. package/internal/client/service.go +24 -2
  33. package/internal/client/service_test.go +35 -0
  34. package/internal/requirements/consolidate.go +86 -24
  35. package/internal/requirements/requirements.go +9 -0
  36. package/internal/requirements/requirements_test.go +129 -0
  37. package/internal/requirements/store.go +11 -3
  38. package/internal/suggest/suggest.go +118 -7
  39. package/internal/suggest/suggest_test.go +50 -0
  40. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  41. package/npm/apk/version.json +3 -3
  42. package/npm/dist/darwin-arm64/magic-agent +0 -0
  43. package/npm/dist/darwin-arm64/magic-client +0 -0
  44. package/npm/dist/darwin-x64/magic-agent +0 -0
  45. package/npm/dist/darwin-x64/magic-client +0 -0
  46. package/npm/dist/linux-arm64/magic-agent +0 -0
  47. package/npm/dist/linux-arm64/magic-client +0 -0
  48. package/npm/dist/linux-x64/magic-agent +0 -0
  49. package/npm/dist/linux-x64/magic-client +0 -0
  50. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  51. package/npm/dist/win32-x64/magic-client.exe +0 -0
  52. package/package.json +3 -2
@@ -128,7 +128,15 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
128
128
  if Root() == "" {
129
129
  return Result{Workspace: ws.ID, Dir: ws.Dir, Error: "无法确定需求存储目录"}, fmt.Errorf("requirements: no storage root")
130
130
  }
131
- res := Result{Workspace: ws.ID, Dir: ws.Dir}
131
+ moduleScope := SlugifyModule(opts.Module)
132
+ res := Result{Workspace: ws.ID, Dir: ws.Dir, Module: moduleScope}
133
+
134
+ // 模块级整理只更新已有模块,不接受一个不存在的名字(否则模型会把新需求
135
+ // 塞进一个凭空的目录,用户在清单里根本找不到它)。
136
+ if moduleScope != "" && !contains(listModules(ws.ID), moduleScope) {
137
+ res.Error = "模块不存在: " + opts.Module
138
+ return res, fmt.Errorf("requirements: module not found: %s", opts.Module)
139
+ }
132
140
 
133
141
  // 锁:同一工作区同时只允许一轮归纳(客户端防抖 + 手动「立即整理」可能撞上)。
134
142
  release, busy := acquireLock(ws.ID)
@@ -152,8 +160,9 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
152
160
  /* 首次「整理」:先按空间目录反推一版需求基线,再把旧会话记为已消费。
153
161
  判断依据是 state.BootstrapAt(不是文档数 / 不是 LastConsolidatedAt):
154
162
  自动归纳可能已经产出过几篇文档,但「有没有按目录初始化过」是另一件事。
155
- opts.Auto 的自动路径不初始化 —— 它每轮都可能跑,不能悄悄花几分钟读代码。 */
156
- if st.BootstrapAt == 0 && !opts.Auto {
163
+ opts.Auto 的自动路径不初始化 —— 它每轮都可能跑,不能悄悄花几分钟读代码。
164
+ 模块级整理也不初始化:目录基线是空间级的,模块只做增量。 */
165
+ if st.BootstrapAt == 0 && !opts.Auto && moduleScope == "" {
157
166
  boot, berr := bootstrapWorkspace(ctx, ws, opts)
158
167
  if berr != nil {
159
168
  res.Error = truncRunes(berr.Error(), 240)
@@ -206,8 +215,11 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
206
215
  if cursor > len(mem) {
207
216
  cursor = len(mem) // 记忆文件被外部截断 → 游标跟着收敛,不越界
208
217
  }
218
+ /* 模块级整理**不重放**(--requirement-all 是空间级回填),只看游标之后的
219
+ 新记忆;也不推进游标,否则别的模块的需求会被这一轮悄悄吞掉。 */
220
+ replay := opts.All && moduleScope == ""
209
221
  entries := mem[cursor:]
210
- if opts.All {
222
+ if replay {
211
223
  entries = mem
212
224
  }
213
225
  if len(entries) == 0 && !opts.Force {
@@ -218,7 +230,7 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
218
230
  }
219
231
 
220
232
  use := entries
221
- if opts.All {
233
+ if replay {
222
234
  if len(use) > MaxReplayEntries {
223
235
  use = use[len(use)-MaxReplayEntries:]
224
236
  }
@@ -241,7 +253,7 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
241
253
  return res, callErr
242
254
  }
243
255
 
244
- chunks := chunkEntries(use, opts.All)
256
+ chunks := chunkEntries(use, replay)
245
257
  merger := newDocMerger(ws.ID)
246
258
  byN := make(map[int]Entry, len(mem))
247
259
  for _, e := range mem {
@@ -256,7 +268,15 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
256
268
  // 每片重新读已有文档:上一片刚写的要点必须出现在这一片的「已有需求点」里,
257
269
  // 否则同一件事会被重复追加。
258
270
  docs := featureDocsOf(ws.ID)
259
- prompt := buildPrompt(wsName, docs, chunk, listModules(ws.ID), opts.All)
271
+ modules := listModules(ws.ID)
272
+ /* 模块级整理:只让模型看到这个模块的已有文档与模块名 ——
273
+ 否则它会把别的模块的新需求也一起写回来(那正是用户点的这一下
274
+ 「只整理一个模块」所不能接受的事)。 */
275
+ if moduleScope != "" {
276
+ docs = docsOfModule(docs, moduleScope)
277
+ modules = []string{moduleScope}
278
+ }
279
+ prompt := buildPrompt(wsName, docs, chunk, modules, replay, moduleScope)
260
280
  text, err := call(prompt, outSchema)
261
281
  res.Calls++
262
282
  if err != nil {
@@ -276,28 +296,34 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
276
296
 
277
297
  // 新模块的同义二审:归纳是逐轮独立的,模型容易把同一件事拆成近义模块
278
298
  //(「任务列表」/「对话列表」)。只有确认已有模块里没有同义项时才允许新建。
299
+ // 模块级整理不新建模块(作用域就是这个已有模块),因而也不做二审。
279
300
  alias := map[string]string{}
280
- existing := listModules(ws.ID)
281
- newMods := []string{}
282
- seenNew := map[string]bool{}
283
- for _, o := range outs {
284
- m := SlugifyModule(o.Module)
285
- if m == "" || seenNew[m] || contains(existing, m) {
286
- continue
301
+ if moduleScope == "" {
302
+ existing := listModules(ws.ID)
303
+ newMods := []string{}
304
+ seenNew := map[string]bool{}
305
+ for _, o := range outs {
306
+ m := SlugifyModule(o.Module)
307
+ if m == "" || seenNew[m] || contains(existing, m) {
308
+ continue
309
+ }
310
+ seenNew[m] = true
311
+ newMods = append(newMods, m)
312
+ }
313
+ if len(newMods) > 0 && len(existing) > 0 {
314
+ alias = resolveModuleAlias(call, newMods, existing)
287
315
  }
288
- seenNew[m] = true
289
- newMods = append(newMods, m)
290
- }
291
- if len(newMods) > 0 && len(existing) > 0 {
292
- alias = resolveModuleAlias(call, newMods, existing)
293
316
  }
294
317
 
295
318
  for _, o := range outs {
319
+ if moduleScope != "" && !outputInModule(o, moduleScope, merger) {
320
+ continue
321
+ }
296
322
  w := writeFeatureItem(ws.ID, o, &writeCtx{
297
323
  merger: merger,
298
324
  alias: alias,
299
325
  kind: "归纳",
300
- all: opts.All,
326
+ all: replay,
301
327
  sourcesOf: func(x featureOut) []string {
302
328
  out := make([]string, 0, len(x.Sources))
303
329
  for _, n := range x.Sources {
@@ -324,7 +350,7 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
324
350
  }
325
351
 
326
352
  consumed := cursor
327
- if !opts.All {
353
+ if !replay && moduleScope == "" {
328
354
  consumed = cursor + len(use)
329
355
  if consumed > len(mem) {
330
356
  consumed = len(mem)
@@ -339,11 +365,11 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
339
365
  }
340
366
  res.OK = true
341
367
  res.Consumed = 0
342
- if !opts.All {
368
+ if !replay && moduleScope == "" {
343
369
  res.Consumed = len(use)
344
370
  }
345
371
  res.Replayed = 0
346
- if opts.All {
372
+ if replay {
347
373
  res.Replayed = len(use)
348
374
  }
349
375
  res.Pending = len(mem) - consumed
@@ -435,12 +461,38 @@ func featureDocsOf(id string) []existsDoc {
435
461
  return out
436
462
  }
437
463
 
464
+ // docsOfModule 只留某个模块下的已有功能点(模块级整理的作用域)。
465
+ func docsOfModule(docs []existsDoc, module string) []existsDoc {
466
+ out := make([]existsDoc, 0, len(docs))
467
+ for _, d := range docs {
468
+ if SlugifyModule(d.Module) == module {
469
+ out = append(out, d)
470
+ }
471
+ }
472
+ return out
473
+ }
474
+
475
+ // outputInModule 一条模型增量是否属于本次模块级整理的作用域。
476
+ //
477
+ // 已有功能点按 name 匹配后以它**当前所属模块**为准(与 writeFeatureItem 同一口径:
478
+ // 文档一旦归类就不搬家);新功能点才看模型给的 module。
479
+ func outputInModule(o featureOut, scope string, merger *docMerger) bool {
480
+ name := strings.TrimSpace(truncRunes(strings.TrimSpace(o.Name), TitleMaxChars))
481
+ if name == "" {
482
+ return false
483
+ }
484
+ if hit, has := merger.match(name); has {
485
+ return SlugifyModule(hit.Module) == scope
486
+ }
487
+ return SlugifyModule(o.Module) == scope
488
+ }
489
+
438
490
  // buildPrompt 组归纳 prompt。
439
491
  //
440
492
  // 规则段是**产品口径**,不是技术参数:哪些话不能写进需求点(缺陷措辞)、
441
493
  // 什么样的模块必须复用、结论与问题的界限。这些是业务项目半年里一条条纠正出来的,
442
494
  // 换实现时原样保留,别按自己的口味重写。
443
- func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []string, replay bool) string {
495
+ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []string, replay bool, scope string) string {
444
496
  mods := make([]string, 0, len(modules))
445
497
  for _, m := range modules {
446
498
  if strings.TrimSpace(m) != "" {
@@ -531,9 +583,19 @@ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []str
531
583
  "desc 可用记忆重新校核改写(这是唯一允许改写 desc 的场景)," +
532
584
  "需求点/结论里已有的一字不改照抄、缺的补上。"
533
585
  }
586
+ /* 模块级整理:模型只该动这一个模块。必须写清楚「别的记忆忽略」——
587
+ 否则它会把整段对话重新归类,用户点一个模块却把别的模块也改了。 */
588
+ if scope != "" {
589
+ rules[0] = "0. **本次只整理模块「" + scope + "」**:只输出属于这个模块的功能点" +
590
+ "(新功能点的 module 必须逐字写「" + scope + "」,已有功能点沿用它的 name)。" +
591
+ "不属于这个模块的记忆**一律忽略** —— 它们会在「整理整个空间」时处理。"
592
+ }
534
593
 
535
594
  var b strings.Builder
536
595
  b.WriteString("你是资深产品经理,负责把「对话记忆」归纳成**两级**功能点需求文档的**增量**。工作区:" + wsName + "\n\n")
596
+ if scope != "" {
597
+ b.WriteString("【模块级整理】本次作用域:模块「" + scope + "」。\n\n")
598
+ }
537
599
  b.WriteString("== 已有模块(大功能点 · 一级)==\n")
538
600
  if len(mods) == 0 {
539
601
  b.WriteString("(暂无模块,可新建)")
@@ -127,6 +127,13 @@ type Options struct {
127
127
  Engine string
128
128
  // Model 归纳用哪个模型(空 = 引擎默认)。
129
129
  Model string
130
+ // Module 只整理这个模块(空 = 整个空间)。
131
+ //
132
+ // 语义:只把「属于该模块」的增量写回该模块,**不推进空间游标** ——
133
+ // 其它模块的记忆仍留在 pending,等空间级「整理」或下一次自动归纳处理。
134
+ // 因此模块级整理是「只增不减、可重复跑」的,不会吞掉别的模块的需求。
135
+ // ⚠️ 它不会触发首次目录初始化(目录基线天然是空间级的)。
136
+ Module string
130
137
  // Timeout 单次调用超时(0 = 归纳 240s;首次目录初始化 9m)。
131
138
  Timeout time.Duration
132
139
  // Auto 自动归纳(插件的轮末防抖 / 每日兜底)。
@@ -141,6 +148,8 @@ type Result struct {
141
148
  // Workspace / Dir 归纳的是哪个工作区。
142
149
  Workspace string `json:"workspace,omitempty"`
143
150
  Dir string `json:"dir"`
151
+ // Module 这一轮只整理了哪个模块(空 = 整个空间)。
152
+ Module string `json:"module,omitempty"`
144
153
  // Noop 没有未归纳记忆、也没要求强制跑(等于什么都没做,不是错)。
145
154
  Noop bool `json:"noop,omitempty"`
146
155
  // Bootstrap 这一轮是否完成了首次初始化。
@@ -326,6 +326,27 @@ func TestWorkspacesGroupsAndSorts(t *testing.T) {
326
326
  }
327
327
  }
328
328
 
329
+ // TestResolveWorkspaceByID 按 ws id 也能解析(客户端手里可能只有 id、没有目录)。
330
+ //
331
+ // 老实现的 safeID 把前缀 `ws-` 里的 w/s 也按十六进制校验,任何 id 都过不了:
332
+ // `--consolidate-requirements ws-xxxx` 直接报「工作区不存在」。
333
+ func TestResolveWorkspaceByID(t *testing.T) {
334
+ root := testEnv(t)
335
+ proj := filepath.Join(root, "proj-id")
336
+ if err := os.MkdirAll(proj, 0o700); err != nil {
337
+ t.Fatal(err)
338
+ }
339
+ e := registerWorkspace(proj)
340
+ got, ok := resolveWorkspace(e.ID)
341
+ if !ok || got.ID != e.ID || got.Dir != proj {
342
+ t.Fatalf("按 id 应解析出 %s/%s,实际 %+v ok=%v", e.ID, proj, got, ok)
343
+ }
344
+ // 路径穿越仍要被挡住。
345
+ if safeID("ws-../../etc") || safeID("ws-") || safeID("x-ws-1") {
346
+ t.Error("非法 id 必须被拒绝")
347
+ }
348
+ }
349
+
329
350
  // TestConsolidatePipeline 走完整链路:摄入 → prompt → 模型输出 → 合并 → 游标前进。
330
351
  // 模型用假调用器(真实归纳的网络/CLI 由真机验收覆盖),验的是代码这一侧。
331
352
  func TestConsolidatePipeline(t *testing.T) {
@@ -411,6 +432,114 @@ func TestConsolidatePipeline(t *testing.T) {
411
432
  }
412
433
  }
413
434
 
435
+ // TestConsolidateModuleScope 模块级整理只动这个模块,且不推进空间游标。
436
+ //
437
+ // 用户 2026-10-23 报障:「点需求的一个模块的整理却触发了所有的整理」。
438
+ // 这里钉住三件事:
439
+ // - 模型只看到这个模块的已有文档(看不到别的模块,才不会把别的模块也改了);
440
+ // - 模型即使输出别的模块的增量,也不落盘;
441
+ // - 空间游标不动 —— 别的模块的需求留给空间级整理,不能被这一下吞掉。
442
+ func TestConsolidateModuleScope(t *testing.T) {
443
+ root := testEnv(t)
444
+ proj := filepath.Join(root, "proj-module")
445
+ if err := os.MkdirAll(proj, 0o700); err != nil {
446
+ t.Fatal(err)
447
+ }
448
+ writeSessionLog(t, session.Dir(), "s-mod", proj, "手机号登录要加验证码", "购物车要支持结算")
449
+
450
+ id := DirID(proj)
451
+ merger := newDocMerger(id)
452
+ wctx := &writeCtx{merger: merger, kind: "归纳"}
453
+ for _, o := range []featureOut{
454
+ {Module: "登录", Name: "手机号登录", Points: []string{"手机号可登录"}},
455
+ {Module: "支付", Name: "购物车结算", Points: []string{"购物车可结算"}},
456
+ } {
457
+ if w := writeFeatureItem(id, o, wctx); !w.ok {
458
+ t.Fatalf("预置文档失败: %+v", w)
459
+ }
460
+ }
461
+ // 已初始化 + 游标 0:模拟「有一批待整理记忆,已过了首次目录初始化」。
462
+ if err := writeState(id, state{BootstrapAt: time.Now().UnixMilli(), Sessions: map[string]memCursor{}}); err != nil {
463
+ t.Fatal(err)
464
+ }
465
+
466
+ old := newCaller
467
+ defer func() { newCaller = old }()
468
+ var prompts []string
469
+ newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
470
+ return func(prompt string, schema *agent.JSONSchema) (string, error) {
471
+ prompts = append(prompts, prompt)
472
+ return `{"features":[
473
+ {"module":"登录","name":"手机号登录","points":["支持验证码登录"],"sources":[1]},
474
+ {"module":"支付","name":"购物车结算","points":["支持优惠券"],"sources":[2]}
475
+ ]}`, nil
476
+ }, nil
477
+ }
478
+
479
+ res, err := Consolidate(context.Background(), proj, Options{Module: "登录"})
480
+ if err != nil {
481
+ t.Fatalf("模块级整理失败: %v", err)
482
+ }
483
+ if !res.OK || res.Module != "登录" {
484
+ t.Fatalf("结果应标明模块级整理: %+v", res)
485
+ }
486
+ if res.Consumed != 0 {
487
+ t.Errorf("模块级整理不该推进游标,实际 consumed=%d", res.Consumed)
488
+ }
489
+ if st := readState(id); st.Consumed != 0 {
490
+ t.Errorf("state 游标不该被模块级整理推进,实际 %d", st.Consumed)
491
+ }
492
+ if res.Updated != 1 || res.Created != 0 {
493
+ t.Errorf("只该更新登录模块的 1 篇,实际 %+v", res)
494
+ }
495
+ if len(prompts) != 1 {
496
+ t.Fatalf("应只调 1 次模型,实际 %d", len(prompts))
497
+ }
498
+ if !strings.Contains(prompts[0], "本次只整理模块「登录」") || !strings.Contains(prompts[0], "【模块级整理】") {
499
+ t.Errorf("prompt 应写明模块作用域:\n%s", prompts[0])
500
+ }
501
+ if strings.Contains(prompts[0], "购物车结算") || strings.Contains(prompts[0], "[模块 支付]") {
502
+ t.Errorf("prompt 不该带出别的模块的文档:\n%s", prompts[0])
503
+ }
504
+
505
+ moduleOf := func(name string) (string, Doc) {
506
+ for _, f := range listFeatureFiles(id) {
507
+ if f.Name == name {
508
+ return f.Module, ParseDoc(readFile(t, f.File))
509
+ }
510
+ }
511
+ t.Fatalf("找不到文档 %s", name)
512
+ return "", Doc{}
513
+ }
514
+ if mod, d := moduleOf("手机号登录"); mod != "登录" || !contains(d.Points, "支持验证码登录") {
515
+ t.Errorf("登录模块应收到本模块增量,实际[%s]%+v", mod, d.Points)
516
+ }
517
+ if _, d := moduleOf("购物车结算"); len(d.Points) != 1 || contains(d.Points, "支持优惠券") {
518
+ t.Errorf("别的模块不该被模块级整理改动,实际 %+v", d.Points)
519
+ }
520
+
521
+ // 空间级整理仍应消费这批记忆:两个模块都更新,游标前进。
522
+ res2, err := Consolidate(context.Background(), proj, Options{})
523
+ if err != nil {
524
+ t.Fatalf("空间级整理失败: %v", err)
525
+ }
526
+ if res2.Consumed != 2 || res2.Updated != 2 {
527
+ t.Errorf("空间级整理应消费 2 条并更新 2 篇,实际 %+v", res2)
528
+ }
529
+ if st := readState(id); st.Consumed != 2 {
530
+ t.Errorf("空间级整理后游标应为 2,实际 %d", st.Consumed)
531
+ }
532
+ if _, d := moduleOf("购物车结算"); !contains(d.Points, "支持优惠券") {
533
+ t.Errorf("空间级整理应补上支付模块的增量,实际 %+v", d.Points)
534
+ }
535
+
536
+ // 不存在的模块要明确报错,不能凭空建目录。
537
+ if _, err := Consolidate(context.Background(), proj, Options{Module: "不存在"}); err == nil ||
538
+ !strings.Contains(err.Error(), "module not found") {
539
+ t.Errorf("不存在的模块应报错,实际 %v", err)
540
+ }
541
+ }
542
+
414
543
  // TestConsolidateBootstrapsOnceThenIncremental 首次「整理」按目录反推,之后只处理新会话。
415
544
  //
416
545
  // 这是 2026-10-22 用户口径的代码化:
@@ -77,11 +77,19 @@ func registryFile() string { return filepath.Join(Root(), "workspaces.json") }
77
77
 
78
78
  // safeID 工作区 id 会拼进路径 → 只放行本包生成过的形状(挡目录穿越)。
79
79
  func safeID(id string) bool {
80
- if !strings.HasPrefix(id, "ws-") || len(id) > 40 {
80
+ const prefix = "ws-"
81
+ if !strings.HasPrefix(id, prefix) || len(id) > 40 {
81
82
  return false
82
83
  }
83
- for _, r := range id {
84
- if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || r == '-' {
84
+ // 前缀 `ws-` 本身含 w/s,不能拿去跑十六进制校验(老实现的循环把整个 id
85
+ // 都按 [0-9a-f-] 过滤,于是任何 ws id 都过不了 —— 按 id 整理 workspace
86
+ // 会报「工作区不存在」)。只校验前缀之后的部分。
87
+ rest := strings.TrimPrefix(id, prefix)
88
+ if rest == "" {
89
+ return false
90
+ }
91
+ for _, r := range rest {
92
+ if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') {
85
93
  continue
86
94
  }
87
95
  return false
@@ -162,6 +162,13 @@ func Parse(raw string, count int) []string {
162
162
  }
163
163
 
164
164
  // Generate 跑一次旁路生成。失败时返回空建议 + error(调用方如实降级:不画按钮)。
165
+ //
166
+ // 2026-10-23 用户定调:「建议的生成要走引擎的不启用工具 json 返回的方式,提高速度」。
167
+ // 这里的主路径就是如此:当前引擎(in.Engine)+ ToolsOff + JSONSchema + NoThinking,
168
+ // 各引擎都通过 NoThinking 走官方关闭参数;失败/超时再降级到 llm 的 no-think 变体
169
+ // (纯文本、无工具、1-2s),保证界面不会跟着主引擎的 CLI 一起卡到 20s。
170
+ // `ToolsOff` / `JSONSchema` / `NoThinking` 不是可选项 —— 少任何一条都会把建议变成
171
+ // 一次带工具痕迹(或默认高档思考)的完整 agent 轮次。
165
172
  func Generate(ctx context.Context, in Request) (Result, error) {
166
173
  reply := strings.TrimSpace(in.Reply)
167
174
  if reply == "" {
@@ -171,8 +178,7 @@ func Generate(ctx context.Context, in Request) (Result, error) {
171
178
  if name == "" {
172
179
  name = DefaultEngine
173
180
  }
174
- engine := agent.Lookup(name)
175
- if engine == nil {
181
+ if agent.Lookup(name) == nil {
176
182
  return Result{}, fmt.Errorf("建议生成:未知引擎 %q", name)
177
183
  }
178
184
  timeout := in.Timeout
@@ -182,11 +188,116 @@ func Generate(ctx context.Context, in Request) (Result, error) {
182
188
  cctx, cancel := context.WithTimeout(ctx, timeout)
183
189
  defer cancel()
184
190
  start := time.Now()
185
- runner := &agent.Runner{Engine: engine, Timeout: timeout}
186
- resp, err := runner.Run(cctx, agent.Request{
187
- Model: strings.TrimSpace(in.Model),
188
- Tools: agent.ToolsOff,
189
- Reasoning: "off", // 建议要快;思考过程只会拖长等待
191
+
192
+ /* 建议一律不思考;所有引擎都尽量走官方开关:
193
+ llm 用 no-think 变体 / reasoning_effort=none,claude --thinking disabled,
194
+ codex effort=none,codebuddy settings alwaysThinkingEnabled=false,
195
+ dsh InitializeParams.reasoningEffort=off。没有可靠开关的引擎(trae/openclaw/arkclaw)
196
+ 仍会被 NoThinking 标记,失败后再降级 llm;不在这里预先改道。 */
197
+ /* 主引擎:优先当前引擎(用户要的“引擎 JSON 返回”)。claude / codex 给 10s ——
198
+ 这是「CLI 启动 + 一次短 JSON」的实测上限;超过 10s 多半是凭据/主会话占用,
199
+ 继续等只会把界面的 20s 预算吃光。之后才降级到 llm(1-2s),并清掉当前引擎的 model。 */
200
+ primaryBudget := timeout
201
+ if name != DefaultEngine && primaryBudget > 10*time.Second {
202
+ primaryBudget = 10 * time.Second
203
+ }
204
+ res, err := tryGenerate(cctx, in, name, primaryBudget, start)
205
+ if err == nil {
206
+ return res, nil
207
+ }
208
+ if name == DefaultEngine {
209
+ return Result{}, err
210
+ }
211
+
212
+ /* 降级:给 llm 留一段独立预算(整体仍受 timeout 约束)。主引擎的错误不丢,
213
+ 只有 fallback 也失败时才报它 —— 至少让调用方知道不是“没有建议”,是真失败。 */
214
+ remain := timeout - time.Since(start)
215
+ if remain < 2*time.Second {
216
+ remain = 2 * time.Second
217
+ }
218
+ if remain > 8*time.Second {
219
+ remain = 8 * time.Second
220
+ }
221
+ fallbackIn := in
222
+ fallbackIn.Model = "" // model 是当前引擎的,不能拿去喂 llm
223
+ fallback, ferr := tryGenerate(cctx, fallbackIn, DefaultEngine, remain, start)
224
+ if ferr != nil {
225
+ return Result{}, err
226
+ }
227
+ return fallback, nil
228
+ }
229
+
230
+ // fastLLMModel 找 models.json 里第一条「不思考」的变体(extraBody.thinking.type=disabled)。
231
+ // 建议是短文本,思考只会把 1s 拖成 4s;有这种条目就用它,不硬编码某个模型 id。
232
+ func fastLLMModel() string {
233
+ list, err := agent.LoadLLMConfigModels()
234
+ if err != nil {
235
+ return ""
236
+ }
237
+ for _, m := range list {
238
+ if llmModelDisablesThinking(m) {
239
+ return m.ID
240
+ }
241
+ }
242
+ return ""
243
+ }
244
+
245
+ // llmModelDisablesThinking 判断 models.json 条目是否声明了不思考变体。
246
+ func llmModelDisablesThinking(m agent.LLMConfigModel) bool {
247
+ if strings.TrimSpace(m.ID) == "" || m.ExtraBody == nil {
248
+ return false
249
+ }
250
+ th, ok := m.ExtraBody["thinking"].(map[string]any)
251
+ if !ok {
252
+ return false
253
+ }
254
+ return strings.EqualFold(strings.TrimSpace(fmt.Sprint(th["type"])), "disabled")
255
+ }
256
+
257
+ // noThinkLLMModel 给建议选一个真正不思考的 llm 模型:
258
+ // 用户选的模型本身就是 no-think 变体 → 用它;否则用 models.json 里第一条
259
+ // thinking.disabled 变体;没有变体才退回用户选的模型(无法关闭,至少不阻断)。
260
+ func noThinkLLMModel(selected string) string {
261
+ selected = strings.TrimSpace(selected)
262
+ if selected != "" {
263
+ if m, ok := agent.LookupLLMConfigModel(selected); ok && llmModelDisablesThinking(m) {
264
+ return m.ID
265
+ }
266
+ }
267
+ if fast := fastLLMModel(); fast != "" {
268
+ return fast
269
+ }
270
+ return selected
271
+ }
272
+
273
+ // tryGenerate 用指定引擎跑一次 ToolsOff + JSONSchema 的短调用。
274
+ func tryGenerate(ctx context.Context, in Request, name string, budget time.Duration, start time.Time) (Result, error) {
275
+ engine := agent.Lookup(name)
276
+ if engine == nil {
277
+ return Result{}, fmt.Errorf("建议生成:未知引擎 %q", name)
278
+ }
279
+ reply := strings.TrimSpace(in.Reply)
280
+ attemptCtx, cancel := context.WithTimeout(ctx, budget)
281
+ defer cancel()
282
+ /* 建议是纯文本短任务,**一律不思考**。Reasoning 只作为没有 NoThinking 通道的
283
+ 引擎的兜底(minimal 仍比默认高档轻);能真正关闭的引擎看 NoThinking。 */
284
+ reasoning := "minimal"
285
+ model := strings.TrimSpace(in.Model)
286
+ if name == DefaultEngine {
287
+ /* 建议一律不思考:llm 有 no-think 变体就用它(默认引擎不配模型时也走这里)。
288
+ 用户显式选了会思考的模型(如 MiniMax-M3.1-Flash)时,宁可换同文件的
289
+ no-think 变体,也不让建议在思考上多花几秒;配置里没有变体才保留原模型。 */
290
+ model = noThinkLLMModel(model)
291
+ }
292
+ runner := &agent.Runner{Engine: engine, Timeout: budget}
293
+ resp, err := runner.Run(attemptCtx, agent.Request{
294
+ Model: model,
295
+ Tools: agent.ToolsOff,
296
+ /* NoThinking:claude → --thinking disabled,codex → effort none,
297
+ llm 换成 thinking.disabled 变体,codebuddy → settings:false,
298
+ dsh → initialize.reasoningEffort=off。 */
299
+ NoThinking: true,
300
+ Reasoning: reasoning,
190
301
  MaxTokens: 300,
191
302
  Messages: []agent.Message{{Role: "user", Content: Prompt(in.User, reply, in.Count)}},
192
303
  JSONSchema: Schema(),
@@ -2,6 +2,8 @@ package suggest
2
2
 
3
3
  import (
4
4
  "context"
5
+ "os"
6
+ "path/filepath"
5
7
  "strings"
6
8
  "testing"
7
9
 
@@ -130,6 +132,12 @@ func TestGenerateUsesToolsOffAndNoSession(t *testing.T) {
130
132
  if f.got.JSONSchema == nil || len(f.got.JSONSchema.Required) != 1 {
131
133
  t.Fatalf("缺少结构化输出契约")
132
134
  }
135
+ if f.got.Reasoning != "minimal" {
136
+ t.Fatalf("建议调用必须用 minimal 兜底档:%q", f.got.Reasoning)
137
+ }
138
+ if !f.got.NoThinking {
139
+ t.Fatalf("建议调用必须显式 NoThinking(claude/codebuddy/codex/dsh 各自关闭思考)")
140
+ }
133
141
  }
134
142
 
135
143
  func TestDropCreativeRestarts(t *testing.T) {
@@ -216,3 +224,45 @@ func TestGenerateEmptyReplyShortCircuits(t *testing.T) {
216
224
  t.Fatalf("空正文不该调引擎:%#v %#v", res.Suggestions, f.got.Messages)
217
225
  }
218
226
  }
227
+
228
+ func TestEverySuggestionEngineUsesNoThinking(t *testing.T) {
229
+ // 官方文档确认 codebuddy 的 alwaysThinkingEnabled / dsh 的 reasoningEffort=off
230
+ // 都能关思考;建议链路不再把它们预先改道到 llm,而是让引擎自己按 NoThinking 执行。
231
+ for _, name := range []string{"llm", "claude", "codex", "codebuddy", "dsh"} {
232
+ f := &fakeEngine{name: name, text: `{"suggestions":["继续"]}`}
233
+ agent.Register(f)
234
+ res, err := Generate(context.Background(), Request{Reply: "要我继续吗?", Engine: name})
235
+ if err != nil {
236
+ t.Fatalf("%s: Generate: %v", name, err)
237
+ }
238
+ if !f.got.NoThinking || len(f.got.Messages) == 0 {
239
+ t.Fatalf("%s 必须真的执行且带 NoThinking:%#v", name, f.got)
240
+ }
241
+ if res.Engine != name {
242
+ t.Fatalf("%s: 不应预先改道,got engine %q", name, res.Engine)
243
+ }
244
+ }
245
+ }
246
+
247
+ func TestFastLLMModelPicksNoThinkVariant(t *testing.T) {
248
+ dir := t.TempDir()
249
+ p := filepath.Join(dir, "models.json")
250
+ raw := `[
251
+ {"id":"slow","model":"MiniMax-M3.1-Flash","url":"http://x/v1","apiKey":"k"},
252
+ {"id":"fast-nothink","model":"MiniMax-M3","url":"http://x/v1","apiKey":"k",
253
+ "extraBody":{"thinking":{"type":"disabled"}}}
254
+ ]`
255
+ if err := os.WriteFile(p, []byte(raw), 0o600); err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ t.Setenv("MAGIC_AGENT_MODELS", p)
259
+ if got := fastLLMModel(); got != "fast-nothink" {
260
+ t.Fatalf("fastLLMModel = %q, want fast-nothink", got)
261
+ }
262
+ if got := noThinkLLMModel("slow"); got != "fast-nothink" {
263
+ t.Fatalf("noThinkLLMModel(slow) = %q, want fast-nothink(用户选了会思考的模型也要换成 no-think)", got)
264
+ }
265
+ if got := noThinkLLMModel("fast-nothink"); got != "fast-nothink" {
266
+ t.Fatalf("noThinkLLMModel(fast-nothink) = %q, want 原样保留", got)
267
+ }
268
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.3.0",
3
- "versionCode": 20300,
4
- "builtAt": "2026-10-23T00:29:48.082Z"
2
+ "version": "2.3.2",
3
+ "versionCode": 20302,
4
+ "builtAt": "2026-10-23T03:26:46.892Z"
5
5
  }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magic-agent",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "专业的 agent CLI 代理工具 - 统一 claude / codebuddy / trae / llm / codex / openclaw 引擎",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -19,7 +19,8 @@
19
19
  "check:conv-host": "node tests/check-conv-shared-host.mjs",
20
20
  "check:conv-mobile": "node tests/check-conv-mobile-host.mjs",
21
21
  "check:conv-comp": "node tests/check-conv-component.mjs",
22
- "check:conv-queue": "node tests/check-conv-queue.mjs"
22
+ "check:conv-queue": "node tests/check-conv-queue.mjs",
23
+ "check:follow": "node tests/check-follow-retry.mjs"
23
24
  },
24
25
  "files": [
25
26
  "npm/bin/",