mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:01:59 +00:00
62aac187d8
MCP / 自定义工具的审批三档(tools/trust.py) - 平台看不出这两类工具做什么,以前审批关卡一律当它们安全、照跑不误。现在默认每次调用都等审批; 工具页可以逐个改成「始终允许 · 门控把关」或「始终允许」,审批卡上的「始终允许」等于前者 - 门控把关:每次调用先由门控模型审一遍工具说明、参数和节点任务;判可疑、出错、超时、答不成格式, 一律交还给人,不会变成放行。判定包进 checkpoint,审批恢复重放时不再问一遍 - agent 节点、工具节点、协作团队三处关卡都接上;团队成员停不下来等人,门控没放行的调用不执行 - 每次运行在发起时快照档位(runs.tool_trust),恢复和接着跑沿用同一份;正式运行不看档位,一律等审批; 升级前发起的运行照旧不问——它们可能正停在某个审批上,多问一次答复就会对错号 - 接口:GET /api/tools 带 trust / trust_key,PUT /api/tools/trust;设置新增门控模型;新事件 tool.gated - 界面:工具页三选一、审批卡「始终允许」、设置页门控模型、运行记录解码 tool.gated; 顺手修了 MCP 工具详情 404、工具审批被驳回却显示成放行 智能体护栏(engine/guards.py),取代固定的「最多 12 步」 - 步数只留兜底:默认 100,设置里可改;以前画布自动写进节点的 12 按「没填」处理 - 同一个工具、同样的参数第二次不再执行,把上次的结果交还给它;连续 3 步拿不到新信息就收尾 - 每个节点的令牌预算(默认 200 万)和金额预算(默认不限),都可以在设置里改成不限 - 上下文用到窗口的 60% 先压缩早期的工具结果,到 85% 就收尾;临近任何一个上限先提醒模型一次 - 下游结构化校验失败时写明是上游哪个 agent 提前收了尾;复核信号带上收尾原因, 问数据页只在真是步数用满时才给「放宽步数重跑」 - 上限在发起时快照(runs.agent_limits);升级前发起的运行一字不改走旧逻辑 检查脚本 - check-all 默认 4 道并行:量时间最多的两项互不重叠;并行时没过的最后单独重跑一遍,重跑才过的 单列成「偶发」。16 项全量从约 21 分钟降到约 7 分钟(加上一次重跑约 10 分钟);--lanes 1 回到串行 顺带修的 - 设置接口里的 limits 改为只读:库里存着旧版设置页写进去的一份(max_agent_steps: 25),从不生效, 却盖住了真实上限,问数据页照着错的数许诺 - test_kb 里嵌入模型那条测试不再真连 api.openai.com(代理慢时一条要等近 4 分钟) - check-stream 读滚动容器前先等它挂上,并行跑时不再偶发 验证(沙箱):tsc 干净,生产构建通过,pytest 1655 passed(两遍),check-all 16 项检查、3562 项全过; 新增后端测试 53 条,关键逻辑做了 22 处变异验证、全部抓到。
248 lines
13 KiB
Python
248 lines
13 KiB
Python
"""agent 节点的护栏:取代以前那个固定的「最多 12 步」。
|
||
|
||
固定步数挡的是四件事,每件都有更贴切的挡法:
|
||
- 循环不收敛(反复查同一张表、在两个做法之间来回)→ 重复调用直接复用上次结果,
|
||
连续几步拿不到新信息就收尾;
|
||
- 花费越滚越大(每一步都重发前面的全部对话)→ 按令牌 / 金额记预算,可以设成不限;
|
||
- 上下文会满(满了是调用直接报错,查到的全丢)→ 接近窗口时先压缩早期工具结果,再不够就收尾;
|
||
- 真正失控的兜底 → 步数只留一个很高的上限(默认 100,设置里可改)。
|
||
|
||
规矩:
|
||
- 上限在运行发起时快照下来(RunContext.agent_limits),恢复、接着跑都用这一份。审批恢复时
|
||
节点整个重放,上限要是变了,循环停在不同的位置,checkpoint 里的任务结果就对错号了;
|
||
- 快照为 None 是升级前发起的运行:一律走旧逻辑(默认 12 步、没有这些护栏)。它们可能正停在
|
||
某个审批上,多一次复用、少一次调用,重放的位置同样会错开;
|
||
- 这里的每个判断只取决于重放时照样拿得到的东西(模型回复里的用量、工具结果),不读时钟。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from langchain_core.messages import BaseMessage, ToolMessage
|
||
|
||
#: 以前画布给新节点写死的步数。几乎都是画布自动写进去的,不是谁特意选的,按「没填」处理
|
||
LEGACY_DEFAULT_STEPS = 12
|
||
#: 连续几步拿不到新信息就收尾
|
||
STALL_STEPS = 3
|
||
#: 目录里查不到窗口的模型,按这个保守值算
|
||
FALLBACK_CONTEXT = 128_000
|
||
#: 用到窗口的这个比例,开始压缩早期工具结果
|
||
COMPRESS_AT = 0.6
|
||
#: 压缩之后还到这个比例,就收尾:留出收尾轮自己要的那部分
|
||
SETTLE_AT = 0.85
|
||
#: 压缩时原样保留最近几条工具结果
|
||
KEEP_RECENT = 3
|
||
#: 超过这个字数的旧工具结果才压缩,压缩后留开头这么多字
|
||
COMPRESS_OVER, COMPRESS_HEAD = 1200, 400
|
||
|
||
DEFAULT_LIMITS: dict[str, Any] = {
|
||
# 兜底步数;实际还受 settings.max_agent_steps(环境变量)封顶
|
||
"agent_max_steps": 100,
|
||
# 每个 agent 节点的令牌预算(输入 + 输出)。None 表示不限
|
||
"agent_budget_tokens": 2_000_000,
|
||
# 金额预算(美元)。目录里没有价格的模型估不出金额,只能靠令牌预算。None 表示不限
|
||
"agent_budget_usd": None,
|
||
}
|
||
|
||
|
||
def _positive(value: Any) -> float | None:
|
||
try:
|
||
number = float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return number if number > 0 else None
|
||
|
||
|
||
def run_limits(run_defaults: dict[str, Any], hard_cap: int) -> dict[str, Any]:
|
||
"""发起运行时的快照。设置里存坏了的值按缺省算;预算写 null / 0 / 空都表示不限。"""
|
||
steps = _positive(run_defaults.get("agent_max_steps"))
|
||
tokens = run_defaults.get("agent_budget_tokens", DEFAULT_LIMITS["agent_budget_tokens"])
|
||
usd = run_defaults.get("agent_budget_usd", DEFAULT_LIMITS["agent_budget_usd"])
|
||
return {
|
||
"max_steps": min(int(steps or DEFAULT_LIMITS["agent_max_steps"]), hard_cap),
|
||
"budget_tokens": int(_positive(tokens)) if _positive(tokens) else None,
|
||
"budget_usd": _positive(usd),
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class Limits:
|
||
max_steps: int
|
||
budget_tokens: int | None
|
||
budget_usd: float | None
|
||
|
||
|
||
def node_limits(config: dict[str, Any], snapshot: dict[str, Any], hard_cap: int) -> Limits:
|
||
"""节点上写了的覆盖快照。步数恰好是 12 的当作没写(见 LEGACY_DEFAULT_STEPS)。"""
|
||
raw_steps = _positive(config.get("max_steps"))
|
||
if raw_steps is not None and int(raw_steps) == LEGACY_DEFAULT_STEPS:
|
||
raw_steps = None
|
||
steps = int(raw_steps) if raw_steps else int(snapshot.get("max_steps") or DEFAULT_LIMITS["agent_max_steps"])
|
||
tokens = _positive(config.get("budget_tokens"))
|
||
usd = _positive(config.get("budget_usd"))
|
||
return Limits(
|
||
max_steps=max(1, min(steps, hard_cap)),
|
||
budget_tokens=int(tokens) if tokens else snapshot.get("budget_tokens"),
|
||
budget_usd=usd if usd else snapshot.get("budget_usd"),
|
||
)
|
||
|
||
|
||
def call_key(name: str, args: dict[str, Any]) -> str:
|
||
return json.dumps({"tool": name, "args": args}, ensure_ascii=False, sort_keys=True, default=str)
|
||
|
||
|
||
#: 收尾的原因 → (给模型的一句话, 给人看的提示)
|
||
REASONS: dict[str, tuple[str, str]] = {
|
||
"steps": ("步数上限用完了({max_steps} 步)",
|
||
"agent 用满了 {max_steps} 步。要跑全就把节点上的「最大步数」或设置里的默认步数调大;"
|
||
"如果它大部分步数花在逐张表查结构上,也可以在提示词里点明该查哪几张表。"),
|
||
"stall": ("连续 {stall} 步没有拿到新信息(重复的调用,或者调用都失败了)",
|
||
"agent 连续 {stall} 步没有拿到新信息,已按查到的部分收尾。多半是提示词里的目标它查不到,"
|
||
"或者工具的参数一直写不对:看一眼它最后几次调用。"),
|
||
"budget_tokens": ("这个节点的令牌预算用完了(已用 {used_tokens:,} / 上限 {budget_tokens:,})",
|
||
"agent 用完了令牌预算({budget_tokens:,}),已按查到的部分收尾。要跑全就把节点上的"
|
||
"「令牌预算」或设置里的默认预算调大,设置里也可以改成不限。"),
|
||
"budget_usd": ("这个节点的金额预算用完了(已用 ${used_usd:.4f} / 上限 ${budget_usd:.4f})",
|
||
"agent 用完了金额预算(${budget_usd:.4f}),已按查到的部分收尾。要跑全就把节点上的"
|
||
"「金额预算」或设置里的默认预算调大,设置里也可以改成不限。"),
|
||
"context": ("对话已经接近模型的上下文上限(约 {used_context:,} / {window:,} token)",
|
||
"agent 的对话接近模型上下文上限,早期工具结果压缩过仍然不够,已按查到的部分收尾。"
|
||
"让它每次查更小的范围(比如 SQL 里加 LIMIT、只选需要的列),或者换一个窗口更大的模型。"),
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class Guard:
|
||
limits: Limits
|
||
window: int
|
||
#: 成功执行过的调用 → (第几步, 结果)
|
||
seen: dict[str, tuple[int, str]] = field(default_factory=dict)
|
||
stalled: int = 0
|
||
reminded: set[str] = field(default_factory=set)
|
||
last_input: int = 0
|
||
compressed: bool = False
|
||
facts: dict[str, Any] = field(default_factory=dict)
|
||
|
||
# ------------------------------------------------------------ 重复调用
|
||
|
||
def repeat(self, name: str, args: dict[str, Any]) -> str | None:
|
||
"""同一个工具、同样的参数已经成功跑过:返回要喂回去的那句话,不再执行。"""
|
||
hit = self.seen.get(call_key(name, args))
|
||
if hit is None:
|
||
return None
|
||
step, content = hit
|
||
return (f"这个调用(同一个工具、同样的参数)第 {step + 1} 步已经执行过,没有再执行。上次的结果:\n"
|
||
f"{content}\n\n不要重复调用:基于已有结果继续,或者换一个能拿到新信息的查询,或者给出结论。")
|
||
|
||
def record(self, step: int, name: str, args: dict[str, Any], ok: bool, content: str) -> None:
|
||
if ok:
|
||
self.seen.setdefault(call_key(name, args), (step, content))
|
||
|
||
def step_done(self, progressed: bool) -> None:
|
||
self.stalled = 0 if progressed else self.stalled + 1
|
||
|
||
# ------------------------------------------------------------ 用量与上下文
|
||
|
||
def observe(self, response: BaseMessage, messages: list[BaseMessage]) -> None:
|
||
"""记下这一次模型调用实际用了多大的上下文。供应商没报用量就按字数粗估。"""
|
||
meta = getattr(response, "usage_metadata", None) or {}
|
||
reported = int(meta.get("input_tokens") or 0)
|
||
if reported:
|
||
self.last_input = reported
|
||
else:
|
||
chars = sum(len(m.content) if isinstance(m.content, str) else len(json.dumps(m.content, default=str))
|
||
for m in messages)
|
||
self.last_input = chars // 2
|
||
|
||
def compress(self, messages: list[BaseMessage]) -> int:
|
||
"""上下文用到 COMPRESS_AT 以上时,把早期的长工具结果换成开头一段。返回压缩了几条。"""
|
||
if self.last_input < self.window * COMPRESS_AT:
|
||
return 0
|
||
tool_positions = [i for i, m in enumerate(messages) if isinstance(m, ToolMessage)]
|
||
squeezed = 0
|
||
for i in tool_positions[:-KEEP_RECENT] if KEEP_RECENT else tool_positions:
|
||
m = messages[i]
|
||
text = m.content if isinstance(m.content, str) else json.dumps(m.content, ensure_ascii=False, default=str)
|
||
if len(text) <= COMPRESS_OVER or text.endswith(_SQUEEZED_TAIL):
|
||
continue
|
||
messages[i] = ToolMessage(
|
||
content=f"{text[:COMPRESS_HEAD]}\n…(早期的工具结果已压缩,原文 {len(text)} 字。"
|
||
f"需要其中的细节就重新查一个更小的范围){_SQUEEZED_TAIL}",
|
||
tool_call_id=m.tool_call_id,
|
||
)
|
||
squeezed += 1
|
||
if squeezed:
|
||
self.compressed = True
|
||
return squeezed
|
||
|
||
def stop_reason(self, usage: dict[str, Any], *, context: bool = True) -> str | None:
|
||
"""该收尾了吗。步数用完由循环自己管,这里管另外三种。context=False 时不按上下文判。"""
|
||
if self.stalled >= STALL_STEPS:
|
||
return "stall"
|
||
used_tokens = int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0)
|
||
if self.limits.budget_tokens and used_tokens >= self.limits.budget_tokens:
|
||
return "budget_tokens"
|
||
if self.limits.budget_usd and float(usage.get("cost_usd") or 0) >= self.limits.budget_usd:
|
||
return "budget_usd"
|
||
if context and self.last_input >= self.window * SETTLE_AT:
|
||
return "context"
|
||
return None
|
||
|
||
def reminder(self, step: int, usage: dict[str, Any]) -> str | None:
|
||
"""临近上限时提醒模型一次(每种只提醒一次)。返回要追加的一句话。"""
|
||
notes = []
|
||
left = self.limits.max_steps - (step + 1)
|
||
if self.limits.max_steps > 5 and left <= 3 and "steps" not in self.reminded:
|
||
self.reminded.add("steps")
|
||
notes.append(f"还剩 {left} 步可以调用工具")
|
||
used_tokens = int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0)
|
||
if self.limits.budget_tokens and used_tokens >= self.limits.budget_tokens * 0.8 \
|
||
and "budget" not in self.reminded:
|
||
self.reminded.add("budget")
|
||
notes.append(f"令牌预算已用 {used_tokens * 100 // self.limits.budget_tokens}%")
|
||
if self.limits.budget_usd and float(usage.get("cost_usd") or 0) >= self.limits.budget_usd * 0.8 \
|
||
and "budget_usd" not in self.reminded:
|
||
self.reminded.add("budget_usd")
|
||
notes.append("金额预算已用到八成")
|
||
if self.stalled == STALL_STEPS - 1 and "stall" not in self.reminded:
|
||
self.reminded.add("stall")
|
||
notes.append(f"已经连续 {self.stalled} 步没有拿到新信息")
|
||
if not notes:
|
||
return None
|
||
return f"(系统提醒:{';'.join(notes)}。优先补齐最关键的信息,然后给出结论。)"
|
||
|
||
def describe(self, reason: str, usage: dict[str, Any]) -> tuple[str, str]:
|
||
"""(给模型的收尾理由, 给人看的提示)"""
|
||
values = {
|
||
"max_steps": self.limits.max_steps, "stall": STALL_STEPS,
|
||
"used_tokens": int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0),
|
||
"budget_tokens": self.limits.budget_tokens or 0,
|
||
"used_usd": float(usage.get("cost_usd") or 0), "budget_usd": self.limits.budget_usd or 0.0,
|
||
"used_context": self.last_input, "window": self.window,
|
||
}
|
||
to_model, to_user = REASONS[reason]
|
||
return to_model.format(**values), to_user.format(**values)
|
||
|
||
|
||
_SQUEEZED_TAIL = "" # 不可见的记号:压缩过的不再压第二遍
|
||
|
||
|
||
def legacy_hint(max_steps: int, settled: bool) -> str:
|
||
"""升级前的运行用的老说法(和以前一字不差)。"""
|
||
if settled:
|
||
return (f"agent 用满了 {max_steps} 步,这个结论是基于已经查到的部分给出的。"
|
||
"要跑全请把节点上的「最大步数」调大;如果它大部分步数花在逐张表"
|
||
"查结构上,也可以在提示词里点明该查哪几张表。")
|
||
return (f"agent 用满了 {max_steps} 步还没给出结论。"
|
||
"把节点上的「最大步数」调大;如果它大部分步数花在逐张表查结构上,"
|
||
"也可以在提示词里点明该查哪几张表。")
|
||
|
||
|
||
#: 结果里 limited 字段的说法,下游校验失败时拿来说明根源
|
||
LIMITED_LABEL = {
|
||
"steps": "用满了步数", "stall": "连续几步没拿到新信息", "budget_tokens": "用完了令牌预算",
|
||
"budget_usd": "用完了金额预算", "context": "对话接近上下文上限",
|
||
}
|