codebee 0.1.49 → 0.1.50

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.
@@ -5,7 +5,8 @@
5
5
  进程与框架开销,且 CLI 的事件流日志(启动命令、提示词回显、重连报错)会污染
6
6
  对话视图。内置智能体直接打 chat API + 工具循环:
7
7
  - 模型选择:编排者配置(resolve_orchestrator)优先;未启用时扫首个可用供应商;
8
- - 工具:list_files / read_file / write_file,全部锁死在工作目录内;
8
+ - 工具:list_files / read_file / write_file(锁死工作目录内)+ run_command
9
+ (本机全权执行命令,产品定位全信任,与 codex/claude 全权沙箱同档);
9
10
  - 协议:anthropic / openai 走原生工具循环;google 无工具协议(纯文本直答)。
10
11
  密钥冷却/多 KEY 展开复用 modelhub 既有记账(note_key_ok / note_key_error)。
11
12
  """
@@ -17,9 +18,12 @@ import json
17
18
  import mimetypes
18
19
  import os
19
20
  import threading
21
+ import time
22
+ import urllib.parse
23
+ import urllib.request
20
24
  from pathlib import Path
21
25
 
22
- from . import modelhub, runner
26
+ from . import modelhub, runner, tlsctx
23
27
 
24
28
  MAX_TOOL_ITERS = 16 # 单步工具循环上限(防模型打转)
25
29
  READ_MAX_BYTES = 64 * 1024 # read_file 单次读取上限
@@ -30,6 +34,13 @@ MAX_IMAGES = 8 # 单次最多随消息传几张图(请求体
30
34
  IMAGE_MAX_EDGE = 1568 # 长边上限(视觉模型通行建议值,超出等比缩)
31
35
  IMAGE_JPEG_BYTES = 3500 * 1024 # 缩放后仍超此大小 → 转 JPEG q85
32
36
  IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 单张最终字节硬上限(超出剔除并日志)
37
+ THINK_MAX_CHARS = 20000 # 单次生成保留的思考正文上限(防超长思维链撑爆 run.json)
38
+ STREAM_MAX_CHARS = 20000 # 运行中实时回答的保留上限(与 THINK_MAX_CHARS 同口径)
39
+
40
+ CMD_DEFAULT_TIMEOUT = 600 # run_command 默认超时(与 CLI 停滞看门狗同口径)
41
+ CMD_MAX_TIMEOUT = 1800 # 上限:模型传 timeout_sec 超过即钳到这
42
+ CMD_OUT_HEAD = 24 * 1024 # 回灌给模型的输出:头 24K + 尾 8K(报错常在尾部)
43
+ CMD_OUT_TAIL = 8 * 1024
33
44
 
34
45
  _SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户交代的任务。用户的目标、背景与工作目录内的附件就是全部输入。
35
46
 
@@ -37,6 +48,9 @@ _SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户
37
48
  - 需要读文件、看目录、写文件时调用工具;所有路径都是工作目录内的相对路径。
38
49
  - read_file 只能读文本文件;图片、压缩包等二进制文件读不了,如实告知用户即可,不要反复尝试。
39
50
  - 用户消息中的图片附件会直接出现在对话里,可直接看图作答,无需用工具读取。
51
+ - run_command 直接在用户的电脑上执行命令并回传退出码与输出:诊断、修复、改配置、重启服务等操作会真实生效。命令跑完看输出再决定下一步,不要一次性罗列步骤让用户自己敲。
52
+ - Windows 上需要管理员权限的命令(flushdns、winsock reset、防火墙、系统服务等),用 powershell -Command "Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait" 触发 UAC——用户屏幕会弹窗,点允许即提权执行;提权进程的输出拿不到,之后要用普通命令复核效果。macOS/Linux 用 sudo 并在输出里提示用户输密码不可行时改写临时脚本让用户跑。
53
+ - 避免跑长驻/交互式命令(ping -t、top、要按键应答的安装器),它们会拖满超时被强杀。
40
54
  - 产出文件一律 UTF-8 编码。
41
55
  - 回答用户的语言与用户一致(默认中文)。直接给结论和内容,不要输出任何机器标记或协议行。"""
42
56
 
@@ -329,6 +343,47 @@ def _tool_write_file(workdir, args):
329
343
  return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
330
344
 
331
345
 
346
+ def _tool_run_command(workdir, args, cancel_event=None):
347
+ """本机执行一条 shell 命令,返回 (退出码, 合并输出)。
348
+
349
+ 薄适配层:执行/杀树/取消/解码全部复用 runner.run_process(shell_cmd 分支
350
+ → cmd /c 或 /bin/sh -c),仓库唯一的 shell 落点,不另起炉灶。全信任档
351
+ (用户拍板 2026-09-22):无白名单无确认闸,与 codex/claude 全权沙箱同档。
352
+ 提权靠模型自己包 Start-Process -Verb RunAs——UAC 必须人点,绕不过也不该绕。
353
+ 输出按字符头尾截断(解码已在 run_process 完成,无残缺多字节问题)。"""
354
+ cmdline = str(args.get("command") or "").strip()
355
+ if not cmdline:
356
+ return "(命令为空,未执行)"
357
+ try:
358
+ timeout = int(float(args.get("timeout_sec") or CMD_DEFAULT_TIMEOUT))
359
+ except (TypeError, ValueError):
360
+ timeout = CMD_DEFAULT_TIMEOUT
361
+ r = runner.run_process(shell_cmd=cmdline, cwd=os.path.abspath(workdir or "."),
362
+ timeout=max(5, min(timeout, CMD_MAX_TIMEOUT)),
363
+ cancel_event=cancel_event)
364
+ out = runner.clean_cli_text(
365
+ (r.get("stdout") or "") + (("\n" + r["stderr"]) if r.get("stderr") else ""))
366
+ limit = CMD_OUT_HEAD + CMD_OUT_TAIL
367
+ if not out.strip():
368
+ body = "(无输出)"
369
+ elif len(out) <= limit:
370
+ body = out
371
+ else:
372
+ body = (out[:CMD_OUT_HEAD]
373
+ + "\n…(中段省略 %d 字符;头 %dK + 尾 %dK 保留)\n" % (
374
+ len(out) - limit, CMD_OUT_HEAD // 1024, CMD_OUT_TAIL // 1024)
375
+ + out[-CMD_OUT_TAIL:])
376
+ if r.get("cancelled"):
377
+ note = "已取消,进程树已终止"
378
+ elif r.get("timed_out"):
379
+ note = "超时 %d 秒,进程树已强制终止" % timeout
380
+ else:
381
+ note = ""
382
+ prefix = ("(%s)\n" % note) if note else ""
383
+ code = r.get("exit_code")
384
+ return "%s退出码: %s\n%s" % (prefix, "未知" if code is None else code, body)
385
+
386
+
332
387
  TOOLS_SPEC = [
333
388
  {"name": "list_files", "description": "列出工作目录(或其子目录)下的文件",
334
389
  "args": {"path": "子目录相对路径,留空=根目录"}},
@@ -336,17 +391,23 @@ TOOLS_SPEC = [
336
391
  "args": {"path": "文件相对路径"}},
337
392
  {"name": "write_file", "description": "把文本内容写入工作目录内一个文件(UTF-8,父目录自动创建)",
338
393
  "args": {"path": "文件相对路径", "content": "完整文本内容"}},
394
+ {"name": "run_command",
395
+ "description": "在本机执行一条 shell 命令,返回退出码与合并输出。诊断、修复、改配置、重启服务等操作真实生效;先跑命令看结果再定下一步。默认超时 600 秒,可用 timeout_sec 调整(5~1800)。Windows 需要管理员权限的命令(flushdns/防火墙/系统服务等)用 powershell -Command \"Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait\" 触发 UAC(用户点允许后提权执行;提权进程输出拿不到,事后用普通命令复核)。避免长驻/交互式命令(ping -t、top、按键应答的安装器)。",
396
+ "args": {"command": "完整命令行(shell 语法,本机直接执行)",
397
+ "timeout_sec": "可选,超时秒数"}},
339
398
  ]
340
399
 
341
400
  _TOOL_IMPL = {"list_files": _tool_list_files, "read_file": _tool_read_file,
342
- "write_file": _tool_write_file}
401
+ "write_file": _tool_write_file, "run_command": _tool_run_command}
343
402
 
344
403
 
345
- def _exec_tool(workdir, name, args):
404
+ def _exec_tool(workdir, name, args, cancel_event=None):
346
405
  fn = _TOOL_IMPL.get(name or "")
347
406
  if fn is None:
348
407
  return "(未知工具: %s)" % name
349
408
  try:
409
+ if name == "run_command":
410
+ return fn(workdir, args or {}, cancel_event=cancel_event)
350
411
  return fn(workdir, args or {})
351
412
  except Exception as e:
352
413
  return "工具执行失败: %s" % (e)
@@ -404,6 +465,272 @@ def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event
404
465
  return box.get("r") or (0, None, "无响应")
405
466
 
406
467
 
468
+ # ---------------------------------------------------------------- 思考过程(流式)
469
+ # 用户诉求(2026-09-22):「把思考过程打印出来」——直连对话此前整轮只有三点
470
+ # 打字动画,模型的思维链(DeepSeek/vLLM 系 reasoning_content、Anthropic
471
+ # thinking 块)被 _sse_parse 明确丢弃。这里自带一份「认思考」的增量解析:
472
+ # 边收边把思考与正文分别攒起来,交给调用方实时落盘 + 结束后存进步骤记录。
473
+
474
+ def _reason_from_delta(proto, obj):
475
+ """从一条流式事件里取「思考增量」。三协议字段各异,取不到返回空串。
476
+
477
+ openai 兼容面最杂:reasoning_content(DeepSeek/vLLM/云知声实测)、
478
+ reasoning(部分网关)、reasoning_details[].text(OpenRouter 系)。
479
+ anthropic 是 thinking_delta(另有 redacted_thinking 不文本化,跳过)。"""
480
+ if proto == "openai":
481
+ total = ""
482
+ for ch in obj.get("choices") or []:
483
+ d = ch.get("delta") or {}
484
+ for k in ("reasoning_content", "reasoning"):
485
+ v = d.get(k)
486
+ if isinstance(v, str):
487
+ total += v
488
+ for det in (d.get("reasoning_details") or []):
489
+ if isinstance(det, dict) and isinstance(det.get("text"), str):
490
+ total += det["text"]
491
+ return total
492
+ if proto == "anthropic" and obj.get("type") == "content_block_delta":
493
+ d = obj.get("delta") or {}
494
+ if d.get("type") == "thinking_delta":
495
+ return d.get("thinking") or ""
496
+ return ""
497
+ if proto == "google":
498
+ total = ""
499
+ for cand in obj.get("candidates") or []:
500
+ for p in ((cand.get("content") or {}).get("parts") or []):
501
+ if isinstance(p, dict) and p.get("thought"):
502
+ total += p.get("text") or ""
503
+ return total
504
+ return ""
505
+
506
+
507
+ def _reason_from_message(proto, data):
508
+ """非流式响应里的思考字段(网关不支持流式时的兜底)。"""
509
+ if not isinstance(data, dict):
510
+ return ""
511
+ if proto == "anthropic":
512
+ return "\n".join(b.get("thinking", "") for b in (data.get("content") or [])
513
+ if isinstance(b, dict) and b.get("type") == "thinking")
514
+ if proto == "google":
515
+ total = ""
516
+ for cand in data.get("candidates") or []:
517
+ for p in ((cand.get("content") or {}).get("parts") or []):
518
+ if isinstance(p, dict) and p.get("thought"):
519
+ total += p.get("text") or ""
520
+ return total
521
+ msg = ((data.get("choices") or [{}])[0].get("message") or {})
522
+ total = ""
523
+ for k in ("reasoning_content", "reasoning"):
524
+ v = msg.get(k)
525
+ if isinstance(v, str):
526
+ total += v
527
+ for det in (msg.get("reasoning_details") or []):
528
+ if isinstance(det, dict) and isinstance(det.get("text"), str):
529
+ total += det["text"]
530
+ return total
531
+
532
+
533
+ def _stream_accumulate(proto, obj, acc):
534
+ """把一条 SSE 事件并入累加器(就地改写 acc)。返回是否有「有意义的进展」。
535
+
536
+ acc = {text, reasoning, tools, usage};工具参数按 index 归并——openai 的
537
+ function.arguments 与 anthropic 的 input_json_delta 都是分片到达的。"""
538
+ progress = False
539
+ if proto == "anthropic":
540
+ t = obj.get("type")
541
+ if t == "content_block_start":
542
+ blk = obj.get("content_block") or {}
543
+ if blk.get("type") == "tool_use":
544
+ idx = int(obj.get("index") or 0)
545
+ acc["tools"][idx] = {"id": blk.get("id") or "",
546
+ "name": blk.get("name") or "", "raw": ""}
547
+ progress = True
548
+ elif t == "content_block_delta":
549
+ d = obj.get("delta") or {}
550
+ if d.get("type") == "text_delta" and d.get("text"):
551
+ acc["text"] += d["text"]
552
+ progress = True
553
+ elif d.get("type") == "input_json_delta":
554
+ idx = int(obj.get("index") or 0)
555
+ slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
556
+ slot["raw"] += d.get("partial_json") or ""
557
+ progress = True
558
+ elif t == "message_start":
559
+ u = (obj.get("message") or {}).get("usage") or obj.get("usage") or {}
560
+ acc["usage"].update({"input": int(u.get("input_tokens") or 0),
561
+ "output": int(u.get("output_tokens") or 0),
562
+ "cached": (int(u.get("cache_read_input_tokens") or 0)
563
+ + int(u.get("cache_creation_input_tokens") or 0))})
564
+ elif t == "message_delta":
565
+ u = obj.get("usage") or {}
566
+ if u.get("output_tokens"):
567
+ acc["usage"]["output"] = int(u["output_tokens"])
568
+ elif proto == "google":
569
+ for cand in obj.get("candidates") or []:
570
+ for p in ((cand.get("content") or {}).get("parts") or []):
571
+ if not isinstance(p, dict):
572
+ continue
573
+ if p.get("thought"):
574
+ acc["reasoning"] += p.get("text") or ""
575
+ progress = True
576
+ elif p.get("functionCall"):
577
+ fc = p["functionCall"]
578
+ acc["tools"][len(acc["tools"])] = {
579
+ "id": "call_%d" % (len(acc["tools"]) + 1),
580
+ "name": fc.get("name") or "",
581
+ "raw": json.dumps(fc.get("args") or {}, ensure_ascii=False)}
582
+ progress = True
583
+ elif p.get("text"):
584
+ acc["text"] += p["text"]
585
+ progress = True
586
+ u = obj.get("usageMetadata")
587
+ if isinstance(u, dict):
588
+ acc["usage"].update({"input": int(u.get("promptTokenCount") or 0),
589
+ "output": int(u.get("candidatesTokenCount") or 0),
590
+ "total": int(u.get("totalTokenCount") or 0)})
591
+ else:
592
+ for ch in obj.get("choices") or []:
593
+ d = ch.get("delta") or {}
594
+ if isinstance(d.get("content"), str) and d["content"]:
595
+ acc["text"] += d["content"]
596
+ progress = True
597
+ for i, tc in enumerate(d.get("tool_calls") or []):
598
+ if not isinstance(tc, dict):
599
+ continue
600
+ idx = int(tc.get("index") if tc.get("index") is not None else i)
601
+ slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
602
+ if tc.get("id"):
603
+ slot["id"] = tc["id"]
604
+ fn = tc.get("function") or {}
605
+ if fn.get("name"):
606
+ slot["name"] = fn["name"]
607
+ if fn.get("arguments"):
608
+ slot["raw"] += fn["arguments"]
609
+ progress = True
610
+ u = obj.get("usage")
611
+ if isinstance(u, dict):
612
+ acc["usage"].update({"input": int(u.get("prompt_tokens") or 0),
613
+ "output": int(u.get("completion_tokens") or 0),
614
+ "total": int(u.get("total_tokens") or 0),
615
+ "cached": int(((u.get("prompt_tokens_details") or {})
616
+ .get("cached_tokens")) or 0)})
617
+ return progress
618
+
619
+
620
+ def _calls_from_acc(acc):
621
+ """累加器里的工具分片 → [{id, name, args}](参数 JSON 解析失败给空对象)。"""
622
+ out = []
623
+ for idx in sorted(acc["tools"].keys()):
624
+ slot = acc["tools"][idx]
625
+ name = slot.get("name") or ""
626
+ if not name:
627
+ continue
628
+ try:
629
+ args = json.loads(slot.get("raw") or "{}")
630
+ except Exception:
631
+ args = {}
632
+ if not isinstance(args, dict):
633
+ args = {}
634
+ out.append({"id": slot.get("id") or ("call_%d" % (idx + 1)),
635
+ "name": name, "args": args})
636
+ return out
637
+
638
+
639
+ def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
640
+ on_reason=None, on_text=None, on_tick=None, cancel_event=None):
641
+ """流式(SSE)模型调用 → dict(status/text/reasoning/calls/usage/error/events)。
642
+
643
+ 与 _post_interruptible 同款的可打断语义:读取放守护线程,cancel_event 置位
644
+ 即刻放弃等待(socket 随线程结束释放)。三个回调:on_reason 每收到一段思考
645
+ 增量(调用方据此实时打印「思考过程」),on_text 收到**累计**正文(只在真的
646
+ 变长时触发,运行中直接当正文预览),on_tick 是无文本进展时的心跳。
647
+ events=0 表示网关根本没按流回(多半忽略了 stream 字段)——调用方据此回落
648
+ 非流式;events>0 而正文为空则是模型真没说话,不该重发(白烧一次生成)。"""
649
+ acc = {"text": "", "reasoning": "", "tools": {}, "usage": {}}
650
+ box = {"status": 0, "err": "", "events": 0, "sent_text": None}
651
+ lock = threading.Lock()
652
+
653
+ def _emit(cb, payload):
654
+ if cb is None:
655
+ return
656
+ try:
657
+ cb(payload)
658
+ except Exception:
659
+ pass
660
+
661
+ def _go():
662
+ try:
663
+ parsed = urllib.parse.urlsplit(url)
664
+ if parsed.scheme not in ("http", "https"):
665
+ box["err"] = "协议必须是 http/https"
666
+ return
667
+ host, herr = modelhub._validate_host(url, allow_private)
668
+ if host is None:
669
+ box["err"] = herr
670
+ return
671
+ req = urllib.request.Request(
672
+ url, method="POST",
673
+ headers=dict(headers, **{"Content-Type": "application/json",
674
+ "Accept": "text/event-stream"}),
675
+ data=json.dumps(body).encode("utf-8"))
676
+ with modelhub._opener().open(req, timeout=timeout) as resp:
677
+ box["status"] = resp.status
678
+ if not 200 <= resp.status < 300:
679
+ raw = resp.read(65536).decode("utf-8", "replace")
680
+ box["err"] = "HTTP %s %s" % (resp.status, raw[:200])
681
+ return
682
+ for raw in resp:
683
+ line = raw.decode("utf-8", "replace").strip()
684
+ if not line.startswith("data:"):
685
+ continue
686
+ payload = line[5:].strip()
687
+ if not payload or payload == "[DONE]":
688
+ continue
689
+ try:
690
+ obj = json.loads(payload)
691
+ except Exception:
692
+ continue # 半行/坏行静默跳过,不中断整流
693
+ if not isinstance(obj, dict):
694
+ continue
695
+ box["events"] += 1
696
+ reason = _reason_from_delta(proto, obj)
697
+ with lock:
698
+ if reason:
699
+ acc["reasoning"] += reason
700
+ moved = _stream_accumulate(proto, obj, acc)
701
+ text_now = acc["text"]
702
+ if reason:
703
+ _emit(on_reason, reason)
704
+ if text_now != box["sent_text"]:
705
+ box["sent_text"] = text_now
706
+ _emit(on_text, text_now) # 累计正文(运行中实时打印)
707
+ elif moved and not reason:
708
+ _emit(on_tick, None) # 只有工具/用量在推进的心跳
709
+ if sum(len(x) for x in (acc["text"], acc["reasoning"])) > 4 * 1024 * 1024:
710
+ break # 防失控
711
+ except Exception as e:
712
+ box["err"] = box["err"] or tlsctx.humanize(repr(e)[:300])
713
+
714
+ th = threading.Thread(target=_go, daemon=True)
715
+ th.start()
716
+ if cancel_event is None:
717
+ th.join(timeout + 10)
718
+ else:
719
+ while th.is_alive() and not cancel_event.wait(0.5):
720
+ pass
721
+ with lock:
722
+ out = {"status": box["status"], "text": acc["text"],
723
+ "reasoning": acc["reasoning"], "usage": dict(acc["usage"]),
724
+ "calls": _calls_from_acc(acc), "events": box["events"],
725
+ "error": box["err"]}
726
+ if cancel_event is not None and cancel_event.is_set():
727
+ out["error"], out["status"] = "已取消", 0
728
+ return out
729
+ if th.is_alive():
730
+ out["error"], out["status"] = "响应超时", 0
731
+ return out
732
+
733
+
407
734
  def _m_openai(m):
408
735
  """内部消息 → openai 消息。tool_results: [(call_id, 结果文本)]。"""
409
736
  if m["role"] == "assistant":
@@ -450,14 +777,20 @@ def _m_anthropic(m):
450
777
  return {"role": "user", "content": [{"type": "text", "text": m.get("content") or ""}]}
451
778
 
452
779
 
453
- def _build_request(proto, base, use_key, model, system, msgs, with_tools):
454
- """按协议构造 (url, headers, body)。msgs 为内部统一形状。"""
780
+ def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream=False):
781
+ """按协议构造 (url, headers, body)。msgs 为内部统一形状。
782
+
783
+ stream=True 时按协议打开流式:google 换 :streamGenerateContent?alt=sse,
784
+ openai/anthropic 置 stream 字段(openai 另要 include_usage,否则收尾
785
+ chunk 不带 usage,这一步的 token 记账会凭空少一截)。"""
455
786
  base = (base or "").rstrip("/")
456
787
  if proto == "google":
457
788
  if base.endswith("/v1beta"):
458
- url = base + "/models/%s:generateContent" % model
789
+ prefix = base + "/models/%s"
459
790
  else:
460
- url = base + "/v1beta/models/%s:generateContent" % model
791
+ prefix = base + "/v1beta/models/%s"
792
+ url = (prefix + (":streamGenerateContent?alt=sse" if stream
793
+ else ":generateContent")) % model
461
794
  headers = {"x-goog-api-key": use_key}
462
795
  contents = []
463
796
  for m in msgs:
@@ -480,6 +813,8 @@ def _build_request(proto, base, use_key, model, system, msgs, with_tools):
480
813
  "messages": [_m_anthropic(m) for m in msgs]}
481
814
  if with_tools:
482
815
  body["tools"] = _anthropic_tools()
816
+ if stream:
817
+ body["stream"] = True
483
818
  return url, headers, body
484
819
  headers = {"Authorization": "Bearer " + use_key}
485
820
  # tool_results 消息展开成多条 role=tool(_m_openai 对它返回列表,不能嵌套)
@@ -494,6 +829,9 @@ def _build_request(proto, base, use_key, model, system, msgs, with_tools):
494
829
  "messages": [{"role": "system", "content": system}] + msgs_wire}
495
830
  if with_tools:
496
831
  body["tools"] = _openai_tools()
832
+ if stream:
833
+ body["stream"] = True
834
+ body["stream_options"] = {"include_usage": True}
497
835
  return url, headers, body
498
836
 
499
837
 
@@ -546,14 +884,34 @@ def _parse_reply(proto, data):
546
884
 
547
885
  # ---------------------------------------------------------------- 主循环
548
886
 
549
- def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None):
887
+ def _norm_usage(usage):
888
+ """三协议 usage → {input, output, cached, total}(缺 total 用三项相加补齐)。"""
889
+ out = {"input": 0, "output": 0, "cached": 0, "total": 0}
890
+ for k in out:
891
+ try:
892
+ out[k] = int((usage or {}).get(k) or 0)
893
+ except Exception:
894
+ out[k] = 0
895
+ if not out["total"]:
896
+ out["total"] = out["input"] + out["output"] + out["cached"]
897
+ return out
898
+
899
+
900
+ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None,
901
+ on_reason=None, on_stream=None, on_activity=None, stream=True):
550
902
  """跑一次内置智能体(内部自带工具循环直到给出最终回答)。
551
903
 
552
904
  bi: resolve() 的返回;prompt: 本轮完整输入(目标/续轮块由 pipeline 拼);
553
905
  images: 用户随本轮消息提供的图片绝对路径列表(任务附件/对话追话);
554
906
  log: 追加一行日志的回调(步骤日志)。返回 runner 风格统一结果:
555
- {ok, text, usage, error, model, provider_name, provider_id, iterations, cost_usd}。
556
- """
907
+ {ok, text, usage, error, model, provider_name, provider_id, iterations,
908
+ cost_usd, reasoning}。
909
+
910
+ 思考过程可视化(2026-09-22 用户诉求「把思考过程打印出来」):stream=True 走
911
+ SSE,模型的思维链(reasoning_content / thinking 块)边收边经 on_reason 回调,
912
+ 正文增量经 on_stream(累计值),工具活动经 on_activity——调用方据此在对话
913
+ 时间线上实时打印,不再让用户干看三点打字动画。网关忽略 stream(零事件)时
914
+ 自动回落非流式,思考字段仍从响应体兜底抽取(on_reason 补发一次)。"""
557
915
  prov = bi["prov"]
558
916
  model = bi["model"]
559
917
  allow_private = bool(prov.get("allow_private"))
@@ -575,48 +933,103 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
575
933
  iters = 0
576
934
  last_err = ""
577
935
  ok = False
936
+ reasons = [] # 各迭代的思考正文(按迭代顺序拼接,供步骤记录展示)
937
+
938
+ def _fire(cb, *cb_args):
939
+ if cb is None:
940
+ return
941
+ try:
942
+ cb(*cb_args)
943
+ except Exception:
944
+ pass # 可视化是增强:回调炸了不能拖垮生成
945
+
946
+ def _take_reason(reason):
947
+ """收一段思考:攒进本轮正文并实时回调(限长,防超长思维链撑爆 run.json)。"""
948
+ if not reason:
949
+ return
950
+ if not reasons:
951
+ reasons.append("")
952
+ if len(reasons[-1]) < THINK_MAX_CHARS:
953
+ reasons[-1] += reason
954
+ _fire(on_reason, reason)
578
955
 
579
956
  def _fail(err):
580
957
  return {"ok": False, "text": "", "usage": dict(total_usage), "error": err,
581
958
  "model": model, "provider_name": bi["provider_name"],
582
- "provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0}
959
+ "provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0,
960
+ "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}
583
961
 
584
962
  for it in range(1, MAX_TOOL_ITERS + 1):
585
963
  if cancel_event is not None and cancel_event.is_set():
586
964
  return _fail("已取消")
965
+ reasons.append("") # 每轮迭代各占一段思考(跨轮拼接时分段,join 时滤空段)
587
966
  done = False
588
967
  for proto, pbase in candidates:
589
968
  keys = modelhub._chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
590
969
  for kk in keys:
591
970
  url, headers, body = _build_request(proto, pbase, kk["key"], model,
592
971
  system, msgs, tools_ok)
972
+ sbody = None
973
+ if stream:
974
+ # 流式体单独构造(+stream / include_usage);非流式体留给回落重发
975
+ _, _, sbody = _build_request(proto, pbase, kk["key"], model,
976
+ system, msgs, tools_ok, stream=True)
593
977
  effort = str(bi.get("reasoning_effort") or "").strip().lower()
594
978
  # reasoning_effort 是 OpenAI wire 字段;Anthropic thinking 使用
595
979
  # 另一套对象结构,向兼容网关硬塞该字段会直接得到 400。
596
980
  if effort in ("low", "medium", "high") and proto == "openai":
597
981
  body["reasoning_effort"] = effort
598
- status, data, err = _post_interruptible(url, headers, body,
599
- allow_private, timeout,
600
- cancel_event)
601
- if cancel_event is not None and cancel_event.is_set():
602
- # 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
603
- return _fail("已取消")
604
- if status == 0 or not (200 <= status < 300):
605
- msg = ""
606
- if isinstance(data, dict):
607
- e = data.get("error")
608
- msg = e.get("message", "") if isinstance(e, dict) else str(e)
609
- last_err = err or ("HTTP %s %s" % (status, str(msg)[:200]))
610
- try:
611
- modelhub.note_key_error(bi["provider_id"], kk.get("id") or "", last_err)
612
- except Exception:
613
- pass
614
- continue
982
+ if sbody is not None:
983
+ sbody["reasoning_effort"] = effort
984
+ text, calls, usage = "", [], {}
985
+ streamed = False
986
+ if sbody is not None:
987
+ part = _post_sse_stream(
988
+ url, headers, sbody, allow_private, timeout, proto,
989
+ on_reason=_take_reason, on_text=on_stream,
990
+ cancel_event=cancel_event)
991
+ if part["error"] == "已取消":
992
+ return _fail("已取消")
993
+ if not part["error"] and part["events"]:
994
+ # 网关确实按流回了:正文/工具取增量累加结果
995
+ text, calls = part["text"], part["calls"]
996
+ usage = part["usage"]
997
+ streamed = True
998
+ if not reasons or not reasons[-1]:
999
+ # 调用方没挂实时回调(无可视化诉求)时思考只在
1000
+ # 累加器里:这里兜底收进结果,落库口径一致
1001
+ _take_reason(part["reasoning"])
1002
+ else:
1003
+ # 零事件(网关忽略 stream)或流式这一步失败(不认 stream 参数、
1004
+ # 断流等):都退回同一条 wire 的非流式重发——比直接判死这家
1005
+ # 供应商稳妥;非流式也失败才走下面的记账/换链。
1006
+ last_err = part["error"]
1007
+ if not streamed:
1008
+ status, data, err = _post_interruptible(url, headers, body,
1009
+ allow_private, timeout,
1010
+ cancel_event)
1011
+ if cancel_event is not None and cancel_event.is_set():
1012
+ # 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
1013
+ return _fail("已取消")
1014
+ if status == 0 or not (200 <= status < 300):
1015
+ msg = ""
1016
+ if isinstance(data, dict):
1017
+ e = data.get("error")
1018
+ msg = e.get("message", "") if isinstance(e, dict) else str(e)
1019
+ last_err = err or ("HTTP %s %s" % (status, str(msg)[:200]))
1020
+ try:
1021
+ modelhub.note_key_error(bi["provider_id"], kk.get("id") or "", last_err)
1022
+ except Exception:
1023
+ pass
1024
+ continue
1025
+ text, calls, usage = _parse_reply(proto, data)
1026
+ # 非流式响应里的思考字段:补发一次,可视化与流式路径同形
1027
+ _take_reason(_reason_from_message(proto, data))
615
1028
  try:
616
1029
  modelhub.note_key_ok(bi["provider_id"], kk.get("id") or "")
617
1030
  except Exception:
618
1031
  pass
619
- text, calls, usage = _parse_reply(proto, data)
1032
+ usage = _norm_usage(usage)
620
1033
  for k in total_usage:
621
1034
  total_usage[k] += int(usage.get(k) or 0)
622
1035
  iters = it
@@ -624,12 +1037,16 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
624
1037
  if log:
625
1038
  log("[迭代 %d] %s 请求工具: %s" % (
626
1039
  it, model, ", ".join(c["name"] for c in calls)))
1040
+ _fire(on_activity, "请求工具: " + "、".join(c["name"] for c in calls))
627
1041
  results = []
628
1042
  for c in calls:
629
- out = _exec_tool(workdir, c["name"], c["args"])
1043
+ out = _exec_tool(workdir, c["name"], c["args"], cancel_event)
630
1044
  if log:
631
1045
  brief = out if len(out) <= 120 else out[:120] + "…"
632
1046
  log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
1047
+ _fire(on_activity, "%s %s" % (
1048
+ c["name"], str(c["args"].get("path")
1049
+ or c["args"].get("command") or "")[:80]))
633
1050
  results.append((c["id"], out))
634
1051
  msgs.append({"role": "assistant", "content": text, "tool_calls": calls})
635
1052
  msgs.append({"role": "tool_results", "tool_results": results})
@@ -654,4 +1071,5 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
654
1071
  return _fail(last_err or "工具循环达上限仍无最终回答")
655
1072
  return {"ok": True, "text": (text or "").strip(), "usage": dict(total_usage),
656
1073
  "error": "", "model": model, "provider_name": bi["provider_name"],
657
- "provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0}
1074
+ "provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0,
1075
+ "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}