# -*- coding: utf-8 -*- """工具结果回填上限(通用,v1 角色 agent + v2 会话 agent 共用)。 问题根因(2026-09-11 商机产线实测会话失败): 工具结果回填给模型时是 `str(result)` **全文不截断**(仅 UI 显示截到 500 字), 数据类工具一次返回 300 条原始记录 ≈ 2 万字符 ≈ 1 万 token;agent 连调 8 次 → 上下文 ~78K token 超过 context_limit(64K) → 压缩摘要调用自身也超时(重试 3 次 全败)→ 整轮会话失败、零产出。对照实验:同模型小上下文 3.4s 正常返回, 证明不是上游故障而是上下文体积。 修法:回填前按字符上限截断,**显式告知**(沿用 file_read.py 的「绝不静默截断」 铁律)——告诉模型全文多大、只给了多少、以及怎么缩小范围(减小 limit / 改用聚合排名类工具),让它自己改策略而不是反复拉全量。 上限走 appbase params 表(tool_result_max_chars)+ 常量兜底,禁硬编码。 """ import logging from typing import Optional logger = logging.getLogger("pipeline.result_cap") # 兜底上限(params 表无配置时用)。12000 字符 ≈ 6000 token: # 够放一份结构化聚合结果或几十条明细,放 300 条原始记录则会被截断并提示。 DEFAULT_MAX_CHARS = 12000 _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 表优先,失败/未配置用常量兜底,进程内缓存)。 sor=None 时自开短连接读取(调用点如 agent_loop_v2.run() 的工具回填处 没有现成 DB context;结果缓存后不再重复开连接)。读失败一律兜底常量, 绝不因为取参数失败而打断 agent 主循环。 """ global _cached_max if _cached_max is not None: return _cached_max _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): """截断工具结果(同步;max_chars=None 用常量兜底)。 返回 (text, truncated)。截断时末尾附显式告知 + 可行动的缩小范围指引。 非字符串结果先 str() 归一(dict/list 也走同一路径)。 """ text = result if isinstance(result, str) else str(result) limit = int(max_chars) if max_chars else DEFAULT_MAX_CHARS total = len(text) if total <= limit: return text, False notice = ( "\n\n⚠️【结果过长已截断】全文 %d 字符,仅回填前 %d 字符(上限可用 params.%s 调整)。\n" "不要重复调用同一工具拉全量——请缩小范围后重试:\n" "- 减小 limit(如 limit=10~20)或缩小 days/时间窗口;\n" "- 优先用聚合/排名类工具(如 hot_demands/hot_software/category 汇总)" "直接拿统计值,而不是拉明细自己数;\n" "- 明细需要落盘时用 write_file 写到项目工作空间,不要全量读进对话。" % (total, limit, _PARAM_KEY) ) 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, _cached_display_max _cached_max = None _cached_display_max = None