diff --git a/CHANGELOG.md b/CHANGELOG.md index 79eb41cb..c1a2a41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,9 @@ All notable changes to this project are documented here. The format is based on - `OptionSlot` / `OPTION_SLOTS` (plan 0032): embodiment plugins can declare boolean behavior toggles that `inspect-robots setup` interviews as yes/no questions and writes into `[embodiment.args]`. First consumer: - inspect-robots-yam's `auto_start` (yam#87). + inspect-robots-yam's `auto_start` (yam#87). A slot can dynamically compute + its suggested default from existing `[embodiment.args]` by declaring a + `suggest: Callable[[Mapping[str, str]], bool]` callback (#303). - Policy connection failures now include an actionable action-server remediation hint in the recorded error message (#219). diff --git a/src/inspect_robots/_setup.py b/src/inspect_robots/_setup.py index 07935473..682f994e 100644 --- a/src/inspect_robots/_setup.py +++ b/src/inspect_robots/_setup.py @@ -3,6 +3,7 @@ from __future__ import annotations import configparser +import contextlib import os import re import struct @@ -944,14 +945,17 @@ def _options_section( """Interview plugin-declared behavior toggles as yes/no questions. The carried config value (parsed as a bool) is the suggested answer when - present and boolean; otherwise the slot's declared default. Answers are - written explicitly (``true``/``false``) so declining a previously enabled - toggle turns it off rather than silently carrying it forward. + present and boolean; otherwise the slot's context-aware or static default. + Answers are written explicitly (``true``/``false``) so declining a previously + enabled toggle turns it off rather than silently carrying it forward. """ existing_args = carried.get("embodiment.args", {}) answers: dict[str, str] = {} for option in options: suggested = option.default + if option.suggest is not None: + with contextlib.suppress(Exception): + suggested = option.suggest(existing_args) if option.arg in existing_args: parsed = _parse_value(existing_args[option.arg]) if isinstance(parsed, bool): diff --git a/src/inspect_robots/conformance.py b/src/inspect_robots/conformance.py index bfc0069d..225dd6f5 100644 --- a/src/inspect_robots/conformance.py +++ b/src/inspect_robots/conformance.py @@ -20,7 +20,7 @@ from __future__ import annotations import importlib.util -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field import numpy as np @@ -85,6 +85,7 @@ class OptionSlot: arg: str label: str default: bool = False + suggest: Callable[[Mapping[str, str]], bool] | None = None def option_slots(factory: object) -> tuple[OptionSlot, ...]: diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 97985cb0..8d6b08ed 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -141,6 +141,7 @@ class _Factory: def test_option_slots_default_is_false() -> None: assert OptionSlot(arg="a", label="A").default is False + assert OptionSlot(arg="a", label="A").suggest is None def test_option_slots_accepts_lists_and_ignores_offending_entries() -> None: diff --git a/tests/test_setup.py b/tests/test_setup.py index c806eaf8..abdbbeae 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -3250,6 +3250,85 @@ class _Factory: assert text.count("auto_start = true") == 1 +def test_run_setup_options_suggest_context_aware( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A slot with suggest callback. It should suggest True if geom is in args. + geom_slot = OptionSlot( + arg="collision_guardrail", + label="Collision guardrail", + default=False, + suggest=lambda args: "geom" in args, + ) + + class _Factory: + OPTION_SLOTS: ClassVar[tuple[OptionSlot, ...]] = (geom_slot,) + + monkeypatch.setattr( + "inspect_robots.registry.registered", + lambda kind: {"option-body": _Factory} if kind == "embodiment" else {}, + ) + path = _config_path(tmp_path) + path.parent.mkdir() + + # 1. Without geom key: suggest default (False) -> [y/N], we accept with "" (means No) + path.write_text( + "[defaults]\nembodiment = option-body\n\n[embodiment.args]\nother = arg\n", + encoding="utf-8", + ) + input_fn, prompts = _scripted_input([*_slot_defaults("option-body"), "n", ""]) + result = run_setup( + {"XDG_CONFIG_HOME": str(tmp_path), "DISPLAY": ":0"}, + input_fn=input_fn, + out=io.StringIO(), + interactive=True, + ) + assert result == 0 + # Confirm it asked and default was [y/N] + prompt = next(p for p in prompts if "Collision guardrail" in p) + assert "[y/N]" in prompt + assert path.read_text(encoding="utf-8").count("collision_guardrail = false") == 1 + + # 2. With geom key: suggest True -> [Y/n], we accept with "" (means Yes) + path.write_text( + "[defaults]\nembodiment = option-body\n\n[embodiment.args]\ngeom = measured\n", + encoding="utf-8", + ) + input_fn, prompts = _scripted_input([*_slot_defaults("option-body"), "n", ""]) + result = run_setup( + {"XDG_CONFIG_HOME": str(tmp_path), "DISPLAY": ":0"}, + input_fn=input_fn, + out=io.StringIO(), + interactive=True, + ) + assert result == 0 + prompt = next(p for p in prompts if "Collision guardrail" in p) + assert "[Y/n]" in prompt + assert path.read_text(encoding="utf-8").count("collision_guardrail = true") == 1 + + # 3. Explicit config overrides suggest callback: if it already sets collision_guardrail = false, + # and geom is present, it should suggest False -> [y/N], we accept with "" (means No) + path.write_text( + "[defaults]\n" + "embodiment = option-body\n\n" + "[embodiment.args]\n" + "geom = measured\n" + "collision_guardrail = false\n", + encoding="utf-8", + ) + input_fn, prompts = _scripted_input([*_slot_defaults("option-body"), "n", ""]) + result = run_setup( + {"XDG_CONFIG_HOME": str(tmp_path), "DISPLAY": ":0"}, + input_fn=input_fn, + out=io.StringIO(), + interactive=True, + ) + assert result == 0 + prompt = next(p for p in prompts if "Collision guardrail" in p) + assert "[y/N]" in prompt + assert path.read_text(encoding="utf-8").count("collision_guardrail = false") == 1 + + def test_run_setup_option_colliding_with_managed_key_is_skipped( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: