Skip to content

Commit

Permalink
docs: document custom start/stop events (#18034)
Browse files Browse the repository at this point in the history
  • Loading branch information
masci authored Mar 6, 2025
1 parent 081a4d7 commit f18200d
Showing 1 changed file with 132 additions and 6 deletions.
138 changes: 132 additions & 6 deletions docs/docs/module_guides/workflow/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,17 @@ class JokeFlow(Workflow):
...
```

Here, we come to the entry-point of our workflow. While events are use-defined, there are two special-case events, the `StartEvent` and the `StopEvent`. Here, the `StartEvent` signifies where to send the initial workflow input.
Here, we come to the entry-point of our workflow. While most events are use-defined, there are two special-case events,
the `StartEvent` and the `StopEvent` that the framework provides out of the box. Here, the `StartEvent` signifies where
to send the initial workflow input.

The `StartEvent` is a bit of a special object since it can hold arbitrary attributes. Here, we accessed the topic with `ev.topic`, which would raise an error if it wasn't there. You could also do `ev.get("topic")` to handle the case where the attribute might not be there without raising an error.
The `StartEvent` is a bit of a special object since it can hold arbitrary attributes. Here, we accessed the topic with
`ev.topic`, which would raise an error if it wasn't there. You could also do `ev.get("topic")` to handle the case where
the attribute might not be there without raising an error.

At this point, you may have noticed that we haven't explicitly told the workflow what events are handled by which steps. Instead, the `@step` decorator is used to infer the input and output types of each step. Furthermore, these inferred input and output types are also used to verify for you that the workflow is valid before running!
At this point, you may have noticed that we haven't explicitly told the workflow what events are handled by which steps.
Instead, the `@step` decorator is used to infer the input and output types of each step. Furthermore, these inferred
input and output types are also used to verify for you that the workflow is valid before running!

### Workflow Exit Points

Expand All @@ -133,7 +139,9 @@ class JokeFlow(Workflow):
...
```

Here, we have our second, and last step, in the workflow. We know its the last step because the special `StopEvent` is returned. When the workflow encounters a returned `StopEvent`, it immediately stops the workflow and returns whatever the result was.
Here, we have our second, and last step, in the workflow. We know its the last step because the special `StopEvent` is
returned. When the workflow encounters a returned `StopEvent`, it immediately stops the workflow and returns whatever
we passed in the `result` parameter.

In this case, the result is a string, but it could be a dictionary, list, or any other object.

Expand All @@ -145,9 +153,127 @@ result = await w.run(topic="pirates")
print(str(result))
```

Lastly, we create and run the workflow. There are some settings like timeouts (in seconds) and verbosity to help with debugging.
Lastly, we create and run the workflow. There are some settings like timeouts (in seconds) and verbosity to help with
debugging.

The `.run()` method is async, so we use await here to wait for the result.
The `.run()` method is async, so we use await here to wait for the result. The keyword arguments passed to `run()` will
become fields of the special `StartEvent` that will be automatically emitted and start the workflow. As we have seen,
in this case `topic` will be accessed from the step with `ev.topic`.

## Customizing entry and exit points

Most of the times, relying on the default entry and exit points we have seen in the [Getting Started] section is enough.
However, workflows support custom events where you normally would expect `StartEvent` and `StopEvent`, let's see how.

### Using a custom `StartEvent`

When we call the `run()` method on a workflow instance, the keyword arguments passed become fields of a `StartEvent`
instance that's automatically created under the hood. In case we want to pass complex data to start a workflow, this
approach might become cumbersome, and it's when we can introduce a custom start event.

To be able to use a custom start event, the first step is creating a custom class that inherits from `StartEvent`:

```python
from pathlib import Path

from llama_index.core.workflow import StartEvent
from llama_index.indices.managed.llama_cloud import LlamaCloudIndex
from llama_index.llms.openai import OpenAI


class MyCustomStartEvent(StartEvent):
a_string_field: str
a_path_to_somewhere: Path
an_index: LlamaCloudIndex
an_llm: OpenAI
```

All we have to do now is using `MyCustomStartEvent` as event type in the steps that act as entry points.
Take this artificially complex step for example:

```python
class JokeFlow(Workflow):
...

@step
async def generate_joke_from_index(
self, ev: MyCustomStartEvent
) -> JokeEvent:
# Build a query engine using the index and the llm from the start event
query_engine = ev.an_index.as_query_engine(llm=ev.an_llm)
topic = query_engine.query(
f"What is the closest topic to {a_string_field}"
)
# Use the llm attached to the start event to instruct the model
prompt = f"Write your best joke about {topic}."
response = await ev.an_llm.acomplete(prompt)
# Dump the response on disk using the Path object from the event
ev.a_path_to_somewhere.write_text(str(response))
# Finally, pass the JokeEvent along
return JokeEvent(joke=str(response))
```

We could still pass the fields of `MyCustomStartEvent` as keyword arguments to the `run` method of our workflow, but
that would be, again, cumbersome. A better approach is to use pass the event instance through the `start_event`
keyword argument like this:

```python
custom_start_event = MyCustomStartEvent(...)
w = JokeFlow(timeout=60, verbose=False)
result = await w.run(start_event=custom_start_event)
print(str(result))
```

This approach makes the code cleaner and more explicit and allows autocompletion in IDEs to work properly.

### Using a custom `StopEvent`

Similarly to `StartEvent`, relying on the built-in `StopEvent` works most of the times but not always. In fact, when we
use `StopEvent`, the result of a workflow must be set to the `result` field of the event instance. Since a result can
be any Python object, the `result` field of `StopEvent` is typed as `Any`, losing any advantage from the typing system.
Additionally, returning more than one object is cumbersome: we usually stuff a bunch of unrelated objects into a
dictionary that we then assign to `StopEvent.result`.

First step to support custom stop events, we need to create a subclass of `StopEvent`:

```python
from llama_index.core.workflow import StopEvent


class MyStopEvent(StopEvent):
critique: CompletionResponse
```

We can now replace `StopEvent` with `MyStopEvent` in our workflow:

```python
class JokeFlow(Workflow):
...

@step
async def critique_joke(self, ev: JokeEvent) -> MyStopEvent:
joke = ev.joke

prompt = f"Give a thorough analysis and critique of the following joke: {joke}"
response = await self.llm.acomplete(prompt)
return MyStopEvent(response)

...
```

The one important thing we need to remember when using a custom stop events, is that the result of a workflow run
will be the instance of the event:

```python
w = JokeFlow(timeout=60, verbose=False)
# Warning! `result` now contains an instance of MyStopEvent!
result = await w.run(topic="pirates")
# We can now access the event fields as any normal Event
print(result.critique.text)
```

This approach takes advantage of the Python typing system, is friendly to autocompletion in IDEs and allows
introspection from outer applications that now know exactly what a workflow run will return.

## Drawing the Workflow

Expand Down

0 comments on commit f18200d

Please sign in to comment.