Building an Agent as a Workflow

The previous chapter “The Workflows API” introduced Workflows in the abstract. This chapter uses them to build the same thing Chapter “Building a ReAct Agent with LangGraph + Ollama” built in LangGraph: a ReAct agent that alternates between “call the model” and “run the tools the model asked for” until the model returns a final answer.

As with Chapter “Building a ReAct Agent with LangGraph + Ollama”, we build it twice. The first version uses FunctionAgent, the LlamaIndex prebuilt equivalent of create_react_agent. The second version constructs the same behavior explicitly as a Workflow. Seeing the two side by side clarifies what the prebuilt is doing on your behalf.

Everything lives in source-code/llama_index_agent/. We use the usual setup:

1 $ cd source-code/llama_index_agent
2 $ uv sync
3 $ ollama pull qwen3.5:4b

The Tools

Here we use the same two-tool shape as Chapter “Building a ReAct Agent with LangGraph + Ollama”:

 1 def multiply(a: int, b: int) -> int:
 2     """Multiply two integers and return their product."""
 3     return a * b
 4 
 5 
 6 def web_search(query: str) -> str:
 7     """Search DuckDuckGo. Returns the top three text results."""
 8     from ddgs import DDGS
 9     try:
10         results = list(DDGS().text(query, max_results=3))
11     except Exception as exc:
12         return f"Search failed: {exc}"
13     if not results:
14         return "No results."
15     return "\n\n".join(
16         f"- {r.get('title', '')}\n  {r.get('body', '')}" for r in results
17     )

Plain Python functions. LlamaIndex wraps them with FunctionTool.from_defaults(fn=...) at the call site, analogous to LangChain’s @tool decorator, just applied later.

Version 1: FunctionAgent

We start by defining a reusable agent library in the file 01_function_agent.py:

 1 import asyncio
 2 
 3 from llama_index.core.agent.workflow import FunctionAgent
 4 from llama_index.core.tools import FunctionTool
 5 from llama_index.llms.ollama import Ollama
 6 
 7 from _tools import multiply, web_search
 8 
 9 tools = [
10     FunctionTool.from_defaults(fn=multiply),
11     FunctionTool.from_defaults(fn=web_search),
12 ]
13 
14 llm = Ollama(model="qwen3.5:4b", temperature=0, request_timeout=180.0)
15 
16 agent = FunctionAgent(
17     tools=tools,
18     llm=llm,
19     system_prompt=(
20         "You are a helpful assistant. Use the provided tools when they can "
21         "answer part of the question. Return a concise final answer."
22     ),
23 )
24 
25 
26 async def main():
27     for q in [
28         "What is 137 times 24?",
29         "What is the population of Canada, doubled?",
30     ]:
31         print(f"USER: {q}")
32         response = await agent.run(user_msg=q)
33         print(f"AGENT: {response}\n")
34 
35 
36 asyncio.run(main())

FunctionAgent(tools, llm, system_prompt=...) returns a compiled Workflow. Under the hood it is exactly the shape you would build in the next version 2 example; LlamaIndex ships it as a factory because 90% of agents want this shape.

Representative output:

1 USER: What is 137 times 24?
2 AGENT: 137 times 24 is 3288.
3 
4 USER: What is the population of Canada, doubled?
5 AGENT: Canada's population is approximately 40,528,396, so doubled that is approximately 81,056,792.

The second question forces two tool calls in sequence: one to web_search, one to multiply. FunctionAgent handles the loop internally.

Version 2: same agent, built explicitly

Here we write the details in 02_agent_workflow.py that builds the same behavior with Workflow:

 1 class ToolCallEvent(Event):
 2     tool_calls: list
 3 
 4 
 5 class DoneEvent(Event):
 6     text: str
 7 
 8 
 9 class ReActWorkflow(Workflow):
10     def __init__(self, *args, **kwargs):
11         super().__init__(*args, **kwargs)
12         self.llm = Ollama(model="qwen3.5:4b", temperature=0, request_timeout=180.0)
13 
14     @step
15     async def call_model(
16         self, ctx: Context, ev: StartEvent | ToolCallEvent
17     ) -> ToolCallEvent | DoneEvent:
18         if isinstance(ev, StartEvent):
19             messages = [ChatMessage(role=MessageRole.USER, content=ev.get("user_msg", ""))]
20         else:
21             messages = await ctx.get("messages")
22 
23         response = await self.llm.achat_with_tools(TOOLS, chat_history=messages)
24         messages.append(response.message)
25         await ctx.set("messages", messages)
26 
27         tool_calls = self.llm.get_tool_calls_from_response(response)
28         if tool_calls:
29             return ToolCallEvent(tool_calls=tool_calls)
30         return DoneEvent(text=str(response))
31 
32     @step
33     async def run_tools(self, ctx: Context, ev: ToolCallEvent) -> ToolCallEvent | DoneEvent:
34         messages = await ctx.get("messages")
35         for tc in ev.tool_calls:
36             tool = TOOLS_BY_NAME[tc.tool_name]
37             result = tool.call(**tc.tool_kwargs)
38             messages.append(
39                 ChatMessage(
40                     role=MessageRole.TOOL,
41                     content=json.dumps(str(result.raw_output)),
42                     additional_kwargs={"tool_call_id": tc.tool_id},
43                 )
44             )
45         await ctx.set("messages", messages)
46         return ToolCallEvent(tool_calls=[])
47 
48     @step
49     async def finish(self, ev: DoneEvent) -> StopEvent:
50         return StopEvent(result=ev.text)

Three things worth spelling out.

Context is shared state. The transcript (messages) lives in ctx because both call_model and run_tools need to read and update it across the loop. ctx.get(key) and ctx.set(key, value) are the two operations you use.

call_model accepts a union of two events. It runs on the initial StartEvent and on every subsequent ToolCallEvent from the run_tools step. Inside, an isinstance check distinguishes the first invocation (which initializes the transcript) from the loop iterations (which pull it from ctx).

Looping is expressed by event flow, not explicit edges. run_tools returns a ToolCallEvent, which call_model accepts, which either returns another ToolCallEvent (looping) or a DoneEvent (terminating). No explicit add_edge("tools", "model"); the loop is implicit in the event types.

The workflow produces the same output as the FunctionAgent version. Which is exactly the point: the prebuilt is just this workflow with the plumbing hidden.

When to Reach for Which Framework

Same guidance as Chapter “Building a ReAct Agent with LangGraph + Ollama”:

  • Use FunctionAgent if you have a flat list of tools and want the standard ReAct behavior.
  • Drop down to Workflow if you need extra steps (a planner before the model, a validator after the tools), custom state fields beyond the transcript, or unusual routing.

What we covered

  • LlamaIndex’s ReAct agent primitive is FunctionAgent, analogous to LangGraph’s create_react_agent. Both build the same “call model, run tools, loop” graph.
  • The manual Workflow version is not much more code and unlocks all of the flexibility of Chapter “The Workflows API”.
  • Context is Workflows’ shared-state mechanism; use it whenever multiple steps need to see or update the same data.

The next chapter “Multi-Index Query Pipelines” builds the LlamaIndex equivalent of the supervisor pattern from Chapter “Multi-Agent Supervisor Pattern”, but for retrieval instead of tool use.