feat(agent): 工具结果UI显示层取消500字硬截——params可配上限(默认50000≈完整显示)+超限显式告知(2026-09-15用户指令:显示完整信息)。显示层与回填层(12000防爆门禁)分离,resolve_max_chars提取共享_resolve_param_int,新增cap_display_result/resolve_display_max_chars(params键tool_result_display_max_chars)

This commit is contained in:
ymq 2026-09-15 17:32:39 +08:00
parent 6dac18e1b1
commit 715be0b387
2 changed files with 91 additions and 28 deletions

View File

@ -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)

View File

@ -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