Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 7 additions & 3 deletions src/inspect_robots/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import configparser
import contextlib
import os
import re
import struct
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion src/inspect_robots/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, ...]:
Expand Down
1 change: 1 addition & 0 deletions tests/test_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
79 changes: 79 additions & 0 deletions tests/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading