mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
perf(agent): defer synchronous httpx.post out of AIAgent.__init__ (#32221)
(cherry picked from commit 1fe63238b0)
This commit is contained in:
parent
abc2069f49
commit
958a81a1e4
2 changed files with 152 additions and 37 deletions
|
|
@ -1354,6 +1354,79 @@ class ContextCompressor(ContextEngine):
|
|||
previous = telemetry.get("aux_call_duration_ms") or 0
|
||||
telemetry["aux_call_duration_ms"] = previous + max(0, int(duration_ms))
|
||||
|
||||
def _resolve_context_length(self) -> int:
|
||||
"""Resolve and cache the model's context length on first access."""
|
||||
if self._resolved_context_length is None:
|
||||
self._resolved_context_length = get_model_context_length(
|
||||
self.model,
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
config_context_length=self._config_context_length,
|
||||
provider=self.provider,
|
||||
)
|
||||
# Small-context threshold floor: models under 512K trigger at
|
||||
# >=75% so compaction doesn't fire with half the window still
|
||||
# free. Raise-only; must run AFTER context_length is resolved
|
||||
# and BEFORE threshold_tokens is derived (deferred here from
|
||||
# __init__ along with the resolution itself, #32221).
|
||||
# _base_threshold_percent already has the per-model override
|
||||
# applied, so the floor stacks on top of it.
|
||||
self.threshold_percent = self._effective_threshold_percent(
|
||||
self._resolved_context_length, self._base_threshold_percent,
|
||||
)
|
||||
return self._resolved_context_length
|
||||
|
||||
@property
|
||||
def context_length(self) -> int:
|
||||
return self._resolve_context_length()
|
||||
|
||||
@context_length.setter
|
||||
def context_length(self, value: int) -> None:
|
||||
self._resolved_context_length = value
|
||||
self._threshold_tokens = None
|
||||
self._tail_token_budget = None
|
||||
self._max_summary_tokens = None
|
||||
|
||||
@property
|
||||
def threshold_tokens(self) -> int:
|
||||
if self._threshold_tokens is None:
|
||||
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even
|
||||
# if the percentage would suggest a lower value (#14690 handles
|
||||
# the degenerate small-window case inside the helper).
|
||||
self._threshold_tokens = self._compute_threshold_tokens(
|
||||
self.context_length, self.threshold_percent, self.max_tokens,
|
||||
)
|
||||
# Apply absolute token cap (compression.threshold_tokens) —
|
||||
# takes the lower of the ratio-based threshold and the cap.
|
||||
self._apply_threshold_tokens_cap()
|
||||
return self._threshold_tokens
|
||||
|
||||
@threshold_tokens.setter
|
||||
def threshold_tokens(self, value: int) -> None:
|
||||
self._threshold_tokens = value
|
||||
|
||||
@property
|
||||
def tail_token_budget(self) -> int:
|
||||
if self._tail_token_budget is None:
|
||||
self._tail_token_budget = int(self.threshold_tokens * self.summary_target_ratio)
|
||||
return self._tail_token_budget
|
||||
|
||||
@tail_token_budget.setter
|
||||
def tail_token_budget(self, value: int) -> None:
|
||||
self._tail_token_budget = value
|
||||
|
||||
@property
|
||||
def max_summary_tokens(self) -> int:
|
||||
if self._max_summary_tokens is None:
|
||||
self._max_summary_tokens = min(
|
||||
int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
|
||||
)
|
||||
return self._max_summary_tokens
|
||||
|
||||
@max_summary_tokens.setter
|
||||
def max_summary_tokens(self, value: int) -> None:
|
||||
self._max_summary_tokens = value
|
||||
|
||||
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
|
||||
"""Clear all per-session compaction state at a real session boundary.
|
||||
|
||||
|
|
@ -1988,46 +2061,23 @@ class ContextCompressor(ContextEngine):
|
|||
# deterministic "summary unavailable" handoff and drop the middle window.
|
||||
self.abort_on_summary_failure = abort_on_summary_failure
|
||||
|
||||
self.context_length = get_model_context_length(
|
||||
model, base_url=base_url, api_key=api_key,
|
||||
config_context_length=config_context_length,
|
||||
provider=provider,
|
||||
)
|
||||
# Small-context threshold floor: models under 512K trigger at >=75%
|
||||
# so compaction doesn't fire with half the window still free (the
|
||||
# incompressible floor makes 50%-triggered compaction thrash on
|
||||
# 128K-262K models). Raise-only; must run AFTER context_length is
|
||||
# resolved and BEFORE threshold_tokens is derived. The pre-floor
|
||||
# value is kept so update_model() can re-derive for a new window
|
||||
# (switching small -> large must drop back to the configured value).
|
||||
# Note: _base_threshold_percent already has the per-model override
|
||||
# applied, so the floor stacks on top of any model-specific threshold.
|
||||
# Defer context-length resolution to first access (#32221):
|
||||
# get_model_context_length() can issue a synchronous /models HTTP
|
||||
# probe, which must not block AIAgent construction. The small-context
|
||||
# threshold floor and the absolute threshold cap both need the
|
||||
# resolved window, so they are applied on first resolution (see
|
||||
# _resolve_context_length / the threshold_tokens property) instead
|
||||
# of here. The pre-floor value is kept so update_model() can
|
||||
# re-derive for a new window (switching small -> large must drop
|
||||
# back to the configured value).
|
||||
self._config_context_length = config_context_length
|
||||
self._configured_threshold_percent = self.threshold_percent
|
||||
self.threshold_percent = self._effective_threshold_percent(
|
||||
self.context_length, self._base_threshold_percent,
|
||||
)
|
||||
threshold_percent = self.threshold_percent
|
||||
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
|
||||
# the percentage would suggest a lower value. This prevents premature
|
||||
# compression on large-context models at 50% while keeping the % sane
|
||||
# for models right at the minimum. _compute_threshold_tokens also
|
||||
# guards the degenerate case where the floor would equal/exceed the
|
||||
# window (small models), so auto-compression can still fire (#14690).
|
||||
self.threshold_tokens = self._compute_threshold_tokens(
|
||||
self.context_length, threshold_percent, self.max_tokens,
|
||||
)
|
||||
# Apply absolute token cap (compression.threshold_tokens) — takes
|
||||
# the lower of the ratio-based threshold and the cap.
|
||||
self._apply_threshold_tokens_cap()
|
||||
self._resolved_context_length: int | None = None
|
||||
self._threshold_tokens: int | None = None
|
||||
self._tail_token_budget: int | None = None
|
||||
self._max_summary_tokens: int | None = None
|
||||
self.compression_count = 0
|
||||
|
||||
# Derive token budgets: ratio is relative to the threshold, not total context
|
||||
target_tokens = int(self.threshold_tokens * self.summary_target_ratio)
|
||||
self.tail_token_budget = target_tokens
|
||||
self.max_summary_tokens = min(
|
||||
int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
|
||||
)
|
||||
|
||||
if not quiet_mode:
|
||||
logger.info(
|
||||
"Context compressor initialized: model=%s context_length=%d "
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ def compressor():
|
|||
protect_last_n=2,
|
||||
quiet_mode=True,
|
||||
)
|
||||
# Resolve context_length while the mock is still active so the
|
||||
# fixture returns a fully-initialized compressor.
|
||||
_ = c.context_length
|
||||
return c
|
||||
|
||||
|
||||
|
|
@ -2584,11 +2587,14 @@ class TestSummaryTargetRatio:
|
|||
"""Tail token budget should be threshold_tokens * summary_target_ratio."""
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.40)
|
||||
# Resolve while mock is active (lazy init defers this past __init__).
|
||||
_ = c.context_length
|
||||
# 200K < 512K → threshold floored at 75%: 150K * 0.40 ratio = 60K
|
||||
assert c.tail_token_budget == 60_000
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.40)
|
||||
_ = c.context_length
|
||||
# 1M * 0.50 threshold * 0.40 ratio = 200K
|
||||
assert c.tail_token_budget == 200_000
|
||||
|
||||
|
|
@ -2596,10 +2602,12 @@ class TestSummaryTargetRatio:
|
|||
"""Max summary tokens should be 5% of context, capped at 10K."""
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
_ = c.context_length
|
||||
assert c.max_summary_tokens == 10_000 # 200K * 0.05
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
_ = c.context_length
|
||||
assert c.max_summary_tokens == 10_000 # capped at 10K ceiling
|
||||
|
||||
def test_ratio_clamped(self):
|
||||
|
|
@ -2616,6 +2624,7 @@ class TestSummaryTargetRatio:
|
|||
"""Sub-512K models get the 75% small-context threshold floor."""
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
_ = c.context_length
|
||||
assert c.threshold_percent == 0.75
|
||||
# 75% of 100K = 75K, above the 64K minimum floor
|
||||
assert c.threshold_tokens == 75_000
|
||||
|
|
@ -2624,6 +2633,7 @@ class TestSummaryTargetRatio:
|
|||
"""At 512K+ the configured (default 50%) percentage is used directly."""
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=512_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
_ = c.context_length
|
||||
assert c.threshold_percent == 0.50
|
||||
assert c.threshold_tokens == 256_000
|
||||
|
||||
|
|
@ -3492,6 +3502,61 @@ class TestTruncateToolCallArgsJson:
|
|||
assert parsed["content"].endswith("...[truncated]")
|
||||
|
||||
|
||||
class TestLazyContextResolution:
|
||||
"""Verify that ContextCompressor defers get_model_context_length until
|
||||
context_length is first accessed, so construction never blocks on network
|
||||
I/O or blocks startup when the model metadata service is slow."""
|
||||
|
||||
def test_init_does_not_call_get_model_context_length(self):
|
||||
"""get_model_context_length must NOT be called during __init__; it
|
||||
should only be called on first access of .context_length."""
|
||||
with patch(
|
||||
"agent.context_compressor.get_model_context_length",
|
||||
) as mock_get:
|
||||
c = ContextCompressor(
|
||||
model="test/model",
|
||||
threshold_percent=0.85,
|
||||
protect_first_n=2,
|
||||
protect_last_n=2,
|
||||
quiet_mode=True,
|
||||
)
|
||||
mock_get.assert_not_called()
|
||||
|
||||
# First access triggers resolution
|
||||
_ = c.context_length
|
||||
mock_get.assert_called_once()
|
||||
|
||||
def test_context_length_setter_bypasses_resolution(self):
|
||||
"""Assigning to .context_length directly must skip the network probe
|
||||
entirely and return the assigned value."""
|
||||
with patch(
|
||||
"agent.context_compressor.get_model_context_length",
|
||||
) as mock_get:
|
||||
c = ContextCompressor(
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
)
|
||||
c.context_length = 100_000
|
||||
result = c.context_length
|
||||
mock_get.assert_not_called()
|
||||
assert result == 100_000
|
||||
|
||||
def test_config_context_length_skips_network_probe(self):
|
||||
"""When config_context_length is provided, the resolver must use it
|
||||
as the cached value and not make a network call."""
|
||||
with patch(
|
||||
"agent.context_compressor.get_model_context_length",
|
||||
side_effect=lambda model, **kwargs: kwargs.get("config_context_length"),
|
||||
) as mock_get:
|
||||
c = ContextCompressor(
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
config_context_length=200_000,
|
||||
)
|
||||
result = c.context_length
|
||||
assert result == 200_000
|
||||
|
||||
|
||||
class TestPreflightSentinelGuard:
|
||||
"""Regression for #36718: the preflight token-display seed in
|
||||
run_conversation must NOT overwrite the -1 sentinel that
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue