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
@@ -5,7 +5,7 @@
5
5
  * 现在 index 只留两个空容器(#reqs / #reqDetail)+ 一次 mount,本文件自包含:
6
6
  * · 一级:空间目录(空间 → 模块,功能点不画);
7
7
  * · 二级:点模块进去,**只看这个模块**的需求列表;
8
- * · 「整理」:POST /desk/requirements/consolidate(全量 / 单空间 / 二级页单空间)。
8
+ * · 「整理」:POST /desk/requirements/consolidate(全量 / 单空间 / 二级页单模块)。
9
9
  *
10
10
  * 与宿主(index.html)的边界:只通过 mount(host) 注入的能力交互 ——
11
11
  * getBase / getToken(切环境后是活的,必须 getter,不能传快照)、
@@ -26,6 +26,7 @@
26
26
  var REQ_DATA = null; // 最近一次清单(重画用,避免每次点都重新拉)
27
27
  var REQ_BUSY = false; // 「整理」进行中(防重复点击)
28
28
  var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
29
+ var REQ_BUSY_KEY = ''; // 本次整理的组头(空间 key;空 = 全部整理,所有组头都置忙)
29
30
  var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
30
31
  var REQ_ALIVE = null; // 仍有会话的空间路径集合;null = 拿不到会话清单(不筛)
31
32
  var REQ_DETAIL = null; // 二级视图:{key: 工作区 key, mod: 模块名, ws: 工作区对象}
@@ -41,7 +42,7 @@
41
42
  '<div class="history__top">' +
42
43
  '<button class="topbar__icon" id="btnReqDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>' +
43
44
  '<span class="history__title" id="reqDetailTitle">需求</span>' +
44
- '<button class="history__disconnect" id="btnReqDetailSync" type="button">整理</button>' +
45
+ '<button class="history__disconnect" id="btnReqDetailSync" type="button" title="只整理这个模块">整理</button>' +
45
46
  '</div>' +
46
47
  '<div class="history__list" id="reqDetailList"></div>';
47
48
 
@@ -330,17 +331,19 @@
330
331
  服务端按 state 判断走哪条路:
331
332
  · 没初始化过 → 先按空间目录反推需求基线(首次可能跑几分钟,按钮要能等);
332
333
  · 已初始化 → 只消费上次整理之后的新会话。
333
- 入口有两个:
334
+ 入口有三个:
334
335
  · 每个空间组头的「整理」——只整理这个空间(用户点哪个就是哪个,不猜);
335
- · 右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
336
- 两个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
337
- function reqPostConsolidate(ws) {
336
+ · 二级页右上角「整理」——只整理当前模块(body 带 module,不推进空间游标);
337
+ · 一级页右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
338
+ 三个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
339
+ function reqPostConsolidate(ws, module) {
338
340
  var hdr = { 'Content-Type': 'application/json' };
339
341
  if (H.getToken()) hdr['X-Magic-Token'] = H.getToken();
340
342
  return fetch(H.getBase() + '/desk/requirements/consolidate', {
341
343
  method: 'POST', headers: hdr,
342
344
  body: JSON.stringify({
343
345
  workspace: ws || '',
346
+ module: module || '',
344
347
  engine: H.getRunPref().engine || '',
345
348
  model: (H.getRunPref().engine && H.getRunPref().model) || ''
346
349
  })
@@ -354,10 +357,13 @@
354
357
  });
355
358
  }
356
359
 
357
- /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
358
- label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
359
- 二级页那枚「整理」也跟着禁用 —— 归纳期间它和组头一样不能重复点。 */
360
- function reqSetBusy(on, label) {
360
+ /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用(防重复点)。
361
+ 只有**真正在整理的那个入口**显示「整理中…」:
362
+ · 点某个空间 / 某个模块的「整理」→ 只有这一组的文案变;
363
+ · 点「全部整理」→ 所有组头一起变(那才是全部在跑)。
364
+ 其余按钮只置灰、保持「整理」—— 否则点一个模块会让人以为所有空间都在整理。 */
365
+ function reqSetBusy(on, label, busyKey) {
366
+ if (busyKey === undefined) busyKey = REQ_BUSY_KEY;
361
367
  if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
362
368
  else REQ_BUSY_LABEL = '全部整理';
363
369
  var btn = els.sync;
@@ -372,38 +378,43 @@
372
378
  }
373
379
  Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
374
380
  b.disabled = !!on;
375
- b.textContent = on ? '整理中…' : '整理';
381
+ if (!on) { b.textContent = '整理'; return; }
382
+ b.textContent = (!busyKey || b.getAttribute('data-ws') === busyKey) ? '整理中…' : '整理';
376
383
  });
377
384
  }
378
385
 
379
- function consolidateRequirements(target) {
386
+ /* 整理一个空间(module 为空)或一个模块(module 非空)。
387
+ · 空间级:消费该空间游标之后的新会话,可能同时更新多个模块;
388
+ · 模块级:只更新这个模块,**不推进空间游标** —— 别的模块的需求仍留在
389
+ pending,等空间级「整理」/下一轮自动归纳处理,不会被这一下吞掉。
390
+ 服务端按 state 判断走哪条路:没初始化过 → 先按空间目录反推基线;
391
+ 模块级不会触发首次初始化(目录基线天然是空间级的)。 */
392
+ function consolidateRequirements(target, module) {
380
393
  if (REQ_BUSY || !target) return;
381
394
  var ws = target.dir || target.id || '';
382
395
  if (!ws) return;
383
- var firstTime = target.initialized === false;
384
- REQ_BUSY = true;
385
- reqSetBusy(true, firstTime ? '初始化中…' : '整理中…');
396
+ module = String(module || '').trim();
397
+ var firstTime = target.initialized === false && !module;
386
398
  var targetKey = target.id || target.dir || '';
387
- var groupBtn = null;
388
- Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
389
- if (!groupBtn && targetKey && b.getAttribute('data-ws') === targetKey) groupBtn = b;
390
- });
391
- /* 点的是哪个空间,就只让哪个组头显示「初始化中…」;其它组头保持「整理中…」。 */
392
- if (groupBtn) groupBtn.textContent = firstTime ? '初始化中…' : '整理中…';
393
- reqPostConsolidate(ws)
399
+ REQ_BUSY = true;
400
+ REQ_BUSY_KEY = targetKey;
401
+ reqSetBusy(true, firstTime ? '初始化中…' : '整理中…', targetKey);
402
+ reqPostConsolidate(ws, module)
394
403
  .then(function (d) {
404
+ var prefix = module ? '模块「' + module + '」' : '';
395
405
  if (d.bootstrap) {
396
406
  if (d.created || d.updated) toast('已按空间目录生成需求:新建 ' + (d.created || 0) + ' · 更新 ' + (d.updated || 0));
397
407
  else toast('已按空间目录初始化:暂未识别到功能点');
398
408
  }
399
- else if (d.noop) toast('没有新的对话需要整理');
400
- else if (d.updated || d.created) toast('整理完成:更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
401
- else toast('整理完成');
409
+ else if (d.noop) toast(prefix ? prefix + '没有新的对话需要整理' : '没有新的对话需要整理');
410
+ else if (d.updated || d.created) toast((prefix || '整理完成') + ':更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
411
+ else toast(prefix ? prefix + '整理完成' : '整理完成');
402
412
  loadRequirements(true); // 后台刷新(不闪「读取中」)
403
413
  })
404
414
  .catch(function (e) { toast('整理失败:' + ((e && e.message) || e)); })
405
415
  .then(function () {
406
416
  REQ_BUSY = false;
417
+ REQ_BUSY_KEY = '';
407
418
  reqSetBusy(false, '全部整理');
408
419
  });
409
420
  }
@@ -438,10 +449,12 @@
438
449
  return;
439
450
  }
440
451
  REQ_BUSY = true;
452
+ REQ_BUSY_KEY = ''; // 空 = 全部整理:所有组头一起显示「整理中…」
441
453
  reqSetBusy(true, '整理中 0/' + list.length);
442
454
  var i = 0, ok = 0, fail = 0, created = 0, updated = 0, firstErr = '';
443
455
  function done() {
444
456
  REQ_BUSY = false;
457
+ REQ_BUSY_KEY = '';
445
458
  reqSetBusy(false, '全部整理');
446
459
  loadRequirements(true);
447
460
  if (fail) {
@@ -456,7 +469,7 @@
456
469
  REQ_BUSY_LABEL = '整理中 ' + i + '/' + list.length;
457
470
  var btn = els.sync;
458
471
  if (btn) btn.textContent = REQ_BUSY_LABEL;
459
- reqPostConsolidate(w.dir || w.id || '')
472
+ reqPostConsolidate(w.dir || w.id || '', '')
460
473
  .then(function (d) {
461
474
  ok++;
462
475
  created += Number(d.created) || 0;
@@ -487,13 +500,13 @@
487
500
  els.sync = root.querySelector('#btnReqSync');
488
501
  els.detailBack = detail.querySelector('#btnReqDetailBack');
489
502
  els.detailSync = detail.querySelector('#btnReqDetailSync');
490
- /* 两个动作:全部整理 / 返回一级;二级页的「整理」只作用于当前空间
491
- (不是全部空间 —— 二级页的作用域就是这个空间,标成「全部整理」会让人以为还是全局动作)。 */
503
+ /* 两个动作:全部整理 / 返回一级;二级页的「整理」只作用于**当前模块**
504
+ (不是整空间、更不是全部空间 —— 用户点哪个模块,就只整理哪个模块)。 */
492
505
  if (els.sync) els.sync.onclick = function () { consolidateAllRequirements(); };
493
506
  if (els.detailBack) els.detailBack.onclick = function () { closeReqDetail(); };
494
507
  if (els.detailSync) els.detailSync.onclick = function () {
495
508
  if (!REQ_DETAIL || !REQ_DETAIL.ws) return;
496
- consolidateRequirements(REQ_DETAIL.ws);
509
+ consolidateRequirements(REQ_DETAIL.ws, REQ_DETAIL.mod);
497
510
  };
498
511
  }
499
512
 
@@ -149,6 +149,13 @@ const (
149
149
  FeatureSessionEvents = "feature.session.events"
150
150
  // FeatureSessionControl `--stream --control`:stdin NDJSON 控制通道(打断 / 收工 / 回审批)。
151
151
  FeatureSessionControl = "feature.session.control"
152
+ // FeatureSessionHosted `--hosted`:这一轮的用户提问由客户端「会话托管」自动发出。
153
+ //
154
+ // 为什么归 core:托管本身是**纯客户端策略**(执行仍走普通 ask),但「这句是托管的」
155
+ // 要写进会话历史,回放时才能把气泡画成「托管发送」而不是「我」—— 历史在 core 手里,
156
+ // 所以标识只能由 core 落。客户端据此决定要不要把 hosted 透传给 core;老 core 没有
157
+ // 这个位时插件忽略该字段(实时气泡仍由客户端自己标),不阻断发送。
158
+ FeatureSessionHosted = "feature.session.hosted"
152
159
  // FeatureSessionTeam `--team`:团队模式(主引擎拆任务、子引擎分头执行、主引擎汇总)。
153
160
  //
154
161
  // 为什么归 core 而不是某个引擎:它是**会话层的一种运行模式**,任何能当主引擎的
@@ -164,5 +171,5 @@ const (
164
171
 
165
172
  // CoreFeatures 返回 core 自身提供的通道能力(字典序,稳定)。
166
173
  func CoreFeatures() []string {
167
- return []string{FeatureRequirements, FeatureSessionControl, FeatureSessionEvents, FeatureSessionTeam}
174
+ return []string{FeatureRequirements, FeatureSessionControl, FeatureSessionEvents, FeatureSessionHosted, FeatureSessionTeam}
168
175
  }
@@ -238,9 +238,11 @@ func (e *ClaudeEngine) buildArgsBase(req Request) []string {
238
238
  if m := stripModelPrefix(req.Model); m != "" {
239
239
  args = append(args, "--model", m)
240
240
  }
241
- // 推理档位:claude 的 --effort 只认 low/medium/high/xhigh/max(无 minimal),
242
- // 传 minimal 时降级为 low,避免 clap 直接拒绝整轮调用。
243
- if lv := ReasoningLevel(req.Reasoning); lv != "" {
241
+ // 推理 / 思考:建议这类短任务显式关闭(--thinking disabled);
242
+ // 其余调用仍按 --effort 档位。两者互斥,关闭时不传 effort,避免又落回默认高档。
243
+ if req.NoThinking {
244
+ args = append(args, "--thinking", "disabled")
245
+ } else if lv := ReasoningLevel(req.Reasoning); lv != "" {
244
246
  if lv == "minimal" {
245
247
  lv = "low"
246
248
  }
@@ -375,8 +375,12 @@ func (c codebuddyCore) buildArgsBase(req Request) []string {
375
375
  args = append(args, "--model", c.model)
376
376
  }
377
377
  // 推理档位:codebuddy 的 --effort 认 minimal/low/medium/high/xhigh/max。
378
- if lv := ReasoningLevel(req.Reasoning); lv != "" {
379
- args = append(args, "--effort", lv)
378
+ // NoThinking 走官方 settings alwaysThinkingEnabled=false(见 agentSettingsPayload),
379
+ // 不传 --effort off(该值不在 --effort 合法枚举里)。
380
+ if !req.NoThinking {
381
+ if lv := ReasoningLevel(req.Reasoning); lv != "" {
382
+ args = append(args, "--effort", lv)
383
+ }
380
384
  }
381
385
  // 统一参数矩阵:`--settings` 只接受一份载荷,故 MaxTokens 的 env 注入与
382
386
  // 四档模型的 sandbox / autoMode / permissions 合并进同一个 JSON。
@@ -540,8 +540,13 @@ func (e *CodexEngine) buildArgs(req Request, prompt, outFile, schemaFile string)
540
540
  }
541
541
  }
542
542
  // 推理档位:`-c` 在 `exec` 与 `exec resume` 下都可用(同 model 覆写),
543
- // 故不按子命令分流。codex 的取值:minimal/low/medium/high/xhigh/max。
544
- if lv := ReasoningLevel(req.Reasoning); lv != "" {
543
+ // 故不按子命令分流。codex 的取值:none/minimal/low/medium/high/xhigh/max。
544
+ // 建议这类短任务用 none 明确关闭思考(不支持 none 的模型会在服务端报错,
545
+ // 由建议层降级到 llm,不会拖住用户会话)。
546
+ if req.NoThinking {
547
+ args = append(args, "-c", "model_reasoning_effort="+tomlBasicString("none"),
548
+ "-c", "model_reasoning_summary="+tomlBasicString("none"))
549
+ } else if lv := ReasoningLevel(req.Reasoning); lv != "" {
545
550
  args = append(args, "-c", "model_reasoning_effort="+tomlBasicString(lv))
546
551
  }
547
552
  // SystemPrompt → -c developer_instructions=<TOML 字符串>
@@ -167,6 +167,11 @@ type dshSDKInitParams struct {
167
167
  Provider string `json:"provider"`
168
168
  Model string `json:"model"`
169
169
  MaxTokens int `json:"maxTokens,omitempty"`
170
+ // ReasoningEffort 为空 = 沿用模型默认;NoThinking 时传官方值 "off"。
171
+ // 官方依据:dsh-sdk-protocol InitializeParams.reasoningEffort;
172
+ // dsh-llm-deepseek README:"off" 序列化为 thinking.type=disabled。
173
+ // pi-ai 路由需在 profile 声明 reasoningEfforts.off(本机 sdk patch 已加)。
174
+ ReasoningEffort string `json:"reasoningEffort,omitempty"`
170
175
  }
171
176
 
172
177
  // dshSDKTextBlock = TextBlock(ContentBlock 的 text 成员)。
@@ -410,6 +415,9 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
410
415
 
411
416
  // ① initialize:握手失败 = SDK 通道不可用(旧版 dsh 没有该 profile 时会走到这里)。
412
417
  initParams := dshSDKInitParams{Cwd: workspace, Provider: provider, Model: model, MaxTokens: req.MaxTokens}
418
+ if req.NoThinking {
419
+ initParams.ReasoningEffort = "off"
420
+ }
413
421
  initID, err := send("initialize", initParams)
414
422
  if err != nil {
415
423
  return dshSDKTurnResult{}, fmt.Errorf("%w: 写 initialize: %v", errDshSDKUnavailable, err)
@@ -261,6 +261,23 @@ func TestDshSDKIgnoresOtherSessions(t *testing.T) {
261
261
  }
262
262
  }
263
263
 
264
+ // NoThinking:官方 InitializeParams.reasoningEffort=off,不能只靠隐藏事件。
265
+ func TestDshSDKNoThinkingSendsOff(t *testing.T) {
266
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
267
+ dir := t.TempDir()
268
+ bin, _, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
269
+
270
+ if _, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
271
+ Messages: []Message{{Role: "user", Content: "hi"}},
272
+ NoThinking: true,
273
+ }); err != nil {
274
+ t.Fatalf("Complete: %v", err)
275
+ }
276
+ if got := readFileString(t, stdinLog); !strings.Contains(got, `"reasoningEffort":"off"`) {
277
+ t.Errorf("initialize 应带 reasoningEffort=off: %s", got)
278
+ }
279
+ }
280
+
264
281
  // initialize 被服务端明确拒绝(模型没配)→ **真失败**,不静默回退 headless。
265
282
  func TestDshSDKInitializeRejectedIsFatal(t *testing.T) {
266
283
  dshSDKHome(t, "modelverse", "no-such-model")
@@ -154,6 +154,16 @@ type Request struct {
154
154
  // trae/llm/openclaw/dsh/arkclaw 无对应参数。
155
155
  Reasoning string
156
156
 
157
+ // NoThinking 请求引擎关闭模型思考:
158
+ // claude --thinking disabled(官方)
159
+ // codex -c model_reasoning_effort="none" + model_reasoning_summary="none"
160
+ // codebuddy settings alwaysThinkingEnabled=false(官方 settings / SDK thinking:disabled)
161
+ // dsh InitializeParams.reasoningEffort="off"(官方 SDK;dsh-llm-deepseek 会发 thinking.type=disabled)
162
+ // llm 选同线 thinking.disabled 变体;没有变体时传 reasoning_effort="none"
163
+ // (CLI 委托路径用 llm 的官方 -o reasoning_effort none)
164
+ // 其余引擎没有已知开关;调用方(建议生成)仍会标记,失败后再降级 llm。
165
+ NoThinking bool
166
+
157
167
  // JSONSchema 是可选的结构化输出约束。
158
168
  //
159
169
  // 各引擎行为:
@@ -274,6 +284,16 @@ type Response struct {
274
284
  Attempts int
275
285
  }
276
286
 
287
+ // NoThinkingSupportOf 引擎是否有已知的关闭思考手段。
288
+ // 只列官方文档/参数确认过的;未知引擎返回 false,调用方应提示而不是假装关闭。
289
+ func NoThinkingSupportOf(name string) bool {
290
+ switch name {
291
+ case "claude", "codex", "codebuddy", "codebuddy-ai", "dsh", "llm":
292
+ return true
293
+ }
294
+ return false
295
+ }
296
+
277
297
  // Engine 是一个 agent CLI 后端的统一抽象。
278
298
  type Engine interface {
279
299
  // Name 引擎标识("claude" / "codebuddy" / "trae")。
@@ -190,6 +190,42 @@ func TestReasoningEffortArgs(t *testing.T) {
190
190
  t.Errorf("codex model_reasoning_effort 未透传: %v", codexArgs)
191
191
  }
192
192
 
193
+ /* NoThinking(建议生成):每个有原生开关的引擎都要真的关闭,不能只是 minimal。 */
194
+ claudeNo := (&ClaudeEngine{}).buildArgsBase(Request{NoThinking: true, Reasoning: "max"})
195
+ if !hasPair(claudeNo, "--thinking", "disabled") || hasPair(claudeNo, "--effort", "max") {
196
+ t.Errorf("claude NoThinking 应 --thinking disabled 且不传 effort: %v", claudeNo)
197
+ }
198
+ // codebuddy:NoThinking 走官方 settings alwaysThinkingEnabled=false,
199
+ // 不能再传 --effort xhigh(那会把思考档位又打开)。
200
+ cbNo := (&CodeBuddyEngine{}).buildArgsBase(Request{NoThinking: true, Reasoning: "xhigh"})
201
+ if hasPair(cbNo, "--effort", "off") || hasPair(cbNo, "--effort", "xhigh") {
202
+ t.Errorf("codebuddy NoThinking 不应传 --effort: %v", cbNo)
203
+ }
204
+ codexNo := (&CodexEngine{}).buildArgs(Request{NoThinking: true, Reasoning: "max"}, "hi", "", "")
205
+ if !hasPair(codexNo, "-c", `model_reasoning_effort="none"`) ||
206
+ !hasPair(codexNo, "-c", `model_reasoning_summary="none"`) ||
207
+ hasPair(codexNo, "-c", `model_reasoning_effort="max"`) {
208
+ t.Errorf("codex NoThinking 应 none 且不传 max: %v", codexNo)
209
+ }
210
+ // dsh 的 launcher/headless 没有 reasoning 开关:NoThinking 对它保持现状,
211
+ // 不能塞 --effort(实测启动器报 "--profile <name> is required")。
212
+ dshNo := dshHeadlessArgs("headless", "", "hi", false)
213
+ if len(dshNo) < 3 || dshNo[0] != "--profile" || dshNo[1] != "headless" {
214
+ t.Errorf("dsh 参数不应被 NoThinking 改坏: %v", dshNo)
215
+ }
216
+
217
+ // 官方关闭思考能力表:只列有依据的;trae/openclaw/arkclaw 不假装支持。
218
+ for _, e := range []string{"claude", "codex", "codebuddy", "dsh", "llm"} {
219
+ if !NoThinkingSupportOf(e) {
220
+ t.Errorf("NoThinkingSupportOf(%s) 应为 true", e)
221
+ }
222
+ }
223
+ for _, e := range []string{"trae", "openclaw", "arkclaw"} {
224
+ if NoThinkingSupportOf(e) {
225
+ t.Errorf("NoThinkingSupportOf(%s) 应为 false", e)
226
+ }
227
+ }
228
+
193
229
  // off/none/disabled 一律归一为「不传」。
194
230
  for _, v := range []string{"off", "none", "disabled", " OFF "} {
195
231
  if lv := ReasoningLevel(v); lv != "" {
@@ -893,6 +929,22 @@ func TestCodeBuddyMaxTokensSettings(t *testing.T) {
893
929
  }
894
930
  }
895
931
 
932
+ // TestCodeBuddyNoThinkingSettings 官方 settings 键:NoThinking 时 alwaysThinkingEnabled=false。
933
+ func TestCodeBuddyNoThinkingSettings(t *testing.T) {
934
+ dir := t.TempDir()
935
+ w, log := argsCaptureCLI(t, dir, "codebuddy", `echo '{"type":"result","result":"ok"}'`)
936
+ if _, err := w.codebuddy().Complete(context.Background(), Request{
937
+ Messages: []Message{{Role: "user", Content: "hi"}},
938
+ NoThinking: true,
939
+ }); err != nil {
940
+ t.Fatalf("Complete: %v", err)
941
+ }
942
+ data, _ := os.ReadFile(log)
943
+ if !strings.Contains(string(data), `"alwaysThinkingEnabled":false`) {
944
+ t.Errorf("codebuddy 应写官方 settings alwaysThinkingEnabled=false: %q", data)
945
+ }
946
+ }
947
+
896
948
  // TestNoSettingsWhenMaxTokensUnset MaxTokens 未设置时不注入 --settings。
897
949
  func TestNoSettingsWhenMaxTokensUnset(t *testing.T) {
898
950
  cases := []struct {
@@ -145,6 +145,12 @@ func (e *LLMEngine) buildArgs(req Request, prompt string) []string {
145
145
  if req.Temperature != nil {
146
146
  args = append(args, "-o", "temperature", formatTemperature(*req.Temperature))
147
147
  }
148
+ if req.NoThinking {
149
+ // simonw/llm 的官方 model option(llm.datasette.io/en/stable/usage.html:
150
+ // -o/--option key value,OpenAI 系模型暴露 reasoning_effort)。支持该 option
151
+ // 的插件会把它发到端点;不支持时直接报错 —— 显式 --disable-thinking 的预期结果。
152
+ args = append(args, "-o", "reasoning_effort", "none")
153
+ }
148
154
  return append(args, appendAttachmentSection(prompt, nonImage))
149
155
  }
150
156
 
@@ -207,6 +213,11 @@ func (e *LLMEngine) ListModels(ctx context.Context) ([]string, error) {
207
213
  // --json 输出同 `llm logs --json` 的结构(数组,每项含 response /
208
214
  // input_tokens / output_tokens),解析失败时退回纯文本路径,不引入新的失败点。
209
215
  func (e *LLMEngine) Complete(ctx context.Context, req Request) (Response, error) {
216
+ // --disable-thinking:优先换成同一条线上的 no-think 变体(MiniMax-M3.1-Flash
217
+ // 这类模型对 thinking.disabled 直接 400,不能靠请求体硬塞)。
218
+ if req.NoThinking {
219
+ req.Model = NoThinkLLMModel(req.Model)
220
+ }
210
221
  // 配置文件模型(models.json):命中 id → 直连端点(见 llmhttp.go)。
211
222
  // 为什么必须按 id:id 唯一,而真实模型名会重复(MiniMax-M3 与 minimax-nothink
212
223
  // 真实名相同,只差 extraBody)—— 用名字做参数就无法区分。
@@ -317,6 +328,9 @@ func parseLLMJSONEntry(raw string) (llmJSONEntry, bool) {
317
328
  // Stream 实现 Streamer:llm prompt 默认流式,逐行读纯文本 stdout,
318
329
  // thinkSplitter 把标签块路由到 thinking 通道。
319
330
  func (e *LLMEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
331
+ if req.NoThinking {
332
+ req.Model = NoThinkLLMModel(req.Model)
333
+ }
320
334
  // 配置文件模型:命中 id → 直连 SSE(见 llmhttp.go / completeConfigured 上方注释)
321
335
  if m, ok := ResolveLLMConfigModel(req.Model); ok {
322
336
  return e.streamConfigured(ctx, m, req, onEvent)
@@ -104,6 +104,13 @@ func TestLLMBuildArgsModelOptions(t *testing.T) {
104
104
  t.Errorf("buildArgs = %q, want %q (温度 0 必须透传)", got, want)
105
105
  }
106
106
 
107
+ // NoThinking:llm 官方 model option;只在 CLI 委托路径出现
108
+ got = e.buildArgs(Request{NoThinking: true}, "hi")
109
+ want = []string{"prompt", "-o", "reasoning_effort", "none", "hi"}
110
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
111
+ t.Errorf("buildArgs = %q, want %q", got, want)
112
+ }
113
+
107
114
  // 都不给
108
115
  got = e.buildArgs(Request{Model: "minimax-m3"}, "hi")
109
116
  for _, a := range got {
@@ -549,3 +556,20 @@ func TestLLMDetectMissingCli(t *testing.T) {
549
556
  t.Errorf("note = %q, want the missing path", note)
550
557
  }
551
558
  }
559
+
560
+ func TestNoThinkLLMModelPicksAnyVariant(t *testing.T) {
561
+ dir := t.TempDir()
562
+ p := filepath.Join(dir, "models.json")
563
+ raw := `[
564
+ {"id":"MiniMax-M3.1-Flash","model":"MiniMax-M3.1-Flash","url":"http://x/v1","apiKey":"k"},
565
+ {"id":"minimax-nothink","model":"MiniMax-M3","url":"http://x/v1","apiKey":"k",
566
+ "extraBody":{"thinking":{"type":"disabled"}}}
567
+ ]`
568
+ if err := os.WriteFile(p, []byte(raw), 0o600); err != nil {
569
+ t.Fatal(err)
570
+ }
571
+ t.Setenv("MAGIC_AGENT_MODELS", p)
572
+ if got := NoThinkLLMModel("MiniMax-M3.1-Flash"); got != "minimax-nothink" {
573
+ t.Fatalf("NoThinkLLMModel = %q, want minimax-nothink", got)
574
+ }
575
+ }
@@ -92,6 +92,12 @@ func buildConfiguredBody(m LLMConfigModel, req Request, stream bool) (map[string
92
92
  } else if m.Temperature != nil {
93
93
  body["temperature"] = *m.Temperature
94
94
  }
95
+ if req.NoThinking && !LLMModelDisablesThinking(m) {
96
+ // 没有同线 no-think 变体时,用 llm/OpenAI 兼容端点认的官方参数。
97
+ // 若端点不支持该参数会返回 400 —— 这是显式 --disable-thinking 的预期失败,
98
+ // 不静默退回「假装关闭」。
99
+ body["reasoning_effort"] = "none"
100
+ }
95
101
  return body, nil
96
102
  }
97
103
 
@@ -274,6 +274,33 @@ func TestUnknownModelFallsBackToCLI(t *testing.T) {
274
274
  }
275
275
  }
276
276
 
277
+ func TestBuildConfiguredBodyNoThinking(t *testing.T) {
278
+ m := LLMConfigModel{ID: "x", Model: "x", URL: "http://x/v1"}
279
+ body, err := buildConfiguredBody(m, Request{
280
+ Messages: []Message{{Role: "user", Content: "hi"}},
281
+ NoThinking: true,
282
+ }, false)
283
+ if err != nil {
284
+ t.Fatal(err)
285
+ }
286
+ if body["reasoning_effort"] != "none" {
287
+ t.Errorf("没有 no-think 变体时应传 reasoning_effort=none: %#v", body["reasoning_effort"])
288
+ }
289
+
290
+ noThink := LLMConfigModel{ID: "x-nothink", Model: "x", URL: "http://x/v1",
291
+ ExtraBody: map[string]any{"thinking": map[string]any{"type": "disabled"}}}
292
+ body, err = buildConfiguredBody(noThink, Request{
293
+ Messages: []Message{{Role: "user", Content: "hi"}},
294
+ NoThinking: true,
295
+ }, false)
296
+ if err != nil {
297
+ t.Fatal(err)
298
+ }
299
+ if _, ok := body["reasoning_effort"]; ok {
300
+ t.Errorf("已有 thinking.disabled 时不应再塞 reasoning_effort: %#v", body)
301
+ }
302
+ }
303
+
277
304
  func TestExpandEnvKeep(t *testing.T) {
278
305
  t.Setenv("LLM_TEST_KEY", "secret")
279
306
  cases := map[string]string{
@@ -76,6 +76,54 @@ func (m LLMConfigModel) Endpoint() string {
76
76
  return strings.TrimRight(u, "/") + "/chat/completions"
77
77
  }
78
78
 
79
+ // LLMModelDisablesThinking 判断 models.json 条目是否声明了不思考变体
80
+ // (extraBody.thinking.type == "disabled")。
81
+ func LLMModelDisablesThinking(m LLMConfigModel) bool {
82
+ if strings.TrimSpace(m.ID) == "" || m.ExtraBody == nil {
83
+ return false
84
+ }
85
+ th, ok := m.ExtraBody["thinking"].(map[string]any)
86
+ if !ok {
87
+ return false
88
+ }
89
+ return strings.EqualFold(strings.TrimSpace(fmt.Sprint(th["type"])), "disabled")
90
+ }
91
+
92
+ // NoThinkLLMModel 给 --disable-thinking 选 no-think 变体:
93
+ // 用户已选 no-think → 原样;否则先找 WireModel 相同、声明 thinking.disabled 的条目,
94
+ // 再退到任意 no-think 条目(M3.1-Flash 只能换模型才能关思考)。都没有则保留原模型
95
+ // (由调用方决定是否再传 reasoning_effort=none)。
96
+ func NoThinkLLMModel(selected string) string {
97
+ m, ok := ResolveLLMConfigModel(selected)
98
+ if !ok {
99
+ return strings.TrimSpace(selected)
100
+ }
101
+ if LLMModelDisablesThinking(m) {
102
+ return m.ID
103
+ }
104
+ list, err := LoadLLMConfigModels()
105
+ if err != nil {
106
+ return m.ID
107
+ }
108
+ // 先找同一条线上的变体(不换真实模型),再退到任意 no-think 变体 ——
109
+ // 显式 --disable-thinking 的目标是「不思考」,MiniMax-M3.1-Flash 这类模型
110
+ // 对 thinking.disabled / reasoning_effort=none 直接 400,只能换到 no-think 条目。
111
+ for _, alt := range list {
112
+ if alt.ID == m.ID || alt.WireModel() != m.WireModel() {
113
+ continue
114
+ }
115
+ if LLMModelDisablesThinking(alt) {
116
+ return alt.ID
117
+ }
118
+ }
119
+ for _, alt := range list {
120
+ if alt.ID != m.ID && LLMModelDisablesThinking(alt) {
121
+ return alt.ID
122
+ }
123
+ }
124
+ return m.ID
125
+ }
126
+
79
127
  // LLMModelsPath 返回 models.json 的路径($MAGIC_AGENT_MODELS 优先)。
80
128
  func LLMModelsPath() string {
81
129
  if p := strings.TrimSpace(os.Getenv("MAGIC_AGENT_MODELS")); p != "" {
@@ -383,6 +383,11 @@ func agentSettingsPayload(req Request, engine string) (string, bool) {
383
383
  if env := maxTokensEnv(req.MaxTokens); env != nil {
384
384
  settings["env"] = env
385
385
  }
386
+ // CodeBuddy 官方 settings 键:始终开启思考。NoThinking 时显式关掉;
387
+ // 这是 codebuddy CLI 能对模型请求施加的官方开关(SDK 另有 thinking.type=disabled)。
388
+ if req.NoThinking {
389
+ settings["alwaysThinkingEnabled"] = false
390
+ }
386
391
 
387
392
  // 沙箱 / autoMode / 权限规则只在真会调用工具时注入。
388
393
  if !toolsIsOff(req) {