Skip to content

refactor(agent-core): one Retry-After parser, reset-grammar table, token estimator, head/tail truncator and NO_PROXY matcher; LLM traffic honours CIDR/wildcard NO_PROXY - #109539

Merged
teknium1 merged 8 commits into
mainfrom
dedup/agent-core
Sep 13, 2026
Merged

teknium1 merged 8 commits into
mainfrom
dedup/agent-core

Conversation

@teknium1

@teknium1 teknium1 commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Retry-After parsing, free-text reset grammars, rough token estimation, head/tail tool-output truncation and NO_PROXY matching each have exactly one implementation now, and the LLM transport bypasses the proxy for CIDR / *. NO_PROXY entries the way the platform adapters already did.

Changes

  • Retry-After (cluster 1): 7 hand-rolled float(headers.get("Retry-After")) sites call agent/retry_utils.py::parse_retry_after_seconds; HTTP-date headers (Cloudflare 5xx, OAuth endpoints) are honoured everywhere the conversation loop already honoured them. Per-site caps/floors (metrics 1–86400 s, GitHub 60 s, ClawHub 15 s) stay at the call site.
  • Reset grammars (cluster 2): the two regex tables (agent_runtime_helpers vs credential_pool._RETRY_DELAY_PATTERNS) become agent/retry_utils.py::RETRY_DELAY_PATTERNS / reset_delay_from_message. The wider resets in grammar wins (h/hr/hrs/hour/hours + m/min/minutes + s/seconds, decimals) — it parses every form the narrower Nhr Nmin table did plus "resets in 2 hours 5 minutes" / "resets in 45s".
  • Token estimation (cluster 4): context_breakdown._chars_to_tokens and native_compaction._approx_tokens call estimate_tokens_rough; the four private = 4 constants import one CHARS_PER_TOKEN from agent/model_metadata.py. Estimates feed UI/budgets only — no prompt or message bytes change.
  • Head/tail truncation (cluster 5): new tools/tool_output_truncate.py (40/60 split + one notice formatter); terminal, MCP, execute_code (byte mode + spill kept) and _BoundedOutputCollector.render use it. Web/browser/file_tools truncators are different products and untouched. hermes_cli/kanban_specify.py::_truncate comment no longer claims escape stripping the body never did.
  • NO_PROXY (cluster 7): new leaf agent/proxy_bypass.py holds the full matcher (exact, .suffix, *.wildcard, IP literal, CIDR, host:port, *) and the six-key proxy env scan. gateway/platforms/base.py::should_bypass_proxy / is_host_excluded_by_no_proxy are one-line forwarders (only that region of base.py touched); agent/process_bootstrap._get_proxy_for_base_url drops the stdlib proxy_bypass_environment.

Sites

path::symbol → canonical
hermes_cli/anon_auth.py::_retry_after_seconds agent/retry_utils.py::parse_retry_after_seconds
hermes_cli/observability/shared_metrics_sender.py::_retry_after_seconds same (+ local clamp)
agent/gemini_native_adapter.py::gemini_http_error (inline) same
agent/agent_runtime_helpers.py::_set_reset_from_retry_after same
agent/nous_rate_guard.py::_parse_reset_seconds (retry-after leg) same
tools/skills_hub_github.py::GitHubClient._github_get (inline) same (+ 60 s cap)
tools/skills_hub_clawhub.py (owner lookup + download, inline ×2) same (+ 15 s cap on download)
agent/agent_runtime_helpers.py::_reset_delay_from_message + 3 regexes agent/retry_utils.py::reset_delay_from_message (deleted)
agent/credential_pool.py::_RETRY_DELAY_PATTERNS / _extract_retry_delay_seconds same (deleted)
agent/context_breakdown.py::_chars_to_tokens / _bytes_to_tokens agent/model_metadata.py::estimate_tokens_rough / CHARS_PER_TOKEN
agent/native_compaction.py::_approx_tokens estimate_tokens_rough
tools/budget_config.py::_CHARS_PER_TOKEN, agent/prompt_builder.py::_CONTEXT_FILE_CHARS_PER_TOKEN (deleted), agent/context_compressor.py::_CHARS_PER_TOKEN, tools/transcription_command.py::_PROMPT_CHARS_PER_TOKEN agent/model_metadata.py::CHARS_PER_TOKEN
tools/terminal_tool_result.py::_truncate_head_tail tools/tool_output_truncate.py::truncate_head_tail
tools/mcp_tool_content.py::_truncate_mcp_text_result same (label="MCP RESULT")
tools/code_execution_tool.py::_truncate_stdout_text head_tail_split + truncation_notice(unit="bytes")
tools/environments/base_output.py::_BoundedOutputCollector.render head_tail_split + truncation_notice
gateway/platforms/base.py::_split_host_port / _no_proxy_entries / _ip_or_none / _no_proxy_entry_matches agent/proxy_bypass.py (moved)
gateway/platforms/base.py::should_bypass_proxy, is_host_excluded_by_no_proxy forwarders to agent/proxy_bypass.py::should_bypass_proxy
agent/process_bootstrap.py::_get_proxy_for_base_url (stdlib proxy_bypass_environment) agent/proxy_bypass.py::should_bypass_proxy
agent/process_bootstrap.py::_get_proxy_from_env + base.py::resolve_proxy_url env-key tuples agent/proxy_bypass.py::first_proxy_env_value

Behavior change

  • LLM/auxiliary httpx clients now bypass the proxy for CIDR (10.0.0.0/8), *.internal wildcard and host:port NO_PROXY entries; before, only exact/.suffix entries bypassed on that path (live-verified gap, adapters already bypassed).
  • HTTP-date Retry-After is honoured at the 7 listed sites (previously fell back to each site's default wait).
  • Credential-pool cooldown parses "resets in N hours M minutes" / "resets in Ns" (previously default cooldown).
  • /context breakdown static categories and native-compaction retention count CJK/Cyrillic text with the byte/codepoint-corrected estimator (higher, matches the conversation slice).
  • Terminal truncation notice gains thousands separators (9,000 chars omitted out of 10,000 total) to match MCP/execute_code/collector; label/wording otherwise unchanged.

Validation

before after
NO_PROXY=10.0.0.0/8, build_keepalive_http_client("http://10.1.2.3:8000/v1") mounts HTTPProxy pool mounts direct ConnectionPool (E2E, fresh interpreter, temp HERMES_HOME)
extract_api_error_context with Retry-After: <HTTP-date +90s> no reset_at reset_at ≈ now+90
_normalize_error_context({"message": "resets in 2 hours 5 minutes"}) no reset_at reset_at ≈ now+7500
_chars_to_tokens("Привет мир…"×50) vs estimate_tokens_rough 263 vs 463 463 vs 463

Tests (invariants, each sabotage-verified red by reverting one site's fix):

  • tests/agent/test_retry_delay_parsers_shared.py — HTTP-date header parsed identically at anon_auth / error-context / nous_rate_guard; pool and error-context agree on 5 reset grammars.
  • tests/agent/test_token_estimator_shared.py — breakdown + retention == canonical on Cyrillic/CJK/ASCII; all ratio constants are the one object.
  • tests/agent/test_proxy_bypass_shared.py — CIDR / *. / .suffix / host:port bypass on LLM and adapter paths; non-matching host keeps the proxy on both.
  • tests/tools/test_tool_output_truncate.py — 10k input at terminal / MCP / execute_code / collector: one notice, exact 40/60 head and tail, correct omitted/total/unit.

scripts/run_tests.sh over tests/agent tests/tools tests/test_retry_utils.py + proxy/slack/commands/anon_auth/observability files: see report. ruff, windows-footguns, compat-pointers, git diff --check clean.

Not done: hermes_cli/commands_platforms.py::_clamp_command_nameshermes_cli/commands.py's copy sits inside the PLUGIN-COMPAT block, and scripts/check_compat_pointers.py fails CI on any in-tree import from it; the dedupe has to wait for the compat block's scheduled removal (or the helper's move to a non-compat module). Discord/Slack/Photon Retry-After sites are the adapters lane.

Infographic

One implementation each: Retry-After, reset grammar, token estimator, head/tail truncation, NO_PROXY

Review follow-ups

Fixes for the SHOULD-FIX findings of the independent review (each its own commit, each test proven red with the fix reverted):

finding fix test
*.example.com no longer matched the apex example.com (the adapter docstring promised apex + subdomains) agent/proxy_bypass.py::no_proxy_entry_matches: *. and . entries share one apex+subdomain rule (curl/requests convention) tests/agent/test_proxy_bypass_shared.py rows *.slack.com/.slack.comslack.com; notslack.com stays proxied
_get_proxy_for_base_url lost its guard: http://host:notaport/v1 raised ValueError out of urlsplit().port and the outer except in build_keepalive_http_client silently dropped the shared keepalive transport agent/proxy_bypass.py::split_host_port catches ValueError from the port parse only → (host, None); the host still matches NO_PROXY test_malformed_port_in_base_url_keeps_the_proxy_instead_of_raising (:notaport, :99999 keep the proxy; NO_PROXY=host still bypasses)
RETRY_DELAY_PATTERNS order flipped the credential pool's precedence: retry after 30s; resets in 4hr cooled 14400 s instead of 30 agent/retry_utils.py::RETRY_DELAY_PATTERNS: quotaResetDelay → explicit retry-after → resets-in (pool precedence restored; the shorter explicit wait is what the provider asks for) tests/agent/test_retry_delay_parsers_shared.py row "Rate limited. Retry after 30s; resets in 4hr" → 30

Additional declared behavior (unchanged by the follow-ups, was undeclared): NO_PROXY and no_proxy are unioned rather than first-non-empty; trailing-dot hosts (example.com.) match; *./. alone match nothing (not everything); "resets in 1 hour and 30 minutes" parses as 3600 s (partial match) where the pool previously returned None.

Tests: scripts/run_tests.sh tests/agent/test_proxy_bypass_shared.py tests/agent/test_retry_delay_parsers_shared.py tests/run_agent/test_create_openai_client_proxy_env.py tests/gateway/test_proxy_mode.py tests/test_retry_utils.py tests/agent/test_credential_pool*.py tests/gateway/test_slack.py → 22 files, 450 passed, 0 failed.

@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 5d29daa — fix(agent): explicit "retry after N s" wins over "resets in

⚠️ Warnings

OSV vulnerability scan · View job

76 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 5m25s vs 5m4s (+6.9%). 6 job(s) slower, 7 faster, 1 unchanged.

  • OS-specific tests / Windows-only tests: +53.0s
  • Python tests / Run tests: -25.0s
  • OS-specific tests / macOS-only tests: +13.0s
  • Python lints / Windows footguns (blocking): +8.0s
  • OSV scan / Scan lockfiles / osv-scan: -5.0s

@gaoanze888 gaoanze888 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The consolidation is mostly coherent, but two behavior regressions remain at exact head 082ccf1f164513f155c799da18bed90cf0f6291e:

  1. skills_hub_clawhub uses parse_retry_after_seconds(...) or 5, so valid Retry-After: 0 becomes a five-second delay. Distinguish None from 0.0 and add a call-site zero-value test.
  2. The NO_PROXY target parser can throw on malformed provider URLs: accessing urlsplit(...).port raises for http://example.com:bad/v1, and malformed IPv6 raises during split. process_bootstrap does not catch this, changing the old fail-safe behavior from “keep the proxy” to client-construction crash. Treat unparsable target/entries as non-matches and test malformed port/IPv6.

Please also lock intentional behavior for wildcard apex matching: *.example.com now excludes only subdomains whereas the old helper included the apex. The current parity test can pass port cases merely because the host string contains :, so add real IPv6/port mismatch/case/malformed coverage. truncate_head_tail(max_chars=0) also returns full text due to text[-0:]; reject or handle non-positive limits.

Diff and compile checks are clean.

@alt-glitch alt-glitch added type/refactor Code restructuring, no behavior change P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/tools Tool registry, model_tools, toolsets comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard tool/terminal Terminal execution and process management tool/mcp MCP client and OAuth tool/code-exec execute_code sandbox tool/skills Skills system (list, view, manage) sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Sep 13, 2026
…eed every retry wait

Seven sites hand-rolled `float(headers.get("Retry-After"))` (anon_auth,
shared_metrics_sender, gemini_native_adapter, extract_api_error_context,
nous_rate_guard, skills_hub_github, skills_hub_clawhub x2) and silently
dropped RFC 7231 HTTP-date values that the conversation loop already honours
via agent/retry_utils.py::parse_retry_after_seconds. They now call it; per-site
caps/floors stay at the call site.

The free-text "resets in / quotaResetDelay / retry after N s" regexes lived in
two tables (agent_runtime_helpers vs credential_pool) whose "resets in"
grammars diverged: the pool accepted only integer `Nhr Nmin` while the error
context accepted h/hr/hours + m/min/minutes + s/seconds with decimals. One table
(agent/retry_utils.py::RETRY_DELAY_PATTERNS / reset_delay_from_message) using
the wider grammar, so a pooled credential's cooldown and the UI's reset time
now agree.
…se the canonical token estimator

context_breakdown._chars_to_tokens and native_compaction._approx_tokens did raw
chars//4, under-counting CJK/Cyrillic by 2-4x next to the conversation slice
that already used estimate_tokens_rough — the /context pie chart mixed two
estimators. Both now call the canonical. The four private `= 4` ratio
constants import one CHARS_PER_TOKEN from agent/model_metadata.py. Estimates
only feed UI and budgets; no prompt or message bytes change.
…r truncate through one head/tail helper

Four copies of the 40/60 head/tail algorithm with a near-identical notice
(terminal_tool_result, mcp_tool_content, code_execution_tool,
environments/base_output) collapse into tools/tool_output_truncate.py, so the
ratio and the `... [<LABEL> TRUNCATED - N <unit> omitted out of T total] ...`
marker are defined once. execute_code keeps byte mode + spill path and only
shares the notice/split. Visible change: the terminal notice now uses
thousands separators like the other three (`9,000 chars` not `9000 chars`).

kanban_specify._truncate: comment claimed escape stripping the body never did;
comment now says what the plain clamp is for.
…ke the platform adapters do

Three answers to "is this host in NO_PROXY": process_bootstrap used the stdlib
proxy_bypass_environment (no CIDR, no `*.`), gateway/platforms/base.py had a
full matcher (should_bypass_proxy) and a second suffix-only one
(is_host_excluded_by_no_proxy, used by Slack). Live-verified: with
NO_PROXY=10.0.0.0/8 Telegram bypassed the proxy while the LLM call to a 10.x
endpoint went through it.

The full matcher moves to the leaf module agent/proxy_bypass.py (stdlib only,
importable at early boot); both base.py functions are one-line forwarders and
process_bootstrap._get_proxy_for_base_url uses it (passing host:port so
port-qualified entries match). The six-key proxy env scan is also shared.
The rebase resolution used 'parsed or 5', which turned a legitimate 0 s
(negative headers clamp to 0) into the 5 s default; the pre-refactor code
kept it. Test None explicitly.

@gaoanze888 gaoanze888 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Retry-After: 0 call-site bug is fixed at 55e0cbd9fdb984071e9698ef045703c7b7d60a58, and the focused shared suites pass 20/20. The other two concrete regressions from my review remain unchanged and are production-reachable:

split_host_port("http://example.com:bad/v1") -> ValueError: Port could not be cast...
split_host_port("http://[::1/v1") -> ValueError: Invalid IPv6 URL
truncate_head_tail("abc", 0) -> notice + "abc" (keeps the full text)
truncate_head_tail("abc", -1) -> impossible "4 chars omitted out of 3" + "bc"

process_bootstrap._get_proxy_for_base_url() does not catch the URL exceptions, so malformed configured provider URLs now crash proxy setup instead of retaining the proxy/failing safely at normal endpoint validation. Please make parsing total (invalid target/entry => non-match), test malformed port/brackets through the actual LLM call site, and reject or define non-positive truncation budgets. Also add explicit wildcard-apex and real IPv6/port mismatch assertions; the current or ":" in host assertion does not verify adapter parity for port entries.

The shared matcher took the gateway adapter's `*.` branch, which only matched
subdomains. The adapter's own `is_host_excluded_by_no_proxy` docstring promised
"leading-dot and `*.` entries match the apex domain and subdomains" (the
curl/requests convention), so `NO_PROXY=*.slack.com` silently stopped covering
`slack.com`. `*.` and `.` entries now share one apex+subdomain rule.

Review follow-up on #109539.
…e transport

`_get_proxy_for_base_url` lost its guard when it moved onto the shared matcher:
`split_host_port` read `urlsplit(...).port`, which raises ValueError for
`http://host:notaport/v1` or `:99999`, and `build_keepalive_http_client`'s
outer except then returned None -- the client silently lost the shared pool
instead of merely skipping the bypass check. The port parse now yields
`(host, None)` on ValueError only; the host still matches NO_PROXY entries.

Review follow-up on #109539.
…he shared reset table

Unifying the credential pool's `_RETRY_DELAY_PATTERNS` into `RETRY_DELAY_PATTERNS`
flipped the pool's precedence: "retry after 30s; resets in 4hr" cooled the
credential for 14400 s where the pool used to take 30. A body carrying both
describes a short throttle inside a long quota window; the explicit retry-after
is the wait the provider actually asks for, so it is tried before "resets in".

Review follow-up on #109539.

@gaoanze888 gaoanze888 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental recheck at 5d29daa1a7d77ba4d457962fb1d5592f6f58cae3: malformed/non-numeric and out-of-range URL ports are now total, wildcard apex semantics are pinned, and the shared retry parser precedence is clearer. One URL parser case and the truncation contract from the prior review remain:

split_host_port("http://[::1/v1") -> ValueError: Invalid IPv6 URL
truncate_head_tail("abc", 0)      -> truncation notice + full "abc"
truncate_head_tail("abc", -1)     -> "4 chars omitted out of 3" + "bc"

The try begins after urlsplit(raw), so malformed bracketed IPv6 still escapes before .port is read and remains reachable through _get_proxy_for_base_url(). Wrap URL splitting and hostname/port extraction as one total parse; invalid target/NO_PROXY token should be a non-match that retains the proxy. Add the malformed-bracket case through the process-bootstrap call site, plus a bracketed valid IPv6 port-match/mismatch case.

Please also reject non-positive max_chars or define it as an empty bounded result before slicing; the current output violates both size and metadata invariants. Existing focused proxy/retry changes otherwise look good.

@teknium1
teknium1 merged commit 3bef6b6 into main Sep 13, 2026
37 checks passed
teknium1 added a commit that referenced this pull request Sep 13, 2026
The shared matcher took the gateway adapter's `*.` branch, which only matched
subdomains. The adapter's own `is_host_excluded_by_no_proxy` docstring promised
"leading-dot and `*.` entries match the apex domain and subdomains" (the
curl/requests convention), so `NO_PROXY=*.slack.com` silently stopped covering
`slack.com`. `*.` and `.` entries now share one apex+subdomain rule.

Review follow-up on #109539.
teknium1 added a commit that referenced this pull request Sep 13, 2026
…e transport

`_get_proxy_for_base_url` lost its guard when it moved onto the shared matcher:
`split_host_port` read `urlsplit(...).port`, which raises ValueError for
`http://host:notaport/v1` or `:99999`, and `build_keepalive_http_client`'s
outer except then returned None -- the client silently lost the shared pool
instead of merely skipping the bypass check. The port parse now yields
`(host, None)` on ValueError only; the host still matches NO_PROXY entries.

Review follow-up on #109539.
@teknium1
teknium1 deleted the dedup/agent-core branch September 13, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/code-exec execute_code sandbox tool/mcp MCP client and OAuth tool/skills Skills system (list, view, manage) tool/terminal Terminal execution and process management type/refactor Code restructuring, no behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants