Skip to content

fix(sdk/python): parse AGENTFIELD_ASYNC boolean flags with the repo's env vocabulary - #1082

Merged
AbirAbbas merged 1 commit into
Agent-Field:mainfrom
remote-controlled-man:codex/agentfield-async-config-20260930
Oct 1, 2026
Merged

AbirAbbas merged 1 commit into
Agent-Field:mainfrom
remote-controlled-man:codex/agentfield-async-config-20260930

Conversation

@remote-controlled-man

Copy link
Copy Markdown
Contributor

Summary

AsyncConfig.from_environment() reads all five boolean feature flags with
lambda x: x.lower() == "true". Four of those fields default to True, so the only expressible
values were true and false — and a value that conventionally means "on" turns the feature off:

AGENTFIELD_ASYNC_ENABLE_RESULT_CACHING= intended actual on main
1 on off
yes on off
on on off
true on off
maybe, 2, `` leave the default off

So AGENTFIELD_ASYNC_ENABLE_RESULT_CACHING=1, the most natural way to say "make sure caching is on",
silently disables result caching — and result_cache.py short-circuits at three separate points when
the flag is false, so the cache is fully off, not degraded. The same inversion applies to
enable_async_execution (the master switch), enable_batch_polling and fallback_to_sync. The fifth
flag, enable_event_stream, defaults to False and fails the other way: 1/yes/on cannot turn
SSE on at all.

This is the default path, not an opt-in helper. Both public constructors build their config this way
when the caller passes nothing — agent.py:846 and client.py:182, both
async_config or AsyncConfig.from_environment(). test_client_default_async_config_uses_environment
already pins that.

The lambda also breaks the documented contract. #714, which wired from_environment() into the client
default, says it "only overrides fields when the corresponding env var is set (falling back to the
default on unparseable values
)". x.lower() == "true" never raises, so get_env_var's
except (ValueError, TypeError): return default_value branch is unreachable for booleans and a junk
value overrides a default-True field with False. The float and int converters in the same function
do fall back correctly.

What changed

The package already has a vocabulary for this, split by which way the field defaults. Default-on
flags opt out with a falsey set: log_writer._queue_enabled, logger._stdout_mirror_enabled,
node_logs.logs_enabled and openrouter_attribution.attribution_enabled all use
("0", "false", "no", "off"). Default-off flags opt in with a truthy set:
litellm_observability._TRUE_VALUES is {"1", "true", "yes", "on"}. async_config.py was the only
module using == "true" for flags that default to True, matching neither.

So this adds two converters named for the polarity they serve and points the five flags at the right
one:

_FALSE_VALUES = ("0", "false", "no", "off")
_TRUE_VALUES = ("1", "true", "yes", "on")


def _env_flag_default_on(value: str) -> bool:
    return value.strip().lower() not in _FALSE_VALUES


def _env_flag_default_off(value: str) -> bool:
    return value.strip().lower() in _TRUE_VALUES

Picking the vocabulary by polarity fixes both halves at once: a value outside the set now yields the
field's default, so "fall back to the default on unparseable values" holds without the converter
having to raise. .strip() is included because all four default-on precedents strip, and values
sourced from a shell or a YAML block routinely carry padding. from_environment()'s docstring now
states the vocabulary, since it previously documented only the prefix and two numeric examples.

Backward compatible. get_env_var returns the default before consulting the converter when the
variable is unset, so with no AGENTFIELD_ASYNC_* set the new code is never reached. Digesting every
field of from_environment() on both trees with a clean environment gives the same SHA-256
(5960894a0c0bd6c817a09800eee26998e09625f7dfb23cd3d75ce1ee9088eb90) and the same boolean map. Every
value that already worked still does — true/TRUE/false/FALSE/0/no/off are unchanged;
1/yes/on, padded values and unparseable ones now behave as documented.

Type of change

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

Test plan

76 parametrized tests added to sdk/python/tests/test_async_config.py; all offline, no network or
credentials. On unmodified main they give 33 failed, 49 passed, distributed exactly as the defect
predicts:

  • 16 × test_default_on_flag_accepts_conventional_truthy_values — 4 flags × 1/yes/on/true
    (the true/TRUE cases already passed)
  • 12 × test_default_on_flag_falls_back_to_default_when_unparseable — 4 flags × maybe/``/2
  • 4 × test_event_stream_opts_in_with_truthy_values
  • 1 × test_boolean_env_flags_reach_the_client_default — the public AgentFieldClient() path

Two groups pass on main too, and are there to pin what must not change:
test_default_on_flag_still_opts_out (24 cases, the falsey vocabulary already worked) and
test_event_stream_stays_off_for_falsey_or_unparseable (9 cases). No existing test pinned the buggy
behaviour — the six pre-existing tests use only "false" and "true", which behave identically
before and after, so all six still pass.

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 → 82 passed
    (6 pre-existing + 76 new; 33 of the new ones fail before the source change)
  • cd sdk/python && python -m pytest --ignore=tests/test_agent_mesh.py --no-cov →
    9 failed, 2466 passed, 6 skipped with the fix, against 9 failed, 2390 passed, 6 skipped
    on unmodified main. 2466 = 2390 + 76, 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 removed
    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.

One thing worth flagging, because the gate will look odd: 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
appears in 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 lines of test code, and the gate reported exactly 2 | 100.00%.

So the patch gate passing here is not evidence that the new code is tested — the 76 tests above
are, and they exercise both converters through from_environment() for all five flags across truthy,
falsey, padded, unparseable and unset values. Adding --cov=agentfield.async_config to addopts would
let the gate see this module, but that means editing pyproject.toml; I kept it out of this PR to
stay on one seam. Happy to send it separately if you want it.

Checklist

Related issues / PRs

Refs #621 ([Python SDK] Inconsistent defaults for AsyncConfig and AIConfig) and #714, which made
from_environment() the client default and documented the fallback-to-default behaviour this restores.
Neither is about boolean value formats, so this isn't reversing a decision made there.

No open PR or branch covers it: git log --all -- sdk/python/agentfield/async_config.py shows four
commits ever, none touching env-flag parsing, and across all 150 remote refs the file is either the
current main blob or one of two historical blobs — both of which still contain the same
x.lower() == "true" lambda five times.

Not touched here, but noting it since it is the same shape: logger.py:112/115/117 uses == "true"
for AGENTFIELD_LOG_PAYLOADS, AGENTFIELD_LOG_TRACKING and AGENTFIELD_LOG_FIRE. All three default
to "false", so they fail in the less harmful direction — =1 can't turn them on, but nothing
default-on gets silently disabled. Separate seam, happy to do it as a follow-up if you'd like the
vocabulary unified across the package.

… env vocabulary

AsyncConfig.from_environment() read all five boolean feature flags with
`lambda x: x.lower() == "true"`. Four of those fields default to True, so the
only values that could be expressed were "true" and "false": setting
AGENTFIELD_ASYNC_ENABLE_RESULT_CACHING=1 -- or =yes, or =on -- evaluated to
False and silently disabled result caching, the opposite of what the value says.
The same inversion applied to enable_async_execution, enable_batch_polling and
fallback_to_sync, and it reaches users by default because both Agent (agent.py:846)
and AgentFieldClient (client.py:182) build their async_config from
from_environment() when none is passed.

The lambda also never raises, so get_env_var's `except (ValueError, TypeError):
return default_value` fallback is unreachable for booleans and an unparseable
value overrides the field with False instead of leaving the default. PR Agent-Field#714,
which wired this into the client default, documents the intended contract:
from_environment() "only overrides fields when the corresponding env var is set
(falling back to the default on unparseable values)".

Use the vocabulary the package already uses, split by the field's default:
default-on flags opt out with ("0", "false", "no", "off") like
log_writer._queue_enabled, logger._stdout_mirror_enabled, node_logs.logs_enabled
and openrouter_attribution.attribution_enabled; the default-off enable_event_stream
opts in with ("1", "true", "yes", "on") like litellm_observability._TRUE_VALUES.
A value outside the vocabulary now leaves the field at its default, which is what
makes the documented fallback hold without the converter having to raise.

With no AGENTFIELD_ASYNC_* variable set the converters are never reached --
get_env_var returns the default before consulting them -- so from_environment()
produces a byte-identical AsyncConfig to before (verified by digesting every
field on both trees). Only explicit opt-in/opt-out values change behaviour, and
every value that already worked still does: "true"/"false"/"0" are unchanged,
while "1"/"yes"/"on"/whitespace-padded values now mean what they say.

logger.py's three AGENTFIELD_LOG_* flags use the same `== "true"` shape but all
default to False, so they fail in the less harmful direction; left alone here to
keep this to one seam.
Copilot AI balanced review requested due to automatic review settings October 1, 2026 02:00

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 1, 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 1, 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 87.90% 87.40% ↑ +0.50 pp 🟡
sdk-go 93.30% 92.00% ↑ +1.30 pp 🟢
sdk-python 94.72% 93.73% ↑ +0.99 pp 🟢
sdk-typescript 91.84% 90.42% ↑ +1.42 pp 🟢
web-ui 84.77% 84.79% ↓ -0.02 pp 🟡
aggregate 85.92% 85.75% ↑ +0.17 pp 🟡

✅ Gate passed

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

@github-actions

github-actions Bot commented Oct 1, 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.

@AbirAbbas AbirAbbas 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.

Reproduced this on main: AGENTFIELD_ASYNC_ENABLE_RESULT_CACHING=1 (and yes/on/padded true) really does switch caching off, and event_stream can't be turned on with 1/yes. Reverting async_config.py to main makes exactly the 33 new cases fail. ruff, mypy and the full run_pytest.sh suite (2512 passed) are green here. The changed lines are 100% covered when I measure async_config directly, since it isn't in the patch gate's --cov list, as you pointed out. One optional follow-up, not blocking: because the polarity is picked per field, a subclass that flips a default (say fallback_to_sync=False) would get True for an unrecognised value. A single parser that raises ValueError on unknown tokens would let get_env_var's existing fallback return cls()'s real default. Thanks for the careful write-up.

@AbirAbbas
AbirAbbas added this pull request to the merge queue Oct 1, 2026
Merged via the queue into Agent-Field:main with commit e960677 Oct 1, 2026
28 checks passed
@remote-controlled-man
remote-controlled-man deleted the codex/agentfield-async-config-20260930 branch October 13, 2026 01:25
ZeroPoint95 pushed a commit that referenced this pull request Oct 14, 2026
…ault (#1100)

The two boolean parsers added in #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 #1089; the remaining strict parsers in logger.py,
agent.py and agent_server.py are handled separately in #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 -- #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 #1089.
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