166 lines
6.2 KiB
Python
166 lines
6.2 KiB
Python
"""Hermes Agent 核心机制移植(以雅文工作台为主体的融合模块)。
|
||
|
||
来源与适配说明(参考 Hermes Agent 2026.8.27 源码):
|
||
- IterationBudget ← agent/iteration_budget.py:线程安全的轮次预算计数器,
|
||
扩展为「运行级预算」,多个 Agent 会话共享同一个 run 的预算池。
|
||
- jittered_backoff ← agent/retry_utils.py:抖动退避替代固定指数退避,
|
||
防止多个会话同时撞限流时形成重试尖峰;支持解析 Retry-After。
|
||
- 工具停滞护栏 ← agent/tool_guardrails.py:对「同名工具 + 相同参数」的
|
||
重复调用做指纹跟踪,重复时注入提示,超限时熔断。
|
||
|
||
该模块只包含纯逻辑,不依赖数据库,便于单元测试。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import random
|
||
import threading
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from email.utils import parsedate_to_datetime
|
||
from typing import Any
|
||
|
||
|
||
# ---------------- 运行级轮次预算(源自 IterationBudget) ----------------
|
||
|
||
class IterationBudget:
|
||
"""线程安全的运行级轮次预算。
|
||
|
||
一次任务运行(CampaignRun)创建一个预算池;其中的每个 Agent 会话
|
||
(拆解/生产/质检/组合)消耗同一个池子,防止某个失控会话无限消耗
|
||
模型调用和点数。execute_code 类可退回的调用可用 refund() 归还。
|
||
"""
|
||
|
||
def __init__(self, max_total: int):
|
||
self.max_total = max(1, int(max_total))
|
||
self._used = 0
|
||
self._lock = threading.Lock()
|
||
|
||
def consume(self) -> bool:
|
||
"""尝试消耗一轮。返回 False 表示预算耗尽,应停止调用模型。"""
|
||
with self._lock:
|
||
if self._used >= self.max_total:
|
||
return False
|
||
self._used += 1
|
||
return True
|
||
|
||
def refund(self) -> None:
|
||
with self._lock:
|
||
if self._used > 0:
|
||
self._used -= 1
|
||
|
||
@property
|
||
def used(self) -> int:
|
||
with self._lock:
|
||
return self._used
|
||
|
||
@property
|
||
def remaining(self) -> int:
|
||
with self._lock:
|
||
return max(0, self.max_total - self._used)
|
||
|
||
|
||
# ---------------- 抖动退避(源自 retry_utils) ----------------
|
||
|
||
def parse_retry_after_seconds(value_or_headers: Any) -> float | None:
|
||
"""把 Retry-After(数字/HTTP 日期/headers 映射)解析成非负秒数。"""
|
||
raw = value_or_headers
|
||
if raw is not None and not isinstance(raw, (str, int, float)):
|
||
getter = getattr(raw, "get", None)
|
||
if callable(getter):
|
||
try:
|
||
raw = getter("Retry-After")
|
||
if raw is None:
|
||
raw = getter("retry-after")
|
||
except Exception:
|
||
raw = None
|
||
if raw is None:
|
||
return None
|
||
try:
|
||
seconds = float(raw)
|
||
except (TypeError, ValueError):
|
||
try:
|
||
from datetime import datetime, timezone
|
||
parsed = parsedate_to_datetime(str(raw))
|
||
if parsed is None:
|
||
return None
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||
seconds = (parsed - datetime.now(timezone.utc)).total_seconds()
|
||
except Exception:
|
||
return None
|
||
return max(0.0, seconds)
|
||
|
||
|
||
def jittered_backoff(attempt: int, base: float = 1.6, cap: float = 24.0) -> float:
|
||
"""全抖动(full jitter)退避:在 [0, min(cap, base*2^n)] 区间随机取值。
|
||
|
||
与固定 `2 ** n` 相比,多个并发会话不会在同一时刻集体重试,
|
||
避免限流恢复瞬间被再次打挂(thundering herd)。
|
||
"""
|
||
ceiling = min(cap, base * (2 ** max(0, attempt)))
|
||
return random.uniform(0, ceiling)
|
||
|
||
|
||
def retry_sleep(attempt: int, retry_after: Any = None,
|
||
base: float = 1.6, cap: float = 24.0) -> float:
|
||
"""决定一次重试应等待的秒数:优先尊重服务端 Retry-After,否则抖动退避。"""
|
||
if retry_after is not None:
|
||
seconds = parse_retry_after_seconds(retry_after)
|
||
if seconds is not None:
|
||
return min(max(seconds, 0.5), cap)
|
||
return jittered_backoff(attempt, base=base, cap=cap)
|
||
|
||
|
||
# ---------------- 工具停滞护栏(源自 tool_guardrails) ----------------
|
||
|
||
def tool_call_fingerprint(tool_name: str, arguments: dict[str, Any] | None) -> str:
|
||
"""工具名 + 归一化参数生成指纹,用于识别「原样重跑」的停滞调用。"""
|
||
try:
|
||
canonical = json.dumps(arguments or {}, ensure_ascii=False,
|
||
sort_keys=True, default=str)
|
||
except (TypeError, ValueError):
|
||
canonical = str(arguments)
|
||
raw = f"{tool_name}\0{canonical}".encode("utf-8", errors="ignore")
|
||
return hashlib.sha256(raw).hexdigest()
|
||
|
||
|
||
@dataclass
|
||
class StallGuard:
|
||
"""跟踪一个 Agent 会话内的工具调用指纹,识别重复停滞。
|
||
|
||
- 同一指纹连续出现达到 ``notice_at`` 次:返回 "notice",运行时把提示注入上下文;
|
||
- 达到 ``halt_at`` 次:返回 "halt",熔断本轮循环并标记失败原因。
|
||
只做纯决策,不产生副作用(与 Hermes 的无副作用护栏一致)。
|
||
"""
|
||
|
||
notice_at: int = 3
|
||
halt_at: int = 5
|
||
_counts: dict[str, int] = field(default_factory=dict)
|
||
|
||
def observe(self, fingerprint: str) -> str:
|
||
"""记录一次调用,返回决策:ok | notice | halt。"""
|
||
count = self._counts.get(fingerprint, 0) + 1
|
||
self._counts[fingerprint] = count
|
||
if count >= max(2, self.halt_at):
|
||
return "halt"
|
||
if count >= max(2, self.notice_at):
|
||
return "notice"
|
||
return "ok"
|
||
|
||
def halt_reason(self, tool_name: str) -> str:
|
||
return (f"工具「{tool_name}」以相同参数连续调用 {self.halt_at} 次无进展,"
|
||
f"已熔断本次会话以保护点数余额")
|
||
|
||
|
||
def stall_notice_text(tool_name: str, count: int) -> str:
|
||
return (f"系统提示:工具「{tool_name}」已用完全相同的参数调用 {count} 次,"
|
||
f"结果不会变化。请改用不同参数,或直接基于已有结果完成任务。")
|
||
|
||
|
||
__all__ = [
|
||
"IterationBudget", "parse_retry_after_seconds", "jittered_backoff", "retry_sleep",
|
||
"tool_call_fingerprint", "StallGuard", "stall_notice_text",
|
||
]
|