Repository: i-am-bee/beeai-framework
Pull request #554: feat: structured agent input
Author: login: michael-desmond
Labels: name: python
description: Python related functionality
name: data-analysis:collaboration
description: Used for data analysis: was worked on collaboratively over video calls
Created: 2025-03-11T03:35:53Z | Merged: 2025-03-13T20:58:48Z

>Based on #531 

Add structured input to the tool calling agent. provides a way for a developer to provide greater structure to the task that the agent will perform. Includes:
- prompt: could also be named task, what the agent needs to do
- context: in a workflow we would set this to the output of the previous agent(s)
- expected_output: allows the user to define output criteria

All of this is ultimately rendered into the user message, and just provides a structure to help the user to organize the task. 

I proposed we include similar structure when defining the agent i.e. role, background, guidelines etc. Need to figure out a terminology.

#### General
- [x] I have read the appropriate contributor guide: [Python](https://github.com/i-am-bee/beeai-framework/blob/main/CONTRIBUTING.md)
/ [TypeScript](https://github.com/i-am-bee/beeai-framework/blob/main/CONTRIBUTING.md)
- [x] I have signed off on my commit: [Python instructions](https://github.com/i-am-bee/beeai-framework/blob/main/python/CONTRIBUTING.md#developer-certificate-of-origin-dco) / [TypeScript instructions](https://github.com/i-am-bee/beeai-framework/blob/main/typescript/CONTRIBUTING.md#developer-certificate-of-origin-dco)
- [x] Commit messages and PR title follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary)
- [x] Appropriate label(s) added to PR: `Python` for Python changes, `TypeScript` for TypeScript changes

#### Code quality checks
- [x] Linting passes: Python `poe lint` or `poe lint --fix` / TypeScript `yarn lint` or `yarn lint:fix`
- [x] Formatting is applied: Python `poe format` or `poe format --fix` / TypeScript: `yarn format` or `yarn format:fix`
- [x] Static type checks pass: Python `poe type-check`

#### Testing
- [x] Unit tests pass: Python `poe test --type unit` / TypeScript `yarn test:unit`
- [x] E2E tests pass: Python `poe test --type e2e` / TypeScript: `yarn test:e2e`
- [x] Integration tests pass: Python `poe test --type integration`
- [x] Tests are included (for bug fixes or new features)

#### Documentation
- [ ] Documentation is updated
- [ ] Embedme embeds code examples in docs. To update after edits, run: Python `poe docs --type build`



Diff:
diff --git a/python/README.md b/python/README.md
index 195e4ef4c..afc686f5a 100644
--- a/python/README.md
+++ b/python/README.md
@@ -64,55 +64,63 @@ The following example demonstrates how to build a multi-agent workflow using the
 
 ```py
 import asyncio
-import traceback
-
-from pydantic import ValidationError
-
-from beeai_framework.agents.react.agent import AgentExecutionConfig
 from beeai_framework.backend.chat import ChatModel
-from beeai_framework.backend.message import UserMessage
-from beeai_framework.memory import UnconstrainedMemory
-from beeai_framework.tools.search.duckduckgo import DuckDuckGoSearchTool
+from beeai_framework.tools.search.wikipedia import WikipediaTool
 from beeai_framework.tools.weather.openmeteo import OpenMeteoTool
-from beeai_framework.workflows.agent import AgentFactoryInput, AgentWorkflow
-from beeai_framework.workflows.workflow import WorkflowError
-
+from beeai_framework.workflows.agent import AgentWorkflow, AgentWorkflowInput
 
 async def main() -> None:
-    llm = ChatModel.from_name("ollama:granite3.1-dense:8b")
-
-    try:
-        workflow = AgentWorkflow(name="Smart assistant")
-        workflow.add_agent(
-            name="WeatherForecaster",
-            instructions="You are a weather assistant. Respond only if you can provide a useful answer.",
-            tools=[OpenMeteoTool()],
-            llm=llm,
-            execution=AgentExecutionConfig(max_iterations=3),
-        )
-        workflow.add_agent(
-            name="Researcher",
-            instructions="You are a researcher assistant. Respond only if you can provide a useful answer.",
-            tools=[DuckDuckGoSearchTool()],
-            llm=llm,
-        )
-        workflow.add_agent(
-            name="Solver",
-            instructions="""Your task is to provide the most useful final answer based on the assistants'
-responses which all are relevant. Ignore those where assistant do not know.""",
-            llm=llm,
-        )
-
-        prompt = "What is the weather in New York?"
-        memory = UnconstrainedMemory()
-        await memory.add(UserMessage(content=prompt))
-        response = await workflow.run(messages=memory.messages)
-        print(f"result: {response.state.final_answer}")
-
-    except WorkflowError:
-        traceback.print_exc()
-    except ValidationError:
-        traceback.print_exc()
+    llm = ChatModel.from_name("ollama:llama3.1")
+    workflow = AgentWorkflow(name="Smart assistant")
+
+    workflow.add_agent(
+        name="Researcher",
+        role="A diligent researcher.",
+        instructions="You look up and provide information about a specific topic.",
+        tools=[WikipediaTool()],
+        llm=llm,
+    )
+
+    workflow.add_agent(
+        name="WeatherForecaster",
+        role="A weather reporter.",
+        instructions="You provide detailed weather reports.",
+        tools=[OpenMeteoTool()],
+        llm=llm,
+    )
+
+    workflow.add_agent(
+        name="DataSynthesizer",
+        role="A meticulous and creative data synthesizer",
+        instructions="You can combine disparate information into a final coherent summary.",
+        llm=llm,
+    )
+
+    location = "Saint-Tropez"
+
+    response = await workflow.run(
+        inputs=[
+            AgentWorkflowInput(
+                prompt=f"Provide a short history of {location}.",
+            ),
+            AgentWorkflowInput(
+                prompt=f"Provide a comprehensive weather summary for {location} today.",
+                expected_output="Essential weather details such as chance of rain, temperature and wind. Only report information that is available.",
+            ),
+            AgentWorkflowInput(
+                prompt=f"Summarize the historical and weather data for {location}.",
+                expected_output=f"A paragraph that describes the history of {location}, followed by the current weather conditions.",
+            ),
+        ]
+    ).on(
+        "success",
+        lambda data, event: print(
+            f"\n-> Step '{data.step}' has been completed with the following outcome.\n\n{data.state.final_answer}"
+        ),
+    )
+    
+    print("==== Final Answer ====")
+    print(response.result.final_answer)
 
 
 if __name__ == "__main__":
diff --git a/python/beeai_framework/agents/tool_calling/agent.py b/python/beeai_framework/agents/tool_calling/agent.py
index 3b4d66d86..cbb149892 100644
--- a/python/beeai_framework/agents/tool_calling/agent.py
+++ b/python/beeai_framework/agents/tool_calling/agent.py
@@ -15,6 +15,8 @@
 import json
 from collections.abc import Sequence
 
+from pydantic import BaseModel
+
 from beeai_framework.agents import AgentError, AgentExecutionConfig
 from beeai_framework.agents.base import BaseAgent
 from beeai_framework.agents.tool_calling.events import (
@@ -22,6 +24,7 @@
     ToolCallingAgentSuccessEvent,
     tool_calling_agent_event_types,
 )
+from beeai_framework.agents.tool_calling.prompts import ToolCallingAgentTaskPromptInput
 from beeai_framework.agents.tool_calling.types import (
     ToolCallingAgentRunOutput,
     ToolCallingAgentRunState,
@@ -39,8 +42,11 @@
 from beeai_framework.emitter import Emitter
 from beeai_framework.memory.base_memory import BaseMemory
 from beeai_framework.memory.unconstrained_memory import UnconstrainedMemory
+from beeai_framework.tools import ToolError
 from beeai_framework.tools.tool import AnyTool
+from beeai_framework.utils.counter import RetryCounter
 from beeai_framework.utils.models import ModelLike, to_model
+from beeai_framework.utils.strings import to_json
 
 __all__ = ["ToolCallingAgent"]
 
@@ -61,23 +67,44 @@ def __init__(
         self._templates = to_model(ToolCallingAgentTemplates, templates or {})
 
     def run(
-        self, prompt: str | None = None, *, execution: AgentExecutionConfig | None = None
+        self,
+        prompt: str | None = None,
+        *,
+        context: str | None = None,
+        expected_output: str | type[BaseModel] | None = None,
+        execution: AgentExecutionConfig | None = None,
     ) -> Run[ToolCallingAgentRunOutput]:
-        execution_config = execution or AgentExecutionConfig()
+        execution_config = execution or AgentExecutionConfig(
+            max_retries_per_step=3,
+            total_max_retries=20,
+            max_iterations=10,
+        )
 
-        async def handler(context: RunContext) -> ToolCallingAgentRunOutput:
+        async def handler(run_context: RunContext) -> ToolCallingAgentRunOutput:
             state = ToolCallingAgentRunState(memory=UnconstrainedMemory(), result=None, iteration=0)
             await state.memory.add(SystemMessage(self._templates.system.render()))
             await state.memory.add_many(self.memory.messages)
+
             if prompt is not None:
-                await state.memory.add(UserMessage(prompt))
+                task_input = ToolCallingAgentTaskPromptInput(
+                    prompt=prompt,
+                    context=context,
+                    expected_output=expected_output if isinstance(expected_output, str) else None,
+                )
+                user_message = UserMessage(self._templates.task.render(task_input))
+                await state.memory.add(user_message)
+
+            global_retries_counter = RetryCounter(
+                error_type=AgentError, max_retries=execution_config.total_max_retries or 1
+            )
 
             while state.result is None:
                 state.iteration += 1
+
                 if execution_config.max_iterations and state.iteration > execution_config.max_iterations:
                     raise AgentError(f"Agent was not able to resolve the task in {state.iteration} iterations.")
 
-                await context.emitter.emit(
+                await run_context.emitter.emit(
                     "start",
                     ToolCallingAgentStartEvent(state=state),
                 )
@@ -86,25 +113,32 @@ async def handler(context: RunContext) -> ToolCallingAgentRunOutput:
 
                 tool_call_messages = response.get_tool_calls()
                 for tool_call in tool_call_messages:
-                    tool = next((tool for tool in self._tools if tool.name == tool_call.tool_name), None)
-                    if not tool:
-                        raise AgentError(f"Tool {tool_call.tool_name} does not exist!")
-
-                    tool_input = json.loads(tool_call.args)
-                    print(tool_input)
-                    tool_response = await tool.run(tool_input).context(
-                        {"state": state.model_dump(), "tool_call_msg": tool_call}
-                    )
-                    print(tool_response.get_text_content())
-                    await state.memory.add(
-                        ToolMessage(
-                            MessageToolResultContent(
-                                result=tool_response.get_text_content(),
-                                tool_name=tool_call.tool_name,
-                                tool_call_id=tool_call.id,
+                    try:
+                        tool = next((tool for tool in self._tools if tool.name == tool_call.tool_name), None)
+                        if not tool:
+                            raise AgentError(f"Tool {tool_call.tool_name} does not exist!")
+
+                        tool_input = json.loads(tool_call.args)
+                        tool_response = await tool.run(tool_input).context(
+                            {"state": state.model_dump(), "tool_call_msg": tool_call}
+                        )
+                        await state.memory.add(
+                            ToolMessage(
+                                MessageToolResultContent(
+                                    result=tool_response.get_text_content(),
+                                    tool_name=tool_call.tool_name,
+                                    tool_call_id=tool_call.id,
+                                )
+                            )
+                        )
+                    except ToolError as e:
+                        global_retries_counter.use(e)
+                        await state.memory.add(
+                            AssistantMessage(
+                                content=self._templates.tool_error.render({"reason": e.explain()}),
+                                meta={"tempMessage": True},
                             )
                         )
-                    )
 
                 # handle empty messages for some models
                 text_messages = response.get_text_messages()
@@ -116,9 +150,24 @@ async def handler(context: RunContext) -> ToolCallingAgentRunOutput:
                     )
 
                 if text_messages:
-                    state.result = AssistantMessage.from_chunks(text_messages)
+                    if (
+                        expected_output is not None
+                        and isinstance(expected_output, type)
+                        and issubclass(expected_output, BaseModel)
+                    ):
+                        structured = await self._llm.create_structure(
+                            schema=expected_output, messages=state.memory.messages
+                        )
+                        state.result = AssistantMessage(
+                            content=to_json(
+                                expected_output.model_validate(structured.object),
+                                indent=4,
+                            )
+                        )
+                    else:
+                        state.result = AssistantMessage.from_chunks(text_messages)
 
-                await context.emitter.emit(
+                await run_context.emitter.emit(
                     "success",
                     ToolCallingAgentSuccessEvent(state=state),
                 )
diff --git a/python/beeai_framework/agents/tool_calling/prompts.py b/python/beeai_framework/agents/tool_calling/prompts.py
index f90dcb3e9..127ef39f1 100644
--- a/python/beeai_framework/agents/tool_calling/prompts.py
+++ b/python/beeai_framework/agents/tool_calling/prompts.py
@@ -29,22 +29,61 @@ class ToolCallingAgentSystemPromptInput(BaseModel):
         functions={
             "formatDate": lambda data: datetime.now(tz=UTC).strftime("%A, %B %d, %Y at %I:%M:%S %p"),
         },
-        defaults={"role": "You are a helpful assistant.", "instructions": ""},
-        template="""{{role}}
+        defaults={"role": "A helpful AI assistant", "instructions": ""},
+        template="""Assume the role of {{role}}.
+
+{{#instructions}}
+Your instructions are:
+{{.}}
+{{/instructions}}
+
 When the user sends a message, figure out a solution and provide a final answer.
+You can use tools to improve your answers if available.
 
 # Best practices
 - Use markdown syntax to format code snippets, links, JSON, tables, images, and files.
 - When the message is unclear, ask for a clarification.
-- When the user wants to chitchat, always respond politely.
+- Do not refer to tools or tool outputs by name when responding.
 
-# Date and Time
 The current date and time is: {{formatDate}}
+""",
+    )
+)
 
-{{#instructions}}
-# Additional instructions
+
+class ToolCallingAgentTaskPromptInput(BaseModel):
+    prompt: str
+    context: str | None = None
+    expected_output: str | type[BaseModel] | None = None
+
+
+ToolCallingAgentTaskPrompt = PromptTemplate(
+    PromptTemplateInput(
+        schema=ToolCallingAgentTaskPromptInput,
+        template="""{{#context}}This is the context that you are working with:
 {{.}}
-{{/instructions}}
+
+{{/context}}
+{{#expected_output}}
+This is the expected criteria for your output:
+{{.}}
+
+{{/expected_output}}
+Your task: {{prompt}}
 """,
     )
 )
+
+
+class ToolCallingAgentToolErrorPromptInput(BaseModel):
+    reason: str
+
+
+ToolCallingAgentToolErrorPrompt = PromptTemplate(
+    PromptTemplateInput(
+        schema=ToolCallingAgentToolErrorPromptInput,
+        template="""The tool has failed; the error log is shown below. If the tool cannot accomplish what you want, use a different tool or explain why you can't use it.
+
+{{&reason}}""",  # noqa: E501
+    )
+)
diff --git a/python/beeai_framework/agents/tool_calling/types.py b/python/beeai_framework/agents/tool_calling/types.py
index b785ea8f0..73ac162b3 100644
--- a/python/beeai_framework/agents/tool_calling/types.py
+++ b/python/beeai_framework/agents/tool_calling/types.py
@@ -15,7 +15,14 @@
 from pydantic import BaseModel, Field, InstanceOf
 
 from beeai_framework import AssistantMessage, BaseMemory
-from beeai_framework.agents.tool_calling.prompts import ToolCallingAgentSystemPrompt, ToolCallingAgentSystemPromptInput
+from beeai_framework.agents.tool_calling.prompts import (
+    ToolCallingAgentSystemPrompt,
+    ToolCallingAgentSystemPromptInput,
+    ToolCallingAgentTaskPrompt,
+    ToolCallingAgentTaskPromptInput,
+    ToolCallingAgentToolErrorPrompt,
+    ToolCallingAgentToolErrorPromptInput,
+)
 from beeai_framework.template import PromptTemplate
 
 
@@ -23,6 +30,12 @@ class ToolCallingAgentTemplates(BaseModel):
     system: InstanceOf[PromptTemplate[ToolCallingAgentSystemPromptInput]] = Field(
         default_factory=lambda: ToolCallingAgentSystemPrompt.fork(None),
     )
+    task: InstanceOf[PromptTemplate[ToolCallingAgentTaskPromptInput]] = Field(
+        default_factory=lambda: ToolCallingAgentTaskPrompt.fork(None),
+    )
+    tool_error: InstanceOf[PromptTemplate[ToolCallingAgentToolErrorPromptInput]] = Field(
+        default_factory=lambda: ToolCallingAgentToolErrorPrompt.fork(None),
+    )
 
 
 class ToolCallingAgentRunOutput(BaseModel):
diff --git a/python/beeai_framework/tools/weather/openmeteo.py b/python/beeai_framework/tools/weather/openmeteo.py
index b89194f5e..001eff86e 100644
--- a/python/beeai_framework/tools/weather/openmeteo.py
+++ b/python/beeai_framework/tools/weather/openmeteo.py
@@ -26,7 +26,7 @@
 from beeai_framework.context import RunContext
 from beeai_framework.emitter.emitter import Emitter
 from beeai_framework.logger import Logger
-from beeai_framework.tools import StringToolOutput, ToolError, ToolInputValidationError
+from beeai_framework.tools import StringToolOutput, ToolInputValidationError
 from beeai_framework.tools.tool import Tool
 from beeai_framework.tools.types import ToolRunOptions
 
@@ -78,7 +78,7 @@ def _geocode(self, input: OpenMeteoToolInput) -> dict[str, str]:
         response.raise_for_status()
         results = response.json().get("results", [])
         if not results:
-            raise ToolError(f"Location '{input.location_name}' was not found.")
+            raise ToolInputValidationError(f"Location '{input.location_name}' was not found.")
         geocode: dict[str, str] = results[0]
         return geocode
 
diff --git a/python/beeai_framework/utils/lists.py b/python/beeai_framework/utils/lists.py
index 357d0675d..0727fa8c9 100644
--- a/python/beeai_framework/utils/lists.py
+++ b/python/beeai_framework/utils/lists.py
@@ -19,3 +19,7 @@
 
 def flatten(xss: list[list[T]]) -> list[T]:
     return [x for xs in xss for x in xs]
+
+
+def remove_falsy(xss: list[T]) -> list[T]:
+    return [x for x in xss if x]
diff --git a/python/beeai_framework/workflows/agent.py b/python/beeai_framework/workflows/agent.py
index 2d0afebee..d27aa2f1d 100644
--- a/python/beeai_framework/workflows/agent.py
+++ b/python/beeai_framework/workflows/agent.py
@@ -12,57 +12,64 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import asyncio
 import random
 import string
-from collections.abc import Awaitable, Callable
-from inspect import isfunction
+from collections.abc import Awaitable, Callable, Sequence
 from typing import Any, Self
 
-from pydantic import BaseModel, ConfigDict, Field, InstanceOf
+from pydantic import BaseModel, InstanceOf
 
-from beeai_framework.agents.base import AnyAgent, BaseAgent
-from beeai_framework.agents.react import ReActAgent
-from beeai_framework.agents.react.types import ReActAgentRunOutput
+from beeai_framework.agents.base import AnyAgent
+from beeai_framework.agents.tool_calling.agent import ToolCallingAgent
+from beeai_framework.agents.tool_calling.types import ToolCallingAgentRunOutput, ToolCallingAgentTemplates
 from beeai_framework.agents.types import (
     AgentExecutionConfig,
-    AgentMeta,
 )
 from beeai_framework.backend.chat import ChatModel
-from beeai_framework.backend.message import AnyMessage, AssistantMessage
+from beeai_framework.backend.message import AnyMessage, AssistantMessage, UserMessage
 from beeai_framework.context import Run
 from beeai_framework.memory import BaseMemory, ReadOnlyMemory, UnconstrainedMemory
 from beeai_framework.template import PromptTemplateInput
 from beeai_framework.tools.tool import AnyTool
-from beeai_framework.utils.asynchronous import ensure_async
+from beeai_framework.utils.lists import remove_falsy
 from beeai_framework.workflows.types import WorkflowRun
 from beeai_framework.workflows.workflow import Workflow
 
 AgentFactory = Callable[[ReadOnlyMemory], AnyAgent | Awaitable[AnyAgent]]
 
 
-class AgentFactoryInput(BaseModel):
-    model_config = ConfigDict(arbitrary_types_allowed=True)
-    name: str
-    llm: ChatModel
-    instructions: str | None = None
-    tools: list[InstanceOf[AnyTool]] | None = None
-    execution: AgentExecutionConfig | None = None
+class AgentWorkflowInput(BaseModel):
+    prompt: str | None = None
+    context: str | None = None
+    expected_output: str | type[BaseModel] | None = None
+
+    @classmethod
+    def from_message(cls, message: AnyMessage) -> Self:
+        return cls(prompt=message.text)
+
+    def to_message(self) -> AssistantMessage:
+        text = "\n\nContext:".join(remove_falsy([self.prompt or "", self.context or ""]))
+        return AssistantMessage(text)
 
 
 class Schema(BaseModel):
-    model_config = ConfigDict(arbitrary_types_allowed=True)
-    messages: list[AnyMessage] = Field(min_length=1)
+    inputs: list[InstanceOf[AgentWorkflowInput]]
     final_answer: str | None = None
-    new_messages: list[AnyMessage] = []
+    new_messages: list[InstanceOf[AnyMessage]] = []
 
 
 class AgentWorkflow:
     def __init__(self, name: str = "AgentWorkflow") -> None:
         self.workflow = Workflow(name=name, schema=Schema)
 
-    def run(self, messages: list[AnyMessage]) -> Run[WorkflowRun[Any, Any]]:
-        return self.workflow.run(Schema(messages=messages))
+    def run(self, inputs: Sequence[AgentWorkflowInput | AnyMessage]) -> Run[WorkflowRun[Any, Any]]:
+        schema = Schema(
+            inputs=[
+                input if isinstance(input, AgentWorkflowInput) else AgentWorkflowInput.from_message(input)
+                for input in inputs
+            ],
+        )
+        return self.workflow.run(schema)
 
     def del_agent(self, name: str) -> "AgentWorkflow":
         self.workflow.delete_step(name)
@@ -70,66 +77,43 @@ def del_agent(self, name: str) -> "AgentWorkflow":
 
     def add_agent(
         self,
-        agent: (
-            AnyAgent | Callable[[ReadOnlyMemory], AnyAgent | asyncio.Future[AnyAgent]] | AgentFactoryInput | None
-        ) = None,
-        /,
-        **kwargs: Any,
+        *,
+        name: str | None = None,
+        role: str | None = None,
+        llm: ChatModel,
+        instructions: str | None = None,
+        tools: list[InstanceOf[AnyTool]] | None = None,
+        execution: AgentExecutionConfig | None = None,
     ) -> "AgentWorkflow":
-        if not agent:
-            if not kwargs:
-                raise ValueError("An agent object or keyword arguments must be provided")
-            elif "agent" in kwargs:
-                agent = kwargs.get("agent")
-            else:
-                agent = AgentFactoryInput.model_validate(kwargs, strict=False, from_attributes=True)
-        elif kwargs:
-            raise ValueError("Agent object required or keyword arguments required but not both")
-        assert agent is not None
-
-        if isinstance(agent, BaseAgent):
-
-            async def factory(memory: ReadOnlyMemory) -> AnyAgent:
-                instance: AnyAgent = await ensure_async(agent)(memory.as_read_only()) if isfunction(agent) else agent
-                instance.memory = memory
-                return instance
-
-            return self._add(agent.meta.name, factory)
-
-        random_string = "".join(random.choice(string.ascii_letters) for _ in range(4))
-        name = agent.name if not callable(agent) else f"Agent{random_string}"
-        return self._add(name, agent if callable(agent) else self._create_factory(agent))
-
-    def _create_factory(self, agent_input: AgentFactoryInput) -> AgentFactory:
-        def factory(memory: BaseMemory) -> ReActAgent:
+        def create_agent(memory: BaseMemory) -> ToolCallingAgent:
             def customizer(config: PromptTemplateInput[Any]) -> PromptTemplateInput[Any]:
                 new_config = config.model_copy()
-                new_config.defaults["instructions"] = agent_input.instructions or config.defaults.get("instructions")
+                new_config.defaults["instructions"] = instructions or config.defaults.get("instructions")
+                new_config.defaults["role"] = role or config.defaults.get("role")
                 return new_config
 
-            return ReActAgent(
-                llm=agent_input.llm,
-                tools=agent_input.tools or [],
+            templates = ToolCallingAgentTemplates()
+            templates.system = templates.system.fork(customizer=customizer)
+
+            return ToolCallingAgent(
+                llm=llm,
+                tools=tools,
                 memory=memory,
-                templates={"system": lambda template: template.fork(customizer=customizer)},
-                meta=AgentMeta(name=agent_input.name, description=agent_input.instructions or "", tools=[]),
-                execution=agent_input.execution,
+                templates=templates,
             )
 
-        return factory
-
-    def _add(self, name: str, factory: AgentFactory) -> Self:
         async def step(state: Schema) -> None:
             memory = UnconstrainedMemory()
-            for message in state.messages + state.new_messages:
-                await memory.add(message)
+            await memory.add_many(state.new_messages)
+
+            run_input = state.inputs.pop(0).model_copy() if state.inputs else AgentWorkflowInput()
+            agent = create_agent(memory.as_read_only())
+            run_output: ToolCallingAgentRunOutput = await agent.run(**run_input.model_dump(), execution=execution)
 
-            agent = await ensure_async(factory)(memory.as_read_only())
-            run_output: ReActAgentRunOutput = await agent.run()
             state.final_answer = run_output.result.text
-            state.new_messages.append(
-                AssistantMessage(f"Assistant Name: {name}\nAssistant Response: {run_output.result.text}")
-            )
+            if run_input.prompt:
+                state.new_messages.append(UserMessage(run_input.prompt))
+            state.new_messages.append(run_output.result)
 
-        self.workflow.add_step(name, step)
+        self.workflow.add_step(name or f"Agent{''.join(random.choice(string.ascii_letters) for _ in range(4))}", step)
         return self
diff --git a/python/beeai_framework/workflows/events.py b/python/beeai_framework/workflows/events.py
index 929528a52..840523bf9 100644
--- a/python/beeai_framework/workflows/events.py
+++ b/python/beeai_framework/workflows/events.py
@@ -18,7 +18,7 @@
 from typing_extensions import TypeVar
 
 from beeai_framework.errors import FrameworkError
-from beeai_framework.workflows.types import WorkflowRun, WorkflowStepDefinition
+from beeai_framework.workflows.types import WorkflowRun
 
 T = TypeVar("T", bound=BaseModel)
 K = TypeVar("K", default=str)
@@ -26,13 +26,13 @@
 
 class WorkflowStartEvent(BaseModel, Generic[T, K]):
     run: WorkflowRun[T, K]
-    step: WorkflowStepDefinition[T, K]
+    step: K
 
 
 class WorkflowSuccessEvent(BaseModel, Generic[T, K]):
     run: WorkflowRun[T, K]
     state: T
-    step: WorkflowStepDefinition[T, K]
+    step: K
     next: K | Literal["__end__"]
 
 
diff --git a/python/beeai_framework/workflows/workflow.py b/python/beeai_framework/workflows/workflow.py
index 3c9684b9e..2628ee11e 100644
--- a/python/beeai_framework/workflows/workflow.py
+++ b/python/beeai_framework/workflows/workflow.py
@@ -134,7 +134,7 @@ async def handler(context: RunContext) -> WorkflowRun[T, K]:
                 if step is None:
                     raise WorkflowError(f"Step '{next}' was not found.")
 
-                await context.emitter.emit("start", WorkflowStartEvent[T, K](run=run, step=step))
+                await context.emitter.emit("start", WorkflowStartEvent[T, K](run=run, step=next))
 
                 try:
                     step_res = WorkflowStepRes[T, K](name=next, state=run.state.model_copy(deep=True))
@@ -165,7 +165,7 @@ async def handler(context: RunContext) -> WorkflowRun[T, K]:
                         WorkflowSuccessEvent[T, K](
                             run=run.model_copy(),
                             state=run.state,
-                            step=step,
+                            step=step_res.name,
                             next=next,
                         ),
                     )
@@ -181,6 +181,9 @@ async def handler(context: RunContext) -> WorkflowRun[T, K]:
                     )
                     raise err from None
 
+            if run.state:  # TODO: add output schema
+                run.result = run.state.model_copy()
+
             return run
 
         return RunContext.enter(
diff --git a/python/docs/agents.md b/python/docs/agents.md
index af0fe3346..9ff701717 100644
--- a/python/docs/agents.md
+++ b/python/docs/agents.md
@@ -452,45 +452,65 @@ import asyncio
 import sys
 import traceback
 
-from beeai_framework.agents.types import AgentExecutionConfig
 from beeai_framework.backend.chat import ChatModel
-from beeai_framework.backend.message import UserMessage
 from beeai_framework.errors import FrameworkError
-from beeai_framework.memory import UnconstrainedMemory
-from beeai_framework.tools.search.duckduckgo import DuckDuckGoSearchTool
+from beeai_framework.tools.search import WikipediaTool
 from beeai_framework.tools.weather.openmeteo import OpenMeteoTool
-from beeai_framework.workflows.agent import AgentWorkflow
+from beeai_framework.workflows.agent import AgentWorkflow, AgentWorkflowInput
+from examples.helpers.io import ConsoleReader
 
 
 async def main() -> None:
-    llm = ChatModel.from_name("ollama:granite3.1-dense:8b")
-
+    llm = ChatModel.from_name("ollama:llama3.1")
     workflow = AgentWorkflow(name="Smart assistant")
+
     workflow.add_agent(
-        name="WeatherForecaster",
-        instructions="You are a weather assistant.",
-        tools=[OpenMeteoTool()],
+        name="Researcher",
+        role="A diligent researcher.",
+        instructions="You look up and provide information about a specific topic.",
+        tools=[WikipediaTool()],
         llm=llm,
-        execution=AgentExecutionConfig(max_iterations=3, total_max_retries=10, max_retries_per_step=3),
     )
+
     workflow.add_agent(
-        name="Researcher",
-        instructions="You are a researcher assistant.",
-        tools=[DuckDuckGoSearchTool()],
+        name="WeatherForecaster",
+        role="A weather reporter.",
+        instructions="You provide detailed weather reports.",
+        tools=[OpenMeteoTool()],
         llm=llm,
     )
+
     workflow.add_agent(
-        name="Solver",
-        instructions="""Your task is to provide the most useful final answer based on the assistants'
-responses which all are relevant. Ignore those where assistant do not know.""",
+        name="DataSynthesizer",
+        role="A meticulous and creative data synthesizer",
+        instructions="You can combine disparate information into a final coherent summary.",
         llm=llm,
     )
 
-    prompt = "What is the weather in New York?"
-    memory = UnconstrainedMemory()
-    await memory.add(UserMessage(content=prompt))
-    response = await workflow.run(messages=memory.messages)
-    print(f"result: {response.state.final_answer}")
+    reader = ConsoleReader()
+
+    reader.write("Assistant 🤖 : ", "What location do you want to learn about?")
+    for prompt in reader:
+        await workflow.run(
+            inputs=[
+                AgentWorkflowInput(prompt="Provide a short history of the location.", context=prompt),
+                AgentWorkflowInput(
+                    prompt="Provide a comprehensive weather summary for the location today.",
+                    expected_output="Essential weather details such as chance of rain, temperature and wind. Only report information that is available.",  # noqa: E501
+                ),
+                AgentWorkflowInput(
+                    prompt="Summarize the historical and weather data for the location.",
+                    expected_output="A paragraph that describes the history of the location, followed by the current weather conditions.",  # noqa: E501
+                ),
+            ]
+        ).on(
+            "success",
+            lambda data, event: reader.write(
+                f"-> Step '{data.step}' has been completed with the following outcome:\n",
+                data.state.final_answer,
+            ),
+        )
+        reader.write("Assistant 🤖 : ", "What location do you want to learn about?")
 
 
 if __name__ == "__main__":
diff --git a/python/docs/workflows.md b/python/docs/workflows.md
index 1fadcc233..5dc01b35a 100644
--- a/python/docs/workflows.md
+++ b/python/docs/workflows.md
@@ -271,45 +271,65 @@ import asyncio
 import sys
 import traceback
 
-from beeai_framework.agents.types import AgentExecutionConfig
 from beeai_framework.backend.chat import ChatModel
-from beeai_framework.backend.message import UserMessage
 from beeai_framework.errors import FrameworkError
-from beeai_framework.memory import UnconstrainedMemory
-from beeai_framework.tools.search.duckduckgo import DuckDuckGoSearchTool
+from beeai_framework.tools.search import WikipediaTool
 from beeai_framework.tools.weather.openmeteo import OpenMeteoTool
-from beeai_framework.workflows.agent import AgentWorkflow
+from beeai_framework.workflows.agent import AgentWorkflow, AgentWorkflowInput
+from examples.helpers.io import ConsoleReader
 
 
 async def main() -> None:
-    llm = ChatModel.from_name("ollama:granite3.1-dense:8b")
-
+    llm = ChatModel.from_name("ollama:llama3.1")
     workflow = AgentWorkflow(name="Smart assistant")
+
     workflow.add_agent(
-        name="WeatherForecaster",
-        instructions="You are a weather assistant.",
-        tools=[OpenMeteoTool()],
+        name="Researcher",
+        role="A diligent researcher.",
+        instructions="You look up and provide information about a specific topic.",
+        tools=[WikipediaTool()],
         llm=llm,
-        execution=AgentExecutionConfig(max_iterations=3, total_max_retries=10, max_retries_per_step=3),
     )
+
     workflow.add_agent(
-        name="Researcher",
-        instructions="You are a researcher assistant.",
-        tools=[DuckDuckGoSearchTool()],
+        name="WeatherForecaster",
+        role="A weather reporter.",
+        instructions="You provide detailed weather reports.",
+        tools=[OpenMeteoTool()],
         llm=llm,
     )
+
     workflow.add_agent(
-        name="Solver",
-        instructions="""Your task is to provide the most useful final answer based on the assistants'
-responses which all are relevant. Ignore those where assistant do not know.""",
+        name="DataSynthesizer",
+        role="A meticulous and creative data synthesizer",
+        instructions="You can combine disparate information into a final coherent summary.",
         llm=llm,
     )
 
-    prompt = "What is the weather in New York?"
-    memory = UnconstrainedMemory()
-    await memory.add(UserMessage(content=prompt))
-    response = await workflow.run(messages=memory.messages)
-    print(f"result: {response.state.final_answer}")
+    reader = ConsoleReader()
+
+    reader.write("Assistant 🤖 : ", "What location do you want to learn about?")
+    for prompt in reader:
+        await workflow.run(
+            inputs=[
+                AgentWorkflowInput(prompt="Provide a short history of the location.", context=prompt),
+                AgentWorkflowInput(
+                    prompt="Provide a comprehensive weather summary for the location today.",
+                    expected_output="Essential weather details such as chance of rain, temperature and wind. Only report information that is available.",  # noqa: E501
+                ),
+                AgentWorkflowInput(
+                    prompt="Summarize the historical and weather data for the location.",
+                    expected_output="A paragraph that describes the history of the location, followed by the current weather conditions.",  # noqa: E501
+                ),
+            ]
+        ).on(
+            "success",
+            lambda data, event: reader.write(
+                f"-> Step '{data.step}' has been completed with the following outcome:\n",
+                data.state.final_answer,
+            ),
+        )
+        reader.write("Assistant 🤖 : ", "What location do you want to learn about?")
 
 
 if __name__ == "__main__":
@@ -329,7 +349,6 @@ This pattern demonstrates:
 - Efficient tool distribution to relevant specialists
 - Parallel processing of different aspects of a query
 - Synthesis of multiple expert perspectives into a cohesive response
-- Declarative agent configuration using the `AgentFactoryInput` pattern
 
 ### Memory in Workflows
 
diff --git a/python/examples/workflows/multi_agents.py b/python/examples/workflows/multi_agents.py
index 9b698a0c8..79d82b3de 100644
--- a/python/examples/workflows/multi_agents.py
+++ b/python/examples/workflows/multi_agents.py
@@ -2,45 +2,65 @@
 import sys
 import traceback
 
-from beeai_framework.agents.types import AgentExecutionConfig
 from beeai_framework.backend.chat import ChatModel
-from beeai_framework.backend.message import UserMessage
 from beeai_framework.errors import FrameworkError
-from beeai_framework.memory import UnconstrainedMemory
-from beeai_framework.tools.search.duckduckgo import DuckDuckGoSearchTool
+from beeai_framework.tools.search import WikipediaTool
 from beeai_framework.tools.weather.openmeteo import OpenMeteoTool
-from beeai_framework.workflows.agent import AgentWorkflow
+from beeai_framework.workflows.agent import AgentWorkflow, AgentWorkflowInput
+from examples.helpers.io import ConsoleReader
 
 
 async def main() -> None:
-    llm = ChatModel.from_name("ollama:granite3.1-dense:8b")
-
+    llm = ChatModel.from_name("ollama:llama3.1")
     workflow = AgentWorkflow(name="Smart assistant")
+
     workflow.add_agent(
-        name="WeatherForecaster",
-        instructions="You are a weather assistant.",
-        tools=[OpenMeteoTool()],
+        name="Researcher",
+        role="A diligent researcher.",
+        instructions="You look up and provide information about a specific topic.",
+        tools=[WikipediaTool()],
         llm=llm,
-        execution=AgentExecutionConfig(max_iterations=3, total_max_retries=10, max_retries_per_step=3),
     )
+
     workflow.add_agent(
-        name="Researcher",
-        instructions="You are a researcher assistant.",
-        tools=[DuckDuckGoSearchTool()],
+        name="WeatherForecaster",
+        role="A weather reporter.",
+        instructions="You provide detailed weather reports.",
+        tools=[OpenMeteoTool()],
         llm=llm,
     )
+
     workflow.add_agent(
-        name="Solver",
-        instructions="""Your task is to provide the most useful final answer based on the assistants'
-responses which all are relevant. Ignore those where assistant do not know.""",
+        name="DataSynthesizer",
+        role="A meticulous and creative data synthesizer",
+        instructions="You can combine disparate information into a final coherent summary.",
         llm=llm,
     )
 
-    prompt = "What is the weather in New York?"
-    memory = UnconstrainedMemory()
-    await memory.add(UserMessage(content=prompt))
-    response = await workflow.run(messages=memory.messages)
-    print(f"result: {response.state.final_answer}")
+    reader = ConsoleReader()
+
+    reader.write("Assistant 🤖 : ", "What location do you want to learn about?")
+    for prompt in reader:
+        await workflow.run(
+            inputs=[
+                AgentWorkflowInput(prompt="Provide a short history of the location.", context=prompt),
+                AgentWorkflowInput(
+                    prompt="Provide a comprehensive weather summary for the location today.",
+                    expected_output="Essential weather details such as chance of rain, temperature and wind. Only report information that is available.",  # noqa: E501
+                ),
+                AgentWorkflowInput(
+                    prompt="Summarize the historical and weather data for the location.",
+                    expected_output="A paragraph that describes the history of the location, followed by the current weather conditions.",  # noqa: E501
+                ),
+            ]
+        ).on(
+            "success",
+            lambda data, event: reader.write(
+                f"-> Step '{data.step}' has been completed with the following outcome:\n",
+                data.state.final_answer,
+            ),
+        )
+        reader.write("Assistant 🤖 : ", "What location do you want to learn about?")
 
 
 if __name__ == "__main__":
diff --git a/python/examples/workflows/multi_agents_simple.py b/python/examples/workflows/multi_agents_simple.py
index 5a69f2f61..e1e7c4fdc 100644
--- a/python/examples/workflows/multi_agents_simple.py
+++ b/python/examples/workflows/multi_agents_simple.py
@@ -2,45 +2,62 @@
 import sys
 import traceback
 
-from beeai_framework.agents.types import AgentExecutionConfig
 from beeai_framework.backend.chat import ChatModel
-from beeai_framework.backend.message import UserMessage
 from beeai_framework.errors import FrameworkError
-from beeai_framework.memory import UnconstrainedMemory
 from beeai_framework.tools.search.wikipedia import WikipediaTool
 from beeai_framework.tools.weather.openmeteo import OpenMeteoTool
-from beeai_framework.workflows.agent import AgentWorkflow
+from beeai_framework.workflows.agent import AgentWorkflow, AgentWorkflowInput
 
 
 async def main() -> None:
-    llm = ChatModel.from_name("ollama:granite3.1-dense:8b")
-
+    llm = ChatModel.from_name("ollama:llama3.1")
     workflow = AgentWorkflow(name="Smart assistant")
+
     workflow.add_agent(
         name="Researcher",
-        instructions="You are a researcher assistant. Respond only if you can provide a useful answer.",
+        role="A diligent researcher.",
+        instructions="You look up and provide information about a specific topic.",
         tools=[WikipediaTool()],
         llm=llm,
     )
+
     workflow.add_agent(
         name="WeatherForecaster",
-        instructions="You are a weather assistant. Respond only if you can provide a useful answer.",
+        role="A weather reporter.",
+        instructions="You provide detailed weather reports.",
         tools=[OpenMeteoTool()],
         llm=llm,
-        execution=AgentExecutionConfig(max_iterations=3, total_max_retries=10, max_retries_per_step=3),
     )
+
     workflow.add_agent(
-        name="Solver",
-        instructions="""Your task is to provide the most useful final answer based on the assistants'
-responses which all are relevant. Ignore those where assistant do not know.""",
+        name="DataSynthesizer",
+        role="A meticulous and creative data synthesizer",
+        instructions="You can combine disparate information into a final coherent summary.",
         llm=llm,
     )
 
-    prompt = "What is the capital of France and what is the current weather there?"
-    memory = UnconstrainedMemory()
-    await memory.add(UserMessage(content=prompt))
-    response = await workflow.run(messages=memory.messages)
-    print(f"result: {response.state.final_answer}")
+    location = "Saint-Tropez"
+
+    await workflow.run(
+        inputs=[
+            AgentWorkflowInput(
+                prompt=f"Provide a short history of {location}.",
+            ),
+            AgentWorkflowInput(
+                prompt=f"Provide a comprehensive weather summary for {location} today.",
+                expected_output="Essential weather details such as chance of rain, temperature and wind. Only report information that is available.",  # noqa: E501
+            ),
+            AgentWorkflowInput(
+                prompt=f"Summarize the historical and weather data for {location}.",
+                expected_output=f"A paragraph that describes the history of {location}, followed by the current weather conditions.",  # noqa: E501
+            ),
+        ]
+    ).on(
+        "success",
+        lambda data, event: print(
+            f"\n-> Step '{data.step}' has been completed with the following outcome.\n\n{data.state.final_answer}"
+        ),
+    )
 
 
 if __name__ == "__main__":
diff --git a/python/examples/workflows/travel_advisor.py b/python/examples/workflows/travel_advisor.py
new file mode 100644
index 000000000..5fcb8c5ca
--- /dev/null
+++ b/python/examples/workflows/travel_advisor.py
@@ -0,0 +1,75 @@
+import asyncio
+import sys
+import traceback
+
+from beeai_framework.backend.chat import ChatModel
+from beeai_framework.errors import FrameworkError
+from beeai_framework.tools.weather.openmeteo import OpenMeteoTool
+from beeai_framework.workflows.agent import AgentWorkflow, AgentWorkflowInput
+
+
+async def main() -> None:
+    # destination = input("Enter your travel destination (e.g., Boston, MA): ")
+    # travel_dates = input("Enter your travel dates (e.g., Mar 10-17, 2025): ")
+
+    destination = "Yosemite, CA"
+    travel_dates = "Mar 15-17"
+
+    llm = ChatModel.from_name("ollama:llama3.1")
+
+    workflow = AgentWorkflow(name="Travel Advisor")
+
+    workflow.add_agent(
+        name="Weather Forecaster",
+        role="A diligent weather forecaster",
+        instructions="You specialize in reporting on the weather.",
+        tools=[OpenMeteoTool()],
+        llm=llm,
+    )
+
+    workflow.add_agent(
+        name="Activity Planner",
+        role="An expert in local attractions",
+        instructions="You know about interesting activities and would like to share.",
+        llm=llm,
+    )
+
+    workflow.add_agent(
+        name="Travel Advisor",
+        role="A travel advisor",
+        instructions="""You can synthesize travel details such as weather and recommended activities and provide a coherent summary.""",  # noqa: E501
+        llm=llm,
+    )
+
+    response = await workflow.run(
+        inputs=[
+            AgentWorkflowInput(
+                prompt=f"Provide a comprehensive weather summary for {destination} from {travel_dates}.",
+                expected_output="Essential weather details such as chance of rain, temperature and wind. Only report information that is available.",  # noqa: E501
+            ),
+            AgentWorkflowInput(
+                prompt=f"Search for a set of activities close to {destination} from {travel_dates} that are appropriate in light of the weather conditions.",  # noqa: E501
+                expected_output="A list of activities including location and description that are weather appropriate.",
+            ),
+            AgentWorkflowInput(
+                prompt=f"Consider the weather report and recommended activities for the trip to {destination} from {travel_dates} and provide a coherent summary.",  # noqa: E501
+                expected_output="A summary of the trip that the traveler could take with them. Break it down by day including weather, location and helpful tips.",  # noqa: E501
+            ),
+        ]
+    ).on(
+        "success",
+        lambda data, event: print(
+            f"-> Step '{data.step}' has been completed with the following outcome.\n\n{data.state.final_answer}"
+        ),
+    )
+
+    print(":earth_africa: Travel Recommendations :earth_africa:")
+    print(response.state.final_answer)
+
+
+if __name__ == "__main__":
+    try:
+        asyncio.run(main())
+    except FrameworkError as e:
+        traceback.print_exc()
+        sys.exit(e.explain())
diff --git a/python/tests/workflows/test_multi_agents.py b/python/tests/workflows/test_multi_agents.py
index 2865110bc..92571ea4c 100644
--- a/python/tests/workflows/test_multi_agents.py
+++ b/python/tests/workflows/test_multi_agents.py
@@ -15,11 +15,9 @@
 import pytest
 
 from beeai_framework.adapters.ollama.backend.chat import OllamaChatModel
-from beeai_framework.agents.react import ReActAgent
-from beeai_framework.agents.types import AgentMeta
 from beeai_framework.backend.message import UserMessage
-from beeai_framework.memory import TokenMemory, UnconstrainedMemory
-from beeai_framework.workflows.agent import AgentFactoryInput, AgentWorkflow
+from beeai_framework.memory import UnconstrainedMemory
+from beeai_framework.workflows.agent import AgentWorkflow
 
 """
 E2E Tests
@@ -32,7 +30,7 @@ async def test_multi_agents_workflow_basic() -> None:
     chat_model = OllamaChatModel()
 
     workflow: AgentWorkflow = AgentWorkflow()
-    workflow.add_agent(AgentFactoryInput(name="Translator assistant", tools=[], llm=chat_model))
+    workflow.add_agent(name="Translator assistant", tools=[], llm=chat_model)
 
     memory = UnconstrainedMemory()
     await memory.add(UserMessage(content="Translate 'Hello' to German."))
@@ -47,49 +45,14 @@ async def test_multi_agents_workflow_creation() -> None:
     chat_model = OllamaChatModel()
 
     workflow: AgentWorkflow = AgentWorkflow()
-    workflow.add_agent(ReActAgent(llm=chat_model, tools=[], memory=TokenMemory(chat_model)))
-    workflow.add_agent(lambda mem: ReActAgent(llm=chat_model, tools=[], memory=mem))
-
+    workflow.add_agent(name="AgentA", llm=chat_model, instructions="You are a translator agent.")
+    workflow.add_agent(name="AgentB", llm=chat_model, instructions="Summarize the final outcome.")
     assert len(workflow.workflow.step_names) == 2
 
     memory = UnconstrainedMemory()
     await memory.add(UserMessage(content="Translate 'Good morning' to Italian."))
     response = await workflow.run(memory.messages)
-    assert "buongiorno" in response.state.final_answer.lower()
-
-
-@pytest.mark.e2e
-@pytest.mark.asyncio
-async def test_multi_agents_workflow_creation_variations() -> None:
-    chat_model = OllamaChatModel()
-
-    workflow: AgentWorkflow = AgentWorkflow()
-
-    # AgentFactoryInput
-    workflow.add_agent(name="AgentFactoryInput_1", tools=[], llm=chat_model)
-    workflow.add_agent(agent=AgentFactoryInput(name="AgentFactoryInput_2", tools=[], llm=chat_model))
-
-    # ReActAgent
-    workflow.add_agent(
-        agent=ReActAgent(
-            llm=chat_model,
-            tools=[],
-            memory=TokenMemory(chat_model),
-            meta=AgentMeta(name="ReActAgent_1", tools=[], description="ReActAgent defined using agent keyword"),
-        )
-    )
-
-    assert len(workflow.workflow.step_names) == 3
-    assert set(workflow.workflow.steps.keys()) == {
-        "AgentFactoryInput_1",
-        "AgentFactoryInput_2",
-        "ReActAgent_1",
-    }
-
-    memory = UnconstrainedMemory()
-    await memory.add(UserMessage(content="Translate 'Good morning' to Portuguese."))
-    response = await workflow.run(memory.messages)
-    assert "bom dia" in response.state.final_answer.lower()
+    assert "buongiorno" in response.state.final_answer.lower().replace(" ", "")
 
 
 @pytest.mark.e2e
@@ -98,8 +61,8 @@ async def test_multi_agents_workflow_agent_delete() -> None:
     chat_model = OllamaChatModel()
 
     workflow: AgentWorkflow = AgentWorkflow()
-    workflow.add_agent(ReActAgent(llm=chat_model, tools=[], memory=UnconstrainedMemory()))
-    workflow.del_agent("ReAct")
-    workflow.add_agent(ReActAgent(llm=chat_model, tools=[], memory=UnconstrainedMemory()))
+    workflow.add_agent(name="AgentA", llm=chat_model, tools=[])
+    workflow.del_agent("AgentA")
+    workflow.add_agent(llm=chat_model, tools=[])
 
     assert len(workflow.workflow.step_names) == 1


Reviews:
user: login: Tomas2D
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T18:55:49Z
user: login: Tomas2D
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T18:59:56Z
user: login: ajbozarth
body: After reading through the code and running pytest locally, this generally LGTM, though `test_multi_agents_workflow_creation` consistently fails for me:
```
FAILED tests/workflows/test_multi_agents.py::test_multi_agents_workflow_creation - assert 'buongiorno' in 'wouldyouliketoknowmoreaboutitaliangreetings?'
```
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-13T15:27:25Z
user: login: Tomas2D
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T19:07:42Z
user: login: Tomas2D
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T19:07:54Z
user: login: ajbozarth
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-13T20:58:39Z

Review comments:
diff_hunk: @@ -116,7 +133,26 @@ async def _run(
                 await memory.add(AssistantMessage("\n"))  # TODO
 
             if text_messages:
-                state.result = AssistantMessage.from_chunks(text_messages)
+                if (
+                    run_input.expected_output is not None
+                    and isinstance(run_input.expected_output, type)
+                    and issubclass(run_input.expected_output, BaseModel)
+                ):
+                    struct_response = await self._llm.create_structure(
+                        schema=run_input.expected_output, messages=memory.messages
+                    )
+                    state.result = AssistantMessage(
+                        content=json.dumps(
path: python/beeai_framework/agents/tool_calling/agent.py
user: login: Tomas2D
body: Use `to_json` method as it does not do any escaping
created_at: 2025-03-12T18:55:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -65,13 +65,30 @@ def __init__(
         self._templates = to_model(ToolCallingAgentTemplates, templates or {})
 
     async def _run(
-        self, run_input: ToolCallingAgentRunInput, options: ToolCallingAgentRunOptions, context: RunContext
+        self,
+        run_input: ToolCallingAgentRunInput,
+        options: ToolCallingAgentRunOptions,
+        context: RunContext,
     ) -> ToolCallingAgentRunOutput:
         memory = UnconstrainedMemory()
         await memory.add(SystemMessage(self._templates.system.render()))
         await memory.add_many(self.memory.messages)
+
         if run_input.prompt is not None:
-            await memory.add(UserMessage(run_input.prompt))
+            if (
+                run_input.expected_output is not None
+                and isinstance(run_input.expected_output, type)
+                and issubclass(run_input.expected_output, BaseModel)
+            ):
+                await memory.add(
+                    UserMessage(
+                        self._templates.task.render(
+                            ToolCallingAgentRunInput(**run_input.model_dump(exclude={"expected_output"}))
path: python/beeai_framework/agents/tool_calling/agent.py
user: login: Tomas2D
body: I think we might consider adding the expected output to the schema. This can prevent us from doing additional iteration.
created_at: 2025-03-12T18:59:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,6 +43,8 @@
 
 __all__ = ["ToolCallingAgent"]
 
+from beeai_framework.utils.strings import to_json
path: python/beeai_framework/agents/tool_calling/agent.py
user: login: ajbozarth
body: is this import supposed to be below the `__all__`?
created_at: 2025-03-13T15:18:13Z
author_association: MEMBER
diff_hunk: @@ -40,6 +43,8 @@
 
 __all__ = ["ToolCallingAgent"]
 
+from beeai_framework.utils.strings import to_json
path: python/beeai_framework/agents/tool_calling/agent.py
user: login: Tomas2D
body: updated
created_at: 2025-03-13T19:07:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: Tomas2D
created_at: 2025-03-12T22:44:49Z
body: I rebased against all the changes we made in the `main` branch, but before doing the final merge, we need to agree on how we will update the current agent workflow and what will and will not be preserved. I will double check by tomorrow.
author_association: CONTRIBUTOR
user: login: ajbozarth
created_at: 2025-03-13T19:10:01Z
body: Note the `test_multi_agents_workflow_creation` error is still consistently occurring for me on this branch
author_association: MEMBER