149 lines
6.8 KiB
Python
149 lines
6.8 KiB
Python
# -*- 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
|