..
   Licensed to the Apache Software Foundation (ASF) under one
   or more contributor license agreements.  See the NOTICE file
   distributed with this work for additional information
   regarding copyright ownership.  The ASF licenses this file
   to you under the Apache License, Version 2.0 (the
   "License"); you may not use this file except in compliance
   with the License.  You may obtain a copy of the License at

     http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing,
   software distributed under the License is distributed on an
   "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
   KIND, either express or implied.  See the License for the
   specific language governing permissions and limitations
   under the License.


.. _parallelism:

=================
Parallelism
=================

Burr can run multiple actions in parallel. Each parallel branch can contain one or more actions, and different branches can have different actions. This is useful when:

- Trying different prompts with an LLM
- Trying a prompt with different LLMs
- Trying multiple prompts with multiple LLMs
- Running multiple tools in parallel then combining/selecting from the result
- Do semantic search and web search simultaneously for information retrieval

And more! Just like Burr in general, these concepts are generic and can be applied to non-LLM applications
This section shows how to enable parallelism and presents use cases.



TL;DR
=====
Burr provides a high-level and a low-level API for parallelism. The high-level API supports many different patterns and should be sufficient for most use cases.

- ``MapStates``: Apply an action to multiple values in state then reduce the action results (e.g., different prompts to the same LLM).
- ``MapActions``: Apply different actions to the same state value then reduce the actions result (e.g., same prompt to different LLMs).
- ``MapActionsAndStates``: Do the full cartesian product of actions and state values (e.g., try different prompts with multiple LLMs)
- ``RunnableGraph``: Combined with the above options, you can replace a single Action by a Graph composed of multiple actions.

With the low-level API, you can manually determine how parallel actions or subgraphs are executed.

.. image:: ../_static/parallelism.png
   :align: center

Overview
========
Burr allows you to define parallel actions by expanding a single action into multiple individual actions or subgraphs which
will execute them all and joining the results. This is a simple `map-reduce <https://en.wikipedia.org/wiki/MapReduce>`_ pattern.

Currently, Burr has two separate APIs for building parallel applications -- higher level (use this first), and lower level.
Beyond that, Burr can support parallelism however you wish to run it -- see the advanced use-cases section for more details.

Higher-level API
================

You select a set of "configurations" over which you want to run, and Burr launches all of them then joins the result.

This means you either:

1. Vary the state and run over the same action/subgraph (think tuning LLM parameters/inputs, running simple experiments/optimization routines, etc...)
2. Vary the action and provide the same state (think running multiple LLMs on the same input, running multiple analyses on the same data, etc...)

Note we do not distinguish between subgraph and action -- under the hood it's all treated as a "sub-application" (more on that later).


Run the same action over different states
-----------------------------------------

For case (1) (mapping states over the same action) you implement the ``MapStates`` class, doing the following:

- We define a regular action ``query_llm()`` using the ``@action`` decorator.
- We also create a subclass of ``MapStates`` named ``TestMultiplePrompts``, which must implement ``.reads()``, ``.writes()``, ``.action()``, ``.states()``, and ``.reduce()``.
    - ``.reads()`` / ``.writes()`` define the state value it can interact with, just like the ``@action`` decorator
    - ``.action()`` leverages the ``query_llm()`` previously defined
    - ``.states()`` can read value from State and yields values to pass to the . action(). In this case, it updates the prompt state value that's read by ``query_llm()```. (the example hardcoded a list of prompts for simplicity, but this would be read from state)
    - ``.reduce()`` receives multiple states, one per ``.action()`` call, where the llm_output value is set by ``query_llm()`` in ``.action()``. Then, it must set all_llm_output as specified in the ``MapStates.writes()`` method.
- We pass an instance of the ``TestMultiplePrompts`` class to the ApplicationBuilder, which will run the action over the states we provide.

This looks as follows -- in this case we're running the same LLM over different prompts:


.. code-block:: python

    from burr.core import action, state
    from burr.core.parallelism import MapStates, RunnableGraph
    from typing import Callable, Generator, List

    @action(reads=["prompt"], writes=["llm_output"])
    def query_llm(state: State) -> State:
        return state.update(llm_output=_query_my_llm(prompt=state["prompt"]))

    class TestMultiplePrompts(MapStates):

        def action(self, state: State, inputs: Dict[str, Any]) -> Action | Callable | RunnableGraph:
            # make sure to add a name to the action
            # This is not necessary for subgraphs, as actions will already have names
            return query_llm.with_name("query_llm")

        def states(self, state: State, context: ApplicationContext, inputs: Dict[str, Any]) -> Generator[State, None, None]:
            # You could easily have a list_prompts upstream action that writes to "prompts" in state
            # And loop through those
            # This hardcodes for simplicity
            for prompt in [
                "What is the meaning of life?",
                "What is the airspeed velocity of an unladen swallow?",
                "What is the best way to cook a steak?",
            ]:
                yield state.update(prompt=prompt)


        def reduce(self, state: State, states: Generator[State, None, None]) -> State:
            all_llm_outputs = []
            for sub_state in states:
                all_llm_outputs.append(sub_state["llm_output"])
            return state.update(all_llm_outputs=all_llm_outputs)

        @property
        def reads(self) -> List[str]:
            return ["prompts"]

        @property
        def writes(self) -> List[str]:
            return ["all_llm_outputs"]

Then, to run the application:

.. code-block:: python

    app = (
        ApplicationBuilder()
        .with_action(
            prompt_generator=generate_prompts, # not defined above, this writes to prompts
            multi_prompt_test=TestMultiplePrompts(),
        ).with_transitions(
            ("prompt_generator", "multi_prompt_test"),
        )
        .build()
    )


Run different actions over the same state
-----------------------------------------

For case (2) (mapping actions over the same state) you implement the ``MapActions`` class, doing the following:

- We define a regular action ``query_llm()`` using the ``@action`` decorator. This takes in a model parameter (which we're going to bind later)
- We also create a subclass of ``MapActions`` named ``TestMultipleModels``, which must implement ``.reads()``, ``.writes()``, ``.actions()``, ``.state()``, and ``.reduce()``.
    - ``.reads()`` / ``.writes()`` define the state value it can interact with, just like the ``@action`` decorator
    - ``.actions()`` leverages the ``query_llm()`` previously defined, binding with the different models we want to test
    - ``.state()`` can read value from State and produces the state to pass to the actions produced by ``actions()``. In this case, it updates the prompt state value that's read by ``query_llm()``.
    - ``.reduce()`` receives multiple states, one per result of the ``.actions()`` call, where the llm_output value is set by ``query_llm()`` in ``.actions()``. Then, it must set all_llm_output as specified in the ``MapStates.writes()`` method.
- We pass an instance of the ``TestMultipleModels`` class to the ``ApplicationBuilder``, which will run the action over the states we provide.

.. code-block:: python

    from burr.core import action, state
    from burr.core.parallelism import MapActions, RunnableGraph
    from typing import Callable, Generator, List, Dict, Any

    @action(reads=["prompt", "model"], writes=["llm_output"])
    def query_llm(state: State, model: str) -> State:
        # TODO -- implement _query_my_llm to call litellm or something
        return state.update(llm_output=_query_my_llm(prompt=state["prompt"], model=model))

    class TestMultipleModels(MapActions):

        def actions(self, state: State, context: ApplicationContext, inputs: Dict[str, Any]) -> Generator[Action | Callable | RunnableGraph, None, None]:
            # Make sure to add a name to the action if you use bind() with a function,
            # note that these can be different actions, functions, etc...
            # in this case we're using `.bind()` to create multiple actions, but we can use some mix of
            # subgraphs, functions, action objects, etc...
            for action in [
                query_llm.bind(model="gpt-4").with_name("gpt_4_answer"),
                query_llm.bind(model="o1").with_name("o1_answer"),
                query_llm.bind(model="claude").with_name("claude_answer"),
            ]
                yield action

        def state(self, state: State, inputs: Dict[str, Any]) -> State:
            return state.update(prompt="What is the meaning of life?")

        def reduce(self, state: State, states: Generator[State, None, None]) -> State:
            all_llm_outputs = []
            for sub_state in states:
                all_llm_outputs.append(sub_state["llm_output"])
            return state.update(all_llm_outputs=all_llm_outputs)

        @property
        def reads(self) -> List[str]:
            return ["prompt"] # we're just running this on a single prompt, for multiple actions

        @property
        def writes(self) -> List[str]:
            return ["all_llm_outputs"]


Then, it's almost identical to the ``MapStates`` case:

.. code-block:: python

    app = (
        ApplicationBuilder()
        .with_action(
            prompt_generator=generate_prompts, # not defined above, this writes to prompts
            multi_prompt_test=TestMultipleModels(),
        ).with_transitions(
            ("prompt_generator", "multi_prompt_test"),
        )
        .build()
    )


Full cartesian product
----------------------

If you want to run all possible combinations of actions/states, you can use the ``MapActionsAndStates`` class  -- this is actually the
base class for the above two classes. For this, you provide a generator of actions and a generator of states, and Burr will run all possible
combinations.

For tracking which states/actions belong to which actions, we recommend you use the values stored in the state (see example).

.. code-block:: python

    from burr.core import action, state
    from burr.core.parallelism import MapActionsAndStates, RunnableGraph
    from typing import Callable, Generator, List

    @action(reads=["prompt", "model"], writes=["llm_output"])
    def query_llm(state: State, model: str) -> State:
        # TODO -- implement _query_my_llm to call litellm or something
        return state.update(llm_output=_query_my_llm(prompt=state["prompt"], model=model))

    class TestModelsOverPrompts(MapActionsAndStates):

        def actions(self, state: State, context: ApplicationContext, inputs: Dict[str, Any]) -> Generator[Action | Callable | RunnableGraph, None, None]:
            # make sure to add a name to the action
            # This is not necessary for subgraphs, as actions will already have names
            for action in [
                query_llm.bind(model="gpt-4").with_name("gpt_4_answer"),
                query_llm.bind(model="o1").with_name("o1_answer"),
                query_llm.bind(model="claude").with_name("claude_answer"),
            ]
                yield action

        def states(self, state: State, context: ApplicationContext, inputs: Dict[str, Any]) -> Generator[State, None, None]:
            for prompt in [
                "What is the meaning of life?",
                "What is the airspeed velocity of an unladen swallow?",
                "What is the best way to cook a steak?",
            ]:
                yield state.update(prompt=prompt)

        def reduce(self, state: State, states: Generator[State, None, None]) -> State:
            all_llm_outputs = []
            for sub_state in states:
                all_llm_outputs.append(
                    {
                        "output" : sub_state["llm_output"],
                        "model" : sub_state["model"],
                        "prompt" : sub_state["prompt"],
                    }
                )
            return state.update(all_llm_outputs=all_llm_outputs)

        @property
        def reads(self) -> List[str]:
            return ["prompts"]

        @property
        def writes(self) -> List[str]:
            return ["all_llm_outputs"]


Subgraphs
---------

While we've been using individual actions above, we can also replace them with subgraphs (E.G. :ref:`using recursion <recursion>`  inside applications).

To do this, we use the Graph API and wrap it in a RunnableGraph:

- The :py:class:`Graph <burr.core.Graph>` API allows us to tell the structure of the action
- The ``RunnableGraph`` is a wrapper that tells the framework other things you need to know to run the graph:
    - The entrypoint of the graph
    - The exit points (corresponding to ``halt_after`` in :py:meth:`run <burr.core.Application.run>`)

This might look as follows -- say we have a simple subflow that takes in a raw prompt from state and returns the LLM output:

.. code-block:: python

    from typing import Dict, Any
    from burr.core import action, state
    from burr.core.graph import Graph
    from burr.core.parallelism import RunnableGraph

    @action(reads=["prompt"], writes=["processed_prompt"])
    def process_prompt(state: State) -> State:
        processed_prompt = f"The user has asked: {state['prompt']}. Please respond directly to that prompt, but only in riddles."
        return state.update(
            processed_prompt=state["prompt"],
        )

    @action(reads=["processed_prompt"], writes=["llm_output"])
    def query_llm(state: State) -> State:
        return state.update(llm_output=_query_my_llm(prompt=state["processed_prompt"]))

    graph = (
        GraphBuilder()
        .with_action(
            process_prompt=process_prompt,
            query_llm=query
        ).with_transitions(
            ("process_prompt", "query_llm")
        ).build()
    )

    runnable_graph = RunnableGraph(
        graph=graph,
        entrypoint="process_prompt",
        halt_after=["query_llm"]
    )

    class TestMultiplePromptsWithSubgraph(MapStates):

        def action(self, state: State, inputs: Dict[str, Any]) -> Action | Callable | RunnableGraph:
            return runnable_graph

        def states(self, state: State, context: ApplicationContext, inputs: Dict[str, Any]) -> Generator[State, None, None]:
            for prompt in [
                "What is the meaning of life?",
                "What is the airspeed velocity of an unladen swallow?",
                "What is the best way to cook a steak?",
            ]:
                yield state.update(prompt=prompt)

        ... # same as above

In the code above, we're effectively treating the graph like an action -- due to the single ``entrypoint``/``halt_after`` condition we specified,
it can run just as the single prompt we did above. Note this is also doable for running multiple actions over the same state.



Passing inputs
--------------

Parallel actions can accept inputs in the same way that class-based actions do. In order to accept inputs you have to declare them in the class. As we're using the :ref:`class-based API <classbasedactions>`,
this is done by declaring the ``inputs`` property -- a list of strings that are used in inputs. Note you have to use the superclasses
inputs as well to ensure it has everything it needs -- we will likely be automating this.

This looks as follows:

.. code-block:: python

    from burr.core import action, State, ApplicationContext
    from burr.core.graph import Graph

    @action(reads=["prompt"], writes=["processed_prompt"])
    def process_prompt(state: State) -> State:
        processed_prompt = f"The user has asked: {state['prompt']}. Please respond directly to that prompt, but only in riddles."
        return state.update(
            prompt=state["prompt"],
        )

    @action(reads=["processed_prompt"], writes=["llm_output"])
    def query_llm(state: State, model: str) -> State:
        return state.update(llm_output=_query_my_llm(prompt=state["processed_prompt"], model=model))

    graph = (
        GraphBuilder()
        .with_action(
            process_prompt=process_prompt,
            query_llm=query
        ).with_transitions(
            ("process_prompt", "query_llm")
        ).build()
    )

    runnable_graph = RunnableGraph(
        graph=graph,
        entrypoint="process_prompt",
        halt_after="query_llm"
    )

    class TestMultiplePromptsWithSubgraph(MapStates):

        def action(self, state: State, inputs: Dict[str, Any]) -> Action | Callable | RunnableGraph:
            return runnable_graph

        @property
        def inputs(self) -> List[str]:
            return ["prompts"] + super().inputs # make sure to include the superclass inputs

        def states(self, state: State, context: ApplicationContext, inputs: Dict[str, Any]) -> Generator[Tuple[State, dict], None, None]:
            for prompt in inputs["prompts"]:
                yield state.update(prompt=prompt)

        ... # same as above


.. note::

    Should ``MapOverInputs`` be its own class? Or should we have ``bind_from_state(prompt="prompt_field_in_state")`` that allows you to pass it in as
    state and just use the mapping capabilities? Or are we happy as it currently is because we can pass in inputs through `MapStates`/`MapActions` (as shown above).


Lower-level API
===============

The above compile into a set of "tasks" -- sub-applications to run. If, however, you want to have more control, you
can use the lower-level API to simply define the tasks. This allows you to provide any combination of actions, input, and state
to the tasks.

All of the aforementioned high-level API are implemented as subclasses of TaskBasedParallelAction.
You can subclass it directly and implement the ``.tasks()`` method that yields SubGraphTask,
which can be actions or subgraphs. These tasks are then executed by the ``burr.Executor`` implementations

This looks as follows:

.. code-block:: python

    from burr.core import action, state, ApplicationContext
    from burr.core.parallelism import MapStates, RunnableGraph
    from typing import Callable, Generator, List

    @action(reads=["prompt", "model"], writes=["llm_output"])
    def query_llm(state: State, model: str) -> State:
        # TODO -- implement _query_my_llm to call litellm or something
        return state.update(llm_output=_query_my_llm(prompt=state["prompt"], model=model))

    class MultipleTaskExample(TaskBasedParallelAction):
        def tasks(state: State, context: ApplicationContext) -> Generator[SubGraphTask, None, None]:
            for prompt in state["prompts"]:
                for action in [
                    query_llm.bind(model="gpt-4").with_name("gpt_4_answer"),
                    query_llm.bind(model="o1").with_name("o1_answer"),
                    query_llm.bind(model="claude").with_name("claude_answer"),
                ]
                    yield SubGraphTask(
                        action=action, # can be a RunnableGraph as well
                        state=state.update(prompt=prompt),
                        inputs={},
                        # stable hash -- up to you to ensure uniqueness
                        application_id=hashlib.sha256(context.application_id + action.name + prompt).hexdigest(),
                        # a few other parameters we might add -- see advanced usage -- failure conditions, etc...
                    )

        def reduce(self, state: State, states: Generator[State, None, None]) -> State:
            all_llm_outputs = []
            for sub_state in states:
                all_llm_outputs.append(
                    {
                        "output" : sub_state["llm_output"],
                        "model" : sub_state["model"],
                        "prompt" : sub_state["prompt"],
                    }
                )
            return state.update(all_llm_outputs=all_llm_outputs)


Advanced Usage
==============

We anticipate the above should cover most of what you want to do, but we have a host of advanced tuning capabilities.


Execution
---------

To enable execution, you need to pass a ``burr.Executor`` to the application, or to the actions themselves. This is just a python executor
instance, although we will likely be adding a new interface.

You can implement your own by subclassing the ``concurrent.futures.Executor`` class and passing to your favorite execution tool.

This only makes sense for synchronous execution -- for async, it will use asyncio.gather. You can pass this either as a global executor for the application, or specify it as part of your class:

Specifying it as part of the application -- will get routed as the default to all parallel actions:

.. code-block:: python

    app = (
        ApplicationBuilder()
        .with_parallel_executor(MultiThreadedExecutor(max_concurrency=10))
        .build()
    )

Specifying it as part of the action -- will override the global executor (note this is not supported yet...).

.. code-block:: python

    class TestMultiplePrompts(MapStates):

        def action(self, state: State, inputs: Dict[str, Any]) -> Action | Callable | RunnableGraph:
            return runnable_graph

        def executor(self) -> Executor:
            return MultiThreadedExecutor(max_concurrency=10)

        ... # same as above


Persistence/Tracking
--------------------

By default, the trackers/persisters will be passed from the parent application to the child application. The application IDs
will be created as a a stable hash of the parent ID + the index of the child ID, requiring the order to be constant to ensure that the same application ID is used for the same task every time.

Note that we will be adding hte ability to create a unique key, per application, but that is not implemented yet.

It will also utilize the same persister to load from the prior state, if that is used on the application level (see :ref:`state persistence <state-persistence>`).

This enables the following:

1. Tracking will automatically be associated with the same application (and sub-application) when reloaded
2. If the concurrent application quits halfway through, the application will be able to pick up where it left off, as will all sub-applications

The default behavior is to cascade all the following constructs through to the parallel child applications:

1. Tracking (given by the :py:meth:`burr.core.application.ApplicationBuilder.with_tracker` function in :py:class:`ApplicationBuilder <burr.core.application.ApplicationBuilder>`)
2. State saving/persistence (given by the :py:meth:`burr.core.application.ApplicationBuilder.with_state_persister` function in :py:class:`burr.core.application.ApplicationBuilder`)
3. State loading (given by the :py:meth:`burr.core.application.ApplicationBuilder.initialize_from` function in :py:class:`burr.core.application.ApplicationBuilder`)

These will then be passed ot the corresponding sub-application tasks. If any of these objects implement a `.copy()` method, that will be called. Otherwise the same instance will be created. Not the tracker/persister/loader must be serializable
to work in non-multithreaded executors, and thread-safe to work in multithreaded instances.

In mst cases, (2) and (3) above will be the same, however there all valid use-cases where you might want to use different persisters/loaders for the sub-application.
Thus, the default will be to pass the same instance for (2) and (3), *if* they both exist and are specified.

You can disable either tracking or persistence at the sub-application level by overriding one of the three methods (corresponding to the three adapter-types above),
on any of the :py:class:`burr.core.parallelism.MapStatesAndActions`, :py:class:`burr.core.parallelism.MapStates`, or :py:class:`burr.core.parallelism.MapActions` classes.

If you return `None`, then no tracker/persister will be used. If you return an object (E.G. a tracker or a persister), that will be used instead.
The function will be called for every instance of the sub-application.

1. Overriding the tracker :py:meth:`burr.core.parallelism.MapStatesAndAction.tracker`
2. Overriding the persister :py:meth:`burr.core.parallelism.MapStatesAndAction.state_persister`
3. Overriding the loader :py:meth:`burr.core.parallelism.MapStatesAndAction.state_initializer`

You can also set the corresponding fields in the :py:class:`burr.core.parallelism.SubGraphTask` object on a per-task basis,
if you are using the lower-level API.

You can also disable it globally using the application builder:

.. code-block:: python

    class TestMultiplePrompts(MapStates):

        def action(self, state: State, inputs: Dict[str, Any]) -> Action | Callable | RunnableGraph:
            return runnable_graph

        def tracker(self, context) -> TrackingBehavior | None:
            # return "cascade" # default
            # return None # no tracking
            return LocalTrackingClient(...) # custom tracker

        def state_persister(self) -> Persister | None:
            # return "cascade" # default
            # return None # no persistence
            return SQLLitePersister(...) # custom persister

        def state_initializer(self) -> StateInitializer | None:
            return self.state_perssiter() # same as the persister

        ... # same as above

Other
-----

Things we will consider after the initial release:

- Customizing execution on a per-action basis -- likely a parameter to ``RunnableGraph``
- Customizing tracking keys for parallelism
- Streaming -- interleaving parallel streaming actions and giving results as they come
- More examples for inter-graph communication/cancellation of one action based on the result of another
- Graceful failure of sub-actions

Under the hood
==============

Beneath the APIs, all this does is simplify the :ref:`recursion <recursion>`: API to allow for multiple actions to be run in parallel.

- ``RunnableGraph`` s are set as subgraphs, and recursively executed by the application, using the executor
- an ``Action`` are turned into a ``RunnableGraph`` by the framework, and executed by the executor

In the UI, this will show up as a "child" application -- see the :ref:`recursion <recursion>`: section for more details.


Additional Use-cases
====================

As this is all just syntactic sugar for recursion, you can use the recursion to get more advanced capabilities.

This involves instantiating a sub-application inside the action, and running it yourself.


Interleaving Generators
-----------------------

Say you want to provide an agent that provides up-to-date progress on it's thoughts. For example, say you want to providea
a planning agent with a similar interface to OpenAI's o1 model.


To do this, you would typically call to :py:meth:`iterate <burr.core.application.Application.iterate>`. Now, say you wanted to run
multiple in parallel!

While this is not built to be easy with the APIs in this section, it's very doable with the underlying recursion API.

The basics (code not present now):

1. Create each sub-application using the ``with_parent_context`` method
2. Run each sub-application in parallel using the executor
3. Combine the generators in parallel, yielding results as they come out


Inter-action communication
---------------------------

Say you have two LLMs answering the same question -- one that gives immediate results back to the user
as they come in, and another that thinks for a while to give more sophisticated results. The user then has the option to say they're happy
with the solution, or they want to wait for more. You may want to eagerly kick off the second LLM
if you're concerned about latency -- thus if the user wants more or does not respond, the more sophisticated
LLM might come up with a solution.

To do this, you would:

1. Run the sub-graph consisting of the first LLM using :py:meth:`iterate <burr.core.application.Application.iterate>`
2. Simultaneously run the second LLM using :py:meth:`iterate <burr.core.application.Application.iterate>` as well
3. Join them in parallel, waiting for any user-input if provided
4. Decide after every step of the first graph whether you want to cancel the second graph or not -- E.G. is the user satisfied.


Async Parallelism
==================

Burr also supports asynchronous parallelism. When working in an async context, you need to make a few adjustments to your parallel actions:

1. Make your methods async
---------------------------

The `action`, `states`, `reduce`, and other methods should be defined as async:

.. code-block:: python

    class AsyncMapActionsAndStatesExample(MapActionsAndStates):

        async def action(self, state: State, inputs: Dict[str, Any]) -> AsyncGenerator[Action, None]:
            # Yield multiple model components to run in parallel
            for i, model_config in enumerate(self._model_configs):
                yield ModelResponse(config=model_config).with_name(f"model_{i}")

        async def states(self, state: State, inputs: Dict[str, Any]) -> AsyncGenerator[State, None]:
            # Prepare the state with the user query
            for prompt in [
                "What is the meaning of life?",
                "What is the airspeed velocity of an unladen swallow?",
                "What is the best way to cook a steak?",
            ]:
                yield state.update(prompt=prompt)

        async def reduce(self, state: State, states: AsyncGenerator[State, None]) -> State:
            # Collect all model responses
            all_responses = []
            async for sub_state in states:
                model_key = sub_state.get("model_key")
                response = sub_state.get(model_key, [])[-1].get("content", "")
                all_responses.append(response)

            return state.update(ensemble_responses=all_responses)

2. Implement the is_async method
--------------------------------

You must override the `is_async` method to return `True`:

.. code-block:: python

    class AsyncMapActionsAndStatesExample(MapActionsAndStates):

        @property
        def is_async(self) -> bool:
            return True

        # ... other methods ...

3. Use async persisters with connection pools
----------------------------------------------

When using state persistence with async parallelism, make sure to use the async version of persisters and initialize them with a connection pool:

.. code-block:: python

    from burr.integrations.persisters.b_asyncpg import AsyncPGPersister

    # Create an async persister with a connection pool
    persister = await AsyncPGPersister.from_values(
        host="localhost",
        port=5432,
        user="postgres",
        password="postgres",
        database="burr",
        use_pool=True  # Important for parallelism!
    )

    app = await (
        ApplicationBuilder()
        .with_state_persister(persister)
        .with_action(
            async_parallel_action=AsyncMapActionsAndStatesExample(),
        )
        .abuild()
    )

Connection pools are crucial for handling concurrent operations. Direct connections cannot be shared across different tasks and may cause errors in concurrent scenarios.

Remember to properly clean up your async persisters when you're done with them:

.. code-block:: python

    # Using as a context manager (recommended)
    async with AsyncPGPersister.from_values(..., use_pool=True) as persister:
        # Use persister here

    # Or manual cleanup
    persister = await AsyncPGPersister.from_values(..., use_pool=True)
    try:
        # Use persister here
    finally:
        await persister.cleanup()


Notes
=====

This is in an experimental release phase. While we anticipate the API to be stable, we reserve the right to make slight changes (and will obviously add new features).
Caveats:

1. Parallelism does not work with typed state (yet) -- we aim to fix this to work soon, but do not rely on sub-actions or parallel actions working together with typed state yet.


Things that may change:

1. We will likely alter the executor API to be more flexible, although we will probably allow for use of the current executor API
2. We will be adding guard-rails for generator-types (sync versus async)
3. The UI is a WIP -- we have more sophisticated capabilities but are still polishing them
4. Support for action-level executors
