Skip to content

fix(core): process inputs before pre_run_step in the streaming entry points - #1018

Open
xihaian251 wants to merge 1 commit into
apache:mainfrom
xihaian251:fix/streaming-entry-input-before-hook
Open

xihaian251 wants to merge 1 commit into
apache:mainfrom
xihaian251:fix/streaming-entry-input-before-hook

Conversation

@xihaian251

Copy link
Copy Markdown

Application.stream_result() and Application.astream_result() announce a step to pre_run_step before _process_inputs() has run, so the hook receives the caller's raw dictionary rather than the one the action gets. step() already does this in the right order.

Why are these changes needed?

Both streaming entry points dispatch pre_run_step from the first try block with inputs=inputs, then call _process_inputs() in the second one, then delegate a non-streaming halt to _step()/_astep() — which processes the same dictionary again. That single mis-ordering is behind three separate symptoms:

  • The hook and the action disagree. Undeclared keys reach the hook; the injected __context/__tracer do not. This is not only cosmetic — TracerFactoryContextHook.pre_run_step reads inputs["__tracer"] and, when absent, builds its own TracerFactory, so on these paths the tracing context and the action run against two different objects. step() shares one.
  • A rejected step gets announced anyway. _process_inputs() raises for reserved __-prefixed keys and missing required inputs, but by then pre_run_step has fired and the error handler fires post_run_step too, so the tracker records a step that never started.
  • Inputs are processed twice on a non-streaming halt (stream_result()/astream_result() process inputs twice when halting on a non-streaming action #1007). The entry computes action_inputs, hands the raw inputs to the delegate, and throws the first result away — the injected dependency is constructed twice.

Changes

Halting on a non-streaming action now hands the step to _step()/_astep() with _run_hooks=True, from inside the first try block — i.e. exactly how step()/astep() run it. The delegate already processes the inputs before firing pre_run_step, so the entry stops firing the hook on that path at all, and stops processing inputs there: one pass, one announcement, one object.

For the streaming branches, _process_inputs() moves above pre_run_step and the hook is handed action_inputs — the same dictionary the streaming runners are about to receive.

_step() and _astep() are not touched, so this does not overlap the astep() fix in #980/#995. No new parameter or internal flag is introduced; the diff removes more lines than it adds.

pre_run_step's docstring (burr/lifecycle/base.py) now states what inputs holds, since the behaviour was previously documented only by example. There is no CHANGELOG file in this repo, so the title carries the release-note text.

How I tested this

New file tests/core/test_lifecycle_input_semantics.py (69 cases, 10 skipped where an axis does not apply) covers step, astep, stream_result, astream_result, stream_iterate, astream_iterate × sync/async/streaming actions × sync/async hooks × tracking on/off. It asserts four properties, none of which compare returned values:

  • _process_inputs call count per action execution (wrapping patch);
  • pre_run_step/post_run_step counts and their order relative to processing;
  • object identity between the __context the hook receives and the one the action receives — _context_factory returns a fresh instance per call, so identity is a direct public-API probe for "built once", and nothing private is reached into;
  • exception type and message, and that stream_state is left empty.

Results:

failures passes
main (222b32c3) 40 29
this PR 14 55
this PR + #995 0 69

The 14 remaining are all astep()/astream_result()-on-a-non-streaming-action cases caused by _astep firing pre_run_step before processing — #979's own scope, owned by #980/#995, deliberately not touched here.

tests/core + tests/tracking: 448 passed, 4 failed — the same four as on main (test_graphviz_display.py, dot not on PATH on this machine). #1008's own regression test (test_streaming_entry_point_injects_dependency_once) fails 2/2 on main and passes 2/2 on this branch, so #1007 is covered here. Verified identically on Python 3.13.1 and 3.12.14 (in the current CI matrix; 3.9 is gone). black 23.11.0 / isort 5.12.0 / flake8 6.1.0 with the repo's own pre-commit args, git diff --check, and scripts/check_asf_headers.py all clean.

Negative controls: re-adding the entry's own post_run_step on top of the delegate's (the one way this change could double-announce) turns 14 failures into 27, caught by the pairing assertions; reverting the hoist turns 14 into 20, caught by the identity assertions. Without the pairing assertions the double-announce mutation passes unnoticed, which is why they are in the suite.

Notes

Checks

  • PR has an informative and human-readable title (this will be pulled into the release notes)
  • Changes are limited to a single goal (no scope creep)
  • Code passed the pre-commit check & code is left cleaner/nicer than when first encountered.
  • Any change in functionality is tested
  • New functions are documented (with a description, list of inputs, and expected output)
  • Placeholder code is flagged / future TODOs are captured in comments
  • Project documentation has been updated if adding/changing functionality.

AI assistance: this change was drafted and verified with an AI coding assistant (Claude, via Qoder); the analysis, measurements and code are mine to stand behind and I have run every number reported above. Commit trailers record the same.

…points

stream_result() and astream_result() dispatched pre_run_step from the first
try block, before _process_inputs() had run, then delegated a non-streaming
halt to _step()/_astep() -- which processed the same dict again. So on those
two entry points the hook saw the caller's raw dict (undeclared keys present,
injected __context/__tracer absent), a rejected step was announced and then
closed, and the dependency factory ran twice with the first object discarded.

Halting on a non-streaming action now hands the whole step to _step()/_astep()
with _run_hooks=True, the same way step()/astep() run it, and the streaming
branch processes the inputs before firing pre_run_step. _step and _astep are
untouched, so this does not overlap the astep fix in apache#980/apache#995.

Consequences, all measured:
- _process_inputs runs exactly once per action execution on all six entry
  points, which also closes apache#1007 (this covers apache#1008's own regression test);
- pre_run_step and the action receive the same processed dict, so
  TracerFactoryContextHook no longer builds a second TracerFactory;
- a step rejected by input validation fires neither pre_run_step nor
  post_run_step and is no longer written to the tracker, matching step().

The error handler that apache#968 added still calls post_execute exactly once, and
post_run_step comes from the delegate's own finally instead of the entry, so
no pair fires twice and no exception type changes.

Generated-by: Claude (Claude Opus 5)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added area/core Application, State, Graph, Actions area/hooks Lifecycle hooks, interceptors labels Nov 1, 2026

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

Thanks for filing #1017. The streaming part looks right to me. I checked out the branch (9b53e4e) on main (222b32c): with the reproduction from #1017, stream_result now gives the hook ['__context', 'value'], and with a reserved key or a missing input, stream_result and the streaming astream_result raise ValueError without firing pre_run_step or post_run_step, also with the local tracker attached.

But CI's test command fails on this branch alone: 14 of the new tests fail, on Python 3.13 and 3.10. They're the astep cases and the non-streaming astream_result/astream_iterate ones, which you describe as #979's scope. I merged the branch with #980 and with #995: both merge cleanly and tests/core passes. If this lands first, maybe mark those cases xfail pointing to #979, so main doesn't go red.

For the same reason, astream_result halting on a non-streaming action isn't fixed by this PR alone: the #1017 reproduction still shows ['noise', 'value'] there, and a missing input still fires pre_run_step and post_run_step once. So #1017 is only fully closed together with #980 or #995.

I ran this on Python 3.13 on macOS, and the tests also on 3.10.

@xihaian251

Copy link
Copy Markdown
Author

Thanks for checking the branch on both Python versions and for testing the combinations with #980 and #995.

I agree with the scope split: #1018 fixes the streaming-action paths in stream_result() and astream_result(), while the 14 remaining failures are the astep() and non-streaming async-halt cases covered by #979 and #980/#995.

Since your combined runs show that #1018 passes tests/core when combined with either #980 or #995, my preference would be to avoid temporary xfails if we can coordinate the merge order.

Could you advise which of #980 or #995 should be the canonical fix for #979 to land first? I can then update #1018 against the resulting main and rerun the full regression suite.

If the project prefers #1018 to land first, I can instead mark only the specific overlapping cases as temporary xfails linked to #979.

Thanks again for the careful review and independent testing.

@enekoreto

Copy link
Copy Markdown
Contributor

I'm not a maintainer, so the merge order isn't my call. Since you wrote on #1019 that you'd be happy to close this one if #1019 lands, that may settle it anyway.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Application, State, Graph, Actions area/hooks Lifecycle hooks, interceptors

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants