Repository navigation
Description
stream_result() and astream_result() build action_inputs with _process_inputs() before checking whether the halt action is streaming. When it is not, they delegate to _step() / _astep(), which process the same inputs again. The first action_inputs is never used on that path, and an injected dependency such as __context or __tracer is constructed twice.
Current behavior
# stream_result
action_inputs = self._process_inputs(inputs, next_action) # 1st
if not next_action.streaming:
action, result, state = self._step(inputs=inputs, _run_hooks=False) # 2nd, inside _step
...
return out
# action_inputs is only used from here on
# astream_result
action_inputs = self._process_inputs(inputs, next_action) # 1st
if not next_action.streaming:
action, result, state = await self._astep(inputs=inputs, _run_hooks=False) # 2nd
...Measured with an action that declares __context, and a factory that returns a fresh object per call:
stream_result : 2 factory calls
astream_result : 2 factory calls
step() : 1 factory call
astep() : 1 factory call
Halting on a non-streaming action is a supported path — the stream_result docstring says the container "can also be used with non-streaming actions". stream_iterate() / astream_iterate() call these two functions internally, so they inherit the behaviour.
Stack Traces
Not applicable — nothing raises. The second construction is silent.
Screenshots
Not applicable.
Steps to replicate behavior
Save as repro_stream_inputs.py in a checkout of main and run python repro_stream_inputs.py (no extra dependencies):
import asyncio
from burr.core import ApplicationBuilder, State, action
@action(reads=[], writes=["finished"])
def work(state: State, __context) -> State:
return state.update(finished=True)
def build():
app = ApplicationBuilder().with_actions(work).with_entrypoint("work").build()
calls = []
def fresh(action_obj, sequence_id):
calls.append(object())
return calls[-1]
# ``__context`` is bound at construction time, so patch the instance mapping.
app._dependency_factory["__context"] = fresh
return app, calls
app, calls = build()
app.stream_result(halt_after=["work"], inputs={"unused": "ignored"})[1].get()
print("stream_result factory calls:", len(calls))
async def main():
app, calls = build()
_, container = await app.astream_result(halt_after=["work"], inputs={"unused": "ignored"})
await container.get()
print("astream_result factory calls:", len(calls))
asyncio.run(main())
app, calls = build()
app.step(inputs={"unused": "ignored"})
print("step factory calls:", len(calls))Observed on main:
stream_result factory calls: 2
astream_result factory calls: 2
step factory calls: 1
Library & System Information
- Apache Burr:
mainat6741dac2b80ff47618885552811bd7dde6689a35 - Python 3.12.14 and 3.13.14, Windows
- pytest 9.2.0, pytest-asyncio 1.4.1
Expected behavior
One _process_inputs() call per step, so a declared dependency is constructed once — matching step() and astep().
Additional context
Suggested fix. Move the _process_inputs() call into the streaming branch, after the non-streaming early return. The non-streaming path already delegates to a function that processes inputs, so it then processes them exactly once. The streaming path is untouched: _process_inputs() still runs before pre_start_stream, and the generators receive the same action_inputs as before. The diff is 8 added and 2 removed lines in burr/core/application.py, plus a parametrised regression test covering both entry points. Happy to open a PR if this is something you want fixed.
What does not change. Measured pre_run_step / post_run_step / pre_start_stream / post_end_stream counts before and after the fix, across both entry points and both the success and missing-input paths: 4 cases × 4 counters = 16 values, zero differences. The exception type and message for a missing required input are unchanged.
Honest scope. __context and __tracer currently have no construction side effects, so the cost today is one wasted object per step plus a computation whose result is thrown away — not data corruption. One visible side effect exists on main alone: a missing-required-input error raised through astream_result() additionally emits the standard astep() ERROR log line, because the error now surfaces from _astep(). On top of #980 or #995 that extra line does not appear, since both move _process_inputs() ahead of _astep()'s try block.
Not a duplicate of the open PRs.
- astep() passes unprocessed inputs to pre_run_step hooks #979 / fix(core): process inputs before astep lifecycle hooks #980 / fix(core): process and validate inputs before pre_run_step in astep (#979) #995 are about
astep()handing unprocessed inputs topre_run_step. They do not changestream_result()orastream_result(). On7a58bba5, the head of fix(core): process and validate inputs before pre_run_step in astep (#979) #995, this reproduction still prints2and2. The fix(core): process and validate inputs before pre_run_step in astep (#979) #995 author measured the same counts and wrote: "Collapsing it to a single call is a separate change if you want it." - astream_result() masks action exceptions with a tracker KeyError when the action is non-streaming #999 / fix(core): only end a stream that was actually started #1000 are about
post_end_streamfiring for a stream that never started. The fix above leaves those hook counts unchanged and composes with fix(core): only end a stream that was actually started #1000. - fix(core): consume iterate inputs once #908 is about passing inputs across steps in
iterate/stream_iterate. It does not modify these two functions; this reproduction still fails on52eef27d, the head of fix(core): consume iterate inputs once #908. - Related but distinct from the hook-ordering observation in fix(core): process inputs before astep lifecycle hooks #980's review (
enekoretonoted thatstream_resultpasses raw inputs topre_run_step). That is a different defect; this one is about the duplicate processing.
Not tested. Linux and macOS, and Python 3.9–3.11. The change is control flow only, but I did not run those combinations.
Disclosure: this report and the suggested fix were prepared with AI assistance, then reviewed and verified by me. Every number above comes from actual local runs on 6741dac2 with Python 3.12.14 and 3.13.14, not projections. The reproduction script is self-contained so it can be re-run independently.
Activity