Skip to content

Commit 6863fcb

Browse files
fix(core): preserve legitimate falsy values in _clean_empty
1 parent 9968f9c commit 6863fcb

2 files changed

Lines changed: 103 additions & 7 deletions

File tree

src/a2a/utils/helpers.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -350,14 +350,16 @@ def are_modalities_compatible(
350350
def _clean_empty(d: Any) -> Any:
351351
"""Recursively remove empty strings, lists and dicts from a dictionary."""
352352
if isinstance(d, dict):
353-
cleaned_dict: dict[Any, Any] = {
354-
k: _clean_empty(v) for k, v in d.items()
355-
}
356-
return {k: v for k, v in cleaned_dict.items() if v}
353+
cleaned = {k: _clean_empty(v) for k, v in d.items()}
354+
cleaned = {k: v for k, v in cleaned.items() if v is not None}
355+
return cleaned or None
357356
if isinstance(d, list):
358-
cleaned_list: list[Any] = [_clean_empty(v) for v in d]
359-
return [v for v in cleaned_list if v]
360-
return d if d not in ['', [], {}] else None
357+
cleaned = [_clean_empty(v) for v in d]
358+
cleaned = [v for v in cleaned if v is not None]
359+
return cleaned or None
360+
if isinstance(d, str) and not d:
361+
return None
362+
return d
361363

362364

363365
def canonicalize_agent_card(agent_card: AgentCard) -> str:

tests/utils/test_helpers.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
build_text_artifact,
2828
create_task_obj,
2929
validate,
30+
_clean_empty,
3031
canonicalize_agent_card,
3132
)
3233

@@ -380,3 +381,96 @@ def test_canonicalize_agent_card():
380381
)
381382
result = canonicalize_agent_card(agent_card)
382383
assert result == expected_jcs
384+
385+
386+
def test_canonicalize_agent_card_preserves_false_capability():
387+
"""Regression #692: streaming=False must not be stripped from canonical JSON."""
388+
card = AgentCard(**{
389+
**SAMPLE_AGENT_CARD,
390+
'capabilities': AgentCapabilities(
391+
streaming=False,
392+
push_notifications=True,
393+
),
394+
})
395+
result = canonicalize_agent_card(card)
396+
assert '"streaming":false' in result
397+
398+
399+
@pytest.mark.parametrize(
400+
'input_val',
401+
[
402+
pytest.param({'a': ''}, id='empty-string'),
403+
pytest.param({'a': []}, id='empty-list'),
404+
pytest.param({'a': {}}, id='empty-dict'),
405+
pytest.param({'a': {'b': []}}, id='nested-empty'),
406+
pytest.param({'a': '', 'b': [], 'c': {}}, id='all-empties'),
407+
pytest.param({'a': {'b': {'c': ''}}}, id='deeply-nested'),
408+
],
409+
)
410+
def test_clean_empty_removes_empties(input_val):
411+
"""_clean_empty removes empty strings, lists, and dicts recursively."""
412+
assert _clean_empty(input_val) is None
413+
414+
415+
def test_clean_empty_top_level_list_becomes_none():
416+
"""Top-level list that becomes empty after cleaning should return None."""
417+
assert _clean_empty(['', {}, []]) is None
418+
419+
420+
@pytest.mark.parametrize(
421+
'input_val,expected',
422+
[
423+
pytest.param({'retries': 0}, {'retries': 0}, id='int-zero'),
424+
pytest.param({'enabled': False}, {'enabled': False}, id='bool-false'),
425+
pytest.param({'score': 0.0}, {'score': 0.0}, id='float-zero'),
426+
pytest.param([0, 1, 2], [0, 1, 2], id='zero-in-list'),
427+
pytest.param([False, True], [False, True], id='false-in-list'),
428+
pytest.param(
429+
{'config': {'max_retries': 0, 'name': 'agent'}},
430+
{'config': {'max_retries': 0, 'name': 'agent'}},
431+
id='nested-zero',
432+
),
433+
],
434+
)
435+
def test_clean_empty_preserves_falsy_values(input_val, expected):
436+
"""_clean_empty preserves legitimate falsy values (0, False, 0.0)."""
437+
assert _clean_empty(input_val) == expected
438+
439+
440+
@pytest.mark.parametrize(
441+
'input_val,expected',
442+
[
443+
pytest.param(
444+
{'count': 0, 'label': '', 'items': []},
445+
{'count': 0},
446+
id='falsy-with-empties',
447+
),
448+
pytest.param(
449+
{'a': 0, 'b': 'hello', 'c': False, 'd': ''},
450+
{'a': 0, 'b': 'hello', 'c': False},
451+
id='mixed-types',
452+
),
453+
pytest.param(
454+
{'name': 'agent', 'retries': 0, 'tags': [], 'desc': ''},
455+
{'name': 'agent', 'retries': 0},
456+
id='realistic-mixed',
457+
),
458+
],
459+
)
460+
def test_clean_empty_mixed(input_val, expected):
461+
"""_clean_empty handles mixed empty and falsy values correctly."""
462+
assert _clean_empty(input_val) == expected
463+
464+
465+
def test_clean_empty_does_not_mutate_input():
466+
"""_clean_empty should not mutate the original input object."""
467+
original = {'a': '', 'b': 1, 'c': {'d': ''}}
468+
copy = {
469+
'a': '',
470+
'b': 1,
471+
'c': {'d': ''},
472+
}
473+
474+
_clean_empty(original)
475+
476+
assert original == copy

0 commit comments

Comments
 (0)