Skip to content

perf: use load_config_readonly() at read-only call sites in agent/ - #56085

Closed
Stoltemberg wants to merge 1 commit into
NousResearch:mainfrom
Stoltemberg:perf/use-readonly-config
Closed

perf: use load_config_readonly() at read-only call sites in agent/#56085
Stoltemberg wants to merge 1 commit into
NousResearch:mainfrom
Stoltemberg:perf/use-readonly-config

Conversation

@Stoltemberg

Copy link
Copy Markdown
Contributor

What does this PR do?

load_config() performs a defensive deepcopy (~135μs per call) of the cached config dict. The codebase has load_config_readonly() specifically for read-only callers, but most call sites in agent/ still used load_config().

Replaced load_config()load_config_readonly() at 31+ read-only call sites across 20 files in agent/. Each saves ~135μs per call. In the agent loop, config is read 20-50x per conversation.

Type of Change

  • ⚡ Performance improvement

Changes Made

20 files in agent/ — all confirmed read-only (only .get() lookups, no mutation):

  • agent_init.py (4 aliased imports), auxiliary_client.py (11 calls), curator.py (2), prompt_builder.py (2), and 16 other files (1 each)

How to Test

uv run --extra dev ruff check agent/
uv run --extra dev python -m pytest tests/test_project_metadata.py tests/test_packaging_metadata.py -q

Checklist

  • All call sites confirmed read-only
  • ruff check passes
  • 19 tests pass
  • No behavior change — same config values, just no deepcopy

@alt-glitch alt-glitch added type/perf Performance improvement or optimization comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have labels Jul 1, 2026

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

Thanks for identifying the cached-config deepcopy cost; the performance premise still holds on current main.

Problems

  • Blocking: agent/agent_runtime_helpers.py:1722 changes _sm_cfg to load_config_readonly() and passes it to get_compatible_custom_providers(). Current hermes_cli/config.py:4699-4700 and :4714-4722 mutate provider-entry dictionaries in place, while load_config_readonly() explicitly forbids nested mutation (hermes_cli/config.py:6766-6781). This can corrupt the shared config cache for camelCase or api_key_env provider configurations.
  • The cleanup is now incomplete: current read-only-only paths remain in agent/auxiliary_client.py:2175-2181, :2196-2202, and agent/coding_context.py:369-376.

Suggested changes

  • First make the provider normalizer operate on a copied entry (and add a regression test that alias normalization does not mutate its input), then re-audit the remaining current-HEAD call sites during conflict resolution.

Automated hermes-sweeper review.

from hermes_cli.config import load_config, get_compatible_custom_providers
_sm_cfg = load_config()
from hermes_cli.config import load_config_readonly, get_compatible_custom_providers
_sm_cfg = load_config_readonly()

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.

load_config_readonly() returns the shared cached dict, but this value is passed to get_compatible_custom_providers(). On current main, its normalizer writes alias keys into provider-entry dicts (hermes_cli/config.py:4699-4700, :4714-4722), so this path can corrupt the config cache. Please make that normalizer/caller non-mutating before using the readonly loader here.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 15, 2026
teknium1 pushed a commit that referenced this pull request Jul 29, 2026
Salvaged from #56085 (@Stoltemberg), rebased onto current main: sites
main had already converted (credential_pool, auxiliary_client MoA
paths, model_metadata, moa_loop, agent_runtime_helpers) resolve to
main's versions; the remaining ~29 read-only sites across 16 agent/
files swap to the no-deepcopy readonly loader (~135us saved per call).

Full per-site mutation audit performed (every enclosing function read,
escapes traced): 23 SAFE, 5 ESCAPES with read-only consumers, 1 UNSAFE
path (init_agent -> get_compatible_custom_providers -> normalizer
in-place alias writes) fixed by the preceding no-mutate commits, which
make the normalizer copy-safe for ALL callers.
@teknium1

Copy link
Copy Markdown
Contributor

Thanks @Stoltemberg — this landed on main via #74322 with your commit preserved as author. Salvage notes: rebased onto current main (several of your sites had been independently converted in the meantime and resolve to main's versions; ~29 survived), and a full per-site mutation audit found one path where the swap was unsafe — init_agent → get_compatible_custom_providers → _normalize_custom_provider_entry mutated cached provider sub-dicts in place. That normalizer bug was fixed first (via @golldyck's #57096) so your swaps are safe everywhere. Measured 332µs → 11.9µs per read (28x) on a real config. Second salvaged PR of yours today (#55176#74194) — appreciate the consistent, well-aimed perf work!

@teknium1 teknium1 closed this Jul 29, 2026
spfcraze added a commit to spfcraze/hermes-agent that referenced this pull request Aug 1, 2026
The terminal-command guard path loaded config 2-3x per invocation via
load_config(), which pays a defensive deepcopy of the entire config on
every call (~356us of the ~376us warm-cache cost measured on a real
config.yaml). All six swapped call sites were audited read-only — every
caller takes scalar reads or iterates the returned structures; none
mutate (the save path at save_permanent_allowlist keeps load_config) —
so they now use load_config_readonly(), the API built for exactly this
(precedent: NousResearch#74211, NousResearch#74322; the one unsafe-site lesson from NousResearch#56085's
salvage is covered by the mutation audit and a cache-integrity test).

Measured (real config.yaml, warm cache): load_config 376.0us ->
load_config_readonly 19.9us (18.9x); full guard pass
check_all_command_guards('ls -la','local') 930.7us -> 241.8us (3.85x).

Tests: new test_approval_config_readonly.py drives the real functions
against a temp HERMES_HOME — readonly call counts per function, a
no-deepcopy pin for the full guard pass, and cache-identity/integrity
checks. Existing test mocks retargeted from load_config to
load_config_readonly (same injection intent). Note: 6
test_approval_mode_parity failures are pre-existing ordering flakes —
identical with the change stashed on clean main.
kshitijk4poor pushed a commit that referenced this pull request Aug 2, 2026
The terminal-command guard path loaded config 2-3x per invocation via
load_config(), which pays a defensive deepcopy of the entire config on
every call (~356us of the ~376us warm-cache cost measured on a real
config.yaml). All six swapped call sites were audited read-only — every
caller takes scalar reads or iterates the returned structures; none
mutate (the save path at save_permanent_allowlist keeps load_config) —
so they now use load_config_readonly(), the API built for exactly this
(precedent: #74211, #74322; the one unsafe-site lesson from #56085's
salvage is covered by the mutation audit and a cache-integrity test).

Measured (real config.yaml, warm cache): load_config 376.0us ->
load_config_readonly 19.9us (18.9x); full guard pass
check_all_command_guards('ls -la','local') 930.7us -> 241.8us (3.85x).

Tests: new test_approval_config_readonly.py drives the real functions
against a temp HERMES_HOME — readonly call counts per function, a
no-deepcopy pin for the full guard pass, and cache-identity/integrity
checks. Existing test mocks retargeted from load_config to
load_config_readonly (same injection intent). Note: 6
test_approval_mode_parity failures are pre-existing ordering flakes —
identical with the change stashed on clean main.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
Salvaged from NousResearch#56085 (@Stoltemberg), rebased onto current main: sites
main had already converted (credential_pool, auxiliary_client MoA
paths, model_metadata, moa_loop, agent_runtime_helpers) resolve to
main's versions; the remaining ~29 read-only sites across 16 agent/
files swap to the no-deepcopy readonly loader (~135us saved per call).

Full per-site mutation audit performed (every enclosing function read,
escapes traced): 23 SAFE, 5 ESCAPES with read-only consumers, 1 UNSAFE
path (init_agent -> get_compatible_custom_providers -> normalizer
in-place alias writes) fixed by the preceding no-mutate commits, which
make the normalizer copy-safe for ALL callers.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
The terminal-command guard path loaded config 2-3x per invocation via
load_config(), which pays a defensive deepcopy of the entire config on
every call (~356us of the ~376us warm-cache cost measured on a real
config.yaml). All six swapped call sites were audited read-only — every
caller takes scalar reads or iterates the returned structures; none
mutate (the save path at save_permanent_allowlist keeps load_config) —
so they now use load_config_readonly(), the API built for exactly this
(precedent: NousResearch#74211, NousResearch#74322; the one unsafe-site lesson from NousResearch#56085's
salvage is covered by the mutation audit and a cache-integrity test).

Measured (real config.yaml, warm cache): load_config 376.0us ->
load_config_readonly 19.9us (18.9x); full guard pass
check_all_command_guards('ls -la','local') 930.7us -> 241.8us (3.85x).

Tests: new test_approval_config_readonly.py drives the real functions
against a temp HERMES_HOME — readonly call counts per function, a
no-deepcopy pin for the full guard pass, and cache-identity/integrity
checks. Existing test mocks retargeted from load_config to
load_config_readonly (same injection intent). Note: 6
test_approval_mode_parity failures are pre-existing ordering flakes —
identical with the change stashed on clean main.
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
Salvaged from NousResearch#56085 (@Stoltemberg), rebased onto current main: sites
main had already converted (credential_pool, auxiliary_client MoA
paths, model_metadata, moa_loop, agent_runtime_helpers) resolve to
main's versions; the remaining ~29 read-only sites across 16 agent/
files swap to the no-deepcopy readonly loader (~135us saved per call).

Full per-site mutation audit performed (every enclosing function read,
escapes traced): 23 SAFE, 5 ESCAPES with read-only consumers, 1 UNSAFE
path (init_agent -> get_compatible_custom_providers -> normalizer
in-place alias writes) fixed by the preceding no-mutate commits, which
make the normalizer copy-safe for ALL callers.
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 P3 Low — cosmetic, nice to have sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants