Skip to content

fix(gateway): refresh cached prompts when SOUL.md changes - #28078

Open
qindongliang wants to merge 3 commits into
NousResearch:mainfrom
qindongliang:codex/soul-cache-invalidation
Open

fix(gateway): refresh cached prompts when SOUL.md changes#28078
qindongliang wants to merge 3 commits into
NousResearch:mainfrom
qindongliang:codex/soul-cache-invalidation

Conversation

@qindongliang

@qindongliang qindongliang commented May 18, 2026

Copy link
Copy Markdown

What does this PR do?

Refresh cached gateway prompts when the active profile's SOUL.md changes.

Gateway sessions intentionally reuse cached AIAgent instances and persisted system_prompt snapshots to preserve prompt-cache hits. That is useful for normal turns, but it also means edits to SOUL.md can keep using stale identity/rules in existing sessions.

This PR adds two cache invalidation layers:

  • Include the active profile's SOUL.md filesystem fingerprint in the gateway agent config signature so cached agents rebuild after SOUL.md is created, edited, or removed.
  • When a fresh agent restores a persisted system_prompt from the session DB, detect if it no longer matches the current SOUL.md or default identity and rebuild/persist a fresh prompt instead of reusing the stale snapshot.

Behavior change: on_session_start now only fires when a brand-new session is created; rebuilding after a stored prompt is stale no longer runs that hook.

Implementation note: Gateway agent cache invalidation uses SOUL.md file metadata (mtime_ns plus size) as a cheap fingerprint; on very low-resolution filesystems, multiple same-size edits inside one timestamp tick may require another edit or restart to bust the cache.

Related Issue

Complements #26789, which wires gateway sessions to honor SOUL.md initially. This PR handles the follow-up case where SOUL.md changes after a session already has a cached/persisted prompt.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/run.py: add the active profile's SOUL.md metadata fingerprint to gateway agent cache-busting config.
  • agent/conversation_loop.py: detect persisted system prompts whose stored identity block no longer matches the current SOUL.md or default identity before reusing them.
  • scripts/release.py: add 18210507492@126.com to AUTHOR_MAP for contributor attribution.
  • tests/gateway/test_agent_cache.py: cover SOUL.md fingerprint extraction and signature busting.
  • tests/run_agent/test_run_agent.py: cover stale prompt rebuilds, SOUL removal, shortened-SOUL regression, and hook behavior.

How to Test

  1. python -m ruff check agent/conversation_loop.py gateway/run.py scripts/release.py tests/run_agent/test_run_agent.py tests/gateway/test_agent_cache.py
  2. python -m pytest tests/gateway/test_agent_cache.py tests/run_agent/test_run_agent.py::TestSystemPromptStability -q
  3. python -m py_compile agent/conversation_loop.py gateway/run.py scripts/release.py
  4. git diff --check

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

N/A

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels May 18, 2026

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

Code Review Summary

Verdict: Comment — solves a real problem (stale SOUL.md in cached prompts) with a sensible two-layer approach. One correctness concern with the staleness check worth discussing.

What it does

Two-layer cache invalidation for SOUL.md changes:

  1. Gateway agent cache (gateway/run.py): fingerprints SOUL.md by mtime_ns + size in the config signature, so cached AIAgent instances are rebuilt after edits
  2. Persisted prompt staleness (conversation_loop.py): when restoring a system prompt from the session DB, checks if the current SOUL.md content is embedded in it; if not, rebuilds the prompt

🔴 Correctness concern: substring staleness check

_stored_system_prompt_stale_for_soul uses current_soul not in stored_prompt — a substring match. This produces false negatives when the edited SOUL.md is a subset of the stored prompt:

# Old SOUL.md: "You are Jasper. Be helpful."
# Stored prompt: "You are Jasper. Be helpful. System config..."
# New SOUL.md: "Be helpful."  (shortened edit)
# → current_soul "Be helpful." IS in stored_prompt → FALSE (not stale)!

This is a corner case but means a user who shortens their SOUL.md won't see the change take effect. Consider comparing the full text rather than using substring membership.

⚠️ Warnings

  • mtime_ns filesystem portability: _soul_md_cache_key() falls back to st_mtime * 1_000_000_000 on systems without nanosecond precision, which loses sub-second resolution. Two edits within the same clock tick could produce the same fingerprint. This is unlikely in practice but worth documenting.
  • on_session_start gating: wrapping the plugin hook in if not stored_prompt_stale changes behavior — plugins that rely on on_session_start firing for prompt rebuilds won't fire. The logic is correct (don't re-init session state on a stale-prompt rebuild), but this behavioral change isn't called out in the PR description.

✅ Looks Good

  • Two-layer approach is correct: the config signature catches cache-busting at the agent-pool level, while the staleness check catches it at the per-session prompt level. Belt and suspenders.
  • Error handling: broad except Exception in the staleness check correctly degrades to "not stale" (conservative).
  • Test coverage: 2 new tests in test_agent_cache.py validate the fingerprint extraction and signature busting. The prompt stability tests in test_run_agent.py cover the staleness detection path.
  • Clean diff: well-structured, no unrelated changes.

Summary

Well-structured fix for a real gap. The substring staleness check is the only correctness concern — fixing it to compare the full SOUL.md text rather than using in would eliminate the false-negative window for shortened edits. Otherwise ready to go.

@qindongliang

Copy link
Copy Markdown
Author

Code Review Summary

Verdict: Comment — solves a real problem (stale SOUL.md in cached prompts) with a sensible two-layer approach. One correctness concern with the staleness check worth discussing.

What it does

Two-layer cache invalidation for SOUL.md changes:

  1. Gateway agent cache (gateway/run.py): fingerprints SOUL.md by mtime_ns + size in the config signature, so cached AIAgent instances are rebuilt after edits
  2. Persisted prompt staleness (conversation_loop.py): when restoring a system prompt from the session DB, checks if the current SOUL.md content is embedded in it; if not, rebuilds the prompt

🔴 Correctness concern: substring staleness check

_stored_system_prompt_stale_for_soul uses current_soul not in stored_prompt — a substring match. This produces false negatives when the edited SOUL.md is a subset of the stored prompt:

# Old SOUL.md: "You are Jasper. Be helpful."
# Stored prompt: "You are Jasper. Be helpful. System config..."
# New SOUL.md: "Be helpful."  (shortened edit)
# → current_soul "Be helpful." IS in stored_prompt → FALSE (not stale)!

This is a corner case but means a user who shortens their SOUL.md won't see the change take effect. Consider comparing the full text rather than using substring membership.

⚠️ Warnings

  • mtime_ns filesystem portability: _soul_md_cache_key() falls back to st_mtime * 1_000_000_000 on systems without nanosecond precision, which loses sub-second resolution. Two edits within the same clock tick could produce the same fingerprint. This is unlikely in practice but worth documenting.
  • on_session_start gating: wrapping the plugin hook in if not stored_prompt_stale changes behavior — plugins that rely on on_session_start firing for prompt rebuilds won't fire. The logic is correct (don't re-init session state on a stale-prompt rebuild), but this behavioral change isn't called out in the PR description.

✅ Looks Good

  • Two-layer approach is correct: the config signature catches cache-busting at the agent-pool level, while the staleness check catches it at the per-session prompt level. Belt and suspenders.
  • Error handling: broad except Exception in the staleness check correctly degrades to "not stale" (conservative).
  • Test coverage: 2 new tests in test_agent_cache.py validate the fingerprint extraction and signature busting. The prompt stability tests in test_run_agent.py cover the staleness detection path.
  • Clean diff: well-structured, no unrelated changes.

Summary

Well-structured fix for a real gap. The substring staleness check is the only correctness concern — fixing it to compare the full SOUL.md text rather than using in would eliminate the false-negative window for shortened edits. Otherwise ready to go.

Thanks for the careful review. Good catch on the substring false negative for shortened SOUL.md content. I’ll update the staleness check to compare against the full SOUL.md content more precisely instead of relying on substring membership, and I’ll also call out the on_session_start behavior change in the PR description.

@qindongliang

Copy link
Copy Markdown
Author

@magnus919 Hi,Updated. The staleness check now compares the stored identity block against the current SOUL/default identity instead of using substring membership, and I added a regression test for the shortened-SOUL case. I also documented the on_session_start behavior change in the PR description.

Could you please take another look when you have a chance?

@magnus919

Copy link
Copy Markdown
Contributor

Nice work on the fixes @qindongliang — the exact comparison with .partition() on the guidance marker is cleaner than the original substring approach, and the shortened-SOUL regression test covers exactly the edge case that needed it. Thanks also for adding the ## Behavior Change note to the description.

A couple of things that will come up before this can merge:

1. AUTHOR_MAP in scripts/release.py — the project has a Contributor Attribution Check CI step that verifies every commit email in the branch is mapped in the AUTHOR_MAP dict in scripts/release.py. Your commit email (18210507492@126.com) isn't in there yet. You'll need to add an entry alphabetically by email, something like:

"18210507492@126.com": "qindongliang",

2. PR template — the PR body doesn't follow the project's .github/PULL_REQUEST_TEMPLATE.md — it uses different section headers (## Summary vs ## What does this PR do?, ## Tests vs ## How to Test, etc.) and is missing the required ## Type of Change and ## Checklist sections with all the checkboxes. The template's structure and checkboxes are enforced by the maintainers, so matching it will avoid a round of requested changes during review.

Just a friendly heads-up from a fellow contributor — I'm not a maintainer, so a project maintainer will need to give final approval and merge. The substance of the change looks solid!

@qindongliang

Copy link
Copy Markdown
Author

Thanks for the heads-up! I added my commit email to AUTHOR_MAP and updated the PR body to follow the project template.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the careful fix. I verified the premise against current main, and this still addresses a real gateway cache bug.

Current main loads SOUL.md into the stable identity slot at agent/system_prompt.py:88, but continuing sessions restore a non-empty persisted system_prompt verbatim at agent/conversation_loop.py:303 without checking whether SOUL.md changed. The gateway cache signature path at gateway/run.py:14363 also has no SOUL.md-specific cache key on current main; git grep for identity.soul_md, _soul_md_cache_key, and _stored_system_prompt_stale_for_soul against origin/main returned no matches.

I did not find a blocking correctness issue in the PR's approach. The exact identity-block comparison also addresses the shortened-SOUL false negative raised earlier in the discussion, and the added tests cover changed, removed, and shortened SOUL.md cases.

Salvage note for maintainers: GitHub reports mergeStateStatus: DIRTY, and git merge-tree shows conflicts in agent/conversation_loop.py, gateway/run.py, scripts/release.py, and tests/gateway/test_agent_cache.py; the conflicts look mechanical rather than a reason to drop the fix.

Automated hermes-sweeper review.

@qindongliang
qindongliang force-pushed the codex/soul-cache-invalidation branch from c092ce4 to b1d6380 Compare June 15, 2026 14:21
@qindongliang

Copy link
Copy Markdown
Author

@teknium1 Thanks for the sweeper review and the salvage note. I rebased this PR onto current upstream/main, resolved the mechanical conflicts in agent/conversation_loop.py, gateway/run.py, scripts/release.py, and tests/gateway/test_agent_cache.py, and force-pushed the updated branch.

GitHub now reports the PR as MERGEABLE on my side. I also re-ran the targeted coverage for the touched logic:

  • tests/run_agent/test_run_agent.py -q -k "SystemPromptStability or stored_prompt" -> passed
  • tests/gateway/test_agent_cache.py::TestAgentConfigSignature tests/gateway/test_agent_cache.py::TestExtractCacheBustingConfig tests/gateway/test_agent_cache.py::TestAgentConfigSignatureUserId -q -> passed

The full tests/gateway/test_agent_cache.py run is blocked locally by a macOS code-signing/system-policy error while importing pydantic_core from my shared venv, but the targeted cache-signature and SOUL prompt tests pass after the rebase.

If this looks good to you, could you please help trigger the maintainer review/merge path when you have a chance?

@alt-glitch alt-glitch added sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jun 26, 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 the careful two-layer implementation. The current branch still demonstrates why a persisted gateway prompt can retain an earlier SOUL.md, but the requested behavior now conflicts with an explicit cache contract.

Problems

  • agent/system_prompt.py:157-161 says Hermes never re-renders the system prompt mid-session to preserve upstream cache warmth; website/docs/user-guide/profiles.md:148 likewise says SOUL.md changes take effect cleanly in a new session. The proposed rebuild in agent/conversation_loop.py changes that active-session contract.
  • Current main's restore path has since gained _stored_prompt_matches_runtime() at agent/conversation_loop.py:331-344. Any approved rework must retain and compose with that runtime-identity validation rather than apply the older unconditional-restore hunk.

Suggested changes

  • Please obtain maintainer direction on whether SOUL.md edits are an intentional exception to the prompt-cache/session-boundary invariant. If approved, rework against current main and cover the result in tests/agent/test_system_prompt_restore.py.

Automated hermes-sweeper review.

@@ -301,10 +350,18 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
)

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.

Rebuilding here changes the system prompt inside an existing conversation. Current main treats that prompt as byte-stable for the session (agent/system_prompt.py:157-161) and documents SOUL.md changes as cleanly taking effect in new sessions (website/docs/user-guide/profiles.md:148); this needs maintainer approval as an explicit cache-invalidation exception.

@alt-glitch alt-glitch removed the sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) label Jul 13, 2026
@qindongliang

Copy link
Copy Markdown
Author

@teknium1 Thanks for flagging the session-boundary invariant and the newer runtime-identity validation.

This PR intentionally proposes treating SOUL.md changes as an exception to the byte-stable system-prompt contract for an existing session. Could a maintainer confirm whether that behavior is desired?

If it is approved, I'll rebase onto current main, compose the SOUL.md validation with _stored_prompt_matches_runtime(), and move the coverage to tests/agent/test_system_prompt_restore.py. If SOUL.md changes should remain new-session-only, I understand that this PR should not change the active-session restore behavior.

@alt-glitch alt-glitch added the sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) label Jul 13, 2026
@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 13, 2026
@alt-glitch alt-glitch removed the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 13, 2026
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/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants