Browse Source

feat: track config value origin with explicit flag

ConfigField.resolve() now records whether the resolved value came from the
user (env var or config.yaml extra) or is merely the declared default.
ConfigValue carries this as .explicit.

The tiered tool-config fallback (cron scope) no longer needs the lossy
value comparison 'current_value != field.default': a deliberate user value
that happens to equal the default string is now correctly respected instead
of being clobbered by the profile .env. Non-explicit default placeholders
still yield to .env, which is the cron-correct behavior.

Also: _MockPluginContext in test_adapter.py now implements register_tool,
which the plugin registers since the chatto_send_* tools landed. Full suite
passes: 207 passed.
Liv Klumpp 2 weeks ago
parent
commit
8b95ae0
3 changed files with 29 additions and 10 deletions
  1. 8 7
      adapter.py
  2. 17 3
      platform_config.py
  3. 4 0
      test_adapter.py

+ 8 - 7
adapter.py

@@ -3665,18 +3665,19 @@ def _chatto_config_for_tools() -> "ChattoConfiguration | None":
 
     for field in ChattoConfiguration.fields():
         current = _resolved_chatto_config._values.get(field.field_name)
-        current_value = current.value if current is not None else None
         # The profile's own .env is authoritative for secrets (token) and for
-        # fields whose resolved value is just the declared default (base_url):
-        # ChattoConfiguration already prefers env vars, so a value that made it
-        # this far came from extra or the default, both weaker than the .env.
+        # fields that merely hold their declared default: an EXPLICIT value
+        # (env var or config.yaml extra) is the user's own intent and wins;
+        # a default placeholder yields to the profile .env. The explicit flag
+        # is carried by ConfigValue since the value alone cannot distinguish
+        # "user set the default string on purpose" from "default leaked in".
+        if current is not None and current.explicit:
+            continue
         secret_value = profile_secrets.get(field.env_name)
         if not secret_value:
             continue
-        if current_value and current_value != field.default:
-            continue  # explicit extra config wins over .env
         _resolved_chatto_config._values[field.field_name] = ConfigValue(
-            secret_value, field.field_name, field.env_name
+            secret_value, field.field_name, field.env_name, explicit=True
         )
     return _resolved_chatto_config
 

+ 17 - 3
platform_config.py

@@ -243,12 +243,20 @@ class ConfigValue(Generic[T]):
     the actual setting instead of the always-truthy wrapper object.
     """
 
-    __slots__ = ("env_name", "field_name", "value")
+    __slots__ = ("env_name", "field_name", "value", "explicit")
 
-    def __init__(self, value: T, field_name: str, env_name: str) -> None:
+    def __init__(
+        self, value: T, field_name: str, env_name: str, explicit: bool = False
+    ) -> None:
         self.value = value
         self.field_name = field_name
         self.env_name = env_name
+        # explicit: True when the value came from the user (env var or extra),
+        # False when it is merely the declared default. Defaults are
+        # structurally indistinguishable from user input by value alone —
+        # a user *may* deliberately set base_url to the default string —
+        # so the origin must be carried alongside the value.
+        self.explicit = explicit
 
     def __bool__(self) -> bool:
         return bool(self.value)
@@ -322,19 +330,25 @@ class ConfigField(Generic[T]):
     def resolve(self, extra: dict[str, Any]) -> ConfigValue:
         """Read this field from the environment, then from ``extra``, then the default."""
         raw = extra.get(self.config_key)
+        raw_present = raw is not None
 
         if self.kind == "list":
             value: Any = _get_env_or_extra_list(self.env_name, raw)
+            explicit = os.getenv(self.env_name) is not None or raw_present
         elif self.kind == "bool":
             value = _get_env_or_extra_truthy(self.env_name, raw, bool(self.default))
+            explicit = os.getenv(self.env_name) is not None or raw_present
         elif self.kind == "int":
             value = _get_env_or_extra_int(self.env_name, raw, int(self.default))
+            explicit = os.getenv(self.env_name) is not None or raw_present
         elif self.kind == "str_opt":
             value = _get_env_or_extra_str_opt(self.env_name, raw, self.default)
+            explicit = os.getenv(self.env_name) is not None or raw_present
         else:
             value = _get_env_or_extra_str(self.env_name, raw, self.default)
+            explicit = os.getenv(self.env_name) is not None or raw_present
 
-        return ConfigValue(value, self.field_name, self.env_name)
+        return ConfigValue(value, self.field_name, self.env_name, explicit=explicit)
 
 
 class ChattoConfiguration:

+ 4 - 0
test_adapter.py

@@ -100,6 +100,10 @@ class _MockPluginContext:
         self.registered_names = []
         self.registered_kwargs = None
 
+    def register_tool(self, **kwargs):
+        """Record plugin-tool registrations (chatto_send_dm / chatto_send_room)."""
+        self.registered_names.append(kwargs.get("name", "<tool>"))
+
     def register_platform(self, **kwargs):
         from gateway.platform_registry import PlatformEntry, platform_registry