Scope settings snapshots to prompt builds

Reuse one normalized settings snapshot only while Agent.prepare_prompt builds a prompt, then restore normalize-on-read behavior immediately. Keep the canonical get_settings function unchanged while prompt-building callers explicitly avoid repeated Git and default resolution.

Alessandro committed Aug 23, 2026 at 04:52 UTC d1a9bfa13e220d449de04f2d4c354a32505cc5f7
10 files changed +139 -8
extensions/python/_functions/AGENTS.md
+1
@@ -17,6 +17,7 @@
17 - Preserve ordering prefixes where exception handling, watchdog registration, or cleanup depends on them.
18 - Hooks that mirror persisted AI responses into UI logs must reuse existing stream log items and avoid duplicating live response-tool logs.
19 - Recovery-loop circuit breakers must stop at the General Settings limit and render their user-visible cost warning from a core framework prompt.
20 +- Prompt settings snapshots must be task-local, accessed through `get_settings_for_prompt()`, and end with the matching `Agent.prepare_prompt` call, including exceptional exits.
21
22 ## Work Guidance
23
extensions/python/_functions/agent/Agent/prepare_prompt/end/_00_restore_settings.py new
+12
@@ -0,0 +1,12 @@
1 +from helpers import settings
2 +from helpers.extension import Extension
3 +
4 +
5 +_TOKEN_KEY = "_prompt_settings_snapshot_token"
6 +
7 +
8 +class RestorePromptSettings(Extension):
9 + def execute(self, data: dict | None = None, **kwargs):
10 + token = data.pop(_TOKEN_KEY, None) if isinstance(data, dict) else None
11 + if token is not None:
12 + settings.end_prompt_settings_snapshot(token)
extensions/python/_functions/agent/Agent/prepare_prompt/start/_99_snapshot_settings.py new
+11
@@ -0,0 +1,11 @@
1 +from helpers import settings
2 +from helpers.extension import Extension
3 +
4 +
5 +_TOKEN_KEY = "_prompt_settings_snapshot_token"
6 +
7 +
8 +class SnapshotPromptSettings(Extension):
9 + def execute(self, data: dict | None = None, **kwargs):
10 + if isinstance(data, dict):
11 + data[_TOKEN_KEY] = settings.begin_prompt_settings_snapshot()
extensions/python/message_loop_prompts_after/_75_include_workdir_extras.py
+1 -1
@@ -38,7 +38,7 @@ class IncludeWorkdirExtras(Extension):
38
39 file_structure = projects.get_file_structure(project_name)
40 else:
41 - set = settings.get_settings()
41 + set = settings.get_settings_for_prompt()
42 enabled = bool(set["workdir_show"])
43
44 if not enabled:
extensions/python/system_prompt/_13_secrets_prompt.py
+2 -2
@@ -23,11 +23,11 @@ class SecretsPrompt(Extension):
23 async def build_prompt(agent: Agent) -> str:
24 try:
25 from helpers.secrets import get_secrets_manager
26 - from helpers.settings import get_settings
26 + from helpers.settings import get_settings_for_prompt
27
28 secrets_manager = get_secrets_manager(agent.context)
29 secrets = secrets_manager.get_secrets_for_prompt()
30 - variables = get_settings()["variables"]
30 + variables = get_settings_for_prompt()["variables"]
31 return agent.read_prompt(
32 "agent.system.secrets.md", secrets=secrets, vars=variables
33 )
helpers/settings.py
+23 -1
@@ -1,4 +1,6 @@
1 import base64
2 +from contextvars import ContextVar, Token
3 +from copy import deepcopy
4 import hashlib
5 import json
6 import os
@@ -175,6 +177,9 @@ UI_CONTROL_VISIBILITY_DEFAULTS = {
177 SETTINGS_FILE = files.get_abs_path("usr/settings.json")
178 _settings: Settings | None = None
179 _runtime_settings_snapshot: Settings | None = None
180 +_prompt_settings_snapshot: ContextVar[Settings | None] = ContextVar(
181 + "prompt_settings_snapshot", default=None
182 +)
183
184 OptionT = TypeVar("OptionT", bound=FieldOption)
185
@@ -375,10 +380,27 @@ def get_settings() -> Settings:
380 return norm
381
382
383 +def get_settings_for_prompt() -> Settings:
384 + if (snapshot := _prompt_settings_snapshot.get()) is not None:
385 + return deepcopy(snapshot)
386 + return get_settings()
387 +
388 +
389 +def begin_prompt_settings_snapshot() -> Token:
390 + return _prompt_settings_snapshot.set(get_settings())
391 +
392 +
393 +def end_prompt_settings_snapshot(token: Token) -> None:
394 + _prompt_settings_snapshot.reset(token)
395 +
396 +
397 def reload_settings() -> Settings:
398 global _settings
399 _settings = None
381 - return get_settings()
400 + current = get_settings()
401 + if _prompt_settings_snapshot.get() is not None:
402 + _prompt_settings_snapshot.set(deepcopy(current))
403 + return current
404
405
406 def set_runtime_settings_snapshot(settings: Settings) -> None:
helpers/settings.py.dox.md
+5 -1
@@ -62,9 +62,13 @@
62
63 ## Key Concepts
64
65 -- Important called helpers/classes observed in the source: `TypeVar`, `files.get_abs_path`, `dotenv.get_dotenv_value`, `opts.insert`, `str.strip`, `_is_valid_timezone`, `str.strip.lower`, `_normalize_timezone_setting`, `SettingsOutput`, `get_default_settings`, `_ensure_option_present`, `_resolve_runtime_timezone`, `get_default_secrets_manager`, `get_settings`, `normalize_settings`, `_load_sensitive_settings`, `settings.copy`, `_write_settings_file`, `reload_settings`, `set_settings`, `initialize_agent`.
65 +- Important called helpers/classes observed in the source: `TypeVar`, `files.get_abs_path`, `dotenv.get_dotenv_value`, `opts.insert`, `str.strip`, `_is_valid_timezone`, `str.strip.lower`, `_normalize_timezone_setting`, `SettingsOutput`, `get_default_settings`, `_ensure_option_present`, `_resolve_runtime_timezone`, `get_default_secrets_manager`, `get_settings`, `get_settings_for_prompt`, `normalize_settings`, `_load_sensitive_settings`, `deepcopy`, `settings.copy`, `_write_settings_file`, `reload_settings`, `set_settings`, `initialize_agent`.
66 - Applying settings refreshes active context configs while preserving each subordinate agent's own profile.
67 - Applying settings starts a deferred `MCPConfig.update(...)` with the current `mcp_servers` string when global MCP server settings change.
68 +- `get_settings()` retains normalize-on-read behavior. Prompt-building callers
69 + explicitly use `get_settings_for_prompt()` to reuse one task-local snapshot
70 + within each `Agent.prepare_prompt()` call.
71 +- Explicit reloads also refresh an active prompt snapshot.
72 - `max_consecutive_unusable_responses` defaults to `5` and controls the cost circuit breaker for malformed or repeated main-model outputs.
73 - `ui_control_visibility` stores validated mobile and desktop visibility flags for the project selector, clock, connection status, and right canvas rail; missing or malformed values fall back per device.
74 - The Global default-profile selector lists only globally available profiles.
plugins/_promptinclude/extensions/python/system_prompt/_16_promptinclude.py
+2 -2
@@ -1,7 +1,7 @@
1 from helpers.extension import Extension
2 from helpers import plugins, files, runtime
3 from helpers import projects
4 -from helpers.settings import get_settings
4 +from helpers.settings import get_settings_for_prompt
5 from agent import Agent, LoopData
6
7 from plugins._promptinclude.helpers.scanner import scan_promptinclude_files, ScanResult
@@ -61,7 +61,7 @@ def _resolve_workdir(agent: Agent) -> str:
61 if runtime.is_development():
62 folder = files.normalize_a0_path(folder)
63 return folder
64 - return get_settings()["workdir_path"]
64 + return get_settings_for_prompt()["workdir_path"]
65
66
67 def _format_includes(agent: Agent, result: ScanResult) -> str:
prompts/agent.system.main.tips.py
+1 -1
@@ -19,6 +19,6 @@ class WorkdirPath(VariablesPlugin):
19 # folder = files.normalize_a0_path(folder)
20 # return {"workdir_path": folder}
21
22 - set = settings.get_settings()
22 + set = settings.get_settings_for_prompt()
23 return {"workdir_path": set["workdir_path"]}
24
tests/test_settings_cache.py new
+81
@@ -0,0 +1,81 @@
1 +import copy
2 +
3 +from helpers import extension, settings
4 +
5 +
6 +def test_settings_snapshot_is_limited_to_one_prompt(monkeypatch):
7 + configured = settings.get_default_settings()
8 + configured["api_keys"] = {"provider": "secret"}
9 + versions = iter(["first", "second", "third", "fourth"])
10 + calls = 0
11 +
12 + def defaults():
13 + nonlocal calls
14 + calls += 1
15 + result = copy.deepcopy(configured)
16 + result["version"] = next(versions)
17 + return result
18 +
19 + monkeypatch.setattr(settings, "_settings", configured)
20 + monkeypatch.setattr(settings, "_read_settings_file", lambda: configured)
21 + monkeypatch.setattr(settings, "get_default_settings", defaults)
22 + monkeypatch.setattr(settings, "_load_sensitive_settings", lambda _value: None)
23 +
24 + token = settings.begin_prompt_settings_snapshot()
25 + try:
26 + configured["workdir_show"] = False
27 + first = settings.get_settings_for_prompt()
28 + second = settings.get_settings_for_prompt()
29 +
30 + assert calls == 1
31 + assert first == second
32 + assert first["workdir_show"] is True
33 + assert first is not second
34 + assert first["api_keys"] is not second["api_keys"]
35 +
36 + first["api_keys"]["provider"] = "masked"
37 + assert settings.get_settings_for_prompt()["api_keys"]["provider"] == "secret"
38 +
39 + current = settings.get_settings()
40 + assert current["version"] == "second"
41 + assert current["workdir_show"] is False
42 + assert settings.get_settings_for_prompt()["workdir_show"] is True
43 +
44 + reloaded = settings.reload_settings()
45 + assert reloaded["version"] == "third"
46 + assert reloaded["workdir_show"] is False
47 + assert settings.get_settings_for_prompt() == reloaded
48 + finally:
49 + settings.end_prompt_settings_snapshot(token)
50 +
51 + refreshed = settings.get_settings()
52 + assert refreshed["workdir_show"] is False
53 + assert refreshed["version"] == "fourth"
54 + assert calls == 4
55 +
56 +
57 +def test_prompt_snapshot_hooks_are_registered_and_paired():
58 + start = next(
59 + cls
60 + for cls in extension._get_extension_classes( # type: ignore[attr-defined]
61 + "_functions/agent/Agent/prepare_prompt/start"
62 + )
63 + if cls.__name__ == "SnapshotPromptSettings"
64 + )
65 + end = next(
66 + cls
67 + for cls in extension._get_extension_classes( # type: ignore[attr-defined]
68 + "_functions/agent/Agent/prepare_prompt/end"
69 + )
70 + if cls.__name__ == "RestorePromptSettings"
71 + )
72 + previous = settings._prompt_settings_snapshot.get()
73 + data = {}
74 +
75 + start(agent=None).execute(data=data)
76 + try:
77 + assert settings._prompt_settings_snapshot.get() is not None
78 + finally:
79 + end(agent=None).execute(data=data)
80 +
81 + assert settings._prompt_settings_snapshot.get() is previous