agent_core/loop_types.py merges stop reasons with a None check:
if stop_reason is None and iv.stop_reason is not None:
stop_reason = iv.stop_reason
Intervention.stop_reason is str | None, so an observer returning Intervention(stop_reason="") — e.g. a reason built from a config value that defaulted empty — claims the slot.
Consequence: a later observer's real stop_reason="budget_exhausted" is discarded, and a loop testing truthiness (if merged.stop_reason:) then sees no stop at all — the run continues past both stop requests. A budget or deadline observer silently stops working.
Fix direction: treat the empty string as absent — normalise "" to None at merge, or gate on truthiness. The docstring rule ("first non-None wins") should be restated as "first non-empty wins". Worth a test in tests/test_loop_types.py pinning the shadowing case.
Provenance: pre-existing in ApodexHarness/miroharness/core/loop_types.py, ported verbatim by #1 — not introduced by the refactor. ApodexHarness carries the same code and needs the same change until it consumes AgentCore.
agent_core/loop_types.pymerges stop reasons with aNonecheck:Intervention.stop_reasonisstr | None, so an observer returningIntervention(stop_reason="")— e.g. a reason built from a config value that defaulted empty — claims the slot.Consequence: a later observer's real
stop_reason="budget_exhausted"is discarded, and a loop testing truthiness (if merged.stop_reason:) then sees no stop at all — the run continues past both stop requests. A budget or deadline observer silently stops working.Fix direction: treat the empty string as absent — normalise
""toNoneat merge, or gate on truthiness. The docstring rule ("first non-None wins") should be restated as "first non-empty wins". Worth a test intests/test_loop_types.pypinning the shadowing case.Provenance: pre-existing in
ApodexHarness/miroharness/core/loop_types.py, ported verbatim by #1 — not introduced by the refactor. ApodexHarness carries the same code and needs the same change until it consumes AgentCore.