Skip to content

stream_result()/astream_result() process inputs twice when halting on a non-streaming action #1007

Open

Description

@xihaian251

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: main at 6741dac2b80ff47618885552811bd7dde6689a35
  • 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.

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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions