From 715be0b387577a9668f6732ed065159185d4c93f Mon Sep 17 00:00:00 2001 From: ymq Date: Tue, 15 Sep 2026 17:32:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E5=B7=A5=E5=85=B7=E7=BB=93?= =?UTF-8?q?=E6=9E=9CUI=E6=98=BE=E7=A4=BA=E5=B1=82=E5=8F=96=E6=B6=88500?= =?UTF-8?q?=E5=AD=97=E7=A1=AC=E6=88=AA=E2=80=94=E2=80=94params=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E4=B8=8A=E9=99=90(=E9=BB=98=E8=AE=A450000=E2=89=88?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=98=BE=E7=A4=BA)+=E8=B6=85=E9=99=90?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=91=8A=E7=9F=A5(2026-09-15=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=8C=87=E4=BB=A4:=E6=98=BE=E7=A4=BA=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E4=BF=A1=E6=81=AF)=E3=80=82=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=B1=82=E4=B8=8E=E5=9B=9E=E5=A1=AB=E5=B1=82(12000=E9=98=B2?= =?UTF-8?q?=E7=88=86=E9=97=A8=E7=A6=81)=E5=88=86=E7=A6=BB,resolve=5Fmax=5F?= =?UTF-8?q?chars=E6=8F=90=E5=8F=96=E5=85=B1=E4=BA=AB=5Fresolve=5Fparam=5Fi?= =?UTF-8?q?nt,=E6=96=B0=E5=A2=9Ecap=5Fdisplay=5Fresult/resolve=5Fdisplay?= =?UTF-8?q?=5Fmax=5Fchars(params=E9=94=AEtool=5Fresult=5Fdisplay=5Fmax=5Fc?= =?UTF-8?q?hars)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_service/agent_loop_v2.py | 16 +++-- pipeline_service/result_cap.py | 103 +++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/pipeline_service/agent_loop_v2.py b/pipeline_service/agent_loop_v2.py index b9ce825..14940f5 100644 --- a/pipeline_service/agent_loop_v2.py +++ b/pipeline_service/agent_loop_v2.py @@ -266,11 +266,14 @@ class AgentExecutor: result = await self._execute_tool(pending_tool, pending_params) if not result.startswith("未知工具") and not result.startswith("ERROR"): self._tool_call_count += 1 - yield json.dumps({"type": "tool_result", "tool": pending_tool, "result": result[:500]}, ensure_ascii=False) + "\n" + # 显示层上限(2026-09-15 用户要求完整显示,替代旧硬截 [:500]): + # 默认 50000 字符 = 正常工具输出完整显示;超限显式告知(绝不静默截断)。 + from .result_cap import cap_tool_result, cap_display_result, resolve_max_chars, resolve_display_max_chars + yield json.dumps({"type": "tool_result", "tool": pending_tool, + "result": cap_display_result(result, await resolve_display_max_chars())}, ensure_ascii=False) + "\n" # 回填上限(2026-09-11):工具结果全文回填会撑爆上下文(商机产线实测 # 8 次数据工具 ~78K token → 压缩摘要自身超时 → 会话零产出)。 # 截断显式告知 + 给缩小范围指引,让 LLM 自己改策略。 - from .result_cap import cap_tool_result, resolve_max_chars _capped, _ = cap_tool_result(result, await resolve_max_chars()) self._msgs.append({"role": "tool", "tool_call_id": fake_id, "content": _capped}) # 不 return,继续进入 Tool Loop,让 LLM 基于工具结果继续后续步骤 @@ -411,8 +414,11 @@ class AgentExecutor: await self._save_turn(user_input, f"[提问] {question}") return + # 显示层上限(同 Step 4.5,默认 50000 = 完整显示;超限显式告知) + from .result_cap import cap_display_result, resolve_display_max_chars yield json.dumps({ - "type": "tool_result", "tool": tool_name, "result": result[:500], + "type": "tool_result", "tool": tool_name, + "result": cap_display_result(result, await resolve_display_max_chars()), }, ensure_ascii=False) + "\n" # 原生回填:role=tool + tool_call_id @@ -515,9 +521,11 @@ class AgentExecutor: await self._save_turn(user_input, f"[提问] {question}") return + # 显示层上限(同 native 路径,默认 50000 = 完整显示;超限显式告知) + from .result_cap import cap_display_result, resolve_display_max_chars yield json.dumps({ "type": "tool_result", "tool": tool_name, - "result": result[:500], + "result": cap_display_result(result, await resolve_display_max_chars()), }, ensure_ascii=False) + "\n" # 反馈给 LLM(关键:不存原始 tool_call JSON) diff --git a/pipeline_service/result_cap.py b/pipeline_service/result_cap.py index 5722304..02e1376 100644 --- a/pipeline_service/result_cap.py +++ b/pipeline_service/result_cap.py @@ -28,6 +28,51 @@ _PARAM_KEY = "tool_result_max_chars" # 进程内缓存(None = 未解析,惰性读 params;invalidate_cache() 清除) _cached_max: Optional[int] = None +# ── UI 显示层上限(2026-09-15,用户要求「显示完整信息,不要裁剪」)── +# 历史行为:agent_loop_v2 yield tool_result 事件时硬截 result[:500] 且无提示 +# (静默截断,违反 file_read 的「绝不静默截断」纪律),前端看到半截输出。 +# 显示层与回填层是两条独立通道:显示只影响人眼,不进模型上下文, +# 因此上限可以远高于回填上限。默认 50000 字符覆盖所有正常工具输出 +# (load_skill 全文 / diagnose_project / read_file 分页 ≈ 数千~3万字符), +# 等于完整显示;只兜底 run_command 输出数 MB 的病态情况(防 NDJSON +# 流 + 前端 MdWidget 渲染卡死)。要完全不限:params 表设大值即可,无需改码。 +DEFAULT_DISPLAY_MAX_CHARS = 50000 +_PARAM_DISPLAY_KEY = "tool_result_display_max_chars" +_cached_display_max: Optional[int] = None + + +async def _resolve_param_int(param_key: str, default: int, floor: int, cache_attr: str, sor=None) -> int: + """从 params 表读整型配置(进程内缓存;读失败一律兜底常量,绝不打断主循环)。 + + sor 非 None 时复用调用方现成连接(v1 agent_loop 路径);否则自开短连接。 + """ + cached = globals().get(cache_attr) + if cached is not None: + return cached + val = default + try: + from .workspace import get_param + if sor is not None: + raw = await get_param(sor, param_key, "") + else: + from sqlor.dbpools import DBPools + db = DBPools() + if not db.databases: + from appPublic.jsonConfig import getConfig + cfg = getConfig() + if cfg and cfg.databases: + db.databases = cfg.databases + async with db.sqlorContext("pipeline") as _sor: + raw = await get_param(_sor, param_key, "") + await _sor.sqlExe("COMMIT", {}) + if raw not in (None, ""): + val = max(floor, int(float(raw))) + except Exception as e: + logger.debug("resolve %s fallback to default: %s", param_key, str(e)[:120]) + val = default + globals()[cache_attr] = val + return val + async def resolve_max_chars(sor=None): """取回填上限(params 表优先,失败/未配置用常量兜底,进程内缓存)。 @@ -39,29 +84,18 @@ async def resolve_max_chars(sor=None): global _cached_max if _cached_max is not None: return _cached_max - val = DEFAULT_MAX_CHARS - try: - from .workspace import get_param - if sor is not None: - raw = await get_param(sor, _PARAM_KEY, "") - else: - from sqlor.dbpools import DBPools - db = DBPools() - if not db.databases: - from appPublic.jsonConfig import getConfig - cfg = getConfig() - if cfg and cfg.databases: - db.databases = cfg.databases - async with db.sqlorContext("pipeline") as _sor: - raw = await get_param(_sor, _PARAM_KEY, "") - await _sor.sqlExe("COMMIT", {}) - if raw not in (None, ""): - val = max(1000, int(float(raw))) - except Exception as e: - logger.debug("resolve_max_chars fallback to default: %s", str(e)[:120]) - val = DEFAULT_MAX_CHARS - _cached_max = val - return val + _cached_max = await _resolve_param_int(_PARAM_KEY, DEFAULT_MAX_CHARS, 1000, "_cached_max", sor) + return _cached_max + + +async def resolve_display_max_chars(): + """取 UI 显示层上限(params.tool_result_display_max_chars,默认 50000)。""" + global _cached_display_max + if _cached_display_max is not None: + return _cached_display_max + _cached_display_max = await _resolve_param_int( + _PARAM_DISPLAY_KEY, DEFAULT_DISPLAY_MAX_CHARS, 500, "_cached_display_max") + return _cached_display_max def cap_tool_result(result, max_chars=None): @@ -87,7 +121,28 @@ def cap_tool_result(result, max_chars=None): return text[:limit] + notice, True +def cap_display_result(result, max_chars=None): + """UI 显示层截断(同步;max_chars=None 用显示层常量兜底)。 + + 与 cap_tool_result(模型回填层)是两条独立通道:这里只影响前端展示, + 不进模型上下文。截断时显式告知(绝不静默截断),正常工具输出 + (<50000 字符)原样完整显示。 + """ + text = result if isinstance(result, str) else str(result) + limit = int(max_chars) if max_chars else DEFAULT_DISPLAY_MAX_CHARS + total = len(text) + if total <= limit: + return text + notice = ( + "\n\n…(输出过长,界面仅显示前 %d 字符,全文共 %d 字符。" + "模型收到的内容不受此显示限制影响;上限可用 params.%s 调整)" + % (limit, total, _PARAM_DISPLAY_KEY) + ) + return text[:limit] + notice + + def invalidate_cache(): """params 变更后让缓存失效(部署/调参后无需重启进程)。""" - global _cached_max + global _cached_max, _cached_display_max _cached_max = None + _cached_display_max = None