Recursive Applications

Burr supports sub-applications. E.G. running Burr within Burr. Currently this capability is only done with tracking (linking applications), but we are actively working on building first-level constructs that allow this.

This can enable the following use-cases (among many others):

  1. Parallel results to different LLMs for comparison

  2. Multiple simultaneous tasks/chains for an LLM to answer at once

  3. Black-boxing an action as a subset of tasks

  4. Running multiple queries simultaneously and getting an LLM to synthesize

And so on. These can be arbitrarily complex and nested, allowing agents consisting of agents consisting of agents…

../../_images/meme.png

You can find an example here.

Tracking

Currently, to leverage nested Burr apps, you do the following:

  1. Create a parent app (as you normally would)

  2. Leverage tracking

  3. Create an action inside that

  4. Run your Burr sub-application inside that action

  5. Wire through tracker + parent data for the sub-application

After creating a simple application (1)/(2) above, you can wire through the tracker like this (some pseudocode, see the example for a fully working application).

@action(reads=["parameters_to_map"], writes=["joined_results"])
def run_multiple_sub_apps(state: State, __context: ApplicationContext) -> State:
    # for every parameter incoming, run a sub-application
    results = []
    for parameter_set in state["parameters_to_map"]:
        subapp = (
            ApplicationBuilder()
            .with_actions(...)
            .with_transitions(...)
            .with_tracker(__context.tracker.copy())
            .with_spawning_parent(
                __context.app_id,
                __context.sequence_id,
                __context.partition_key,
            )
            .with_entrypoint(...)
            .with_state(...)
            .build()
        )
        results.append(subapp.run(...))

    joined = _join(results)

    return state.update(joined_results= _join(results))

Note we’re simply passing through the tracker to the next app, which sets a link between them. The tracker knows how to record the link so its in the UI.

You can also run this in parallel – this leverages joblib’s Parallel and delayed functions, but you can use whatever tooling you want (multiprocessing, threading, etc…).

@action(reads=["parameters_to_map"], writes=["joined_results"])
def run_multiple_sub_apps(state: State, __context: ApplicationContext) -> State:
    def run_subapp(parameter_set):
        subapp = (
            ApplicationBuilder()
            .with_actions(...)
            .with_transitions(...)
            .with_tracker(__context.tracker.copy() if __context.tracker is not None else None)
            .with_spawning_parent(
                __context.app_id,
                __context.sequence_id,
                __context.partition_key,
            )
            .with_entrypoint(...)
            .with_state(...)
            .build()
        )
        return subapp.run(...)

    results = Parallel(n_jobs=-1)(delayed(run_subapp)(parameter_set) for parameter_set in state["parameters_to_map"])
    joined = _join(results)

    return state.update(joined_results=joined)

Note the following:

  1. You don’t have to pass in partition_key, but you need to pass in the app_id and sequence_id so the app can track

  2. You currently need to run __context.tracker.copy() to ensure the tracker is passed through correctly. We will likely be relaxing this, but it could corrupt the tracking state if you forget.

  3. You may want to have more control over the application ID’s – you may want to use a stable hash of the parameters and the parent App ID

For persistence, you’ll want to use (3) to ensure a stable hash – have the sub-application load its own state from the ID, resuming where it left off.

We currently don’t wire through persisters, although we are planning to add that shortly. For the meanwhile, you’ll want to leverage the right persister yourself.

When you track in the UI, you will see the following (in this example we’re generating poems of different styles in parallel):

../../_images/recursive_steps.png

Alternatives

If, for some reason, you want to run a Burr application inside a burr application but can’t effectively wire through the tracker/context (say, for instance, that it’s run through levels of other frameworks), you can get access to the active context and tracker by using the following:

from burr import get_active_context, get_active_tracker, ApplicationContext

@action(reads=["parameters_to_map"], writes=["joined_results"])
def my_action(state: State) -> State:
   context = ApplicationContext.get()
   def run_subapp(parameter_set):
        subapp = (
            ApplicationBuilder()
            .with_actions(...)
            .with_transitions(...)
            .with_tracker(context.tracker.copy() if context.tracker is not None else None)
            .with_spawning_parent(
                context.app_id,
                context.sequence_id,
                context.partition_key,
            )
            .with_entrypoint(...)
            .with_state(...)
            .build()
        )
        return subapp.run(...)

    results = Parallel(n_jobs=-1)(delayed(run_subapp)(parameter_set) for parameter_set in state["parameters_to_map"])
    joined = _join(results)

    # do something with the context and tracker

    return state

Future Improvements

We are working on the following:

  1. A first class way to define a recursive application application to allow for more ergonomic management of the above

  2. Static visualization of the recursive application when using (1)

  3. A more ergonomic way to pass through the tracker

Stay tuned!