Skip to content

fix(sdk/python): let AsyncConfig env flags fall back to the class default - #1100

Merged
ZeroPoint95 merged 1 commit into
Agent-Field:mainfrom
remote-controlled-man:codex/agentfield-1089-asyncconfig-defaults-20261003
Oct 14, 2026
Merged

ZeroPoint95 merged 1 commit into
Agent-Field:mainfrom
remote-controlled-man:codex/agentfield-1089-asyncconfig-defaults-20261003

Conversation

@remote-controlled-man

Copy link
Copy Markdown
Contributor

Summary

Takes the AsyncConfig half of #1089 — the point raised there about #1082. The other half (the
remaining strict parsers in logger.py, agent.py and agent_server.py) is #1090, and this PR does
not touch those files.

#1082 replaced five lambda x: x.lower() == "true" converters with two polarity-aware helpers. Each
one hard-codes the answer for "unrecognised string": _env_flag_default_on returns True,
_env_flag_default_off returns False. That is right for AsyncConfig's own field defaults, but it
makes the fallback independent of the class, so a subclass that changes a default gets the parser's
answer instead of its own:

@dataclass
class CachingOff(AsyncConfig):
    enable_result_caching: bool = False

# AGENTFIELD_ASYNC_ENABLE_RESULT_CACHING=maybe
CachingOff.from_environment().enable_result_caching   # True on main — should be False

Subclassing is a supported pattern here, not a hypothetical: AsyncConfig is a plain dataclass and both
public constructors take one (agent.py:846, client.py:182), and
test_client_keeps_explicit_async_config already pins that a caller-supplied instance is used as-is.

The fallback machinery is already correct — get_env_var catches (ValueError, TypeError) and returns
the default_value it was handed, which from_environment() passes as config.<field>, i.e. the
class's real default. The boolean converters simply never reach it, because a membership test cannot
fail. The float and int converters in the same function do reach it.

So this adds one polarity-free parser and points all five flags at it:

def _env_flag(value: str) -> bool:
    normalized = value.strip().lower()
    if normalized in _TRUE_VALUES:
        return True
    if normalized in _FALSE_VALUES:
        return False
    raise ValueError(f"unrecognised boolean environment value: {value!r}")

An unrecognised value now raises, get_env_var catches it, and the default comes from the class.

This also matches the Go control plane, which has always parsed this way: config.applyBoolEnv
(control-plane/internal/config/config.go:1128) leaves *target untouched and logs
warning: invalid %s=%q: %v; keeping %t when strconv.ParseBool rejects the string, and the handler
tests pin parseBool(" YeS ") → true, parseBool("0") → false, parseBool("maybe") → error.
Whitespace-tolerant, vocabulary-based, error on unknown. Go additionally logs a warning; Python's
float and int converters fall back silently today, so this matches the local convention rather than
adding logging — happy to add a warning too if you'd prefer the Go behaviour exactly.

AsyncConfig's own observable behaviour does not change. With no AGENTFIELD_ASYNC_* set,
get_env_var returns the default before consulting any converter, so the new code is never reached;
digesting every field of from_environment() on both trees gives the same SHA-256
(5960894a0c0bd6c817a09800eee26998e09625f7dfb23cd3d75ce1ee9088eb90). Vocabulary values parse exactly
as before, and out-of-vocabulary values still yield the field default for AsyncConfig itself — the
default just now comes from the class, which is what fixes the subclass case.

One thing to decide as a reviewer

_env_flag_default_on and _env_flag_default_off are kept, re-expressed in terms of _env_flag,
because #1090 contains from .async_config import _env_flag_default_off in logger.py and removing it
would break an open PR. A test pins both wrappers' contract.

The trade-off: after this change _env_flag_default_on has no caller anywhere in the repository (it was
only used by the four call sites this PR rewrites). I kept it because it is the matching half of a
two-day-old pair whose sibling #1090 depends on, and deleting one while keeping the other seemed like
arbitrary churn. If you would rather drop it, that is a one-line change and I am happy to.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs only
  • Tests only
  • CI / tooling
  • Breaking change

Test plan

19 tests added to sdk/python/tests/test_async_config.py. On unmodified main they give 9 failed,
92 passed
, distributed exactly as the defect predicts:

  • 2 × the subclass scenarios above (a default-on flag forced off, and the default-off
    enable_event_stream forced on), each getting the parser's baked-in answer instead of the class's.
  • 6 × test_every_flag_falls_back_to_its_own_class_default — all five flags × three unparseable values
    × three classes, of which only the two fields whose subclass default contradicts the baked-in polarity
    fail. The other nine pass on main too, so the failures are surgical rather than blanket.
  • 1 × test_env_flag_rejects_values_outside_the_vocabulary (" ON " → True, "Off" → False,
    maybe/``/2/`truthy` → `ValueError`).
  • test_polarity_wrappers_keep_their_contract_for_external_callers passes on main as well as after
    the fix
    — that is deliberate, it is the guard for fix(python-sdk): use SDK-wide truthy/falsy vocabulary for remaining strict env flags #1090's import.

All 82 tests already in the file (from #1082 and before) pass unchanged. The test subclasses are
decorated @dataclass; without that the inherited __init__ keeps the base defaults and the scenario
does not reproduce at all.

Commands run locally (Windows, CPython 3.13.12 in an isolated venv, pip install -e ".[dev]" plus
ruff==0.15.22 at the version sdk-python.yml pins):

  • cd sdk/python && python -m pytest tests/test_async_config.py --no-cov → 101 passed
  • cd sdk/python && python -m pytest --ignore=tests/test_agent_mesh.py --no-cov →
    9 failed, 2487 passed, 6 skipped, against 9 failed, 2468 passed, 6 skipped on unmodified
    main. 2487 = 2468 + 19, and the sorted set of failing test ids is byte-identical to main, so
    nothing regressed. Those 9 are pre-existing Windows-host failures — 'gbk' codec can't decode from
    pathlib.read_text, and b'inline\r' != b'inline\n' CRLF assertions — that pass on CI's
    ubuntu-latest. tests/test_agent_mesh.py is excluded because it hangs on this host at ~8%,
    verified to hang identically on unmodified main.
  • cd sdk/python && python -m mypy --config-file mypy.ini agentfield/ → Success: no issues found in
    81 source files
  • cd sdk/python && python -m ruff check . → All checks passed
  • cd sdk/python && python -m ruff format --diff agentfield/async_config.py tests/test_async_config.py
    → 2 files already formatted

Test coverage

  • I ran tests for the surface(s) I changed locally.
  • New code paths are covered by tests in this PR (no bare additions).
  • If I removed code, I updated coverage-baseline.json — not applicable; no source lines were
    removed that carried coverage, and no surface percentage can move (see below).
  • The coverage gate check is green in CI before requesting review — I cannot run
    ./scripts/coverage-summary.sh locally because it measures all five surfaces and this machine has
    no Go, Bun, or Docker engine. Leaving this for CI rather than claiming it.

Heads-up so the gate output is not misread: agentfield.async_config is not in the --cov whitelist
in pyproject.toml's pytest addopts (which lists nine modules), and scripts/coverage-surface.sh
measures sdk-python by running bare pytest, so this file never reaches sdk-python-coverage.xml.
patch-coverage-gate.sh runs diff-cover against that XML and can only count lines the report knows
about, so this PR will report sdk-python | 0 | — | ➖ no changes. #1078 shows the mechanism — it
touched 2 executable lines in rate_limiter.py (whitelisted) plus 48 test lines and the gate reported
exactly 2 | 100.00%.

So the gate reporting "no changes" here is not evidence that the new code is untested. _env_flag is
exercised directly and through from_environment() for all five flags across truthy, falsey, padded,
unparseable and unset values, and both wrappers are pinned. Adding --cov=agentfield.async_config to
addopts would let the gate see this module — I left pyproject.toml alone to keep this to one seam,
and can send that separately if you want it.

Checklist

Related issues / PRs

…ault

The two boolean parsers added in Agent-Field#1082 each bake in a polarity: _env_flag_default_on
returns True for anything outside the falsey vocabulary and _env_flag_default_off
returns False for anything outside the truthy one. That is correct for AsyncConfig's
own field defaults, but it makes the fallback independent of the class -- a subclass
that changes a default gets the parser's answer for an unrecognised string instead of
its own:

    @DataClass
    class CachingOff(AsyncConfig):
        enable_result_caching: bool = False

    # AGENTFIELD_ASYNC_ENABLE_RESULT_CACHING=maybe
    CachingOff.from_environment().enable_result_caching   # True, should be False

Add one polarity-free parser, _env_flag, that raises ValueError outside the
vocabulary, and use it for all five flags. get_env_var already catches
(ValueError, TypeError) and returns the default_value it was handed, which
from_environment() passes as config.<field> -- so the fallback now comes from the
class, exactly as the float and int converters in the same function already do.

This is the second half of Agent-Field#1089; the remaining strict parsers in logger.py,
agent.py and agent_server.py are handled separately in Agent-Field#1090.

It also matches the Go control plane, which has always parsed this way:
config.applyBoolEnv leaves *target untouched and logs a warning when
strconv.ParseBool rejects the string, and the handler tests pin parseBool(" YeS ")
-> true, parseBool("0") -> false, parseBool("maybe") -> error.

_env_flag_default_on and _env_flag_default_off are kept, now expressed in terms of
_env_flag, for call sites that know their default statically -- Agent-Field#1090 imports
_env_flag_default_off into logger.py for the default-off AGENTFIELD_LOG_* flags, so
removing them would break an open PR. A test pins both wrappers' contract.

AsyncConfig's own observable behaviour is unchanged: with no AGENTFIELD_ASYNC_*
variable set the converters are never reached, and digesting every field of
from_environment() on both trees gives the same SHA-256
(5960894a0c0bd6c817a09800eee26998e09625f7dfb23cd3d75ce1ee9088eb90). For a set
variable, every value in the vocabulary parses as before and every value outside it
still yields the field default -- the default just now comes from the class.

Fixes the AsyncConfig half of Agent-Field#1089.
Copilot AI balanced review requested due to automatic review settings October 8, 2026 02:14

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.31 µs -11% ✓ ✓

✓ No regressions detected

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 88.00% 87.40% ↑ +0.60 pp 🟡
sdk-go 93.30% 92.00% ↑ +1.30 pp 🟢
sdk-python 94.72% 93.73% ↑ +0.99 pp 🟢
sdk-typescript 91.77% 90.42% ↑ +1.35 pp 🟢
web-ui 84.77% 84.79% ↓ -0.02 pp 🟡
aggregate 85.96% 85.75% ↑ +0.21 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 — ➖ no changes
sdk-go 0 — ➖ no changes
sdk-python 0 — ➖ no changes
sdk-typescript 0 — ➖ no changes
web-ui 0 — ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@ZeroPoint95

ZeroPoint95 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Thank you @remote-controlled-man for the contribution! Approved and will merge soon.

@ZeroPoint95
ZeroPoint95 merged commit 88a5f73 into Agent-Field:main Oct 14, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants