From b7e86ed64dd7084a244dca437d82588ebb69f035 Mon Sep 17 00:00:00 2001 From: jinyuan Date: Thu, 25 Jun 2026 14:40:08 +0100 Subject: [PATCH 1/9] update agent.py and action.py --- evoagentx/actions/action.py | 12 +++--- evoagentx/agents/agent.py | 75 ++++++++++++++++++++++++------------- 2 files changed, 55 insertions(+), 32 deletions(-) diff --git a/evoagentx/actions/action.py b/evoagentx/actions/action.py index 8b1785f6..a19f4e30 100644 --- a/evoagentx/actions/action.py +++ b/evoagentx/actions/action.py @@ -1,14 +1,14 @@ import json from pydantic import model_validator from pydantic_core import PydanticUndefined -from typing import Optional, Type, Tuple, Union, List, Any +from typing import ClassVar, Optional, Type, Tuple, Union, List, Any from ..core.module import BaseModule from ..core.registry import MODULE_REGISTRY from ..core.parser import Parser from ..core.message import Message from ..models.base_model import BaseLLM, LLMOutputParser -from ..tools.tool import Toolkit +from ..tools.tool import Tool, Toolkit from ..prompts.context_extraction import CONTEXT_EXTRACTION from ..prompts.template import PromptTemplate @@ -53,6 +53,7 @@ class ActionOutput(LLMOutputParser): to convert the output to structured data. It inherits from LLMOutputParser to support parsing of LLM outputs into structured action results. """ + fix_json_schema_error: ClassVar[bool] = True def to_str(self) -> str: """Convert the output to a formatted JSON string. @@ -60,8 +61,7 @@ def to_str(self) -> str: Returns: A pretty-printed JSON string representation of the structured data. """ - return json.dumps(self.get_structured_data(), indent=4) - + return json.dumps(self.get_structured_data(), indent=4, ensure_ascii=False) class Action(BaseModule): """Base class for all actions in the EvoAgentX framework. @@ -83,7 +83,7 @@ class Action(BaseModule): description: str prompt: Optional[str] = None prompt_template: Optional[PromptTemplate] = None - tools: Optional[List[Toolkit]] = None # specify the possible tool for the action + tools: Optional[List[Union[Tool, Toolkit]]] = None # specify the possible tool for the action inputs_format: Optional[Type[ActionInput]] = None # specify the input format of the action outputs_format: Optional[Type[Parser]] = None # specify the possible structured output format @@ -211,4 +211,4 @@ def execute(self, llm: Optional[BaseLLM] = None, action: Action = None, context: ) action_inputs_data = action_inputs.get_structured_data() - return action_inputs_data \ No newline at end of file + return action_inputs_data diff --git a/evoagentx/agents/agent.py b/evoagentx/agents/agent.py index 099a69aa..242ae6ab 100644 --- a/evoagentx/agents/agent.py +++ b/evoagentx/agents/agent.py @@ -1,20 +1,22 @@ import asyncio -import inspect +import inspect +from collections.abc import Coroutine +from typing import Any, Dict, List, Optional, Tuple, Type, Union + from pydantic import Field -from typing import Type, Optional, Union, Tuple, List, Any, Coroutine +from ..actions.action import Action, ContextExtraction +from ..core.message import Message, MessageType from ..core.module import BaseModule from ..core.module_utils import generate_id -from ..core.message import Message, MessageType from ..core.registry import MODEL_REGISTRY -from ..models.model_configs import LLMConfig -from ..models.base_model import BaseLLM -from ..memory.memory import ShortTermMemory from ..memory.long_term_memory import LongTermMemory +from ..memory.memory import ShortTermMemory from ..memory.memory_manager import MemoryManager +from ..models.base_model import BaseLLM +from ..models.model_configs import LLMConfig from ..storages.base import StorageHandler -from ..actions.action import Action -from ..actions.action import ContextExtraction +from ..utils.utils import add_llm_config_to_agent_dict class Agent(BaseModule): @@ -280,20 +282,42 @@ def execute( return message, action_input_data return message + def set_llm_config(self, llm_config: LLMConfig): + """Set a new LLM config and rebuild the LLM instance from it.""" + self.llm_config = llm_config + llm_cls = MODEL_REGISTRY.get_model(llm_config.llm_type) + self.llm = llm_cls(config=llm_config) + + def set_llm(self, llm: BaseLLM): + """Set a new LLM instance and sync llm_config from it.""" + self.llm = llm + self.llm_config = llm.config + def init_llm(self): """ Initialize the language model for the agent. """ - # Only initialize LLM if not human and LLM is provided - if not self.is_human and (not self.llm_config and not self.llm): - raise ValueError("must provide `llm_config` or `llm` when `is_human` is False") - if not self.is_human and (self.llm_config or self.llm): - if self.llm_config and not self.llm: - llm_cls = MODEL_REGISTRY.get_model(self.llm_config.llm_type) - self.llm = llm_cls(config=self.llm_config) - if self.llm: - self.llm_config = self.llm.config - # If is_human=True or no LLM provided, self.llm remains None + if self.is_human: + # if human, no need to initialize LLM + return + + # 1. Handle the case where both llm and llm_config are set + if self.llm and self.llm_config: + if self.llm.config != self.llm_config: + raise ValueError( + f"Inconsistent LLM setup for agent '{self.name}': " + "The provided `llm` does not match `llm_config`. " + "Ensure they match or only provide one." + ) + return + + # 2. Handle the case where only one (llm or llm_config) is provided + if self.llm_config: + self.set_llm_config(self.llm_config) + elif self.llm: + self.set_llm(self.llm) + else: + raise ValueError(f"Must provide `llm_config` or `llm` for agent '{self.name}'.") def init_long_term_memory(self): """ @@ -503,20 +527,19 @@ def save_module(self, path: str, ignore: List[str] = [], **kwargs)-> str: super().save_module(path=path, ignore=ignore_fields, **kwargs) @classmethod - def load_module(cls, path: str, llm_config: LLMConfig = None, **kwargs) -> "Agent": + def from_dict(cls, data: Dict, llm_config: Optional[LLMConfig] = None, **kwargs) -> 'Agent': """ - load the agent from local storage. Must provide `llm_config` when loading the agent from local storage. + Create an agent instance from a dictionary. Args: - path: The path of the file + data: The dictionary containing all necessary configuration to recreate this agent llm_config: The LLMConfig instance Returns: - Agent: The loaded agent instance + Agent: The agent instance """ - agent = super().load_module(path=path, **kwargs) - if llm_config is not None: - agent["llm_config"] = llm_config.to_dict() + data = add_llm_config_to_agent_dict(data, llm_config) + agent = cls._create_instance(data) return agent def get_config(self) -> dict: @@ -528,4 +551,4 @@ def get_config(self) -> dict: with the same properties as this one. """ config = self.to_dict() - return config \ No newline at end of file + return config From 443920e8640cbfe979b5f5d2de1a3aa79ed92fb0 Mon Sep 17 00:00:00 2001 From: jinyuan Date: Thu, 25 Jun 2026 16:02:40 +0100 Subject: [PATCH 2/9] update customize_agent.py --- evoagentx/agents/customize_agent.py | 737 ++++++++++++++++++---------- 1 file changed, 477 insertions(+), 260 deletions(-) diff --git a/evoagentx/agents/customize_agent.py b/evoagentx/agents/customize_agent.py index 5b60e9e0..11d9e490 100644 --- a/evoagentx/agents/customize_agent.py +++ b/evoagentx/agents/customize_agent.py @@ -1,47 +1,61 @@ -import json import inspect -from pydantic import create_model, Field -from typing import Optional, Callable, Type, List, Any, Union, Dict +import json +from collections.abc import Callable +from copy import deepcopy +from typing import Any, Dict, List, Literal, Optional, Type, Union -from .agent import Agent +from pydantic import ConfigDict, Field, create_model + +from ..actions.action import Action, ActionInput, ActionOutput +from ..actions.customize_action import CustomizeAction +from ..core.base_config import Parameter from ..core.logging import logger -from ..core.registry import MODULE_REGISTRY, PARSE_FUNCTION_REGISTRY from ..core.message import Message, MessageType -from ..models.model_configs import LLMConfig +from ..core.registry import MODULE_REGISTRY, PARSE_FUNCTION_REGISTRY from ..models.base_model import PARSER_VALID_MODE -from ..prompts.utils import DEFAULT_SYSTEM_PROMPT +from ..models.model_configs import LLMConfig from ..prompts.template import PromptTemplate -from ..actions.action import Action, ActionOutput -from ..utils.utils import generate_dynamic_class_name, make_parent_folder -from ..actions.customize_action import CustomizeAction -from ..actions.action import ActionInput -from ..tools.tool import Toolkit, Tool +from ..prompts.utils import DEFAULT_SYSTEM_PROMPT +from ..tools.tool import Tool, Toolkit +from ..utils.utils import ( + add_llm_config_to_agent_dict, + generate_dynamic_class_name, + get_unique_class_name, + make_parent_folder, + string_to_json_schema_type, + string_to_python_type, + to_params, + tool_names_to_tools, +) +from .agent import Agent class CustomizeAgent(Agent): """ - CustomizeAgent provides a flexible framework for creating specialized LLM-powered agents without - writing custom code. It enables the creation of agents with well-defined inputs and outputs, - custom prompt templates, and configurable parsing strategies. - + CustomizeAgent provides a flexible framework for creating specialized LLM-powered agents without + writing custom code. It enables the creation of agents with well-defined inputs and outputs, + custom prompt templates, and configurable parsing strategies. + Attributes: name (str): The name of the agent. description (str): A description of the agent's purpose and capabilities. - prompt_template (PromptTemplate, optional): The prompt template that will be used for the agent's primary action. + prompt_template (PromptTemplate, optional): The prompt template that will be used for the agent's primary action. prompt (str, optional): The prompt template that will be used for the agent's primary action. Should contain placeholders in the format `{input_name}` for each input parameter. llm_config (LLMConfig, optional): Configuration for the language model. - inputs (List[dict], optional): List of input specifications, where each dict (e.g., `{"name": str, "type": str, "description": str, ["required": bool]}`) contains: + inputs (List[Union[dict, Parameter]], optional): List of input specifications as dicts or Parameter objects. Each dict (e.g., `{"name": str, "type": str, "description": str, ["required": bool, "json_schema": dict]}`) contains: - name (str): Name of the input parameter - type (str): Type of the input - description (str): Description of what the input represents - required (bool, optional): Whether this input is required (default: True) - outputs (List[dict], optional): List of output specifications, where each dict (e.g., `{"name": str, "type": str, "description": str, ["required": bool]}`) contains: + - json_schema (dict, optional): The json schema of the input, only used when type is `object` or `array`. + outputs (List[Union[dict, Parameter]], optional): List of output specifications as dicts or Parameter objects. Each dict (e.g., `{"name": str, "type": str, "description": str, ["required": bool, "json_schema": dict]}`) contains: - name (str): Name of the output field - type (str): Type of the output - description (str): Description of what the output represents - required (bool, optional): Whether this output is required (default: True) + - json_schema (dict, optional): The json schema of the output, only used when type is `object` or `array`. system_prompt (str, optional): The system prompt for the LLM. Defaults to DEFAULT_SYSTEM_PROMPT. output_parser (Type[ActionOutput], optional): A custom class for parsing the LLM's output. Must be a subclass of ActionOutput. @@ -56,100 +70,98 @@ class CustomizeAgent(Agent): title_format (str, optional): Format string for title parsing mode with {title} placeholder. Default is "## {title}". tools (list[Toolkit], optional): List of tools to be used by the agent. - max_tool_calls (int, optional): Maximum number of tool calls. Defaults to 5. - custom_output_format (str, optional): Specify the output format. Only used when `prompt_template` is used. - If not provided, the output format will be constructed from the `outputs` specification and `parse_mode`. + custom_output_format (str, optional): Specify the output format. Only used when `prompt_template` is used. + If not provided, the output format will be constructed from the `outputs` specification and `parse_mode`. """ def __init__( - self, - name: str, - description: str, - prompt: Optional[str] = None, - prompt_template: Optional[PromptTemplate] = None, - llm_config: Optional[LLMConfig] = None, - inputs: Optional[List[dict]] = None, - outputs: Optional[List[dict]] = None, + self, + name: str, + description: str, + prompt: Optional[str] = None, + prompt_template: Optional[PromptTemplate] = None, + llm_config: Optional[LLMConfig] = None, + inputs: Optional[List[Union[dict, Parameter]]] = None, + outputs: Optional[List[Union[dict, Parameter]]] = None, system_prompt: Optional[str] = None, - output_parser: Optional[Type[ActionOutput]] = None, - parse_mode: Optional[str] = "title", - parse_func: Optional[Callable] = None, - title_format: Optional[str] = None, + output_parser: Optional[Type[ActionOutput]] = None, + parse_mode: Optional[str] = "title", + parse_func: Optional[Callable] = None, + title_format: Optional[str] = None, tools: Optional[List[Union[Toolkit, Tool]]] = None, - max_tool_calls: Optional[int] = 5, - custom_output_format: Optional[str] = None, + custom_output_format: Optional[str] = None, + max_steps: int = 20, + max_tool_call_concurrency: int = 5, **kwargs ): system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT - inputs = inputs or [] - outputs = outputs or [] - if tools is not None: - raw_tool_map = {tool.name: tool for tool in tools} - tools = [tool if isinstance(tool, Toolkit) else Toolkit(name=tool.name, tools=[tool]) for tool in tools] - else: - raw_tool_map = None + inputs = inputs or [] + outputs = outputs or [] if prompt is not None and prompt_template is not None: logger.warning("Both `prompt` and `prompt_template` are provided in `CustomizeAgent`. `prompt_template` will be used.") - prompt = None + prompt = None if isinstance(parse_func, str): if not PARSE_FUNCTION_REGISTRY.has_function(parse_func): raise ValueError(f"parse function `{parse_func}` is not registered! To instantiate a CustomizeAgent from a file, you should use decorator `@register_parse_function` to register the parse function.") parse_func = PARSE_FUNCTION_REGISTRY.get_function(parse_func) - + if isinstance(output_parser, str): output_parser = MODULE_REGISTRY.get_module(output_parser) - - # set default title format + + # set default title format if parse_mode == "title" and title_format is None: title_format = "## {title}" - # validate the data - self.validate_data( - prompt = prompt, - prompt_template = prompt_template, - inputs = inputs, - outputs = outputs, - output_parser = output_parser, - parse_mode = parse_mode, - parse_func = parse_func, - title_format = title_format + # validate the data and normalize inputs/outputs to Parameter objects + valid_inputs, valid_outputs, parse_mode = self.validate_data( + prompt=prompt, + prompt_template=prompt_template, + inputs=inputs, + outputs=outputs, + output_parser=output_parser, + parse_mode=parse_mode, + parse_func=parse_func, + title_format=title_format ) - customize_action = self.create_customize_action( - name=name, - desc=description, - prompt=prompt, - prompt_template=prompt_template, - inputs=inputs, - outputs=outputs, - parse_mode=parse_mode, + customize_action = CustomizeAgent.create_customize_action( + name=name, + desc=description, + prompt=prompt, + prompt_template=prompt_template, + inputs=valid_inputs, + outputs=valid_outputs, + parse_mode=parse_mode, parse_func=parse_func, output_parser=output_parser, title_format=title_format, - custom_output_format=custom_output_format , + custom_output_format=custom_output_format, tools=tools, - max_tool_calls=max_tool_calls + max_steps=max_steps, + max_tool_call_concurrency=max_tool_call_concurrency, ) super().__init__( - name=name, - description=description, - llm_config=llm_config, - system_prompt=system_prompt, - actions=[customize_action], + name=name, + description=description, + llm_config=llm_config, + system_prompt=system_prompt, + actions=[customize_action], **kwargs ) - self._store_inputs_outputs_info(inputs, outputs, raw_tool_map) - self.output_parser = output_parser - self.parse_mode = parse_mode - self.parse_func = parse_func - self.title_format = title_format - self.tools = tools - self.max_tool_calls = max_tool_calls + + # Set backing attributes after super().__init__ so Pydantic doesn't wipe them. + # parse_func must be stored before parse_mode because the parse_mode setter reads it. + self._inputs = valid_inputs + self._outputs = valid_outputs + self.output_parser = output_parser + self._parse_func = parse_func + self.parse_mode = parse_mode + self._title_format = title_format self.custom_output_format = custom_output_format def _add_tools(self, tools: List[Toolkit]): - self.get_action(self.customize_action_name).add_tools(tools) + self.action.add_tools(tools) @property def customize_action_name(self) -> str: @@ -174,6 +186,46 @@ def action(self) -> Action: """ return self.get_action(self.customize_action_name) + @property + def inputs(self) -> List[Parameter]: + return self._inputs + + @inputs.setter + def inputs(self, inputs: List[Union[dict, Parameter]]): + valid_inputs, valid_outputs, parse_mode = self.validate_data( + prompt=self.prompt, + prompt_template=self.prompt_template, + inputs=inputs, + outputs=self.outputs, + output_parser=self.output_parser, + parse_mode=self.parse_mode, + parse_func=self.parse_func, + title_format=self.title_format + ) + self._inputs = valid_inputs + self.action.inputs_format = CustomizeAgent.create_action_input(valid_inputs, self.name) + self.parse_mode = parse_mode + + @property + def outputs(self) -> List[Parameter]: + return self._outputs + + @outputs.setter + def outputs(self, outputs: List[Union[dict, Parameter]]): + valid_inputs, valid_outputs, parse_mode = self.validate_data( + prompt=self.prompt, + prompt_template=self.prompt_template, + inputs=self.inputs, + outputs=outputs, + output_parser=self.output_parser, + parse_mode=self.parse_mode, + parse_func=self.parse_func, + title_format=self.title_format + ) + self._outputs = valid_outputs + self.action.outputs_format = CustomizeAgent.create_action_output(valid_outputs, self.name) + self.parse_mode = parse_mode + @property def prompt(self) -> str: """ @@ -194,77 +246,223 @@ def prompt_template(self) -> PromptTemplate: """ return self.action.prompt_template - def validate_data(self, prompt: str, prompt_template: PromptTemplate, inputs: List[dict], outputs: List[dict], output_parser: Type[ActionOutput], parse_mode: str, parse_func: Callable, title_format: str): + @property + def tools(self) -> List[Union[Tool, Toolkit]]: + return self.action.tools + + @property + def parse_mode(self) -> str: + return self._parse_mode + + @parse_mode.setter + def parse_mode(self, parse_mode: str): + if parse_mode not in PARSER_VALID_MODE: + raise ValueError(f"'{parse_mode}' is an invalid value for `parse_mode`. Available choices: {PARSER_VALID_MODE}.") + if CustomizeAgent._outputs_require_json_mode(self.outputs, self.parse_func) and parse_mode != "json": + raise ValueError( + f"Cannot set parse_mode='{parse_mode}': current outputs contain object/array types or json_schema. " + f"Set parse_mode='json', or provide a custom parse_func first." + ) + if parse_mode == "custom" and self.parse_func is None: + raise ValueError("`parse_func` must be set before switching parse_mode to 'custom'.") + self._parse_mode = parse_mode + self.action.parse_mode = parse_mode + + @property + def parse_func(self) -> Optional[Callable]: + return self._parse_func + + @parse_func.setter + def parse_func(self, parse_func: Optional[Callable]): + if parse_func is None: + if self.parse_mode == "custom": + raise ValueError("Cannot set parse_func to None while parse_mode is 'custom'. Change parse_mode first.") + else: + CustomizeAgent._validate_parse_func(parse_func) + self._parse_func = parse_func + self.action.parse_func = parse_func + + @property + def title_format(self) -> Optional[str]: + return self._title_format + + @title_format.setter + def title_format(self, title_format: Optional[str]): + CustomizeAgent._validate_title_format(title_format, self.parse_mode) + self._title_format = title_format + self.action.title_format = title_format + + @staticmethod + def _outputs_require_json_mode(outputs: List[Parameter], parse_func: Optional[Callable]) -> bool: + """Return True when outputs force parse_mode='json' (only relevant if no parse_func is supplied).""" + if parse_func is not None: + return False + return ( + any(p.type in {"object", "array"} for p in outputs) + or CustomizeAgent.contain_json_schema(outputs) + ) + + @staticmethod + def _validate_parse_func(parse_func: Optional[Callable]) -> None: + """Raise ValueError / emit a warning if parse_func is not a valid parsing callable.""" + if parse_func is None: + return + if not callable(parse_func): + raise ValueError("`parse_func` must be a callable function with an input argument `content`.") + signature = inspect.signature(parse_func) + if "content" not in signature.parameters: + raise ValueError("`parse_func` must have an input argument `content`.") + if not PARSE_FUNCTION_REGISTRY.has_function(parse_func.__name__): + logger.warning( + f"parse function `{parse_func.__name__}` is not registered. This can cause issues when loading " + f"the agent from a file. It is recommended to register the parse function using " + f"`register_parse_function`:\n" + f"from evoagentx.core.registry import register_parse_function\n" + f"@register_parse_function\n" + f"def {parse_func.__name__}(content: str) -> dict:\n" + r" return {'output_name': output_value}" + ) + + @staticmethod + def _validate_title_format(title_format: Optional[str], parse_mode: Optional[str]) -> None: + """Raise ValueError / emit a warning if title_format is invalid or parse_mode is incompatible.""" + if title_format is None: + return + if r"{title}" not in title_format: + raise ValueError(r"`title_format` must contain the placeholder `{title}`.") + if parse_mode is not None and parse_mode != "title": + logger.warning( + f"`title_format` will not be used because `parse_mode` is '{parse_mode}', not 'title'. " + f"Set `parse_mode='title'` to use title formatting." + ) + + def _check_params_types(self, params: List[Union[dict, Parameter]], param_name: str) -> List[Parameter]: + """ + Converts `params` into a list of `Parameter` objects and at the same time validates them. + + Args: + params: A list of `dict` or `Parameter` objects to convert and validate + param_name: The name of the parameter (used for error messages) + + Returns: + A list of `Parameter` objects + """ + if not params: + return + + # check if params is a list of dict + if not isinstance(params, list): + raise ValueError(f"`{param_name}` must be a list of dict or Parameter objects.") + + valid_params = [] + for param in params: + if isinstance(param, dict): + try: + valid_params.append(Parameter(**param)) + except Exception as e: + raise ValueError( + f"`{param}` is an invalid {param_name} item. \n" + f"Expected format: `{{'name': str, 'type': str, 'description': str, ['required': bool, 'json_schema': dict]}}`. \n" + f"Details: {e}" + ) + elif isinstance(param, Parameter): + valid_params.append(param) + else: + raise ValueError(f"`{param}` is an invalid {param_name} item. \nExpected type: `dict` or `Parameter`.") + + return valid_params + + def validate_data( + self, + prompt: str, + prompt_template: PromptTemplate, + inputs: List[Union[dict, Parameter]], + outputs: List[Union[dict, Parameter]], + output_parser: Type[ActionOutput], + parse_mode: str, + parse_func: Callable, + title_format: str, + ) -> tuple: + """Validate and normalize agent configuration, auto-correcting parse_mode where needed. + + Converts `inputs` and `outputs` to `Parameter` objects, validates all + parsing-related options, and auto-corrects `parse_mode` to `"json"` when the + output schema contains `object`/`array` types or a `json_schema` without a + custom parse function. + + Returns: + A tuple containing: + - `valid_inputs` (List[Parameter]): normalized input parameters. + - `valid_outputs` (List[Parameter]): normalized output parameters. + - `parse_mode` (str): validated (and possibly auto-corrected) parse mode. + """ + # Normalize inputs and outputs to Parameter objects first so all subsequent code + # can safely use attribute access regardless of what the caller passed in. + valid_inputs = self._check_params_types(inputs, "inputs") or [] + valid_outputs = self._check_params_types(outputs, "outputs") or [] # check if the prompt is provided if prompt is None and prompt_template is None: raise ValueError("`prompt` or `prompt_template` is required when creating a CustomizeAgent.") - + # check if all the inputs are in the prompt (only used when prompt_template is not provided) - if prompt_template is None and inputs: - all_input_names = [input_item["name"] for input_item in inputs] + if prompt_template is None and valid_inputs: + all_input_names = [input_item.name for input_item in valid_inputs] inputs_names_not_in_prompt = [name for name in all_input_names if f'{{{name}}}' not in prompt] if inputs_names_not_in_prompt: - raise KeyError(f"The following inputs are not found in the prompt: {inputs_names_not_in_prompt}.") - - # check if the output_parser is valid + raise KeyError(f"The following inputs are not found in the prompt: {inputs_names_not_in_prompt}.") + + # check if the output_parser is valid if output_parser is not None: - self._check_output_parser(outputs, output_parser) - - # check the parse_mode, parse_func, and title_format + self._check_output_parser(valid_outputs, output_parser) + + # check the parse_mode value itself if parse_mode not in PARSER_VALID_MODE: raise ValueError(f"'{parse_mode}' is an invalid value for `parse_mode`. Available choices: {PARSER_VALID_MODE}.") - - if parse_mode == "custom": - if parse_func is None: - raise ValueError("`parse_func` (a callable function with an input argument `content`) must be provided when `parse_mode` is 'custom'.") - - if parse_func is not None: - if not callable(parse_func): - raise ValueError("`parse_func` must be a callable function with an input argument `content`.") - signature = inspect.signature(parse_func) - if "content" not in signature.parameters: - raise ValueError("`parse_func` must have an input argument `content`.") - if not PARSE_FUNCTION_REGISTRY.has_function(parse_func.__name__): - logger.warning( - f"parse function `{parse_func.__name__}` is not registered. This can cause issues when loading the agent from a file. " - f"It is recommended to register the parse function using `register_parse_function`:\n" - f"from evoagentx.core.registry import register_parse_function\n" - f"@register_parse_function\n" - f"def {parse_func.__name__}(content: str) -> dict:\n" - r" return {'output_name': output_value}" - ) - if title_format is not None: - if parse_mode != "title": - logger.warning(f"`title_format` will not be used because `parse_mode` is '{parse_mode}', not 'title'. Set `parse_mode='title'` to use title formatting.") - if r'{title}' not in title_format: - raise ValueError(r"`title_format` must contain the placeholder `{title}`.") - + # Auto-correct parse_mode to "json" when outputs require it and no custom parse_func is provided + if CustomizeAgent._outputs_require_json_mode(valid_outputs, parse_func) and parse_mode != "json": + logger.warning( + f"parse_mode='{parse_mode}' is not compatible with the current outputs (object/array types or " + f"json_schema). Auto-correcting to parse_mode='json'. To suppress this warning, explicitly set " + f"parse_mode='json'." + ) + return valid_inputs, valid_outputs, "json" + + if parse_mode == "custom" and parse_func is None: + raise ValueError("`parse_func` (a callable function with an input argument `content`) must be provided when `parse_mode` is 'custom'.") + + CustomizeAgent._validate_parse_func(parse_func) + CustomizeAgent._validate_title_format(title_format, parse_mode) + + return valid_inputs, valid_outputs, parse_mode + + @staticmethod def create_customize_action( - self, - name: str, - desc: str, - prompt: str, - prompt_template: PromptTemplate, - inputs: List[dict], - outputs: List[dict], - parse_mode: str, + name: str, + desc: str, + prompt: str, + prompt_template: PromptTemplate, + inputs: List[Union[dict, Parameter]], + outputs: List[Union[dict, Parameter]], + parse_mode: str, parse_func: Optional[Callable] = None, output_parser: Optional[ActionOutput] = None, title_format: Optional[str] = "## {title}", custom_output_format: Optional[str] = None, - tools: Optional[List[Toolkit]] = None, - max_tool_calls: Optional[int] = 5 + tools: Optional[List[Union[Tool, Toolkit]]] = None, + max_steps: int = 20, + max_tool_call_concurrency: int = 5, + **kwargs ) -> Action: """Create a custom action based on the provided specifications. - + This method dynamically generates an Action class and instance with: - Input parameters defined by the inputs specification - Output format defined by the outputs specification - Custom execution logic using the customize_action_execute function - If tools is provided, returns a CustomizeAction action instead - + Args: name: Base name for the action desc: Description of the action @@ -276,112 +474,156 @@ def create_customize_action( parse_func: Optional custom parsing function output_parser: Optional custom output parser class tools: Optional list of tools - + max_steps: Maximum number of steps the agent can take + max_tool_call_concurrency: Maximum number of concurrent tool calls + Returns: A newly created Action instance """ assert prompt is not None or prompt_template is not None, "must provide `prompt` or `prompt_template` when creating CustomizeAgent" - # create the action input type - action_input_fields = {} - for field in inputs: - required = field.get("required", True) - if required: - action_input_fields[field["name"]] = (str, Field(description=field["description"])) - else: - action_input_fields[field["name"]] = (Optional[str], Field(default=None, description=field["description"])) + inputs: List[Parameter] = to_params(inputs) + outputs: List[Parameter] = to_params(outputs) + + action_input_type = CustomizeAgent.create_action_input(inputs, name) - action_input_type = create_model( - self._get_unique_class_name( - generate_dynamic_class_name(name+" action_input") - ), - **action_input_fields, - __base__=ActionInput - ) - - # create the action output type if output_parser is None: - action_output_fields = {} - for field in outputs: - required = field.get("required", True) - if required: - action_output_fields[field["name"]] = (Any, Field(description=field["description"])) - else: - action_output_fields[field["name"]] = (Optional[Any], Field(default=None, description=field["description"])) - action_output_type = create_model( - self._get_unique_class_name( - generate_dynamic_class_name(name+" action_output") - ), - **action_output_fields, - __base__=ActionOutput, - # get_content_data=customize_get_content_data, - # to_str=customize_to_str - ) + action_output_type = CustomizeAgent.create_action_output(outputs, name) else: - # self._check_output_parser(outputs, output_parser) action_output_type = output_parser - - action_cls_name = self._get_unique_class_name( - generate_dynamic_class_name(name+" action") - ) - # Create CustomizeAction-based action with parsing properties only - customize_action_cls = create_model( - action_cls_name, - __base__=CustomizeAction + action_cls_name = get_unique_class_name( + generate_dynamic_class_name(name + " action") ) - customize_action = customize_action_cls( + customize_action = CustomizeAction( name=action_cls_name, - description=desc, + description=desc, prompt=prompt, - prompt_template=prompt_template, + prompt_template=prompt_template, inputs_format=action_input_type, outputs_format=action_output_type, parse_mode=parse_mode, parse_func=parse_func, title_format=title_format, custom_output_format=custom_output_format, - max_tool_try=max_tool_calls, - tools=tools + tools=tools, + max_steps=max_steps, + max_tool_call_concurrency=max_tool_call_concurrency, ) return customize_action - def _check_output_parser(self, outputs: List[dict], output_parser: Type[ActionOutput]): + @staticmethod + def _prepare_action_info(params: List[Parameter]) -> Dict: + """Returns the fields for ActionInput/ActionOutput.""" + action_fields = {} + for field in params: + required = field.required if field.required is not None else True + try: + field_type = string_to_python_type[field.type] + except KeyError: + logger.warning(f'Could not find Python type for "{field.type}" (field: "{field.name}"), falling back to `Any`.') + field_type = Any + + json_schema = field.json_schema + + if required: + action_fields[field.name] = (field_type, Field(description=field.description, json_schema_extra=json_schema)) + else: + action_fields[field.name] = (Optional[field_type], Field(default=None, description=field.description, json_schema_extra=json_schema)) + + return action_fields + + @staticmethod + def _create_action_parser(params: List[Union[dict, Parameter]], action_name: str, type: Literal["input", "output"]) -> Type[Union[ActionInput, ActionOutput]]: + params: List[Parameter] = to_params(params) + + action_parser_type = ActionInput if type == "input" else ActionOutput + action_fields = CustomizeAgent._prepare_action_info(params) + + if CustomizeAgent.contain_json_schema(params): + json_schema = CustomizeAgent.create_json_schema(params) + else: + json_schema = None + + action_parser_class = create_model( + get_unique_class_name( + generate_dynamic_class_name(action_name + " action_input_output") + ), + **action_fields, + __base__=action_parser_type, + __config__=ConfigDict( + json_schema_extra=json_schema + ) + ) + return action_parser_class + + @staticmethod + def create_action_input(inputs: List[Union[dict, Parameter]], action_name: str) -> Type[ActionInput]: + return CustomizeAgent._create_action_parser(inputs, action_name, "input") + + @staticmethod + def create_action_output(outputs: List[Union[dict, Parameter]], action_name: str) -> Type[ActionOutput]: + return CustomizeAgent._create_action_parser(outputs, action_name, "output") + + @staticmethod + def create_json_schema(params: List[Union[dict, Parameter]]) -> Optional[dict]: + params: List[Parameter] = to_params(params) + + if not params: + return None + + properties = {} + required_params = [] + for param in params: + param_name = param.name + param_type = string_to_json_schema_type[param.type] + param_description = param.description + param_required = param.required if param.required is not None else True + param_json_schema = param.json_schema + if not param_json_schema: + param_json_schema = { + "type": param_type, + "description": param_description + } + properties[param_name] = param_json_schema + if param_required: + required_params.append(param_name) + + json_schema = { + "type": "object", + "properties": properties, + "required": required_params + } + + return json_schema + + @staticmethod + def contain_json_schema(params: List[Union[dict, Parameter]]) -> bool: + params: List[Parameter] = to_params(params) + return any(param.json_schema for param in params) + + def _check_output_parser(self, outputs: List[Parameter], output_parser: Type[ActionOutput]): if output_parser is not None: if not isinstance(output_parser, type): raise TypeError(f"output_parser must be a class, but got {type(output_parser).__name__}") if not issubclass(output_parser, ActionOutput): raise ValueError(f"`output_parser` must be a class and a subclass of `ActionOutput`, but got `{output_parser.__name__}`.") - + # check if the output parser is compatible with the outputs output_parser_fields = output_parser.get_attrs() - all_output_names = [output_item["name"] for output_item in outputs] + all_output_names = [output_item.name for output_item in outputs] for field in output_parser_fields: if field not in all_output_names: raise ValueError( f"The output parser `{output_parser.__name__}` is not compatible with the `outputs`.\n" f"The output parser fields: {output_parser_fields}.\n" f"The outputs: {all_output_names}.\n" - f"All the fields in the output parser must be present in the outputs." + f"All the fields in the output parser must be present in the outputs." ) - def _store_inputs_outputs_info(self, inputs: List[dict], outputs: List[dict], tool_map: Dict[str, Union[Toolkit, Tool]]): - - self._action_input_types, self._action_input_required = {}, {} - for field in inputs: - required = field.get("required", True) - self._action_input_types[field["name"]] = field["type"] - self._action_input_required[field["name"]] = required - self._action_output_types, self._action_output_required = {}, {} - for field in outputs: - required = field.get("required", True) - self._action_output_types[field["name"]] = field["type"] - self._action_output_required[field["name"]] = required - self._raw_tool_map = tool_map - def __call__(self, inputs: dict = None, return_msg_type: MessageType = MessageType.UNKNOWN, **kwargs) -> Message: """ Call the customize action. @@ -402,8 +644,6 @@ def get_customize_agent_info(self) -> dict: Get the information of the customize agent. """ customize_action = self.get_action(self.customize_action_name) - action_input_params = customize_action.inputs_format.get_attrs() - action_output_params = customize_action.outputs_format.get_attrs() config = { "class_name": "CustomizeAgent", @@ -412,60 +652,20 @@ def get_customize_agent_info(self) -> dict: "prompt": customize_action.prompt, "prompt_template": customize_action.prompt_template.to_dict() if customize_action.prompt_template is not None else None, # "llm_config": self.llm_config.to_dict(exclude_none=True), - "inputs": [ - { - "name": field, - "type": self._action_input_types[field], - "description": field_info.description, - "required": self._action_input_required[field] - } - for field, field_info in customize_action.inputs_format.model_fields.items() if field in action_input_params - ], - "outputs": [ - { - "name": field, - "type": self._action_output_types[field], - "description": field_info.description, - "required": self._action_output_required[field] - } - for field, field_info in customize_action.outputs_format.model_fields.items() if field in action_output_params - ], + "inputs": [p.to_dict(ignore=["class_name"]) for p in self.inputs] if self.inputs else [], + "outputs": [p.to_dict(ignore=["class_name"]) for p in self.outputs] if self.outputs else [], "system_prompt": self.system_prompt, "output_parser": self.output_parser.__name__ if self.output_parser is not None else None, "parse_mode": self.parse_mode, "parse_func": self.parse_func.__name__ if self.parse_func is not None else None, "title_format": self.title_format, "tool_names": [tool.name for tool in customize_action.tools] if customize_action.tools else [], - "max_tool_calls": self.max_tool_calls, - "custom_output_format": self.custom_output_format + "custom_output_format": self.custom_output_format, + "max_steps": customize_action.max_steps, + "max_tool_call_concurrency": customize_action.max_tool_call_concurrency, } return config - @classmethod - def load_module(cls, path: str, llm_config: LLMConfig = None, tools: List[Union[Toolkit, Tool]] = None, **kwargs) -> "CustomizeAgent": - """ - load the agent from local storage. Must provide `llm_config` when loading the agent from local storage. - If tools is provided, tool_names must also be provided. - - Args: - path: The path of the file - llm_config: The LLMConfig instance - tool_names: List of tool names to be used by the agent. If provided, - tool_dict: Dictionary mapping tool names to Tool instances. Required when tool_names is provided. - - Returns: - CustomizeAgent: The loaded agent instance - """ - match_dict = {} - agent = super().load_module(path=path, llm_config=llm_config, **kwargs) - if tools: - match_dict = {tool.name:tool for tool in tools} - if agent.get("tool_names", None): - assert tools is not None, "must provide `tools: List[Union[Toolkit, Tool]]` when using `load_module` or `from_file` to load the agent from local storage and `tool_names` is not None or empty" - added_tools = [match_dict[tool_name] for tool_name in agent["tool_names"]] - agent["tools"] = [tool if isinstance(tool, Toolkit) else Toolkit(name=tool.name, tools=[tool]) for tool in added_tools] - return agent - def save_module(self, path: str, ignore: List[str] = [], **kwargs)-> str: """Save the customize agent's configuration to a JSON file. @@ -489,22 +689,6 @@ def save_module(self, path: str, ignore: List[str] = [], **kwargs)-> str: return path - def _get_unique_class_name(self, candidate_name: str) -> str: - """ - Get a unique class name by checking if it already exists in the registry. - If it does, append "Vx" to make it unique. - """ - if not MODULE_REGISTRY.has_module(candidate_name): - return candidate_name - - i = 1 - while True: - unique_name = f"{candidate_name}V{i}" - if not MODULE_REGISTRY.has_module(unique_name): - break - i += 1 - return unique_name - def get_config(self) -> dict: """ Get a dictionary containing all necessary configuration to recreate this agent. @@ -515,8 +699,41 @@ def get_config(self) -> dict: """ config = self.get_customize_agent_info() config["llm_config"] = self.llm_config.to_dict() - tool_names = config.pop("tool_names", None) - if tool_names: - config["tools"] = [self._raw_tool_map[name] for name in tool_names] return config + + @classmethod + def from_dict( + cls, + data: Dict[str, Any], + llm_config: Optional[LLMConfig] = None, + tools: Optional[List[Union[Toolkit, Tool]]] = None, + **kwargs + ) -> 'CustomizeAgent': + + agent_data = deepcopy(data) + + class_name = agent_data.pop("class_name", None) + if class_name is not None and class_name != "CustomizeAgent": + raise ValueError(f"Expected class name 'CustomizeAgent', but got '{class_name}'") + + agent_data = add_llm_config_to_agent_dict(agent_data, llm_config) + tool_names = agent_data.pop("tool_names", None) + + if tool_names: + agent_data["tools"] = tool_names_to_tools(tool_names, tools) + + parse_mode = agent_data.get("parse_mode") + + # if parse_mode is not 'json', check if there are outputs in format 'object' or 'array' + if parse_mode != "json": + agent_outputs = agent_data.get("outputs") + + if agent_outputs is not None: + for output in agent_outputs: + if output["type"] == "object" or output["type"] == "array": + agent_data["parse_mode"] = "json" + logger.warning(f"`parse_mode` is set to 'json' for '{agent_data['name']}' because it has outputs in format 'object' or 'array'") + break + + return cls(**agent_data, **kwargs) \ No newline at end of file From edb009162d29bc81b3d76e64f9bc400711577da3 Mon Sep 17 00:00:00 2001 From: jinyuan Date: Thu, 25 Jun 2026 16:34:13 +0100 Subject: [PATCH 3/9] update template.py and tool_calling.py --- evoagentx/prompts/template.py | 57 ++++++--- evoagentx/prompts/tool_calling.py | 198 +++++++++++++++++------------- 2 files changed, 157 insertions(+), 98 deletions(-) diff --git a/evoagentx/prompts/template.py b/evoagentx/prompts/template.py index 9763cb54..b23e9deb 100644 --- a/evoagentx/prompts/template.py +++ b/evoagentx/prompts/template.py @@ -238,7 +238,7 @@ def render_tools(self, tools: Optional[List[Union[Tool, Toolkit]]] = None) -> st tools = tools or self.tools tool_schemas = compile_tool_schemas(tools) tool_schemas_str = json.dumps(tool_schemas, indent=4, ensure_ascii=False) - return TOOL_CALLING_TEMPLATE.format(tools_description=tool_schemas_str) + return TOOL_CALLING_TEMPLATE.format(tool_descriptions=tool_schemas_str) def render_constraints(self) -> str: if not self.constraints: @@ -485,12 +485,13 @@ def _create_message(self, role: str, content: str) -> dict: return {"role": role, "content": content} def render_demonstrations( - self, - inputs_format: Type[LLMOutputParser], - outputs_format: Type[LLMOutputParser], - parse_mode: str, - title_format: str = None, - custom_output_format: str = None + self, + inputs_format: Type[LLMOutputParser], + outputs_format: Type[LLMOutputParser], + parse_mode: str, + title_format: str = None, + custom_output_format: str = None, + **kwargs ) -> List[dict]: """ Render demonstrations as alternating user and assistant messages. @@ -525,9 +526,29 @@ def render_demonstrations( return messages - # def render_history(self) -> List[dict]: - # """Render conversation history as alternating user and assistant messages.""" - # raise NotImplementedError("`render_history` method is not supported for `{self.__class__.__name__}`. Returning empty list.") + def render_history(self) -> List[dict]: + """ + Render conversation history as a list of chat messages. + + Unlike demonstrations (synthetic few-shot examples that are re-rendered + through `inputs_format`/`outputs_format`), history represents actual prior + conversation turns and is passed through directly as chat messages without + reformatting. Each history item is expected to be a message dict with + "role" and "content" keys. Items that are not in this shape are wrapped into + a single "user" message so that no history content is silently dropped. + """ + if not self.history: + return [] + + valid_roles = {"system", "user", "assistant", "tool"} + messages = [] + for item in self.history: + if isinstance(item, dict) and "role" in item and "content" in item: + role = item["role"] if item["role"] in valid_roles else "user" + messages.append(self._create_message(role, item["content"])) + else: + messages.append(self._create_message("user", str(item))) + return messages def render_current_user_message( self, @@ -597,9 +618,9 @@ def format( system_content = self._render_system_message(system_prompt, tools) if custom_output_format: - system_content += f"### Outputs Format\n{custom_output_format}" + system_content += f"\n### Outputs Format\n{custom_output_format}" else: - system_content += self.render_outputs(outputs_format, parse_mode, title_format) + system_content += "\n" + self.render_outputs(outputs_format, parse_mode, title_format) messages.append(self._create_message("system", system_content)) @@ -615,9 +636,13 @@ def format( ) ) + # Add conversation history + if self.history: + messages.extend(self.render_history()) + # Add current user input & output format requirements current_input = self.render_current_user_message( - values=values, + values=values, inputs_format=inputs_format ) messages.append(self._create_message("user", current_input)) @@ -627,11 +652,11 @@ def format( class MiproPromptTemplate(ChatTemplate): - def render_demonstrations(self, inputs_format: LLMOutputParser, outputs_format: LLMOutputParser, parse_mode: str, title_format: str = None, custom_output_format: str = None) -> List[dict]: - + def render_demonstrations(self, inputs_format: LLMOutputParser, outputs_format: LLMOutputParser, parse_mode: str, title_format: str = None, custom_output_format: str = None, **kwargs) -> List[dict]: + import dspy if self.demonstrations: demo = self.demonstrations[0] if isinstance(demo, dspy.Example): self.demonstrations = [demo.toDict() for demo in self.demonstrations] - return super().render_demonstrations(inputs_format, outputs_format, parse_mode, title_format, custom_output_format) \ No newline at end of file + return super().render_demonstrations(inputs_format, outputs_format, parse_mode, title_format, custom_output_format, **kwargs) \ No newline at end of file diff --git a/evoagentx/prompts/tool_calling.py b/evoagentx/prompts/tool_calling.py index 21e9a29a..d4a5c8f5 100644 --- a/evoagentx/prompts/tool_calling.py +++ b/evoagentx/prompts/tool_calling.py @@ -1,95 +1,58 @@ -# todo: Switch back to once CustomizeAction supports the canonical tool-call tag. TOOL_CALL_FORMAT = """ - + {tool_calls} - + """ - -OUTPUT_EXTRACTION_PROMPT = """ -You are given the following text: -{text} - -We need you to process this text and generate high-quality outputs for each of the following fields: -{output_description} - -**Instructions:** -1. Read through the provided text carefully. -2. For each of the listed output fields, analyze the relevant information from the text and generate a well-formulated response. -3. You may summarize, process, restructure, or enhance the information as needed to provide the best possible answer. -4. Your analysis should be faithful to the content but can go beyond simple extraction - provide meaningful insights where appropriate. -5. Return your processed outputs in a single JSON object, where the JSON keys **exactly match** the output names given above. -6. If there is insufficient information for an output, provide your best reasonable inference or set its value to an empty string ("") or `null`. -7. Do not include any additional keys in the JSON. -8. Your final output should be valid JSON and should not include any explanatory text. - -**Example JSON format:** -{{ - "": "Processed content here", - "": "Processed content here", - "": "Processed content here" -}} - -Now, based on the text and the instructions above, provide your final JSON output. -""" - - TOOL_CALLING_HISTORY_PROMPT = """ -Iteration {iteration_number}: -Executed tool calls: -{tool_call_args} -Results: + {results} - + """ AGENT_GENERATION_TOOLS_PROMPT = """ -In the following Tools Description section, you are offered with the following tools. A short description of each functionality is also provided for each tool. +### Tools +In the following **Tool Descriptions** section, you are offered with the following tools. A short description of each functionality is also provided for each tool. You should assign tools to agent if you think it would be helpful for the agent to use the tool. -A sample output for tool argument looks like this following line (The example tools are not real tools): -tools: ["File Tool", "Browser Tool"] -**Tools Description** -{tools_description} +**Tool Descriptions** +{tool_descriptions} """ - TOOL_CALLING_TEMPLATE = """ -# Tool Calling Guide - -You can call the following tools: -{tools_description} - -## Rules -- ONLY use tools listed above. Do not invent or use non-existent tools. -- Check the conversation history before calling: If the needed information is already available (e.g., from previous tool results), do not call tools again. Summarize and use it directly. -- If a previous tool call failed (e.g., error in history), try a different tool or adjust arguments; do not repeat the same call. -- Call tools ONLY when necessary for the task (e.g., external data, computation). Otherwise, proceed to the final output without tools. -- Support multiple parallel calls: Use an array with multiple objects if needed. -- Each call MUST include "function_name" (exact match from tool's Action) and "function_args" (a dict with exact argument names and values). -- For arguments: Each parameter must be a valid JSON type (e.g., string, integer) and required/optional status as described. Do not add extra args. -- Output STRICTLY in the format below. NO explanations, comments, thoughts, or extra text outside the block. If no tools needed, do not output this block at all. - -## Output format -Always return a JSON array of tool calls, like: - - +### Tool Calling Guide + +The following tools are available: +{tool_descriptions} + +#### Tool Calling Rules +- Only use the tools provided. Do not invent or use non-existent ones. +- Check the conversation history before calling a tool. If the information you need is already present, do not make another call. +- If a tool call fails, try a different tool or adjust the arguments. Do not repeat the failed call. +- Only use a tool when it is essential to complete the task, such as to retrieve external data. +- Each tool call must include `function_name` and `function_args`. +- The arguments in `function_args` must exactly match the tool's required parameters and their data types. Do not include any extra arguments. +- Each argument must be a valid JSON type (e.g., string, number, boolean, array, object). +- Output only the JSON format shown in the **Tool Calling Output Format** section. Do not include any additional text, explanations, or comments within the `` block. If no tools are needed, do not output this block at all. + +#### Tool Calling Output Format + [ - {{ - "function_name": "tool_name", - "function_args": {{ - "param1": "value1", - "param2": "value2" - }} - }}, - ... + {{ + "function_name": "tool_name", + "function_args": {{ + "param1": "value1", + "param2": "value2" + }} + }}, + ... ] - + -## Examples +#### Tool Calling Examples Example 1: Single tool call for web search. - + [ {{ "function_name": "web_search", @@ -99,15 +62,16 @@ }} }} ] - + -Example 2: Multiple parallel calls (e.g., search and code execution). - +Example 2: Multiple calls that don't depend on each other (e.g., search and code execution). + [ {{ "function_name": "web_search", "function_args": {{ - "query": "python tips" + "query": "latest tech news", + "num_results": 5 }} }}, {{ @@ -117,13 +81,83 @@ }} }} ] - + """ TOOL_CALLING_RETRY_PROMPT = """ -The following output is supposed to be a JSON list of tool calls, but it's invalid. -Please fix it and return ONLY the valid JSON array: ---- Invalid Output --- +The following is an invalid JSON array. Please correct it and return only the valid JSON array. + +**Invalid JSON Array** +```json {text} ---- End --- +``` """ + + +OUTPUT_EXTRACTION_PROMPT = """ +You are given the following text: +{text} + +We need you to process this text and generate high-quality outputs for each of the following fields: +{output_description} + +**Instructions:** +1. Read through the provided text carefully. +2. For each of the listed output fields, analyze the relevant information from the text and generate a well-formulated response. +3. You may summarize, process, restructure, or enhance the information as needed to provide the best possible answer. +4. Your analysis should be faithful to the content but can go beyond simple extraction - provide meaningful insights where appropriate. +5. Return your processed outputs in a single JSON object, where the JSON keys **exactly match** the output names given above. +6. If there is insufficient information for an output, provide your best reasonable inference or set its value to an empty string ("") or `null`. +7. Do not include any additional keys in the JSON. +8. Your final output should be valid JSON and should not include any explanatory text. + +**Example JSON format:** +{{ + "": "Processed content here", + "": "Processed content here", + "": "Processed content here" +}} + +Now, based on the text and the instructions above, provide your final JSON output. +""" + +def format_tool_descriptions(tools, default: str = "No tools provided.") -> str: + """ + Args: + tools: List of tools to format. + default: Default description to use if no tools are provided. + + Returns: + str: Formatted tool descriptions. + + Example output: + - **Tool 1**: Description of tool 1 + - **Tool 2**: Description of tool 2 + - **Toolkit** is a toolkit that provides the following functionalities: + * Description of tool 1 in toolkit + * Description of tool 2 in toolkit + """ + from ..tools import Tool, Toolkit + + if not tools: + return default + + descriptions = [] + + for tool in tools: + if isinstance(tool, Tool): + name = tool.name + description = tool.description.replace("\n", "\n ") + descriptions.append(f"- **{name}**: {description}") + elif isinstance(tool, Toolkit): + name = tool.name + description = "is a toolkit that provides the following functionalities:" + + for tool in tool.get_tools(): + tool_description = tool.description.replace("\n", "\n ") + description += f"\n * {tool_description}" + + descriptions.append(f"- **{name}** {description}") + + descriptions = "\n".join(descriptions) + return descriptions \ No newline at end of file From 166922a061184ff46d47ca6d40651b4f087bd121 Mon Sep 17 00:00:00 2001 From: jinyuan Date: Fri, 26 Jun 2026 00:21:49 +0100 Subject: [PATCH 4/9] update agents and actions --- evoagentx/actions/customize_action.py | 830 ++++++++---------- evoagentx/agents/agent_manager.py | 175 +--- evoagentx/core/exception.py | 8 + evoagentx/memory/context_manager.py | 167 ++++ evoagentx/models/base_model.py | 18 +- evoagentx/prompts/customize_agent.py | 22 + evoagentx/prompts/output_extraction.py | 104 +++ evoagentx/prompts/tool_calling.py | 28 - evoagentx/tools/tool.py | 14 + tests/src/agents/test_customize_action.py | 251 ++++++ tests/src/agents/test_customize_agent.py | 10 +- .../src/agents/test_customize_agent_config.py | 342 ++++++++ 12 files changed, 1320 insertions(+), 649 deletions(-) create mode 100644 evoagentx/core/exception.py create mode 100644 evoagentx/memory/context_manager.py create mode 100644 evoagentx/prompts/customize_agent.py create mode 100644 evoagentx/prompts/output_extraction.py create mode 100644 tests/src/agents/test_customize_action.py create mode 100644 tests/src/agents/test_customize_agent_config.py diff --git a/evoagentx/actions/customize_action.py b/evoagentx/actions/customize_action.py index d96c81df..21ec2727 100644 --- a/evoagentx/actions/customize_action.py +++ b/evoagentx/actions/customize_action.py @@ -1,21 +1,35 @@ -from pydantic import Field -from typing import Optional, Any, Callable, List, Union -import re -import json import asyncio -import inspect -import concurrent.futures +import json +import re +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from typing import List, Optional, Union + +from pydantic import Field, PositiveInt +from ..core.exception import NoAnswerError from ..core.logging import logger -from ..models.base_model import BaseLLM -from .action import Action from ..core.message import Message -from ..prompts.template import StringTemplate, ChatTemplate -from ..prompts.tool_calling import OUTPUT_EXTRACTION_PROMPT, TOOL_CALLING_TEMPLATE, TOOL_CALLING_HISTORY_PROMPT, TOOL_CALLING_RETRY_PROMPT -from ..tools.tool import Toolkit -from ..core.registry import MODULE_REGISTRY -from ..models.base_model import LLMOutputParser from ..core.module_utils import parse_json_from_llm_output, parse_json_from_text +from ..memory.context_manager import ContextManager +from ..models import BaseLLM, LLMOutputParser, OpenRouterLLM +from ..prompts.customize_agent import ( + ANSWER_HINT, + ANSWER_PROMPT, + LAST_ATTEMPT_PROMPT, + NO_TOOL_CALL_PROMPT, + RETRY_TOOL_PROMPT, +) +from ..prompts.output_extraction import OUTPUT_EXTRACTION_PROMPT +from ..prompts.tool_calling import ( + TOOL_CALLING_RETRY_PROMPT, + TOOL_CALLING_TEMPLATE, +) +from ..prompts.utils import DEFAULT_SYSTEM_PROMPT +from ..tools.tool import Tool, Toolkit, ToolMetadata, ToolResult +from ..utils.utils import compile_tool_schemas, pydantic_to_parameters +from .action import Action + class CustomizeAction(Action): @@ -23,292 +37,172 @@ class CustomizeAction(Action): parse_func: Optional[Callable] = Field(default=None, exclude=True, description="the function to parse the LLM output. It receives the LLM output and returns a dict.") title_format: Optional[str] = Field(default="## {title}", exclude=True, description="the format of the title. It is used when the `parse_mode` is 'title'.") custom_output_format: Optional[str] = Field(default=None, exclude=True, description="the format of the output. It is used when the `prompt_template` is provided.") - - tools: Optional[List[Toolkit]] = Field(default=None, description="The tools that the action can use") + tools: Optional[List[Union[Tool, Toolkit]]] = Field(default=None, description="The tools that the action can use") conversation: Optional[Message] = Field(default=None, description="Current conversation state") + max_steps: PositiveInt = Field(default=20, description="The maximum number of LLM calls allowed") + max_tool_call_concurrency: PositiveInt = Field(default=5, description="The maximum number of tool calls that can be executed concurrently") - max_tool_try: int = Field(default=2, description="Maximum number of tool calling attempts allowed") - def __init__(self, **kwargs): name = kwargs.pop("name", "CustomizeAction") description = kwargs.pop("description", "Customized action that can use tools to accomplish its task") + tools = kwargs.pop("tools", None) super().__init__(name=name, description=description, **kwargs) - + # Validate that at least one of prompt or prompt_template is provided if not self.prompt and not self.prompt_template: raise ValueError("`prompt` or `prompt_template` is required when creating CustomizeAction action") # Prioritize template and give warning if both are provided if self.prompt and self.prompt_template: logger.warning("Both `prompt` and `prompt_template` are provided for CustomizeAction action. Prioritizing `prompt_template` and ignoring `prompt`.") - if self.tools: - self.tools_caller = {} - self.add_tools(self.tools) - - def prepare_action_prompt( - self, - inputs: Optional[dict] = None, - system_prompt: Optional[str] = None, - **kwargs - ) -> Union[str, List[dict]]: - """Prepare prompt for action execution. - - This helper function transforms the input dictionary into a formatted prompt - for the language model, handling different prompting modes. - - Args: - inputs: Dictionary of input parameters - system_prompt: Optional system prompt to include - - Returns: - Union[str, List[dict]]: Formatted prompt ready for LLM (string or chat messages) - - Raises: - TypeError: If an input value type is not supported - ValueError: If neither prompt nor prompt_template is available - """ - # Process inputs into prompt parameter values - if inputs is None: - inputs = {} - - prompt_params_names = self.inputs_format.get_attrs() - prompt_params_values = {} - for param in prompt_params_names: - value = inputs.get(param, "") - if isinstance(value, str): - prompt_params_values[param] = value - elif isinstance(value, (dict, list)): - prompt_params_values[param] = json.dumps(value, indent=4) - else: - raise TypeError(f"The input type {type(value)} is invalid! Valid types: [str, dict, list].") - - if self.prompt: - prompt = self.prompt.format(**prompt_params_values) if prompt_params_values else self.prompt - if self.tools: - tools_schemas = [j["function"] for i in [tool.get_tool_schemas() for tool in self.tools] for j in i] - prompt += "\n\n" + TOOL_CALLING_TEMPLATE.format(tools_description = tools_schemas) - return prompt - else: - # Use goal-based tool calling mode - if self.tools: - self.prompt_template.set_tools(self.tools) - return self.prompt_template.format( - system_prompt=system_prompt, - values=prompt_params_values, - inputs_format=self.inputs_format, - outputs_format=self.outputs_format, - parse_mode=self.parse_mode, - title_format=self.title_format, - custom_output_format=self.custom_output_format, - tools=self.tools - ) + + self.tools_caller = dict() + self.tools = [] + if tools: + self.add_tools(tools) + self.tool_schemas: List[dict] = compile_tool_schemas(self.tools) + + self.semaphore = asyncio.Semaphore(self.max_tool_call_concurrency) def prepare_extraction_prompt(self, llm_output_content: str) -> str: """Prepare extraction prompt for fallback extraction when parsing fails. - + Args: self: The action instance llm_output_content: Raw output content from LLM - + Returns: str: Formatted extraction prompt """ - attr_descriptions: dict = self.outputs_format.get_attr_descriptions() - output_description_list = [] - for i, (name, desc) in enumerate(attr_descriptions.items()): - output_description_list.append(f"{i+1}. {name}\nDescription: {desc}") - output_description = "\n\n".join(output_description_list) - return OUTPUT_EXTRACTION_PROMPT.format(text=llm_output_content, output_description=output_description) - - def _get_unique_class_name(self, candidate_name: str) -> str: - """ - Get a unique class name by checking if it already exists in the registry. - If it does, append "Vx" to make it unique. - """ - if not MODULE_REGISTRY.has_module(candidate_name): - return candidate_name - - i = 1 - while True: - unique_name = f"{candidate_name}V{i}" - if not MODULE_REGISTRY.has_module(unique_name): - break - i += 1 - return unique_name - - def add_tools(self, tools: Union[Toolkit, List[Toolkit]]): + ignore = ["class_name"] + + if not self.outputs_format._is_content_defined_in_subclass(): + ignore.append("content") + + output_params = pydantic_to_parameters(self.outputs_format, ignore=ignore) + output_params = [param.to_dict(ignore=["class_name"]) for param in output_params] + output_params_json = json.dumps(output_params, indent=4, ensure_ascii=False) + prompt = OUTPUT_EXTRACTION_PROMPT.format(text=llm_output_content, output_description=output_params_json) + return prompt + + def add_tools(self, tools: List[Union[Tool, Toolkit]]): if not tools: return - if isinstance(tools,Toolkit): - tools = [tools] - if not all(isinstance(tool, Toolkit) for tool in tools): - raise TypeError("`tools` must be a Toolkit or list of Toolkit instances.") - if not self.tools: - self.tools_caller = {} - self.tools = [] - # self.tools += tools - # tools_callers = [tool.get_tools() for tool in tools] - # tools_callers = [j for i in tools_callers for j in i] - # for tool_caller in tools_callers: - # self.tools_caller[tool_caller.name] = tool_caller - - # avoid duplication & type checks - for toolkit in tools: - try: - tool_callers = toolkit.get_tools() - if not isinstance(tool_callers, list): - logger.warning(f"Expected list of tool functions from '{toolkit.name}.get_tools()', got {type(tool_callers)}.") - continue - - # add tool callers to the tools_caller dictionary - valid_tools_count = 0 - valid_tools_names, valid_tool_callers = [], [] - for tool_caller in tool_callers: - tool_caller_name = getattr(tool_caller, "name", None) - if not tool_caller_name or not callable(tool_caller): - logger.warning(f"Invalid tool function in '{toolkit.name}': missing name or not callable.") - continue - if tool_caller_name in self.tools_caller: - logger.warning(f"Duplicate tool function '{tool_caller_name}' detected. Overwriting previous function.") - # self.tools_caller[tool_caller_name] = tool_caller - valid_tools_count += 1 - valid_tools_names.append(tool_caller_name) - valid_tool_callers.append(tool_caller) - - if valid_tools_count == 0: - logger.info(f"No valid tools found in toolkit '{toolkit.name}'. Skipping.") - continue - - if valid_tools_count > 0 and all(name in self.tools_caller for name in valid_tools_names): - logger.info(f"All tools from toolkit '{toolkit.name}' are already added. Skipping.") - continue - - if valid_tools_count > 0: - self.tools_caller.update({name: caller for name, caller in zip(valid_tools_names, valid_tool_callers)}) - - # only add toolkit if at least one valid tool is added and toolkit is not already added - existing_toolkit_names = {tkt.name for tkt in self.tools} - if valid_tools_count > 0 and toolkit.name not in existing_toolkit_names: - self.tools.append(toolkit) - if valid_tools_count > 0: - logger.info(f"Added toolkit '{toolkit.name}' with {valid_tools_count} valid tools in {self.name}: {valid_tools_names}.") - - except Exception as e: - logger.error(f"Failed to load tools from toolkit '{toolkit.name}': {e}") - - + + duplicate = False + # avoid duplication & type checks + for tool in tools: + new_tools: List[Tool] = [] + + if isinstance(tool, Toolkit): + new_tools = tool.get_tools() + elif isinstance(tool, Tool): + new_tools = [tool] + else: + raise ValueError(f"Invalid tool type: {type(tool)}") + + for new_tool in new_tools: + if not isinstance(new_tool, Tool): + raise ValueError(f"Invalid tool type: {type(new_tool)}") + + if not callable(new_tool): + raise ValueError(f"Invalid tool '{new_tool.name}' in '{tool.name}': not callable.") + + if new_tool.name in self.tools_caller: + logger.warning(f"Duplicate tool '{new_tool.name}' detected. Overwriting previous tool.") + duplicate = True + + # update tools caller + self.tools_caller[new_tool.name] = new_tool + + logger.info(f"Added '{tool.name}' to '{self.name}'") + + if duplicate: + self.tools = [t for t in self.tools if t.name != tool.name] + duplicate = False + + self.tools.append(tool) + # update tool schemas + self.tool_schemas = compile_tool_schemas(self.tools) + def _extract_tool_calls(self, llm_output: str, llm: Optional[BaseLLM] = None) -> List[dict]: - pattern = r"\s*(.*?)\s*" - - - # Find all ToolCalling blocks in the output - matches = re.findall(pattern, llm_output, re.DOTALL) + pattern = r"\s*(.*?)\s*" + # Find all tool call blocks in the output + matches = re.findall(pattern, llm_output, re.DOTALL) if not matches: return [] - + + # NOTE: This is a temporary workaround to address an issue where models + # sometimes include an extra block in the response, + # in addition to the native tool calls, which results in duplicated tool calls. + matches = [matches[-1]] + + def _parse_tool_calls(text: str) -> List[dict]: + text = text.strip() + json_list = parse_json_from_text(text) + if not json_list: + logger.warning("No valid JSON found in tool call block") + return [] + # Only use the first JSON string from each block + parsed_tool_call = json.loads(json_list[0]) + if isinstance(parsed_tool_call, dict): + return [parsed_tool_call] + elif isinstance(parsed_tool_call, list): + return parsed_tool_call + else: + logger.warning(f"Invalid tool call format: {parsed_tool_call}") + return [] + parsed_tool_calls = [] for match_content in matches: try: - json_content = match_content.strip() - json_list = parse_json_from_text(json_content) - if not json_list: - logger.warning("No valid JSON found in ToolCalling block") - continue - # Only use the first JSON string from each block - parsed_tool_call = json.loads(json_list[0]) - if isinstance(parsed_tool_call, dict): - parsed_tool_calls.append(parsed_tool_call) - elif isinstance(parsed_tool_call, list): - parsed_tool_calls.extend(parsed_tool_call) - else: - logger.warning(f"Invalid tool call format: {parsed_tool_call}") - continue + parsed_tool_calls.extend(_parse_tool_calls(match_content)) except (json.JSONDecodeError, IndexError) as e: logger.warning(f"Failed to parse tool calls from LLM output: {e}") if llm is not None: retry_prompt = TOOL_CALLING_RETRY_PROMPT.format(text=match_content) try: - fixed_output = llm.generate(prompt=retry_prompt).content.strip() - logger.info(f"Retrying tool call parse with fixed output:\n{fixed_output}") - - fixed_list = parse_json_from_text(fixed_output) - if fixed_list: - parsed_tool_call = json.loads(fixed_list[0]) - if isinstance(parsed_tool_call, dict): - parsed_tool_calls.append(parsed_tool_call) - elif isinstance(parsed_tool_call, list): - parsed_tool_calls.extend(parsed_tool_call) + logger.info("Fixing tool call with LLM...") + fixed_output = llm.generate(prompt=retry_prompt).content + logger.info(f"Retrying with fixed tool call:\n{fixed_output}") + parsed_tool_calls.extend(_parse_tool_calls(fixed_output)) except Exception as retry_err: logger.error(f"Retry failed: {retry_err}") - continue - else: - continue return parsed_tool_calls - - def _extract_output(self, llm_output: Any, llm: BaseLLM = None, **kwargs): + @staticmethod + def _extract_answer(llm_output: str) -> Union[str, None]: + pattern = r"\s*(.*?)\s*" + matches = re.findall(pattern, llm_output, re.DOTALL) + if matches: + final_answer = matches[0].strip() + return final_answer + return None + + def _extract_no_answer(self, llm_output: str) -> Union[str, None]: + pattern = r"\s*(.*?)\s*" + matches = re.findall(pattern, llm_output, re.DOTALL) + if matches: + final_answer = matches[0].strip() + return final_answer + return None + + async def _async_extract_output(self, llm_output: Union[str, LLMOutputParser], llm: BaseLLM = None, **kwargs) -> LLMOutputParser: # Get the raw output content llm_output_content = getattr(llm_output, "content", str(llm_output)) - - # Check if there are any defined output fields - output_attrs = self.outputs_format.get_attrs() - - # If no output fields are defined, create a simple content-only output - if not output_attrs: - # Create output with just the content field - output = self.outputs_format.parse(content=llm_output_content) - # print("Created simple content output for agent with no defined outputs:") - # print(output) - return output - - # Use the action's parse_mode and parse_func for parsing - try: - # Use the outputs_format's parse method with the action's parse settings - parsed_output = self.outputs_format.parse( - content=llm_output_content, - parse_mode=self.parse_mode, - parse_func=getattr(self, 'parse_func', None), - title_format=getattr(self, 'title_format', "## {title}") - ) - - # print("Successfully parsed output using action's parse settings:") - # print(parsed_output) - return parsed_output - - except Exception as e: - logger.info(f"Failed to parse with action's parse settings: {e}") - logger.info("Falling back to using LLM to extract outputs...") - - # Fall back to extraction prompt if direct parsing fails - extraction_prompt = self.prepare_extraction_prompt(llm_output_content) - - llm_extracted_output: LLMOutputParser = llm.generate(prompt=extraction_prompt) - llm_extracted_data: dict = parse_json_from_llm_output(llm_extracted_output.content) - output = self.outputs_format.from_dict(llm_extracted_data) - - # print("Extracted output using fallback:") - # print(output) - return output - - async def _async_extract_output(self, llm_output: Any, llm: BaseLLM = None, **kwargs): - - # Get the raw output content - llm_output_content = getattr(llm_output, "content", str(llm_output)) - + # Check if there are any defined output fields output_attrs = self.outputs_format.get_attrs() - + # If no output fields are defined, create a simple content-only output if not output_attrs: # Create output with just the content field output = self.outputs_format.parse(content=llm_output_content) - # print("Created simple content output for agent with no defined outputs:") - # print(output) return output - + # Use the action's parse_mode and parse_func for parsing try: # Use the outputs_format's parse method with the action's parse settings @@ -318,242 +212,254 @@ async def _async_extract_output(self, llm_output: Any, llm: BaseLLM = None, **kw parse_func=getattr(self, 'parse_func', None), title_format=getattr(self, 'title_format', "## {title}") ) - - # print("Successfully parsed output using action's parse settings:") - # print(parsed_output) return parsed_output - + except Exception as e: logger.info(f"Failed to parse with action's parse settings: {e}") logger.info("Falling back to using LLM to extract outputs...") - + # Fall back to extraction prompt if direct parsing fails extraction_prompt = self.prepare_extraction_prompt(llm_output_content) - + llm_extracted_output = await llm.async_generate(prompt=extraction_prompt) llm_extracted_data: dict = parse_json_from_llm_output(llm_extracted_output.content) - output = self.outputs_format.from_dict(llm_extracted_data) - - # print("Extracted output using fallback:") - # print(output) + output = self.outputs_format(**llm_extracted_data) return output - - def _call_single_tool(self, function_param: dict) -> tuple: + + async def _call_single_tool(self, function_param: dict) -> ToolResult: + tool_call_id = function_param.get("id") + function_name = function_param.get("function_name") or "" + function_args = function_param.get("function_args") or {} + + metadata = ToolMetadata( + tool_name=function_name, + args=function_args + ) + + if not function_name: + output = {"error": "No tool name provided"} + return ToolResult(result=output, metadata=metadata, id=tool_call_id) + + tool = self.tools_caller.get(function_name, None) + if tool is None: + output = {"error": f"Tool '{function_name}' not found"} + return ToolResult(result=output, metadata=metadata, id=tool_call_id) + + if not callable(tool): + output = {"error": f"Tool '{function_name}' is not callable"} + return ToolResult(result=output, metadata=metadata, id=tool_call_id) + try: - function_name = function_param.get("function_name") - function_args = function_param.get("function_args") or {} - - if not function_name: - return None, "No function name provided" - - callable_fn = self.tools_caller.get(function_name) - if not callable(callable_fn): - return None, f"Function '{function_name}' not found or not callable" - - print("_____________________ Start Function Calling _____________________") - print(f"Executing function calling: {function_name} with parameters: {function_args}") - result = callable_fn(**function_args) - return result, None + async with self.semaphore: + tool_args_str = json.dumps(function_args, indent=4, ensure_ascii=False) + logger.info(f"[Tool Call] Executing tool `{function_name}` with parameters:\n{tool_args_str}") + + if asyncio.iscoroutinefunction(tool.__call__): + result = await tool(**function_args) + else: + result = await asyncio.to_thread(tool, **function_args) + + # Adapter: tools may return a `ToolResult` directly, or a raw value. + # Wrap raw values into a `ToolResult` so the agent loop is uniform. + if isinstance(result, ToolResult): + result.id = tool_call_id + return result + return ToolResult(result=result, metadata=metadata, id=tool_call_id) except Exception as e: - logger.error(f"Error executing tool {function_name}: {e}") - return None, f"Error executing tool {function_name}: {str(e)}" + logger.exception(f"Error calling tool '{function_name}': {e}") + return ToolResult(result={"error": str(e)}, metadata=metadata, id=tool_call_id) + + async def _calling_tools(self, tool_call_args: List[dict]) -> List[ToolResult]: + tasks = [ + self._call_single_tool(args) + for args in tool_call_args + ] + + results = await asyncio.gather(*tasks) + return results + + def execute( + self, + llm: Optional[BaseLLM] = None, + inputs: Optional[dict] = None, + sys_msg: Optional[str] = None, + return_prompt: bool = False, + **kwargs + ): + + coro = self.async_execute( + llm=llm, + inputs=inputs, + sys_msg=sys_msg, + return_prompt=return_prompt, + **kwargs + ) - def _calling_tools(self, tool_call_args: List[dict]) -> dict: - ## ___________ Call the tools in parallel___________ - errors = [] - results = [] - - with concurrent.futures.ThreadPoolExecutor() as executor: - future_to_tool = {executor.submit(self._call_single_tool, param): param for param in tool_call_args} - - for future in concurrent.futures.as_completed(future_to_tool): - result, error = future.result() - if error: - errors.append(error) - if result is not None: - results.append(result) - - return {"result": results, "error": errors} - - async def _async_call_single_tool(self, function_param: dict) -> tuple: try: - function_name = function_param.get("function_name") - function_args = function_param.get("function_args") or {} + asyncio.get_running_loop() + except RuntimeError: + # No event loop is running in this thread: drive the coroutine directly. + return asyncio.run(coro) - if not function_name: - return None, "No function name provided" + # We are already inside a running event loop (e.g. `execute` was called from + # async code). `asyncio.run()` would raise `RuntimeError` here, so run the + # coroutine to completion on a dedicated thread that owns its own event loop. + with ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(asyncio.run, coro).result() - callable_fn = self.tools_caller.get(function_name) - if not callable(callable_fn): - return None, f"Function '{function_name}' not found or not callable" + async def async_execute( + self, + llm: Optional[BaseLLM] = None, + inputs: Optional[dict] = None, + sys_msg: Optional[str] = None, + return_prompt: bool = False, + context_manager: Optional[ContextManager] = None, + **kwargs + ): + if llm is None: + raise ValueError(f"LLM is required for CustomizeAction '{self.name}'.") - print("_____________________ Start Function Calling _____________________") - print(f"Executing function calling: {function_name} with parameters: {function_args}") + inputs = inputs or {} + self.inputs_format(**inputs) - if inspect.iscoroutinefunction(callable_fn): - result = await callable_fn(**function_args) - else: - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, lambda: callable_fn(**function_args)) - - return result, None - - except Exception as e: - logger.error(f"Error executing tool {function_name}: {e}") - return None, f"Error executing tool {function_name}: {str(e)}" - - async def _async_calling_tools(self, tool_call_args: List[dict]) -> dict: - ## ___________ Call the tools concurrently ___________ - tasks = [self._async_call_single_tool(param) for param in tool_call_args] - results_with_errors = await asyncio.gather(*tasks) - - results = [res for res, err in results_with_errors if err is None and res is not None] - errors = [err for _, err in results_with_errors if err is not None] - - return {"result": results, "error": errors} - - def execute(self, llm: Optional[BaseLLM] = None, inputs: Optional[dict] = None, sys_msg: Optional[str]=None, return_prompt: bool = False, time_out = 0, **kwargs): - # Allow empty inputs if the action has no required input attributes - input_attributes: dict = self.inputs_format.get_attr_descriptions() - if not inputs and input_attributes: - logger.error("CustomizeAction action received invalid `inputs`: None or empty.") - raise ValueError('The `inputs` to CustomizeAction action is None or empty.') - # Set inputs to empty dict if None and no inputs are required - if inputs is None: - inputs = {} - final_llm_response = None - - if self.prompt_template: + context_manager = await self.prepare_context(llm, inputs, sys_msg, context_manager) - if isinstance(self.prompt_template, ChatTemplate): - # must determine whether prompt_template is ChatTemplate first since ChatTemplate is a subclass of StringTemplate - conversation = self.prepare_action_prompt(inputs=inputs, system_prompt=sys_msg) - elif isinstance(self.prompt_template, StringTemplate): - conversation = [{"role": "system", "content": self.prepare_action_prompt(inputs=inputs, system_prompt=sys_msg)}] - else: - raise ValueError(f"`prompt_template` must be a StringTemplate or ChatTemplate instance, but got {type(self.prompt_template)}") - else: - conversation = [{"role": "system", "content": sys_msg}, {"role": "user", "content": self.prepare_action_prompt(inputs=inputs, system_prompt=sys_msg)}] - + final_answer = None + tool_calls = 0 + no_tool_call_answer = 0 + failed_tool_calls = 0 + iter = 0 + + is_anthropic = llm.config.model.startswith("anthropic/") + is_openrouter = isinstance(llm, OpenRouterLLM) + has_many_tools = self.tools and len(self.tools) > 1 + + llm_extra_kwargs = {} + if is_openrouter and is_anthropic and has_many_tools: + # Enable prompt caching + llm_extra_kwargs = {"cache_control": {"type": "ephemeral"}} - ## 1. get all the input parameters - prompt_params_values = {k: inputs.get(k, "") for k in input_attributes.keys()} while True: - ### Generate response from LLM - if time_out > self.max_tool_try: - # Get the appropriate prompt for return - current_prompt = self.prepare_action_prompt(inputs=prompt_params_values or {}) - # Use the final LLM response if available, otherwise fall back to execution history - content_to_extract = final_llm_response if final_llm_response is not None else "{content}".format(content = conversation) - if return_prompt: - return self._extract_output(content_to_extract, llm = llm), current_prompt - return self._extract_output(content_to_extract, llm = llm) - time_out += 1 - - # Handle both string prompts and chat message lists - llm_response = llm.generate(messages=conversation) - conversation.append({"role": "assistant", "content": llm_response.content}) - - # Store the final LLM response - final_llm_response = llm_response - - tool_call_args = self._extract_tool_calls(llm_response.content) - if not tool_call_args: + if iter >= self.max_steps: + logger.error(f"{self.name} exceeded maximum number of steps ({self.max_steps}).") + logger.info(f"[Final Output] `{self.name}` failed to produce the requested output within the maximum number of allowed attempts.") + raise NoAnswerError("Failed to produce the requested output within the maximum number of allowed attempts.") + + if iter == self.max_steps - 1: + context_manager.add_user_prompt(LAST_ATTEMPT_PROMPT) + + # todo: tools and extra_body might be OpenRouter specific, should be adapted for other LLMs + llm_response = await llm.async_generate( + messages=context_manager.context, + tools=self.tool_schemas if context_manager.mode == "openrouter" else None, + extra_body=llm_extra_kwargs + ) + + logger.info(f"[Raw LLM Response]: {llm_response.content}") + iter += 1 + + if no_tool_call_answer == 1 and "yes" in llm_response.content.lower(): break - - logger.info("Extracted tool call args:") - logger.info(json.dumps(tool_call_args, indent=4)) - - results = self._calling_tools(tool_call_args) - - logger.info("Tool call results:") - logger.info(json.dumps(results, indent=4)) - - conversation.append({"role": "assistant", "content": TOOL_CALLING_HISTORY_PROMPT.format( - iteration_number=time_out, - tool_call_args=f"{tool_call_args}", - results=f"{results}" - )}) - - # Get the appropriate prompt for return - current_prompt = self.prepare_action_prompt(inputs=prompt_params_values or {}) - # Use the final LLM response if available, otherwise fall back to execution history - content_to_extract = final_llm_response if final_llm_response is not None else "{content}".format(content = conversation) - if return_prompt: - return self._extract_output(content_to_extract, llm = llm), current_prompt - return self._extract_output(content_to_extract, llm = llm) - - async def async_execute(self, llm: Optional[BaseLLM] = None, inputs: Optional[dict] = None, sys_msg: Optional[str]=None, return_prompt: bool = False, time_out = 0, **kwargs): - # Allow empty inputs if the action has no required input attributes - input_attributes: dict = self.inputs_format.get_attr_descriptions() - if not inputs and input_attributes: - logger.error("CustomizeAction action received invalid `inputs`: None or empty.") - raise ValueError('The `inputs` to CustomizeAction action is None or empty.') - # Set inputs to empty dict if None and no inputs are required - if inputs is None: - inputs = {} - final_llm_response = None - - if self.prompt_template: - if isinstance(self.prompt_template, ChatTemplate): - # must determine whether prompt_template is ChatTemplate first since ChatTemplate is a subclass of StringTemplate - conversation = self.prepare_action_prompt(inputs=inputs, system_prompt=sys_msg) - elif isinstance(self.prompt_template, StringTemplate): - conversation = [{"role": "system", "content": self.prepare_action_prompt(inputs=inputs, system_prompt=sys_msg)}] - else: - raise ValueError(f"`prompt_template` must be a StringTemplate or ChatTemplate instance, but got {type(self.prompt_template)}") - else: - conversation = [{"role": "system", "content": sys_msg}, {"role": "user", "content": self.prepare_action_prompt(inputs=inputs, system_prompt=sys_msg)}] - - - ## 1. get all the input parameters - prompt_params_values = {k: inputs.get(k, "") for k in input_attributes.keys()} - while True: - ### Generate response from LLM - if time_out > self.max_tool_try: - # Get the appropriate prompt for return - current_prompt = self.prepare_action_prompt(inputs=prompt_params_values or {}) - # Use the final LLM response if available, otherwise fall back to execution history - content_to_extract = final_llm_response if final_llm_response is not None else "{content}".format(content = conversation) - if return_prompt: - return await self._async_extract_output(content_to_extract, llm = llm), current_prompt - return await self._async_extract_output(content_to_extract, llm = llm) - time_out += 1 - - # Handle both string prompts and chat message lists - llm_response = await llm.async_generate(messages=conversation) - conversation.append({"role": "assistant", "content": llm_response.content}) - - # Store the final LLM response - final_llm_response = llm_response - - tool_call_args = self._extract_tool_calls(llm_response.content) + tool_call_args = self._extract_tool_calls(llm_response.content, llm=llm) + if not tool_call_args: - break - - logger.info("Extracted tool call args:") - logger.info(json.dumps(tool_call_args, indent=4)) - - results = self._calling_tools(tool_call_args) - - logger.info("Tool call results:") - try: - logger.info(json.dumps(results, indent=4)) - except Exception: - logger.info(str(results)) - - conversation.append({"role": "assistant", "content": TOOL_CALLING_HISTORY_PROMPT.format( - iteration_number=time_out, - tool_call_args=f"{tool_call_args}", - results=f"{results}" - )}) - - # Get the appropriate prompt for return - current_prompt = self.prepare_action_prompt(inputs=prompt_params_values or {}) - # Use the final LLM response if available, otherwise fall back to execution history - content_to_extract = final_llm_response if final_llm_response is not None else "{content}".format(content = conversation) + context_manager.add_llm_response(llm_response.content) + final_answer = CustomizeAction._extract_answer(llm_response.content) + if final_answer is not None: + if self.tools and tool_calls == 0 and no_tool_call_answer == 0: + # if tools are provided but no tool call has been made, + # ask to confirm no tool is needed for final answer (only ask once) + context_manager.add_user_prompt(NO_TOOL_CALL_PROMPT) + no_tool_call_answer += 1 + continue + break + + no_answer = self._extract_no_answer(llm_response.content) + if no_answer is not None: + logger.error(f"{self.name} was unable to produce requested output: {no_answer}") + raise NoAnswerError(no_answer) + + context_manager.add_user_prompt(ANSWER_HINT) + continue + + non_tool_call_response = llm_response.content.split("", 1)[0].strip() or None + context_manager.add_llm_response(non_tool_call_response, tool_calls=tool_call_args) + + tool_results = await self._calling_tools(tool_call_args) + tool_calls += 1 + + context_manager.add_tool_results(tool_results) + for result in tool_results: + result_str = json.dumps(result.result, indent=4, ensure_ascii=False) + logger.info(f"[Tool Call] Executed tool `{result.metadata.tool_name}` results:\n{result_str}") + + if failed_tool_calls == 0: + # if this is the first time any tool has failed, ask agent to retry. + for result in tool_results: + if isinstance(result.result, dict) and "error" in result.result: + failed_tool_calls += 1 + context_manager.add_user_prompt(RETRY_TOOL_PROMPT) + break + + final_output = await self._async_extract_output(final_answer, llm=llm) + logger.info(f"[Final Output] `{self.name}` final output:\n{final_output.to_str()}") + if return_prompt: - return await self._async_extract_output(content_to_extract, llm = llm), current_prompt - return await self._async_extract_output(content_to_extract, llm = llm) \ No newline at end of file + system_prompt = context_manager.get_system_prompt() + + user_prompt = "" + for msg in context_manager.context: + if msg["role"] == "user": + user_prompt = msg["content"] + break + + return final_output, f"\n{system_prompt}\n\n\n----\n\n\n{user_prompt}\n" + return final_output + + async def prepare_context( + self, + llm: BaseLLM, + inputs: Optional[dict] = None, + sys_msg: Optional[str] = None, + context_manager: Optional[ContextManager] = None, + ) -> ContextManager: + + inputs = inputs or {} + + if context_manager is None: + context_manager = ContextManager(llm=llm) + elif len(context_manager.context) > 0: + return context_manager + + if self.prompt_template is not None: + context_manager.add_prompt_template( + self.prompt_template, + sys_msg=sys_msg, + values=inputs, + inputs_format=self.inputs_format, + outputs_format=self.outputs_format, + parse_mode=self.parse_mode, + title_format=self.title_format, + custom_output_format=self.custom_output_format, + tools=self.tools + ) + elif self.prompt is not None: + sys_msg = sys_msg or DEFAULT_SYSTEM_PROMPT + context_manager.add_system_prompt(sys_msg) + user_prompt = self.prompt.format(**inputs) + # Only append the textual tool-calling guide when tools are actually + # available AND we are not using native tool calling. Without tools, + # the guide's web_search/code_execution examples can induce the model + # to emit blocks for non-existent tools, causing loops or + # failures. In OpenRouter native mode, tools are passed to the model + # directly, so the textual guide would only duplicate the prompt. + if self.tools and context_manager.mode != "openrouter": + user_prompt += "\n\n" + TOOL_CALLING_TEMPLATE.format( + tool_descriptions=json.dumps(self.tool_schemas, indent=4, ensure_ascii=False) + ) + context_manager.add_user_prompt(user_prompt) + + context_manager.add_system_prompt(ANSWER_PROMPT) + return context_manager diff --git a/evoagentx/agents/agent_manager.py b/evoagentx/agents/agent_manager.py index 63d64671..1ea39c4d 100644 --- a/evoagentx/agents/agent_manager.py +++ b/evoagentx/agents/agent_manager.py @@ -2,16 +2,16 @@ from enum import Enum from typing import Union, Optional, Dict, List from pydantic import Field -from copy import deepcopy from .agent import Agent -# from .agent_generator import AgentGenerator -from .customize_agent import CustomizeAgent from ..core.module import BaseModule from ..core.decorators import atomic_method from ..storages.base import StorageHandler from ..models.model_configs import LLMConfig -from ..tools.tool import Toolkit, Tool +from ..tools.tool import Tool, Toolkit +from ..utils.utils import create_agent_from_dict + + class AgentState(str, Enum): AVAILABLE = "available" RUNNING = "running" @@ -29,8 +29,6 @@ class AgentManager(BaseModule): agents: List[Agent] = Field(default_factory=list) agent_states: Dict[str, AgentState] = Field(default_factory=dict) # agent_name to AgentState mapping storage_handler: Optional[StorageHandler] = None # used to load and save agent from storage. - # agent_generator: Optional[AgentGenerator] = None # used to generate agents for a specific subtask - tools: Optional[List[Union[Toolkit, Tool]]] = None def init_module(self): self._lock = threading.Lock() @@ -98,22 +96,23 @@ def size(self): """ return len(self.agents) - def load_agent(self, agent_name: str, **kwargs) -> Agent: + def load_agent(self, agent_name: str, tools: Optional[List[Union[Tool, Toolkit]]] = None, **kwargs) -> Agent: """Load an agent from local storage through storage_handler. - + Retrieves agent data from storage and creates an Agent instance. - + Args: agent_name: The name of the agent to load + tools: Optional list of tools available for the agent **kwargs (Any): Additional parameters for agent creation - + Returns: Agent instance with data loaded from storage """ if not self.storage_handler: raise ValueError("must provide ``self.storage_handler`` to use ``load_agent``") agent_data = self.storage_handler.load_agent(agent_name=agent_name) - agent: Agent = self.create_customize_agent(agent_data=agent_data) + agent: Agent = create_agent_from_dict(agent_dict=agent_data, tools=tools, **kwargs) return agent def load_all_agents(self, **kwargs): @@ -127,115 +126,6 @@ def load_all_agents(self, **kwargs): """ pass - def update_tools(self, agent_data: dict) -> None: - """ - Update agent_data with tools based on tool_names. - - Handles four scenarios: - 1. Neither tool_names nor tools exist: return directly - 2. Only tool_names exists: resolve tool_names to tools and set tools field - 3. Only tools exists: return directly (no action needed) - 4. Both exist: merge tool_names into existing tools (skip duplicates) - - Args: - agent_data (dict): Agent configuration dictionary that may contain 'tool_names' and/or 'tools' - - Raises: - ValueError: If tool_names exist but self.tools is None, or if requested tools are not found - """ - tool_names = agent_data.get("tool_names", None) - existing_tools = agent_data.get("tools", None) - - # Case 1: Neither tool_names nor tools exist - if not tool_names and not existing_tools: - return - - # Case 3: Only tools exist (no tool_names) - if not tool_names and existing_tools: - return - - # For cases 2 and 4: tool_names exists, need to resolve - if self.tools is None: - raise ValueError( - f"Agent requires tools {tool_names}, but no tools are available in AgentManager. " - f"Please set self.tools before creating agents with tool_names." - ) - - # Create tool mapping from available tools - tool_mapping = {} - for tool in self.tools: - tool_mapping[tool.name] = tool - - # Case 2: Only tool_names exists - initialize empty tools list - if tool_names and not existing_tools: - existing_tools = [] - - # Case 2 & 4: Process tool_names (either with empty or existing tools list) - if tool_names: - # Create a set of existing tool names for quick lookup - existing_tool_names = {tool.name for tool in existing_tools} - - tools_to_add = [] - missing_tools = [] - - for tool_name in tool_names: - # Skip if tool already exists in tools - if tool_name in existing_tool_names: - continue - - # Try to resolve new tool - if tool_name in tool_mapping: - tools_to_add.append(tool_mapping[tool_name]) - else: - missing_tools.append(tool_name) - - if missing_tools: - available_tools = list(tool_mapping.keys()) - raise ValueError( - f"The following tools are not available: {missing_tools}. " - f"Available tools: {available_tools}" - ) - - # Merge new tools with existing ones - if tools_to_add: - agent_data["tools"] = list(existing_tools) + tools_to_add - - def create_customize_agent(self, agent_data: dict, llm_config: Optional[Union[LLMConfig, dict]]=None, **kwargs) -> CustomizeAgent: - """ - create a customized agent from the provided `agent_data`. - - Args: - agent_data: The data used to create an Agent instance, must contain 'name', 'description' and 'prompt' keys. - llm_config (Optional[LLMConfig]): The LLM configuration to be used for the agent. - It will be used as the default LLM for agents without a `llm_config` key. - If not provided, the `agent_data` should contain a `llm_config` key. - If provided and `agent_data` contains a `llm_config` key, the `llm_config` in `agent_data` will be used. - **kwargs (Any): Additional parameters for agent creation - - Returns: - Agent: the instantiated agent instance. - """ - - agent_data = deepcopy(agent_data) - agent_llm_config = agent_data.get("llm_config", llm_config) - if not agent_data.get("is_human", False) and not agent_llm_config: - raise ValueError("`agent_data` should contain a `llm_config` key or `llm_config` should be provided.") - - if agent_llm_config: - if isinstance(agent_llm_config, dict): - agent_data["llm_config"] = agent_llm_config - elif isinstance(agent_llm_config, LLMConfig): - agent_data["llm_config"] = agent_llm_config.to_dict() - - # tool_mapping = {} - # if self.tools is not None: - # for tool in self.tools: - # tool_mapping[tool.name] = tool - # if agent_data.get("tool_names", None): - # agent_data["tools"] = [tool_mapping[tool_name] for tool_name in agent_data["tool_names"]] - self.update_tools(agent_data=agent_data) # add `tools` field if needed - return CustomizeAgent.from_dict(data=agent_data) - def get_agent_name(self, agent: Union[str, dict, Agent]) -> str: """Extract agent name from different agent representations. @@ -259,7 +149,7 @@ def get_agent_name(self, agent: Union[str, dict, Agent]) -> str: raise ValueError(f"{type(agent)} is not a supported type for ``get_agent_name``. Supported types: [str, dict, Agent].") return agent_name - def create_agent(self, agent: Union[str, dict, Agent], llm_config: Optional[LLMConfig]=None, **kwargs) -> Agent: + def create_agent(self, agent: Union[str, dict, Agent], llm_config: Optional[LLMConfig]=None, tools: Optional[List[Union[Tool, Toolkit]]]=None, **kwargs) -> Agent: if isinstance(agent, str): if self.storage_handler is None: @@ -269,11 +159,11 @@ def create_agent(self, agent: Union[str, dict, Agent], llm_config: Optional[LLMC return self.get_agent(agent_name=agent) else: # if self.storage_handler is not None, the agent (str) must exist in the storage and will be loaded from the storage. - agent_instance = self.load_agent(agent_name=agent) + agent_instance = self.load_agent(agent_name=agent, tools=tools) elif isinstance(agent, dict): if not agent.get("is_human", False) and (llm_config is None and "llm_config" not in agent): raise ValueError("When providing an agent as a dictionary, you must either include 'llm_config' in the dictionary or provide it as a parameter.") - agent_instance = self.create_customize_agent(agent_data=agent, llm_config=llm_config, **kwargs) + agent_instance = create_agent_from_dict(agent_dict=agent, llm_config=llm_config, tools=tools, **kwargs) elif isinstance(agent, Agent): agent_instance = agent else: @@ -281,7 +171,7 @@ def create_agent(self, agent: Union[str, dict, Agent], llm_config: Optional[LLMC return agent_instance @atomic_method - def add_agent(self, agent: Union[str, dict, Agent], llm_config: Optional[LLMConfig]=None, **kwargs): + def add_agent(self, agent: Union[str, dict, Agent], llm_config: Optional[LLMConfig]=None, tools: Optional[List[Union[Tool, Toolkit]]]=None, **kwargs): """ add a single agent, ignore if the agent already exists (judged by the name of an agent). @@ -290,42 +180,35 @@ def add_agent(self, agent: Union[str, dict, Agent], llm_config: Optional[LLMConf - String: Agent name to load from storage - Dictionary: Agent specification to create a CustomizeAgent - Agent: Existing Agent instance to add directly - llm_config (Optional[LLMConfig]): The LLM configuration to be used for the agent. Only used when the `agent` is a dictionary, used to create a CustomizeAgent. + llm_config (Optional[LLMConfig]): The LLM configuration to be used for the agent. Only used when the `agent` is a dictionary, used to create a CustomizeAgent. + tools: Optional list of tools available for the agent, used to resolve tool_names in agent config. **kwargs (Any): Additional parameters for agent creation """ - # Check for 'tool' key and convert it to 'tools' if needed - # if isinstance(agent, dict) and "tool_names" in agent: - # tools_mapping = {} - # if self.tools is not None: - # for tool in self.tools: - # tools_mapping[tool.name] = tool - # agent["tools"] = [tools_mapping[tool_name] for tool_name in agent["tool_names"]] - # agent["tools"] = [tool if isinstance(tool, Toolkit) else Toolkit(name=tool.name, tools=[tool]) for tool in agent["tools"]] - agent_name = self.get_agent_name(agent=agent) if self.has_agent(agent_name=agent_name): return - agent_instance = self.create_agent(agent=agent, llm_config=llm_config, **kwargs) + agent_instance = self.create_agent(agent=agent, llm_config=llm_config, tools=tools, **kwargs) self.agents.append(agent_instance) self.agent_states[agent_instance.name] = AgentState.AVAILABLE if agent_instance.name not in self._state_conditions: self._state_conditions[agent_instance.name] = threading.Condition() self.check_agents() - def add_agents(self, agents: List[Union[str, dict, Agent]], llm_config: Optional[LLMConfig]=None, **kwargs): + def add_agents(self, agents: List[Union[str, dict, Agent]], llm_config: Optional[LLMConfig]=None, tools: Optional[List[Union[Tool, Toolkit]]]=None, **kwargs): """ add several agents by using self.add_agent(). """ for agent in agents: - self.add_agent(agent=agent, llm_config=llm_config, **kwargs) + self.add_agent(agent=agent, llm_config=llm_config, tools=tools, **kwargs) - def add_agents_from_workflow(self, workflow_graph, llm_config: Optional[LLMConfig]=None, **kwargs): + def add_agents_from_workflow(self, workflow_graph, llm_config: Optional[LLMConfig]=None, tools: Optional[List[Union[Tool, Toolkit]]]=None, **kwargs): """ - Initialize agents from the nodes of a given WorkFlowGraph and add these agents to self.agents. + Initialize agents from the nodes of a given WorkFlowGraph and add these agents to self.agents. Args: workflow_graph (WorkFlowGraph): The workflow graph containing nodes with agents information. llm_config (Optional[LLMConfig]): The LLM configuration to be used for the agents. + tools: Optional list of tools available for the agents, used to resolve tool_names in agent config. **kwargs (Any): Additional parameters passed to add_agent """ from ..workflow.workflow_graph import WorkFlowGraph @@ -334,15 +217,16 @@ def add_agents_from_workflow(self, workflow_graph, llm_config: Optional[LLMConfi for node in workflow_graph.nodes: if node.agents: for agent in node.agents: - self.add_agent(agent=agent, llm_config=llm_config, **kwargs) + self.add_agent(agent=agent, llm_config=llm_config, tools=tools, **kwargs) - def update_agents_from_workflow(self, workflow_graph, llm_config: Optional[LLMConfig]=None, **kwargs): + def update_agents_from_workflow(self, workflow_graph, llm_config: Optional[LLMConfig]=None, tools: Optional[List[Union[Tool, Toolkit]]]=None, **kwargs): """ Update agents from a given WorkFlowGraph. Args: workflow_graph (WorkFlowGraph): The workflow graph containing nodes with agents information. llm_config (Optional[LLMConfig]): The LLM configuration to be used for the agents. + tools: Optional list of tools available for the agents, used to resolve tool_names in agent config. **kwargs: Additional parameters passed to update_agent """ from ..workflow.workflow_graph import WorkFlowGraph @@ -355,9 +239,9 @@ def update_agents_from_workflow(self, workflow_graph, llm_config: Optional[LLMCo if self.has_agent(agent_name=agent_name): # use the llm_config of the existing agent agent_llm_config = self.get_agent(agent_name).llm_config - self.update_agent(agent=agent, llm_config=agent_llm_config, **kwargs) + self.update_agent(agent=agent, llm_config=agent_llm_config, tools=tools, **kwargs) else: - self.add_agent(agent=agent, llm_config=llm_config, **kwargs) + self.add_agent(agent=agent, llm_config=llm_config, tools=tools, **kwargs) def get_agent(self, agent_name: str, **kwargs) -> Agent: """Retrieve an agent by its name from managed agents. @@ -376,7 +260,7 @@ def get_agent(self, agent_name: str, **kwargs) -> Agent: return agent raise ValueError(f"Agent ``{agent_name}`` does not exists!") - def update_agent(self, agent: Union[dict, Agent], llm_config: Optional[LLMConfig]=None, **kwargs): + def update_agent(self, agent: Union[dict, Agent], llm_config: Optional[LLMConfig]=None, tools: Optional[List[Union[Tool, Toolkit]]]=None, **kwargs): """ Update an agent in the manager. @@ -385,10 +269,11 @@ def update_agent(self, agent: Union[dict, Agent], llm_config: Optional[LLMConfig - Dictionary: Agent specification to update a CustomizeAgent - Agent: Existing Agent instance to update llm_config (Optional[LLMConfig]): The LLM configuration to be used for the agent. + tools: Optional list of tools available for the agent, used to resolve tool_names in agent config. """ agent_name = self.get_agent_name(agent=agent) self.remove_agent(agent_name=agent_name) - self.add_agent(agent=agent, llm_config=llm_config, **kwargs) + self.add_agent(agent=agent, llm_config=llm_config, tools=tools, **kwargs) @atomic_method def remove_agent(self, agent_name: str, remove_from_storage: bool=False, **kwargs): diff --git a/evoagentx/core/exception.py b/evoagentx/core/exception.py new file mode 100644 index 00000000..f88e85c0 --- /dev/null +++ b/evoagentx/core/exception.py @@ -0,0 +1,8 @@ +class DisplayableException(Exception): + pass + +class NoAnswerError(DisplayableException): + pass + +class InputValidationError(DisplayableException): + pass diff --git a/evoagentx/memory/context_manager.py b/evoagentx/memory/context_manager.py new file mode 100644 index 00000000..109c6420 --- /dev/null +++ b/evoagentx/memory/context_manager.py @@ -0,0 +1,167 @@ +import json +from typing import List, Optional, Union + +from ..models import BaseLLM, OpenRouterLLM +from ..prompts.template import ChatTemplate, PromptTemplate, StringTemplate +from ..prompts.tool_calling import TOOL_CALL_FORMAT, TOOL_CALLING_HISTORY_PROMPT +from ..prompts.utils import DEFAULT_SYSTEM_PROMPT +from ..tools.tool import ToolResult + + +class ContextManager: + def __init__( + self, + llm: BaseLLM, + system_prompt: Optional[str] = None + ): + self.context = [] + self.llm = llm + + if isinstance(llm, OpenRouterLLM): + self.mode = "openrouter" + else: + self.mode = "default" + + if system_prompt is not None: + self.context.append({"role": "system", "content": system_prompt}) + + + def add_system_prompt(self, system_prompt: str): + if len(self.context) == 0: + self.context.append({"role": "system", "content": system_prompt}) + return + + for i, msg in enumerate(self.context): + if msg["role"] == "system": + self.context[i]["content"] += "\n\n" + system_prompt + return + + self.context.insert(0, {"role": "system", "content": system_prompt}) + + + def replace_system_prompt(self, system_prompt: str): + for i, msg in enumerate(self.context): + if msg["role"] == "system": + self.context[i]["content"] = system_prompt + return + + self.context.insert(0, {"role": "system", "content": system_prompt}) + + + def add_prompt_template(self, prompt_template: PromptTemplate, sys_msg: Optional[str] = None, **kwargs): + if isinstance(prompt_template, ChatTemplate): + if self.mode == "openrouter": + # if using OpenRouter models, remove tools from the prompt template + # because we use native tool calling support from OpenRouter + template_copy = prompt_template.copy() + template_copy.tools = None + prompts = template_copy.format(**kwargs) + else: + prompts = prompt_template.format(**kwargs) + + if prompts[0]["role"] == "system": + self.replace_system_prompt(prompts[0]["content"]) + self.context.extend(prompts[1:]) + else: + self.context.extend(prompts) + + elif isinstance(prompt_template, StringTemplate): + # `StringTemplate.format()` returns one consolidated prompt, so mirror the + # `self.prompt` path: `sys_msg` (or the default) becomes the system message + # and the whole formatted string becomes the user message. + self.add_system_prompt(sys_msg or DEFAULT_SYSTEM_PROMPT) + self.add_user_prompt(prompt_template.format(**kwargs)) + + else: + raise TypeError(f"Invalid prompt template type {type(prompt_template)}.") + + + def add_user_prompt(self, user_prompt: Union[str, list]): + if len(self.context) == 0 or self.context[-1]["role"] != "user": + self.context.append({"role": "user", "content": user_prompt}) + return + + if isinstance(user_prompt, list): + last_msg = self.context[-1]["content"] + + if isinstance(last_msg, str): + merged_prompt = [{"type": "text", "text": last_msg}, *user_prompt] + self.context[-1]["content"] = merged_prompt + else: + self.context[-1]["content"] = last_msg + user_prompt + + else: + self.context[-1]["content"] += "\n\n" + user_prompt + + + def add_tool_results(self, tool_results: List[ToolResult]): + if self.mode == "default": + formatted_tool_results = [] + for result in tool_results: + formatted_tool_results.append({ + "tool_name": result.metadata.tool_name, + "result": result.result + }) + + tool_results_str = json.dumps(formatted_tool_results, indent=4, ensure_ascii=False) + self.context.append({ + "role": "user", + "content": TOOL_CALLING_HISTORY_PROMPT.format(results=tool_results_str) + }) + + elif self.mode == "openrouter": + for result in tool_results: + result_str = json.dumps(result.result, indent=4, ensure_ascii=False) + + self.context.append({ + "role": "tool", + "content": result_str, + "tool_call_id": getattr(result, "id", None) + }) + + + def add_llm_response(self, llm_response: Optional[str] = None, tool_calls: Optional[List[dict]] = None): + + if llm_response is None and tool_calls is None: + raise ValueError("Either `llm_response` or `tool_calls` must be provided.") + + if self.mode == "default": + response = llm_response or "" + + if tool_calls is not None: + response += TOOL_CALL_FORMAT.format(tool_calls=json.dumps(tool_calls, indent=4, ensure_ascii=False)) + + self.context.append({"role": "assistant", "content": response}) + + elif self.mode == "openrouter": + if not tool_calls: + self.context.append({"role": "assistant", "content": llm_response}) + return + + formatted_tool_calls = [] + + for tool_call in tool_calls: + tool_args = json.dumps(tool_call["function_args"], indent=4, ensure_ascii=False) + formatted_tool_calls.append( + { + "id": tool_call["id"], + "function": { + "name": tool_call["function_name"], + "arguments": tool_args + }, + "type": "function" + } + ) + + self.context.append({ + "role": "assistant", + "content": llm_response, + "tool_calls": formatted_tool_calls + }) + + + def get_system_prompt(self) -> str: + for msg in self.context: + if msg["role"] == "system": + return msg["content"] + return "" diff --git a/evoagentx/models/base_model.py b/evoagentx/models/base_model.py index 6f3b1700..9fdee362 100644 --- a/evoagentx/models/base_model.py +++ b/evoagentx/models/base_model.py @@ -792,23 +792,23 @@ def batch_generate(self, batch_messages: List[List[dict]], **kwargs) -> List[str """ pass + @abstractmethod async def single_generate_async(self, messages: List[dict], **kwargs) -> str: """Asynchronously generates LLM output for a single set of messages. - - This default implementation wraps the synchronous method in an async executor. - Subclasses should override this for true async implementation if supported. - + + Subclasses must provide a true async implementation. There is intentionally + no default that wraps `single_generate` in an executor: such a wrapper both + mishandles `**kwargs` through `run_in_executor` and silently diverges from the + provider's real async path (which can break test mocking and behavior parity). + Args: messages: The input messages to the LLM in chat format. **kwargs (Any): Additional keyword arguments for generation settings. - + Returns: The generated output text from the LLM. """ - # Default implementation for backward compatibility - loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, self.single_generate, messages, **kwargs) - return result + pass async def batch_generate_async(self, batch_messages: List[List[dict]], **kwargs) -> List[str]: """Asynchronously generates outputs for a batch of message sets. diff --git a/evoagentx/prompts/customize_agent.py b/evoagentx/prompts/customize_agent.py new file mode 100644 index 00000000..81cf2401 --- /dev/null +++ b/evoagentx/prompts/customize_agent.py @@ -0,0 +1,22 @@ +ANSWER_HINT = ( + "You have not provided a final answer or made a tool call. If you are ready to give your final answer, enclose it within and tags. " + "If you cannot complete the task, provide a concise error message explaining why it cannot be completed without using first-person pronouns, and enclose the message in and tags." +) + +ANSWER_PROMPT = ( + "When you are ready to provide your final answer after performing any necessary tool calls, enclose your final answer (including all required outputs in their specified formats) within and tags. " + "If you cannot complete the task, provide a concise error message explaining why it cannot be completed without using first-person pronouns, and enclose the message in and tags." +) + +NO_TOOL_CALL_PROMPT = ( + "You haven't used any of the provided tools. Are you absolutely certain that none are needed to obtain your final answer? " + "If you are certain that none are needed, return a single 'yes'. " +) + +LAST_ATTEMPT_PROMPT = ( + "This is your last attempt to provide a final answer. " + "If you are ready to give your final answer, enclose it within and tags. " + "If you cannot complete the task, provide a concise error message explaining why it cannot be completed without using first-person pronouns, and enclose the message in and tags." +) + +RETRY_TOOL_PROMPT = "An error occurred while executing a tool. Review the error message and retry, adjusting the arguments if necessary." diff --git a/evoagentx/prompts/output_extraction.py b/evoagentx/prompts/output_extraction.py new file mode 100644 index 00000000..75024581 --- /dev/null +++ b/evoagentx/prompts/output_extraction.py @@ -0,0 +1,104 @@ +OUTPUT_EXTRACTION_PROMPT = """ +## Task +You are given a piece of unstructured text and a list of fields to extract. Each field includes: +- `name`: The name of the field. +- `description`: A description of the field. +- `type`: The type of the field. +- `required`: Whether the field is required. +- `json_schema`: The JSON schema for the field. If provided, your output must strictly follow the JSON schema. + +Your task is to analyze the text carefully and generate a valid JSON object that includes all of the requested fields + +## Instructions +1. Read through the provided text carefully. +2. For each of the listed fields, analyze the relevant information from the text and generate a well-formulated response. +3. You may summarize, process, restructure, or enhance the information as needed to provide the best possible answer. +4. Your analysis should be faithful to the content but can go beyond simple extraction - provide meaningful insights where appropriate. +5. Return your processed outputs in a single JSON object, where the JSON keys **exactly match** the field names provided in **Fields** section. +6. If there is insufficient information for an output follow the following rules: + - If the field is not required, set it to `null`. + - If the field is required and `type` is `string`, return an empty string `""`. + - If the field is required and `type` is `array`, return an empty array `[]`. + - If the field is required and `type` is `object`, return an empty object `{{}}`. + - If the field is required and `type` is none of the above, provide your best reasonable inference. +7. Do not include any additional keys in the JSON. +8. Your final output should be valid JSON and should not include any explanatory text. + +## Example +### Text +Alex is a designer with 5 years of experience. He currently lives in Berlin. He speaks English, German, and a bit of French. You can contact him at alex.design@mail.com + +### Fields +```json +[ + {{ + "name": "person_name", + "type": "string", + "description": "name of the person", + "required": true + }}, + {{ + "name": "email", + "type": "string", + "description": "email of the person", + "required": true + }}, + {{ + "name": "location", + "type": "string", + "description": "current city or location of the person", + "required": true + }}, + {{ + "name": "languages", + "type": "array", + "description": "languages the person speaks", + "required": true, + "json_schema": {{ + "type": "array", + "items": {{ + "type": "string" + }} + }} + }}, + {{ + "name": "specialties", + "type": "array", + "description": "a list of the person's specialties", + "required": false, + "json_schema": {{ + "type": "array", + "items": {{ + "type": "string" + }} + }} + }} +] +``` + +### Output +```json +{{ + "person_name": "Alex", + "email": "alex.design@mail.com", + "location": "Berlin", + "languages": ["English", "German", "French"], + "specialties": ["design"] +}} +``` + +--- + +Now let's begin! + +## Text +{text} + +## Fields +```json +{output_description} +``` + +## Output +```json +""" \ No newline at end of file diff --git a/evoagentx/prompts/tool_calling.py b/evoagentx/prompts/tool_calling.py index d4a5c8f5..ffe2cc5e 100644 --- a/evoagentx/prompts/tool_calling.py +++ b/evoagentx/prompts/tool_calling.py @@ -93,34 +93,6 @@ ``` """ - -OUTPUT_EXTRACTION_PROMPT = """ -You are given the following text: -{text} - -We need you to process this text and generate high-quality outputs for each of the following fields: -{output_description} - -**Instructions:** -1. Read through the provided text carefully. -2. For each of the listed output fields, analyze the relevant information from the text and generate a well-formulated response. -3. You may summarize, process, restructure, or enhance the information as needed to provide the best possible answer. -4. Your analysis should be faithful to the content but can go beyond simple extraction - provide meaningful insights where appropriate. -5. Return your processed outputs in a single JSON object, where the JSON keys **exactly match** the output names given above. -6. If there is insufficient information for an output, provide your best reasonable inference or set its value to an empty string ("") or `null`. -7. Do not include any additional keys in the JSON. -8. Your final output should be valid JSON and should not include any explanatory text. - -**Example JSON format:** -{{ - "": "Processed content here", - "": "Processed content here", - "": "Processed content here" -}} - -Now, based on the text and the instructions above, provide your final JSON output. -""" - def format_tool_descriptions(tools, default: str = "No tools provided.") -> str: """ Args: diff --git a/evoagentx/tools/tool.py b/evoagentx/tools/tool.py index 284026dd..dce8d5cc 100644 --- a/evoagentx/tools/tool.py +++ b/evoagentx/tools/tool.py @@ -2,11 +2,25 @@ import inspect from typing import Dict, List, Optional, Any +from pydantic import Field + from ..core.module import BaseModule +from ..core.metadata import Metadata ALLOWED_TYPES = ["string", "number", "integer", "boolean", "object", "array"] +class ToolMetadata(Metadata): + tool_name: str + args: Dict[str, Any] = Field(default_factory=dict, description="The arguments passed to the tool") + + +class ToolResult(BaseModule): + result: Any + metadata: ToolMetadata + id: Optional[str] = None + + class Tool(BaseModule): name: str description: str diff --git a/tests/src/agents/test_customize_action.py b/tests/src/agents/test_customize_action.py new file mode 100644 index 00000000..5b7c88ea --- /dev/null +++ b/tests/src/agents/test_customize_action.py @@ -0,0 +1,251 @@ +"""Unit tests for CustomizeAction internals: prompt/context preparation, +tool-call extraction, tool execution, the agent loop, and the sync `execute` +bridge. The agent loop tests mock `single_generate_async` so no real LLM is +called.""" + +import asyncio +import unittest +from typing import Any, Dict, List, Optional +from unittest.mock import AsyncMock, patch + +from evoagentx.actions.customize_action import CustomizeAction +from evoagentx.agents.customize_agent import CustomizeAgent +from evoagentx.core.exception import NoAnswerError +from evoagentx.memory.context_manager import ContextManager +from evoagentx.models.litellm_model import LiteLLM +from evoagentx.models.model_configs import LiteLLMConfig +from evoagentx.tools.tool import Tool, ToolResult + + +def make_config() -> LiteLLMConfig: + return LiteLLMConfig(model="gpt-4o-mini", openai_key="xxxxx") + + +def make_llm() -> LiteLLM: + return LiteLLM(config=make_config()) + + +class AddNumbersTool(Tool): + name: str = "add_numbers" + description: str = "Add two integers and return their sum." + inputs: Dict[str, Dict[str, Any]] = { + "a": {"type": "integer", "description": "First integer."}, + "b": {"type": "integer", "description": "Second integer."}, + } + required: Optional[List[str]] = ["a", "b"] + + def __call__(self, a: int, b: int) -> Dict[str, int]: + return {"sum": a + b} + + +def _user_messages(context: List[dict]) -> str: + return "\n".join(m["content"] for m in context if m["role"] == "user" and isinstance(m["content"], str)) + + +GUIDE_MARKER = "Tool Calling Guide" + + +class TestPrepareContext(unittest.IsolatedAsyncioTestCase): + """A4: prepare_context tool-guide gating (validates the prepare_context fix).""" + + def _action(self, tools=None): + agent = CustomizeAgent( + name="PCAgent", description="d", + prompt="Answer the question.", + llm_config=make_config(), + tools=tools, + ) + return agent.action + + async def test_prompt_no_tools_omits_guide(self): + action = self._action(tools=None) + cm = ContextManager(llm=make_llm()) # default mode + await action.prepare_context(llm=make_llm(), inputs={}, context_manager=cm) + self.assertNotIn(GUIDE_MARKER, _user_messages(cm.context)) + + async def test_prompt_with_tools_default_mode_includes_guide(self): + action = self._action(tools=[AddNumbersTool()]) + cm = ContextManager(llm=make_llm()) # default mode + await action.prepare_context(llm=make_llm(), inputs={}, context_manager=cm) + self.assertIn(GUIDE_MARKER, _user_messages(cm.context)) + + async def test_prompt_with_tools_openrouter_mode_omits_guide(self): + action = self._action(tools=[AddNumbersTool()]) + cm = ContextManager(llm=make_llm()) + cm.mode = "openrouter" # simulate native tool-calling path + await action.prepare_context(llm=make_llm(), inputs={}, context_manager=cm) + self.assertNotIn(GUIDE_MARKER, _user_messages(cm.context)) + + +class TestExtractHelpers(unittest.TestCase): + """A4: tool-call / answer extraction helpers.""" + + def _action(self): + return CustomizeAgent( + name="ExAgent", description="d", prompt="p", llm_config=make_config(), + ).action + + def test_extract_single_tool_call(self): + action = self._action() + out = '\n[{"function_name": "add_numbers", "function_args": {"a": 1, "b": 2}}]\n' + calls = action._extract_tool_calls(out) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["function_name"], "add_numbers") + + def test_extract_keeps_last_block_only(self): + action = self._action() + out = ( + '\n[{"function_name": "a", "function_args": {}}]\n' + '\n[{"function_name": "b", "function_args": {}}]\n' + ) + calls = action._extract_tool_calls(out) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["function_name"], "b") + + def test_extract_no_tool_call(self): + action = self._action() + self.assertEqual(action._extract_tool_calls("just text"), []) + + def test_extract_answer(self): + self.assertEqual(CustomizeAction._extract_answer("hello"), "hello") + self.assertIsNone(CustomizeAction._extract_answer("no answer tag")) + + def test_extract_no_answer(self): + action = self._action() + self.assertEqual(action._extract_no_answer("nope"), "nope") + self.assertIsNone(action._extract_no_answer("no tag")) + + +class TestAddTools(unittest.TestCase): + """A4: add_tools registration / dedup.""" + + def _action(self): + return CustomizeAgent( + name="ToolReg", description="d", prompt="p", llm_config=make_config(), + ).action + + def test_add_tool_registers_caller_and_schema(self): + action = self._action() + action.add_tools([AddNumbersTool()]) + self.assertIn("add_numbers", action.tools_caller) + self.assertEqual(len(action.tool_schemas), 1) + + def test_duplicate_tool_overwrites(self): + action = self._action() + action.add_tools([AddNumbersTool()]) + action.add_tools([AddNumbersTool()]) + self.assertEqual(len(action.tools_caller), 1) + + def test_invalid_tool_type_raises(self): + action = self._action() + with self.assertRaises(ValueError): + action.add_tools(["not a tool"]) + + +class TestToolExecution(unittest.IsolatedAsyncioTestCase): + """A5: _call_single_tool / _calling_tools.""" + + def _action(self, tools=None): + return CustomizeAgent( + name="ExecAgent", description="d", prompt="p", + llm_config=make_config(), tools=tools, + ).action + + async def test_unknown_tool_returns_error(self): + action = self._action(tools=[AddNumbersTool()]) + result = await action._call_single_tool({"function_name": "nope", "function_args": {}}) + self.assertIn("error", result.result) + + async def test_missing_tool_name_returns_error(self): + action = self._action(tools=[AddNumbersTool()]) + result = await action._call_single_tool({"function_args": {}}) + self.assertIn("error", result.result) + + async def test_successful_tool_wrapped_in_tool_result(self): + action = self._action(tools=[AddNumbersTool()]) + result = await action._call_single_tool( + {"function_name": "add_numbers", "function_args": {"a": 2, "b": 3}} + ) + self.assertIsInstance(result, ToolResult) + self.assertEqual(result.result, {"sum": 5}) + + async def test_calling_tools_preserves_order(self): + action = self._action(tools=[AddNumbersTool()]) + args = [ + {"function_name": "add_numbers", "function_args": {"a": 1, "b": 1}}, + {"function_name": "add_numbers", "function_args": {"a": 10, "b": 10}}, + ] + results = await action._calling_tools(args) + self.assertEqual([r.result["sum"] for r in results], [2, 20]) + + +class TestAgentLoop(unittest.TestCase): + """A5: full loop driven by a mocked single_generate_async.""" + + @patch("evoagentx.models.litellm_model.LiteLLM.single_generate_async", new_callable=AsyncMock) + def test_tool_call_then_answer(self, mock_gen): + mock_gen.side_effect = [ + '\n[{"function_name": "add_numbers", "function_args": {"a": 2, "b": 3}}]\n', + "5", + ] + agent = CustomizeAgent( + name="LoopAgent", description="d", + prompt="Add 2 and 3 using the tool.", + llm_config=make_config(), + tools=[AddNumbersTool()], + max_steps=5, + ) + msg = agent() + self.assertEqual(msg.content.content, "5") + self.assertEqual(mock_gen.call_count, 2) + + @patch("evoagentx.models.litellm_model.LiteLLM.single_generate_async", new_callable=AsyncMock) + def test_no_answer_raises(self, mock_gen): + mock_gen.return_value = "cannot be done" + agent = CustomizeAgent( + name="NoAnsAgent", description="d", prompt="Do it.", + llm_config=make_config(), max_steps=3, + ) + with self.assertRaises(NoAnswerError): + agent() + + @patch("evoagentx.models.litellm_model.LiteLLM.single_generate_async", new_callable=AsyncMock) + def test_max_steps_exhausted_raises(self, mock_gen): + mock_gen.return_value = "still thinking, no final answer yet" + agent = CustomizeAgent( + name="LoopForever", description="d", prompt="Do it.", + llm_config=make_config(), max_steps=3, + ) + with self.assertRaises(NoAnswerError): + agent() + + +class TestExecuteSyncBridge(unittest.TestCase): + """A6: execute() works both in a plain sync context and inside a running loop.""" + + @patch("evoagentx.models.litellm_model.LiteLLM.single_generate_async", new_callable=AsyncMock) + def test_execute_in_sync_context(self, mock_gen): + mock_gen.return_value = "sync-ok" + agent = CustomizeAgent( + name="SyncBridge", description="d", prompt="p", llm_config=make_config(), + ) + out = agent.action.execute(llm=agent.llm) + self.assertEqual(out.content, "sync-ok") + + @patch("evoagentx.models.litellm_model.LiteLLM.single_generate_async", new_callable=AsyncMock) + def test_execute_inside_running_loop(self, mock_gen): + mock_gen.return_value = "loop-ok" + agent = CustomizeAgent( + name="LoopBridge", description="d", prompt="p", llm_config=make_config(), + ) + + async def driver(): + # sync execute() invoked while an event loop is already running + return agent.action.execute(llm=agent.llm) + + out = asyncio.run(driver()) + self.assertEqual(out.content, "loop-ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/src/agents/test_customize_agent.py b/tests/src/agents/test_customize_agent.py index bcf72531..bcab93fc 100644 --- a/tests/src/agents/test_customize_agent.py +++ b/tests/src/agents/test_customize_agent.py @@ -1,6 +1,6 @@ import os import unittest -from unittest.mock import patch +from unittest.mock import patch, AsyncMock from pydantic import Field from evoagentx.core.registry import register_parse_function from evoagentx.models.model_configs import LiteLLMConfig @@ -28,9 +28,9 @@ def setUp(self): "tests/agents/saved_customize_agent_with_parser.json" ] - @patch("evoagentx.models.litellm_model.LiteLLM.single_generate") + @patch("evoagentx.models.litellm_model.LiteLLM.single_generate_async", new_callable=AsyncMock) def test_simple_agent(self, mock_generate): - mock_generate.return_value = "Hello, world!" + mock_generate.return_value = "Hello, world!" llm_config = LiteLLMConfig(model="gpt-4o-mini", openai_key="xxxxx") simple_agent = CustomizeAgent( @@ -59,9 +59,9 @@ def test_simple_agent(self, mock_generate): self.assertEqual(msg.msg_type, MessageType.UNKNOWN) self.assertEqual(msg.content.content, "Hello, world!") - @patch("evoagentx.models.litellm_model.LiteLLM.single_generate") + @patch("evoagentx.models.litellm_model.LiteLLM.single_generate_async", new_callable=AsyncMock) def test_agent_with_inputs_and_outputs(self, mock_generate): - mock_generate.return_value = "```python\nprint('Hello, world!')```" + mock_generate.return_value = "\n```python\nprint('Hello, world!')```\n" llm_config = LiteLLMConfig(model="gpt-4o-mini", openai_key="xxxxx") agent_with_inputs = CustomizeAgent( name = "CodeWriter", diff --git a/tests/src/agents/test_customize_agent_config.py b/tests/src/agents/test_customize_agent_config.py new file mode 100644 index 00000000..96d6f785 --- /dev/null +++ b/tests/src/agents/test_customize_agent_config.py @@ -0,0 +1,342 @@ +"""Pure-logic tests for CustomizeAgent: construction, validation, property +setters, and config serialization. None of these require a real LLM call.""" + +import os +import unittest +from typing import Any, Dict, List, Optional + +from pydantic import Field + +from evoagentx.actions.action import ActionOutput +from evoagentx.agents.customize_agent import CustomizeAgent +from evoagentx.core.base_config import Parameter +from evoagentx.core.registry import register_parse_function +from evoagentx.models.model_configs import LiteLLMConfig +from evoagentx.prompts.template import ChatTemplate +from evoagentx.tools.tool import Tool + + +class CfgAddNumbersTool(Tool): + name: str = "add_numbers" + description: str = "Add two integers and return their sum." + inputs: Dict[str, Dict[str, Any]] = { + "a": {"type": "integer", "description": "First integer."}, + "b": {"type": "integer", "description": "Second integer."}, + } + required: Optional[List[str]] = ["a", "b"] + + def __call__(self, a: int, b: int) -> Dict[str, int]: + return {"sum": a + b} + + +@register_parse_function +def _cfg_parse_func(content: str) -> dict: + return {"code": content} + + +class CodeOutput(ActionOutput): + code: str = Field(description="The generated code") + + +def make_config() -> LiteLLMConfig: + return LiteLLMConfig(model="gpt-4o-mini", openai_key="xxxxx") + + +class TestConfigSerialization(unittest.TestCase): + """A1: get_config / get_customize_agent_info / round-trip parity.""" + + def test_get_config_contains_expected_keys(self): + agent = CustomizeAgent( + name="CfgAgent", + description="agent for config test", + prompt="Do {task}", + llm_config=make_config(), + inputs=[{"name": "task", "type": "string", "description": "the task"}], + outputs=[{"name": "result", "type": "string", "description": "the result"}], + parse_mode="title", + max_steps=7, + max_tool_call_concurrency=3, + custom_output_format=None, + ) + + info = agent.get_customize_agent_info() + for key in [ + "class_name", "name", "description", "prompt", "prompt_template", + "inputs", "outputs", "system_prompt", "output_parser", "parse_mode", + "parse_func", "title_format", "tool_names", "custom_output_format", + "max_steps", "max_tool_call_concurrency", + ]: + self.assertIn(key, info) + + self.assertEqual(info["class_name"], "CustomizeAgent") + self.assertEqual(info["name"], "CfgAgent") + self.assertEqual(info["prompt"], "Do {task}") + self.assertEqual(info["max_steps"], 7) + self.assertEqual(info["max_tool_call_concurrency"], 3) + self.assertEqual(len(info["inputs"]), 1) + self.assertEqual(len(info["outputs"]), 1) + self.assertEqual(info["tool_names"], []) + + # get_config adds llm_config on top of the info dict + config = agent.get_config() + self.assertIn("llm_config", config) + + def test_get_config_round_trip_parity(self): + agent = CustomizeAgent( + name="RoundTrip", + description="round trip parity", + prompt="Implement {requirement}", + llm_config=make_config(), + inputs=[{"name": "requirement", "type": "string", "description": "req"}], + outputs=[ + {"name": "code", "type": "string", "description": "the code"}, + ], + output_parser=CodeOutput, + parse_mode="custom", + parse_func=_cfg_parse_func, + max_steps=5, + ) + + config = agent.get_config() + rebuilt = CustomizeAgent.from_dict(config, llm_config=make_config()) + + self.assertEqual(rebuilt.get_customize_agent_info(), agent.get_customize_agent_info()) + + def test_save_and_load_parity(self): + path = "tests/agents/_tmp_cfg_agent.json" + try: + agent = CustomizeAgent( + name="SaveLoad", + description="save load", + prompt="Echo {value}", + llm_config=make_config(), + inputs=[{"name": "value", "type": "string", "description": "v"}], + ) + agent.save_module(path) + loaded = CustomizeAgent.from_file(path, llm_config=make_config()) + self.assertEqual(loaded.get_customize_agent_info(), agent.get_customize_agent_info()) + finally: + if os.path.exists(path): + os.remove(path) + + def test_from_dict_rejects_class_name_mismatch(self): + config = CustomizeAgent( + name="X", description="d", prompt="p", llm_config=make_config(), + ).get_config() + config["class_name"] = "SomethingElse" + with self.assertRaises(ValueError): + CustomizeAgent.from_dict(config, llm_config=make_config()) + + def test_from_dict_auto_corrects_parse_mode_for_object_output(self): + # object/array outputs must be parsed as json; from_dict should flip parse_mode + config = { + "class_name": "CustomizeAgent", + "name": "ObjAgent", + "description": "d", + "prompt": "p", + "inputs": [], + "outputs": [{ + "name": "data", "type": "object", "description": "obj", + "json_schema": {"type": "object", "properties": {"k": {"type": "string"}}}, + }], + "parse_mode": "title", + } + agent = CustomizeAgent.from_dict(config, llm_config=make_config()) + self.assertEqual(agent.parse_mode, "json") + + +class TestToolSerialization(unittest.TestCase): + """A1 (tools): tool save/load round-trip via tool_names rehydration.""" + + def _tool_agent(self): + return CustomizeAgent( + name="ToolCfgAgent", + description="agent with a tool", + prompt="Add the two numbers using the tool.", + llm_config=make_config(), + tools=[CfgAddNumbersTool()], + max_steps=5, + ) + + def test_get_config_serializes_tool_names(self): + agent = self._tool_agent() + info = agent.get_customize_agent_info() + self.assertEqual(info["tool_names"], ["add_numbers"]) + + def test_round_trip_with_tools(self): + agent = self._tool_agent() + config = agent.get_config() + + # tools must be supplied so the names can be rehydrated to instances + rebuilt = CustomizeAgent.from_dict( + config, llm_config=make_config(), tools=[CfgAddNumbersTool()] + ) + self.assertEqual([t.name for t in rebuilt.tools], ["add_numbers"]) + self.assertEqual(rebuilt.get_customize_agent_info(), agent.get_customize_agent_info()) + + def test_save_and_load_with_tools(self): + path = "tests/agents/_tmp_tool_agent.json" + try: + agent = self._tool_agent() + agent.save_module(path) + loaded = CustomizeAgent.from_file( + path, llm_config=make_config(), tools=[CfgAddNumbersTool()] + ) + self.assertEqual([t.name for t in loaded.tools], ["add_numbers"]) + self.assertEqual(loaded.get_customize_agent_info(), agent.get_customize_agent_info()) + finally: + if os.path.exists(path): + os.remove(path) + + def test_from_dict_with_tool_names_but_no_tools_raises(self): + config = self._tool_agent().get_config() + # tool_names present in config but no tools provided to resolve them + with self.assertRaises(ValueError): + CustomizeAgent.from_dict(config, llm_config=make_config()) + + +class TestValidation(unittest.TestCase): + """A2: validate_data / construction error paths.""" + + def test_missing_prompt_and_template_raises(self): + with self.assertRaises(ValueError): + CustomizeAgent(name="N", description="d", llm_config=make_config()) + + def test_input_not_in_prompt_raises(self): + with self.assertRaises(KeyError): + CustomizeAgent( + name="N", description="d", + prompt="No placeholder here", + llm_config=make_config(), + inputs=[{"name": "missing", "type": "string", "description": "x"}], + ) + + def test_prompt_and_template_together_prefers_template(self): + agent = CustomizeAgent( + name="N", description="d", + prompt="ignored {x}", + prompt_template=ChatTemplate(instruction="Do the task"), + llm_config=make_config(), + ) + # prompt is nulled, prompt_template wins + self.assertIsNone(agent.prompt) + self.assertIsNotNone(agent.prompt_template) + + def test_invalid_parse_mode_raises(self): + with self.assertRaises(ValueError): + CustomizeAgent( + name="N", description="d", prompt="p", + llm_config=make_config(), parse_mode="not_a_mode", + ) + + def test_custom_parse_mode_without_func_raises(self): + with self.assertRaises(ValueError): + CustomizeAgent( + name="N", description="d", prompt="p", + llm_config=make_config(), parse_mode="custom", + ) + + def test_object_output_auto_corrects_to_json(self): + agent = CustomizeAgent( + name="N", description="d", prompt="p", + llm_config=make_config(), + outputs=[{ + "name": "data", "type": "object", "description": "obj", + "json_schema": {"type": "object", "properties": {"k": {"type": "string"}}}, + }], + parse_mode="title", + ) + self.assertEqual(agent.parse_mode, "json") + + def test_invalid_input_item_type_raises(self): + with self.assertRaises(ValueError): + CustomizeAgent( + name="N", description="d", prompt="p {x}", + llm_config=make_config(), + inputs=["not a dict or Parameter"], + ) + + +class TestPropertySetters(unittest.TestCase): + """A3: property setters propagate to the underlying action and validate.""" + + def _agent(self, **overrides): + kwargs = dict( + name="Setter", description="d", prompt="Do {task}", + llm_config=make_config(), + inputs=[{"name": "task", "type": "string", "description": "t"}], + outputs=[{"name": "result", "type": "string", "description": "r"}], + ) + kwargs.update(overrides) + return CustomizeAgent(**kwargs) + + def test_parse_mode_setter_rejects_non_json_for_object_outputs(self): + agent = self._agent( + outputs=[{ + "name": "data", "type": "object", "description": "obj", + "json_schema": {"type": "object", "properties": {"k": {"type": "string"}}}, + }], + parse_mode="json", + ) + with self.assertRaises(ValueError): + agent.parse_mode = "title" + + def test_parse_func_none_while_custom_raises(self): + agent = self._agent(parse_mode="custom", parse_func=_cfg_parse_func) + with self.assertRaises(ValueError): + agent.parse_func = None + + def test_title_format_requires_placeholder(self): + agent = self._agent() + with self.assertRaises(ValueError): + agent.title_format = "no placeholder" + + def test_title_format_setter_propagates_to_action(self): + agent = self._agent() + agent.title_format = "### {title}" + self.assertEqual(agent.action.title_format, "### {title}") + + def test_inputs_setter_rebuilds_action_format(self): + # prompt must contain every input placeholder, so provide both up front + agent = self._agent( + prompt="Process {task} and optionally {extra}", + inputs=[{"name": "task", "type": "string", "description": "t"}], + ) + self.assertEqual(len(agent.action.inputs_format.get_attrs()), 1) + agent.inputs = [ + {"name": "task", "type": "string", "description": "t"}, + {"name": "extra", "type": "string", "description": "e"}, + ] + self.assertEqual(len(agent.action.inputs_format.get_attrs()), 2) + + def test_outputs_setter_rebuilds_action_format(self): + agent = self._agent() + agent.outputs = [ + {"name": "result", "type": "string", "description": "r"}, + {"name": "explanation", "type": "string", "description": "e"}, + ] + self.assertEqual(len(agent.action.outputs_format.get_attrs()), 2) + + def test_output_parser_must_subclass_action_output(self): + class NotAnOutput: + pass + with self.assertRaises(ValueError): + self._agent(output_parser=NotAnOutput) + + def test_output_parser_fields_must_be_subset_of_outputs(self): + class ExtraFieldOutput(ActionOutput): + result: str = Field(description="r") + unknown: str = Field(description="not in outputs") + with self.assertRaises(ValueError): + self._agent(output_parser=ExtraFieldOutput) + + def test_parameter_objects_accepted_as_inputs(self): + agent = self._agent( + inputs=[Parameter(name="task", type="string", description="t")], + ) + self.assertEqual(len(agent.inputs), 1) + self.assertIsInstance(agent.inputs[0], Parameter) + + +if __name__ == "__main__": + unittest.main() From 3b8fa94dc98c6c9088d3535b00cda560cd698954 Mon Sep 17 00:00:00 2001 From: jinyuan Date: Fri, 26 Jun 2026 10:18:59 +0100 Subject: [PATCH 5/9] add native function tool call support for LLMs --- evoagentx/actions/customize_action.py | 31 +++++++-- evoagentx/memory/context_manager.py | 47 +++++++------ evoagentx/models/base_model.py | 15 ++++ evoagentx/models/litellm_model.py | 9 +++ evoagentx/models/model_configs.py | 8 +-- evoagentx/models/openai_model.py | 7 ++ evoagentx/models/openrouter_model.py | 5 ++ evoagentx/prompts/template.py | 11 ++- tests/src/agents/test_customize_action.py | 83 ++++++++++++++++++++++- tests/src/models/test_openai_model.py | 8 +-- tests/src/models/test_openrouter_model.py | 8 +-- 11 files changed, 190 insertions(+), 42 deletions(-) diff --git a/evoagentx/actions/customize_action.py b/evoagentx/actions/customize_action.py index 21ec2727..c99ec239 100644 --- a/evoagentx/actions/customize_action.py +++ b/evoagentx/actions/customize_action.py @@ -1,6 +1,7 @@ import asyncio import json import re +import uuid from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from typing import List, Optional, Union @@ -56,6 +57,14 @@ def __init__(self, **kwargs): # Prioritize template and give warning if both are provided if self.prompt and self.prompt_template: logger.warning("Both `prompt` and `prompt_template` are provided for CustomizeAction action. Prioritizing `prompt_template` and ignoring `prompt`.") + if tools and self.prompt_template is not None and getattr(self.prompt_template, "tools", None): + logger.warning( + "Both `CustomizeAction.tools` and `prompt_template.tools` are provided. " + "`CustomizeAction.tools` will override `prompt_template.tools`. " + "`PromptTemplate.tools` is legacy and will be removed in a future release; " + "prefer passing tools to `CustomizeAction`/`CustomizeAgent`, or to " + "`PromptTemplate.format(..., tools=...)` when rendering prompt-based tool instructions." + ) self.tools_caller = dict() self.tools = [] @@ -171,6 +180,15 @@ def _parse_tool_calls(text: str) -> List[dict]: except Exception as retry_err: logger.error(f"Retry failed: {retry_err}") + # Guarantee every tool call carries an `id`. Native tool calls come back with + # provider-issued ids, but a model may also emit a hand-written + # block with no id. In native mode the id links the assistant `tool_calls` + # message to its `role: tool` result, and providers reject a null/mismatched + # id, so synthesize one when absent. + for tool_call in parsed_tool_calls: + if isinstance(tool_call, dict) and not tool_call.get("id"): + tool_call["id"] = f"call_{uuid.uuid4().hex}" + return parsed_tool_calls @staticmethod @@ -349,10 +367,13 @@ async def async_execute( if iter == self.max_steps - 1: context_manager.add_user_prompt(LAST_ATTEMPT_PROMPT) - # todo: tools and extra_body might be OpenRouter specific, should be adapted for other LLMs + # In native mode the tools schema is passed to the model directly; in + # default mode tools are described in the prompt and we parse a textual + # block instead. `extra_body` is OpenRouter-specific (e.g. + # Anthropic prompt caching) and is silently dropped by other LLMs. llm_response = await llm.async_generate( messages=context_manager.context, - tools=self.tool_schemas if context_manager.mode == "openrouter" else None, + tools=self.tool_schemas if context_manager.mode == "native" else None, extra_body=llm_extra_kwargs ) @@ -453,9 +474,9 @@ async def prepare_context( # available AND we are not using native tool calling. Without tools, # the guide's web_search/code_execution examples can induce the model # to emit blocks for non-existent tools, causing loops or - # failures. In OpenRouter native mode, tools are passed to the model - # directly, so the textual guide would only duplicate the prompt. - if self.tools and context_manager.mode != "openrouter": + # failures. In native mode, tools are passed to the model directly, so + # the textual guide would only duplicate the prompt. + if self.tools and context_manager.mode != "native": user_prompt += "\n\n" + TOOL_CALLING_TEMPLATE.format( tool_descriptions=json.dumps(self.tool_schemas, indent=4, ensure_ascii=False) ) diff --git a/evoagentx/memory/context_manager.py b/evoagentx/memory/context_manager.py index 109c6420..b23b11f2 100644 --- a/evoagentx/memory/context_manager.py +++ b/evoagentx/memory/context_manager.py @@ -1,7 +1,7 @@ import json from typing import List, Optional, Union -from ..models import BaseLLM, OpenRouterLLM +from ..models import BaseLLM from ..prompts.template import ChatTemplate, PromptTemplate, StringTemplate from ..prompts.tool_calling import TOOL_CALL_FORMAT, TOOL_CALLING_HISTORY_PROMPT from ..prompts.utils import DEFAULT_SYSTEM_PROMPT @@ -17,8 +17,12 @@ def __init__( self.context = [] self.llm = llm - if isinstance(llm, OpenRouterLLM): - self.mode = "openrouter" + # "native": pass tools to the model and exchange structured tool_calls / + # role:tool messages. "default": describe tools in the prompt and parse a + # textual block from the response. Decided per-LLM by whether the + # provider supports the native function-calling protocol. + if llm.supports_native_tool_calling(): + self.mode = "native" else: self.mode = "default" @@ -49,15 +53,18 @@ def replace_system_prompt(self, system_prompt: str): def add_prompt_template(self, prompt_template: PromptTemplate, sys_msg: Optional[str] = None, **kwargs): - if isinstance(prompt_template, ChatTemplate): - if self.mode == "openrouter": - # if using OpenRouter models, remove tools from the prompt template - # because we use native tool calling support from OpenRouter - template_copy = prompt_template.copy() - template_copy.tools = None - prompts = template_copy.format(**kwargs) - else: - prompts = prompt_template.format(**kwargs) + format_kwargs = dict(kwargs) + template = prompt_template + if self.mode == "native": + # In native mode the tool schema is sent via the model `tools` + # parameter, so remove both template-owned tools and call-time tools + # from the rendered prompt. + format_kwargs["tools"] = None + if getattr(prompt_template, "tools", None): + template = prompt_template.copy(tools=None) + + if isinstance(template, ChatTemplate): + prompts = template.format(**format_kwargs) if prompts[0]["role"] == "system": self.replace_system_prompt(prompts[0]["content"]) @@ -65,15 +72,15 @@ def add_prompt_template(self, prompt_template: PromptTemplate, sys_msg: Optional else: self.context.extend(prompts) - elif isinstance(prompt_template, StringTemplate): + elif isinstance(template, StringTemplate): # `StringTemplate.format()` returns one consolidated prompt, so mirror the # `self.prompt` path: `sys_msg` (or the default) becomes the system message # and the whole formatted string becomes the user message. self.add_system_prompt(sys_msg or DEFAULT_SYSTEM_PROMPT) - self.add_user_prompt(prompt_template.format(**kwargs)) + self.add_user_prompt(template.format(**format_kwargs)) else: - raise TypeError(f"Invalid prompt template type {type(prompt_template)}.") + raise TypeError(f"Invalid prompt template type {type(template)}.") def add_user_prompt(self, user_prompt: Union[str, list]): @@ -105,11 +112,11 @@ def add_tool_results(self, tool_results: List[ToolResult]): tool_results_str = json.dumps(formatted_tool_results, indent=4, ensure_ascii=False) self.context.append({ - "role": "user", + "role": "user", "content": TOOL_CALLING_HISTORY_PROMPT.format(results=tool_results_str) }) - elif self.mode == "openrouter": + elif self.mode == "native": for result in tool_results: result_str = json.dumps(result.result, indent=4, ensure_ascii=False) @@ -132,8 +139,8 @@ def add_llm_response(self, llm_response: Optional[str] = None, tool_calls: Optio response += TOOL_CALL_FORMAT.format(tool_calls=json.dumps(tool_calls, indent=4, ensure_ascii=False)) self.context.append({"role": "assistant", "content": response}) - - elif self.mode == "openrouter": + + elif self.mode == "native": if not tool_calls: self.context.append({"role": "assistant", "content": llm_response}) return @@ -144,7 +151,7 @@ def add_llm_response(self, llm_response: Optional[str] = None, tool_calls: Optio tool_args = json.dumps(tool_call["function_args"], indent=4, ensure_ascii=False) formatted_tool_calls.append( { - "id": tool_call["id"], + "id": tool_call.get("id"), "function": { "name": tool_call["function_name"], "arguments": tool_args diff --git a/evoagentx/models/base_model.py b/evoagentx/models/base_model.py index 9fdee362..23c05481 100644 --- a/evoagentx/models/base_model.py +++ b/evoagentx/models/base_model.py @@ -753,6 +753,21 @@ def __deepcopy__(self, memo) -> "BaseLLM": memo[id(self)] = self return self + def supports_native_tool_calling(self) -> bool: + """Whether this LLM supports the native (OpenAI-style) function-calling protocol. + + "Native" means the provider accepts a `tools` schema, returns structured + `tool_calls`, and accepts the round-trip of an assistant message carrying + `tool_calls` followed by `role: "tool"` result messages. When True, the agent + loop passes tools to the model directly instead of describing them in the prompt + and asking the model to emit a `` block. + + Defaults to False so that unknown/unverified subclasses fall back to the + prompt-based tool-calling guide. Subclasses whose providers have been verified + end-to-end against the real API should override this to return True. + """ + return False + @abstractmethod def formulate_messages(self, prompts: List[str], system_messages: Optional[List[str]] = None) -> List[List[dict]]: """Converts input prompts into the chat format compatible with different LLMs. diff --git a/evoagentx/models/litellm_model.py b/evoagentx/models/litellm_model.py index 1a3b5a7e..9f4fb755 100644 --- a/evoagentx/models/litellm_model.py +++ b/evoagentx/models/litellm_model.py @@ -88,6 +88,15 @@ def init_model(self): "groq_key", "api_base", "is_local", "azure_endpoint", "azure_key", "api_version", "api_key" ] # parameters in LiteLLMConfig that are not LiteLLM models' input parameters + def supports_native_tool_calling(self) -> bool: + # LiteLLM is a meta-provider. For cloud providers it translates the native + # tool-calling round-trip (tool_calls + role:tool) per backend, which works + # for the OpenAI/Anthropic/Gemini/DeepSeek families. Local models (Ollama, + # LM Studio, etc.) have unreliable tool support, so keep the prompt-based + # fallback for them. (`litellm.supports_function_calling` is not used here: + # it wrongly reports False for many tool-capable hosted models.) + return not bool(self.config.is_local) + def _apply_provider_params(self, completion_params: dict) -> dict: """Inject provider-specific routing parameters (local / Azure) into the LiteLLM completion params. OpenAI and the remaining providers are routed diff --git a/evoagentx/models/model_configs.py b/evoagentx/models/model_configs.py index 80e60f00..1e56f7bf 100644 --- a/evoagentx/models/model_configs.py +++ b/evoagentx/models/model_configs.py @@ -32,7 +32,7 @@ class OpenAILLMConfig(LLMConfig): # tools tools: Optional[List] = Field(default=None, description="A list of tools the model may call. Currently, only functions are supported as a tool. Use this to provide a list of functions the model may generate JSON inputs for.") - tool_choice: Optional[str] = Field(default=None, description="Controls which (if any) function is called by the model. none means the model will not call a function and instead generates a message. auto means the model can pick between generating a message or calling a function. Specifying a particular function via {\"type\": \"function\", \"function\": {\"name\": \"my_function\"}} forces the model to call that function.") + tool_choice: Optional[Union[str, dict]] = Field(default=None, description="Controls which (if any) function is called by the model. none means the model will not call a function and instead generates a message. auto means the model can pick between generating a message or calling a function. required forces the model to call a tool. Specifying a particular function via {\"type\": \"function\", \"function\": {\"name\": \"my_function\"}} forces the model to call that function.") parallel_tool_calls: Optional[bool] = Field(default=None, description="Whether to enable parallel function calling during tool use. OpenAI default is true.") # reasoning parameters @@ -94,7 +94,7 @@ class LiteLLMConfig(LLMConfig): # tools tools: Optional[List] = Field(default=None, description="A list of tools the model may call. Currently, only functions are supported as a tool. Use this to provide a list of functions the model may generate JSON inputs for.") - tool_choice: Optional[str] = Field(default=None, description="Controls which (if any) function is called by the model. none means the model will not call a function and instead generates a message. auto means the model can pick between generating a message or calling a function. Specifying a particular function via {\"type\": \"function\", \"function\": {\"name\": \"my_function\"}} forces the model to call that function.") + tool_choice: Optional[Union[str, dict]] = Field(default=None, description="Controls which (if any) function is called by the model. none means the model will not call a function and instead generates a message. auto means the model can pick between generating a message or calling a function. required forces the model to call a tool. Specifying a particular function via {\"type\": \"function\", \"function\": {\"name\": \"my_function\"}} forces the model to call that function.") parallel_tool_calls: Optional[bool] = Field(default=None, description="Whether to enable parallel function calling during tool use. OpenAI default is true.") # token probabilities @@ -126,7 +126,7 @@ class SiliconFlowConfig(LLMConfig): # tools tools: Optional[List] = Field(default=None, description="A list of tools the model may call. Currently, only functions are supported as a tool. Use this to provide a list of functions the model may generate JSON inputs for.") - tool_choice: Optional[str] = Field(default=None, description="Controls which (if any) function is called by the model. none means the model will not call a function and instead generates a message. auto means the model can pick between generating a message or calling a function. Specifying a particular function via {\"type\": \"function\", \"function\": {\"name\": \"my_function\"}} forces the model to call that function.") + tool_choice: Optional[Union[str, dict]] = Field(default=None, description="Controls which (if any) function is called by the model. none means the model will not call a function and instead generates a message. auto means the model can pick between generating a message or calling a function. required forces the model to call a tool. Specifying a particular function via {\"type\": \"function\", \"function\": {\"name\": \"my_function\"}} forces the model to call that function.") parallel_tool_calls: Optional[bool] = Field(default=None, description="Whether to enable parallel function calling during tool use. OpenAI default is true.") # token probabilities @@ -193,7 +193,7 @@ class AliyunLLMConfig(LLMConfig): # tools tools: Optional[List] = Field(default=None, description="A list of tools or functions the model may call. Aliyun supports function calling for specific models.") - tool_choice: Optional[str] = Field(default=None, description="Controls whether the model should call a tool. Options include 'none' (no tool call), 'auto' (model decides), or a specific tool name.") + tool_choice: Optional[Union[str, dict]] = Field(default=None, description="Controls whether the model should call a tool. Options include 'none' (no tool call), 'auto' (model decides), 'required' (force a tool call), or a specific tool via {\"type\": \"function\", \"function\": {\"name\": \"my_function\"}}.") # model-specific parameters enable_search: Optional[bool] = Field(default=None, description="Whether to enable web search augmentation for the model, if supported.") diff --git a/evoagentx/models/openai_model.py b/evoagentx/models/openai_model.py index fe5187c7..75708ed2 100644 --- a/evoagentx/models/openai_model.py +++ b/evoagentx/models/openai_model.py @@ -35,6 +35,13 @@ def init_model(self): if self.config.model not in get_openai_model_cost(): raise KeyError(f"'{self.config.model}' is not a valid OpenAI model name!") + def supports_native_tool_calling(self) -> bool: + # OpenAI's chat-completions API is the reference implementation of native + # function calling. SiliconFlow and Aliyun (DashScope compatible-mode) speak + # the same protocol and inherit this; all three were verified end-to-end + # (native tool_calls + role:tool round-trip) against the real APIs. + return True + def _init_client(self, config: OpenAILLMConfig): return OpenAI(api_key=config.openai_key) diff --git a/evoagentx/models/openrouter_model.py b/evoagentx/models/openrouter_model.py index 79ee3c9b..f833ee54 100644 --- a/evoagentx/models/openrouter_model.py +++ b/evoagentx/models/openrouter_model.py @@ -26,6 +26,11 @@ def init_model(self): self._async_client = None self._default_ignore_fields = ["llm_type", "openrouter_key", "openrouter_base", "openrouter_model_base", "output_response"] + def supports_native_tool_calling(self) -> bool: + # OpenRouter proxies the OpenAI tool-calling protocol for the models that + # support it; native tool calling was the original behavior here. + return True + def _init_client(self, config: OpenRouterConfig): return OpenAI(api_key=config.openrouter_key, base_url=config.openrouter_base) diff --git a/evoagentx/prompts/template.py b/evoagentx/prompts/template.py index b23e9deb..3e90bfd5 100644 --- a/evoagentx/prompts/template.py +++ b/evoagentx/prompts/template.py @@ -24,7 +24,14 @@ class PromptTemplate(BaseModule): instruction: str = Field(description="The instruction that the LLM will follow.") context: Optional[str] = Field(default=None, description="Additional context that can help the LLM understand the instruction.") constraints: Optional[Union[List[str], str]] = Field(default=None, description="Constraints that the LLM must follow.") - tools: Optional[List[Union[Tool, Toolkit]]] = Field(default=None, description="Tools that the LLM can use.") + tools: Optional[List[Union[Tool, Toolkit]]] = Field( + default=None, + description=( + "Legacy prompt-owned tools used only for rendering prompt-based tool " + "instructions. This field will be removed in a future release; prefer " + "passing tools to `format(..., tools=...)` when rendering tool instructions." + ), + ) demonstrations: Optional[List[dict]] = Field(default=None, description="Examples of how to use the instruction.") history: Optional[List[Any]] = Field(default=None, description="History of the conversation between the user and the LLM.") @@ -659,4 +666,4 @@ def render_demonstrations(self, inputs_format: LLMOutputParser, outputs_format: demo = self.demonstrations[0] if isinstance(demo, dspy.Example): self.demonstrations = [demo.toDict() for demo in self.demonstrations] - return super().render_demonstrations(inputs_format, outputs_format, parse_mode, title_format, custom_output_format, **kwargs) \ No newline at end of file + return super().render_demonstrations(inputs_format, outputs_format, parse_mode, title_format, custom_output_format, **kwargs) diff --git a/tests/src/agents/test_customize_action.py b/tests/src/agents/test_customize_action.py index 5b7c88ea..33f9ce89 100644 --- a/tests/src/agents/test_customize_action.py +++ b/tests/src/agents/test_customize_action.py @@ -14,6 +14,7 @@ from evoagentx.memory.context_manager import ContextManager from evoagentx.models.litellm_model import LiteLLM from evoagentx.models.model_configs import LiteLLMConfig +from evoagentx.prompts.template import ChatTemplate, StringTemplate from evoagentx.tools.tool import Tool, ToolResult @@ -38,10 +39,26 @@ def __call__(self, a: int, b: int) -> Dict[str, int]: return {"sum": a + b} +class PromptOnlyTool(Tool): + name: str = "prompt_only" + description: str = "A legacy prompt-template tool that should be overridden." + inputs: Dict[str, Dict[str, Any]] = { + "text": {"type": "string", "description": "Text to echo."}, + } + required: Optional[List[str]] = ["text"] + + def __call__(self, text: str) -> Dict[str, str]: + return {"text": text} + + def _user_messages(context: List[dict]) -> str: return "\n".join(m["content"] for m in context if m["role"] == "user" and isinstance(m["content"], str)) +def _all_message_text(context: List[dict]) -> str: + return "\n".join(m["content"] for m in context if isinstance(m.get("content"), str)) + + GUIDE_MARKER = "Tool Calling Guide" @@ -65,17 +82,77 @@ async def test_prompt_no_tools_omits_guide(self): async def test_prompt_with_tools_default_mode_includes_guide(self): action = self._action(tools=[AddNumbersTool()]) - cm = ContextManager(llm=make_llm()) # default mode + cm = ContextManager(llm=make_llm()) + cm.mode = "default" # simulate an LLM without native tool calling await action.prepare_context(llm=make_llm(), inputs={}, context_manager=cm) self.assertIn(GUIDE_MARKER, _user_messages(cm.context)) - async def test_prompt_with_tools_openrouter_mode_omits_guide(self): + async def test_prompt_with_tools_native_mode_omits_guide(self): action = self._action(tools=[AddNumbersTool()]) cm = ContextManager(llm=make_llm()) - cm.mode = "openrouter" # simulate native tool-calling path + cm.mode = "native" # native tool-calling path await action.prepare_context(llm=make_llm(), inputs={}, context_manager=cm) self.assertNotIn(GUIDE_MARKER, _user_messages(cm.context)) + async def test_string_template_with_tools_native_mode_omits_guide(self): + agent = CustomizeAgent( + name="StringTemplateTools", description="d", + prompt_template=StringTemplate(instruction="Add two numbers using tools."), + llm_config=make_config(), + tools=[AddNumbersTool()], + ) + cm = ContextManager(llm=make_llm()) + cm.mode = "native" + await agent.action.prepare_context(llm=make_llm(), inputs={}, context_manager=cm) + self.assertNotIn(GUIDE_MARKER, _all_message_text(cm.context)) + + async def test_chat_template_with_tools_native_mode_omits_guide(self): + agent = CustomizeAgent( + name="ChatTemplateTools", description="d", + prompt_template=ChatTemplate(instruction="Add two numbers using tools."), + llm_config=make_config(), + tools=[AddNumbersTool()], + ) + cm = ContextManager(llm=make_llm()) + cm.mode = "native" + await agent.action.prepare_context(llm=make_llm(), inputs={}, context_manager=cm) + self.assertNotIn(GUIDE_MARKER, _all_message_text(cm.context)) + + async def test_string_template_with_tools_default_mode_includes_guide(self): + agent = CustomizeAgent( + name="StringTemplateToolsDefault", description="d", + prompt_template=StringTemplate(instruction="Add two numbers using tools."), + llm_config=make_config(), + tools=[AddNumbersTool()], + ) + cm = ContextManager(llm=make_llm()) + cm.mode = "default" + await agent.action.prepare_context(llm=make_llm(), inputs={}, context_manager=cm) + self.assertIn(GUIDE_MARKER, _all_message_text(cm.context)) + + async def test_action_tools_override_prompt_template_tools(self): + template = StringTemplate( + instruction="Use the available tool.", + tools=[PromptOnlyTool()], + ) + with patch("evoagentx.actions.customize_action.logger.warning") as mock_warning: + agent = CustomizeAgent( + name="OverrideTemplateTools", description="d", + prompt_template=template, + llm_config=make_config(), + tools=[AddNumbersTool()], + ) + + warning_text = "\n".join(str(call.args[0]) for call in mock_warning.call_args_list) + self.assertIn("`CustomizeAction.tools` will override `prompt_template.tools`", warning_text) + + cm = ContextManager(llm=make_llm()) + cm.mode = "default" + await agent.action.prepare_context(llm=make_llm(), inputs={}, context_manager=cm) + prompt_text = _all_message_text(cm.context) + self.assertIn("add_numbers", prompt_text) + self.assertNotIn("prompt_only", prompt_text) + class TestExtractHelpers(unittest.TestCase): """A4: tool-call / answer extraction helpers.""" diff --git a/tests/src/models/test_openai_model.py b/tests/src/models/test_openai_model.py index 7f342276..9c829e70 100644 --- a/tests/src/models/test_openai_model.py +++ b/tests/src/models/test_openai_model.py @@ -111,7 +111,7 @@ def test_sync_tool_call_non_stream(mocker): llm = _make_llm(stream=False, tools=[GET_WEATHER_TOOL], tool_choice="auto") out = llm.generate(prompt="What is the weather in Tokyo?") assert isinstance(out, LLMOutputParser) - assert "" in out.content + assert "" in out.content assert "get_weather" in out.content assert "Tokyo" in out.content _assert_cost_updated() @@ -126,7 +126,7 @@ def test_sync_tool_call_stream(mocker): llm = _make_llm(stream=True, tools=[GET_WEATHER_TOOL], tool_choice="auto") out = llm.generate(prompt="What is the weather in Tokyo?") assert isinstance(out, LLMOutputParser) - assert "" in out.content + assert "" in out.content assert "get_weather" in out.content assert "Tokyo" in out.content _assert_cost_updated() @@ -167,7 +167,7 @@ async def test_async_tool_call_non_stream(mocker): llm = _make_llm(stream=False, tools=[GET_WEATHER_TOOL], tool_choice="auto") out = await llm.async_generate(prompt="What is the weather in Tokyo?") assert isinstance(out, LLMOutputParser) - assert "" in out.content + assert "" in out.content assert "get_weather" in out.content _assert_cost_updated() @@ -181,7 +181,7 @@ async def test_async_tool_call_stream(mocker): llm = _make_llm(stream=True, tools=[GET_WEATHER_TOOL], tool_choice="auto") out = await llm.async_generate(prompt="What is the weather in Tokyo?") assert isinstance(out, LLMOutputParser) - assert "" in out.content + assert "" in out.content assert "get_weather" in out.content _assert_cost_updated() diff --git a/tests/src/models/test_openrouter_model.py b/tests/src/models/test_openrouter_model.py index 1a27e9d3..1739c23f 100644 --- a/tests/src/models/test_openrouter_model.py +++ b/tests/src/models/test_openrouter_model.py @@ -96,7 +96,7 @@ def test_sync_tool_call_non_stream(mocker): llm = _make_llm(stream=False, tools=[GET_WEATHER_TOOL], tool_choice="auto") out = llm.generate(prompt="What is the weather in Tokyo?") assert isinstance(out, LLMOutputParser) - assert "" in out.content + assert "" in out.content assert "get_weather" in out.content assert "Tokyo" in out.content _assert_cost_updated() @@ -111,7 +111,7 @@ def test_sync_tool_call_stream(mocker): llm = _make_llm(stream=True, tools=[GET_WEATHER_TOOL], tool_choice="auto") out = llm.generate(prompt="What is the weather in Tokyo?") assert isinstance(out, LLMOutputParser) - assert "" in out.content + assert "" in out.content assert "get_weather" in out.content assert "Tokyo" in out.content _assert_cost_updated() @@ -152,7 +152,7 @@ async def test_async_tool_call_non_stream(mocker): llm = _make_llm(stream=False, tools=[GET_WEATHER_TOOL], tool_choice="auto") out = await llm.async_generate(prompt="What is the weather in Tokyo?") assert isinstance(out, LLMOutputParser) - assert "" in out.content + assert "" in out.content assert "get_weather" in out.content _assert_cost_updated() @@ -166,7 +166,7 @@ async def test_async_tool_call_stream(mocker): llm = _make_llm(stream=True, tools=[GET_WEATHER_TOOL], tool_choice="auto") out = await llm.async_generate(prompt="What is the weather in Tokyo?") assert isinstance(out, LLMOutputParser) - assert "" in out.content + assert "" in out.content assert "get_weather" in out.content _assert_cost_updated() From adcf8e418182b89c765709771e090e399f4560f0 Mon Sep 17 00:00:00 2001 From: jinyuan Date: Fri, 26 Jun 2026 13:13:41 +0100 Subject: [PATCH 6/9] fix pytest errors --- evoagentx/actions/customize_action.py | 19 ++++++++++++++----- pyproject.toml | 4 ++++ requirements.txt | 4 ++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/evoagentx/actions/customize_action.py b/evoagentx/actions/customize_action.py index c99ec239..630cb56d 100644 --- a/evoagentx/actions/customize_action.py +++ b/evoagentx/actions/customize_action.py @@ -72,8 +72,6 @@ def __init__(self, **kwargs): self.add_tools(tools) self.tool_schemas: List[dict] = compile_tool_schemas(self.tools) - self.semaphore = asyncio.Semaphore(self.max_tool_call_concurrency) - def prepare_extraction_prompt(self, llm_output_content: str) -> str: """Prepare extraction prompt for fallback extraction when parsing fails. @@ -244,7 +242,11 @@ async def _async_extract_output(self, llm_output: Union[str, LLMOutputParser], l output = self.outputs_format(**llm_extracted_data) return output - async def _call_single_tool(self, function_param: dict) -> ToolResult: + async def _call_single_tool(self, function_param: dict, semaphore: Optional[asyncio.Semaphore] = None) -> ToolResult: + # When called outside of `_calling_tools` (e.g. directly in tests), create a + # loop-bound semaphore on the fly so concurrency limiting still applies. + if semaphore is None: + semaphore = asyncio.Semaphore(self.max_tool_call_concurrency) tool_call_id = function_param.get("id") function_name = function_param.get("function_name") or "" function_args = function_param.get("function_args") or {} @@ -268,7 +270,7 @@ async def _call_single_tool(self, function_param: dict) -> ToolResult: return ToolResult(result=output, metadata=metadata, id=tool_call_id) try: - async with self.semaphore: + async with semaphore: tool_args_str = json.dumps(function_args, indent=4, ensure_ascii=False) logger.info(f"[Tool Call] Executing tool `{function_name}` with parameters:\n{tool_args_str}") @@ -289,8 +291,15 @@ async def _call_single_tool(self, function_param: dict) -> ToolResult: return ToolResult(result={"error": str(e)}, metadata=metadata, id=tool_call_id) async def _calling_tools(self, tool_call_args: List[dict]) -> List[ToolResult]: + # Create the semaphore inside the running event loop. `asyncio.Semaphore` + # binds to the loop on first await, so a long-lived instance attribute would + # be reused across the fresh loops that `execute()` spins up via + # `asyncio.run()` / the thread-pool loop, raising "Semaphore is bound to a + # different event loop". A per-call semaphore is loop-safe and still bounds + # concurrency within a single tool-calling round. + semaphore = asyncio.Semaphore(self.max_tool_call_concurrency) tasks = [ - self._call_single_tool(args) + self._call_single_tool(args, semaphore) for args in tool_call_args ] diff --git a/pyproject.toml b/pyproject.toml index e59893b2..8892ddb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,9 @@ tools = [ multimodal = [ "torch", "datasets>=3.4.0", + # Transitive dep of `datasets`; 0.70.18+ resource_tracker raises a harmless + # AttributeError at shutdown on some Python builds. Pin below it. + "multiprocess<0.70.18", "voyageai" ] optimizers = [ @@ -147,6 +150,7 @@ all = [ "google-auth-httplib2>=0.1.0", "torch", "datasets>=3.4.0", + "multiprocess<0.70.18", "voyageai", "textgrad>=0.1.8", "dspy", diff --git a/requirements.txt b/requirements.txt index bdfc4357..35a05b96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -70,6 +70,10 @@ google-auth-httplib2>=0.1.0 # multimodal # torch # uncomment and pin as needed, e.g. for cu118: --extra-index-url https://download.pytorch.org/whl/cu118 datasets>=3.4.0 +# Pulled in transitively by `datasets`. 0.70.18+ resource_tracker calls +# RLock._recursion_count(), which is absent on some Python builds, raising a +# harmless AttributeError at interpreter shutdown. Pin below it to avoid the noise. +multiprocess<0.70.18 voyageai # optimizers From cf4ff9478faf2b7242684c3f636f35bdd5dffbb4 Mon Sep 17 00:00:00 2001 From: jinyuan Date: Fri, 26 Jun 2026 21:20:49 +0100 Subject: [PATCH 7/9] update prompt cacheing mechanism in OpenRouterLLM & CustomizeAgent --- evoagentx/actions/customize_action.py | 23 ++- evoagentx/models/base_model.py | 24 ++- evoagentx/models/model_configs.py | 1 + evoagentx/models/openrouter_model.py | 108 ++++++++++++- tests/src/models/test_openrouter_model.py | 185 ++++++++++++++++++++++ 5 files changed, 326 insertions(+), 15 deletions(-) diff --git a/evoagentx/actions/customize_action.py b/evoagentx/actions/customize_action.py index 630cb56d..57367d8a 100644 --- a/evoagentx/actions/customize_action.py +++ b/evoagentx/actions/customize_action.py @@ -13,7 +13,7 @@ from ..core.message import Message from ..core.module_utils import parse_json_from_llm_output, parse_json_from_text from ..memory.context_manager import ContextManager -from ..models import BaseLLM, LLMOutputParser, OpenRouterLLM +from ..models import BaseLLM, LLMOutputParser from ..prompts.customize_agent import ( ANSWER_HINT, ANSWER_PROMPT, @@ -358,15 +358,6 @@ async def async_execute( failed_tool_calls = 0 iter = 0 - is_anthropic = llm.config.model.startswith("anthropic/") - is_openrouter = isinstance(llm, OpenRouterLLM) - has_many_tools = self.tools and len(self.tools) > 1 - - llm_extra_kwargs = {} - if is_openrouter and is_anthropic and has_many_tools: - # Enable prompt caching - llm_extra_kwargs = {"cache_control": {"type": "ephemeral"}} - while True: if iter >= self.max_steps: logger.error(f"{self.name} exceeded maximum number of steps ({self.max_steps}).") @@ -378,12 +369,18 @@ async def async_execute( # In native mode the tools schema is passed to the model directly; in # default mode tools are described in the prompt and we parse a textual - # block instead. `extra_body` is OpenRouter-specific (e.g. - # Anthropic prompt caching) and is silently dropped by other LLMs. + # block instead. + # + # `enable_prompt_caching=True` opts this agent loop into provider prompt + # caching: the loop re-sends a growing-but-shared prefix every iteration, + # so cache reads from iteration 2 onward outweigh the first-call write + # premium. The LLM layer decides what (if anything) to do per provider — + # the action stays provider-agnostic. Non-OpenRouter providers ignore + # the flag (it is filtered out before the request). llm_response = await llm.async_generate( messages=context_manager.context, tools=self.tool_schemas if context_manager.mode == "native" else None, - extra_body=llm_extra_kwargs + enable_prompt_caching=True, ) logger.info(f"[Raw LLM Response]: {llm_response.content}") diff --git a/evoagentx/models/base_model.py b/evoagentx/models/base_model.py index 23c05481..1edf8099 100644 --- a/evoagentx/models/base_model.py +++ b/evoagentx/models/base_model.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from copy import copy, deepcopy -from typing import Any, ClassVar, Dict, List, Optional, Type, Union +from typing import Any, ClassVar, Dict, List, Optional, Tuple, Type, Union import yaml from jsonschema import Draft7Validator @@ -768,6 +768,28 @@ def supports_native_tool_calling(self) -> bool: """ return False + def prepare_request(self, messages: List[dict], params: dict) -> Tuple[List[dict], dict]: + """Provider-specific request-shaping hook, applied just before the API call. + + This is the single extension point for rewriting the outgoing request to + opt into provider-specific features (e.g. prompt caching). Subclasses should + invoke it on every path that reaches the provider — sync/async, + streaming/non-streaming, tool-call or not — so callers (actions, agents) + never need to know about provider quirks or model naming. + + The default is a no-op. Implementations must NOT mutate the caller's + `messages`; return a new list (e.g. via `deepcopy`) if content changes are + needed. `params` is a fresh per-call dict and may be mutated in place. + + Args: + messages: The chat messages about to be sent to the provider. + params: The keyword params about to be passed to the completion call. + + Returns: + A `(messages, params)` tuple to use for the actual request. + """ + return messages, params + @abstractmethod def formulate_messages(self, prompts: List[str], system_messages: Optional[List[str]] = None) -> List[List[dict]]: """Converts input prompts into the chat format compatible with different LLMs. diff --git a/evoagentx/models/model_configs.py b/evoagentx/models/model_configs.py index 1e56f7bf..60b2ca4b 100644 --- a/evoagentx/models/model_configs.py +++ b/evoagentx/models/model_configs.py @@ -172,6 +172,7 @@ class OpenRouterConfig(LLMConfig): stream: Optional[bool] = Field(default=None, description="If set to true, it sends partial message deltas. Tokens will be sent as they become available, with the stream terminated by a [DONE] message.") extra_body: Optional[dict] = Field(default=None, description="Additional request body parameters for provider-specific features.") + enable_prompt_caching: Optional[bool] = Field(default=False, description="Opt into OpenRouter prompt caching for providers that require explicit cache_control breakpoints (Anthropic, Gemini, Qwen). Defaults to False because cache WRITES on these providers are billed at a premium (e.g. Anthropic ~1.25x input price, Gemini adds storage fees, Qwen applies a write multiplier), so a single non-repeated call costs more than without caching — it only pays off across repeated calls sharing a prompt prefix. Agent loops such as CustomizeAction enable it per-call. Automatic-caching providers (OpenAI, DeepSeek, Grok, Moonshot) are unaffected.") def __str__(self): return self.model diff --git a/evoagentx/models/openrouter_model.py b/evoagentx/models/openrouter_model.py index f833ee54..e31e140d 100644 --- a/evoagentx/models/openrouter_model.py +++ b/evoagentx/models/openrouter_model.py @@ -1,5 +1,6 @@ import json -from typing import Dict, List, Optional, Union +from copy import deepcopy +from typing import Dict, List, Optional, Tuple, Union from openai import AsyncOpenAI, OpenAI, Stream from openai.types.chat import ChatCompletion, ChatCompletionChunk @@ -18,9 +19,24 @@ from .model_utils import Cost, cost_manager +# Models already warned about paid cache writes. Process-wide so the warning +# fires at most once per model regardless of how many LLM instances are created. +_PROMPT_CACHING_COST_WARNED: set = set() + + @register_model(config_cls=OpenRouterConfig, alias=["openrouter"]) class OpenRouterLLM(BaseLLM): + # Model-family prefixes that require an explicit `cache_control` breakpoint AND + # bill cache writes at a premium (verified against OpenRouter's pricing docs): + # anthropic/* -> writes at ~1.25x input (5-min) / 2x (1-hour) + # google/gemini* -> writes at input price plus a cache-storage fee + # qwen/* -> Alibaba explicit-cache write multiplier + # Automatic-cache providers (openai/*, deepseek/*, x-ai/*, moonshot/*) are + # intentionally absent: their writes are free and need no breakpoint, so we + # leave their requests untouched and let OpenRouter cache them server-side. + _PAID_CACHE_WRITE_PREFIXES: Tuple[str, ...] = ("anthropic/", "google/gemini", "qwen/") + def init_model(self): self._client = None self._async_client = None @@ -31,6 +47,94 @@ def supports_native_tool_calling(self) -> bool: # support it; native tool calling was the original behavior here. return True + def prepare_request(self, messages: List[dict], params: dict) -> Tuple[List[dict], dict]: + """Inject OpenRouter prompt-caching breakpoints when opted in. + + Caching is gated behind `enable_prompt_caching` (per-call kwarg, else the + config default of False) because cache writes are billed at a premium on + the providers that need explicit breakpoints (see + `_PAID_CACHE_WRITE_PREFIXES`). The flag is popped here so it never leaks + into the OpenAI-compatible request body. + """ + # `enable_prompt_caching` is a config field, so a per-call kwarg flows into + # `params` via get_completion_params; pop it regardless to keep it off the wire. + enabled = params.pop("enable_prompt_caching", None) + if enabled is None: + enabled = bool(getattr(self.config, "enable_prompt_caching", False)) + if not enabled: + return messages, params + + model = (getattr(self.config, "model", "") or "").lower() + if not model.startswith(self._PAID_CACHE_WRITE_PREFIXES): + # Automatic-cache providers need no breakpoint and bill no write premium. + return messages, params + + self._warn_prompt_caching_cost_once(model) + return self._add_cache_control_to_last_text_block(messages), params + + @staticmethod + def _warn_prompt_caching_cost_once(model: str) -> None: + if model in _PROMPT_CACHING_COST_WARNED: + return + _PROMPT_CACHING_COST_WARNED.add(model) + logger.warning( + f"[OpenRouterLLM] Prompt caching is enabled for '{model}'. On this provider " + "OpenRouter bills cache WRITES at a premium (e.g. Anthropic ~1.25x input " + "price; Gemini adds a cache-storage fee; Qwen applies a write multiplier), " + "so a single non-repeated call costs MORE than without caching — it only " + "pays off across repeated calls that share a prompt prefix. To disable, set " + "`enable_prompt_caching=False` on the OpenRouterConfig." + ) + + @staticmethod + def _add_cache_control_to_last_text_block(messages: List[dict]) -> List[dict]: + """Return a copy with OpenRouter block-level prompt caching enabled. + + OpenRouter routes Anthropic/Gemini/Qwen prompt caching through + Anthropic-style content-block metadata. Use a single breakpoint on the + latest cacheable text block so multi-turn agent loops cache the growing + shared prefix without accumulating multiple paid cache writes. The input + `messages` is never mutated. + """ + cached_messages = deepcopy(messages) + target_message: Optional[dict] = None + target_block: Optional[dict] = None + + for message in cached_messages: + content = message.get("content") + if isinstance(content, str): + if content: + target_message = message + target_block = None + continue + + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + block.pop("cache_control", None) + if ( + block.get("type") == "text" + and isinstance(block.get("text"), str) + and block.get("text") + ): + target_message = None + target_block = block + + if target_block is not None: + target_block["cache_control"] = {"type": "ephemeral"} + return cached_messages + + if target_message is not None: + target_message["content"] = [ + { + "type": "text", + "text": target_message["content"], + "cache_control": {"type": "ephemeral"}, + } + ] + return cached_messages + def _init_client(self, config: OpenRouterConfig): return OpenAI(api_key=config.openrouter_key, base_url=config.openrouter_base) @@ -213,6 +317,7 @@ def single_generate(self, messages: List[dict], **kwargs) -> str: try: client = self.ensure_client() completion_params = self.get_completion_params(**kwargs) + messages, completion_params = self.prepare_request(messages, completion_params) response = client.chat.completions.create(messages=messages, **completion_params) if stream: output = self.get_stream_output(response, output_response=output_response) @@ -233,6 +338,7 @@ async def single_generate_async(self, messages: List[dict], **kwargs) -> str: try: async_client = self.ensure_async_client() completion_params = self.get_completion_params(**kwargs) + messages, completion_params = self.prepare_request(messages, completion_params) response = await async_client.chat.completions.create( messages=messages, **completion_params ) diff --git a/tests/src/models/test_openrouter_model.py b/tests/src/models/test_openrouter_model.py index 1739c23f..ad08e5a1 100644 --- a/tests/src/models/test_openrouter_model.py +++ b/tests/src/models/test_openrouter_model.py @@ -219,3 +219,188 @@ def test_cost_accumulation(mocker): assert tokens_2 == tokens_1 * 2 assert cost_2 == pytest.approx(cost_1 * 2) + + +# --------------------------------------------------------------------------- +# 11. prepare_request — provider-specific prompt caching +# --------------------------------------------------------------------------- + +def _caching_llm(model: str, **kwargs) -> OpenRouterLLM: + return OpenRouterLLM( + config=OpenRouterConfig( + model=model, openrouter_key="mock_or_key", output_response=False, **kwargs + ) + ) + + +@pytest.fixture +def reset_caching_warnings(): + from evoagentx.models import openrouter_model + + openrouter_model._PROMPT_CACHING_COST_WARNED.clear() + yield + openrouter_model._PROMPT_CACHING_COST_WARNED.clear() + + +def test_prepare_request_disabled_by_default_is_noop(): + llm = _caching_llm("anthropic/claude-haiku-4.5") + messages = [{"role": "user", "content": "stable prompt"}] + out_messages, params = llm.prepare_request(messages, {"temperature": 0}) + + # No opt-in -> request untouched; the flag never reaches the wire. + assert out_messages is messages + assert params == {"temperature": 0} + + +@pytest.mark.parametrize( + "model", + ["anthropic/claude-haiku-4.5", "google/gemini-2.5-flash", "qwen/qwen-plus"], +) +def test_prepare_request_enables_block_cache_without_mutating_input(model, reset_caching_warnings): + llm = _caching_llm(model) + messages = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "stable user prompt"}, + ] + out_messages, params = llm.prepare_request( + messages, {"enable_prompt_caching": True} + ) + + # Flag popped (never sent to the OpenAI-compatible API). + assert "enable_prompt_caching" not in params + # Original messages untouched. + assert messages[1]["content"] == "stable user prompt" + # Latest user text block carries the breakpoint in the returned copy. + assert out_messages[1]["content"] == [ + { + "type": "text", + "text": "stable user prompt", + "cache_control": {"type": "ephemeral"}, + } + ] + + +def test_prepare_request_marks_existing_text_block(reset_caching_warnings): + llm = _caching_llm("qwen/qwen-plus") + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "stable block"}, + {"type": "text", "text": "question"}, + ], + } + ] + out_messages, _ = llm.prepare_request(messages, {"enable_prompt_caching": True}) + + assert "cache_control" not in messages[0]["content"][0] + assert "cache_control" not in out_messages[0]["content"][0] + assert out_messages[0]["content"][1]["cache_control"] == {"type": "ephemeral"} + + +@pytest.mark.parametrize( + "model", + ["anthropic/claude-haiku-4.5", "google/gemini-2.5-flash", "qwen/qwen-plus"], +) +def test_prepare_request_moves_cache_to_latest_text_block(model, reset_caching_warnings): + llm = _caching_llm(model) + messages = [ + {"role": "system", "content": "stable system prompt"}, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "initial task", + "cache_control": {"type": "ephemeral"}, + } + ], + }, + {"role": "assistant", "content": "thinking before using a tool"}, + {"role": "tool", "tool_call_id": "call_1", "content": "tool result"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "continue"}, + {"type": "text", "text": "latest question"}, + ], + }, + ] + + out_messages, _ = llm.prepare_request(messages, {"enable_prompt_caching": True}) + + # Original caller-owned messages are untouched, including any pre-existing marker. + assert messages[1]["content"][0]["cache_control"] == {"type": "ephemeral"} + # The returned request has one breakpoint on the latest cacheable text block. + assert "cache_control" not in out_messages[1]["content"][0] + assert "cache_control" not in out_messages[4]["content"][0] + assert out_messages[4]["content"][1]["cache_control"] == {"type": "ephemeral"} + + +@pytest.mark.parametrize( + "model", + ["anthropic/claude-haiku-4.5", "google/gemini-2.5-flash", "qwen/qwen-plus"], +) +def test_prepare_request_marks_last_tool_result_text(model, reset_caching_warnings): + llm = _caching_llm(model) + messages = [ + {"role": "system", "content": "stable system prompt"}, + {"role": "user", "content": "call a tool"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "tool result"}, + ] + + out_messages, _ = llm.prepare_request(messages, {"enable_prompt_caching": True}) + + assert messages[-1]["content"] == "tool result" + assert out_messages[-1]["content"] == [ + { + "type": "text", + "text": "tool result", + "cache_control": {"type": "ephemeral"}, + } + ] + + +@pytest.mark.parametrize( + "model", + ["openai/gpt-5.4-mini", "z-ai/glm-4.5-air", "deepseek/deepseek-v3.2"], +) +def test_prepare_request_automatic_cache_providers_untouched(model, reset_caching_warnings): + llm = _caching_llm(model) + messages = [{"role": "user", "content": "stable prompt"}] + out_messages, params = llm.prepare_request(messages, {"enable_prompt_caching": True}) + + # Opted in, but these providers auto-cache: leave the request alone. + assert out_messages is messages + assert "enable_prompt_caching" not in params + + +def test_prepare_request_config_default_enables_caching(reset_caching_warnings): + llm = _caching_llm("anthropic/claude-haiku-4.5", enable_prompt_caching=True) + messages = [{"role": "user", "content": "stable prompt"}] + out_messages, _ = llm.prepare_request(messages, {}) + + assert out_messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + + +def test_prepare_request_warns_once_per_model(reset_caching_warnings, mocker): + warn = mocker.patch("evoagentx.models.openrouter_model.logger.warning") + llm = _caching_llm("anthropic/claude-haiku-4.5") + messages = [{"role": "user", "content": "stable prompt"}] + + llm.prepare_request(messages, {"enable_prompt_caching": True}) + llm.prepare_request(messages, {"enable_prompt_caching": True}) + + assert warn.call_count == 1 + assert "cost" in warn.call_args[0][0].lower() or "premium" in warn.call_args[0][0].lower() From ee9d68b8ed86183e68c471ed515a3b801aebe6f8 Mon Sep 17 00:00:00 2001 From: jinyuan Date: Fri, 26 Jun 2026 22:56:20 +0100 Subject: [PATCH 8/9] add json_schema & WorkFlowGenerator backward compatibility --- evoagentx/actions/agent_generation.py | 2 +- evoagentx/actions/customize_action.py | 6 ++- evoagentx/agents/customize_agent.py | 37 +++++++++---- evoagentx/core/base_config.py | 17 +++--- evoagentx/prompts/template.py | 2 +- examples/workflow/arxiv_workflow.py | 24 ++++----- tests/src/agents/test_customize_action.py | 54 +++++++++++++++++++ .../src/agents/test_customize_agent_config.py | 40 +++++++++++++- tests/src/core/test_base_config.py | 17 +++--- 9 files changed, 159 insertions(+), 40 deletions(-) diff --git a/evoagentx/actions/agent_generation.py b/evoagentx/actions/agent_generation.py index 209c2e9d..f92c61f3 100644 --- a/evoagentx/actions/agent_generation.py +++ b/evoagentx/actions/agent_generation.py @@ -182,7 +182,7 @@ def execute(self, llm: Optional[BaseLLM] = None, inputs: Optional[dict] = None, } for tool in self.tools ] - prompt_params_values["tools"] = AGENT_GENERATION_TOOLS_PROMPT.format(tools_description=tool_description) + prompt_params_values["tools"] = AGENT_GENERATION_TOOLS_PROMPT.format(tool_descriptions=tool_description) prompt = self.prompt.format(**prompt_params_values) agents = llm.generate( prompt = prompt, diff --git a/evoagentx/actions/customize_action.py b/evoagentx/actions/customize_action.py index 57367d8a..ab8b2b1e 100644 --- a/evoagentx/actions/customize_action.py +++ b/evoagentx/actions/customize_action.py @@ -475,7 +475,11 @@ async def prepare_context( elif self.prompt is not None: sys_msg = sys_msg or DEFAULT_SYSTEM_PROMPT context_manager.add_system_prompt(sys_msg) - user_prompt = self.prompt.format(**inputs) + prompt_inputs = { + key: json.dumps(value, indent=2, ensure_ascii=False) if isinstance(value, (dict, list)) else value + for key, value in inputs.items() + } + user_prompt = self.prompt.format(**prompt_inputs) # Only append the textual tool-calling guide when tools are actually # available AND we are not using native tool calling. Without tools, # the guide's web_search/code_execution examples can induce the model diff --git a/evoagentx/agents/customize_agent.py b/evoagentx/agents/customize_agent.py index 11d9e490..5404273a 100644 --- a/evoagentx/agents/customize_agent.py +++ b/evoagentx/agents/customize_agent.py @@ -30,6 +30,9 @@ from .agent import Agent +COMPLEX_PARAM_TYPES = {"object", "array", "dict", "list"} + + class CustomizeAgent(Agent): """ @@ -49,13 +52,13 @@ class CustomizeAgent(Agent): - type (str): Type of the input - description (str): Description of what the input represents - required (bool, optional): Whether this input is required (default: True) - - json_schema (dict, optional): The json schema of the input, only used when type is `object` or `array`. + - json_schema (dict, optional): The json schema of the input, recommended when type is `object` or `array`. outputs (List[Union[dict, Parameter]], optional): List of output specifications as dicts or Parameter objects. Each dict (e.g., `{"name": str, "type": str, "description": str, ["required": bool, "json_schema": dict]}`) contains: - name (str): Name of the output field - type (str): Type of the output - description (str): Description of what the output represents - required (bool, optional): Whether this output is required (default: True) - - json_schema (dict, optional): The json schema of the output, only used when type is `object` or `array`. + - json_schema (dict, optional): The json schema of the output, recommended when type is `object` or `array`. system_prompt (str, optional): The system prompt for the LLM. Defaults to DEFAULT_SYSTEM_PROMPT. output_parser (Type[ActionOutput], optional): A custom class for parsing the LLM's output. Must be a subclass of ActionOutput. @@ -258,7 +261,9 @@ def parse_mode(self) -> str: def parse_mode(self, parse_mode: str): if parse_mode not in PARSER_VALID_MODE: raise ValueError(f"'{parse_mode}' is an invalid value for `parse_mode`. Available choices: {PARSER_VALID_MODE}.") - if CustomizeAgent._outputs_require_json_mode(self.outputs, self.parse_func) and parse_mode != "json": + # Only enforce json for prompt_template-based agents (see validate_data): a raw `prompt` + # owns its output format, so the user is free to pair complex outputs with any parse_mode. + if self.prompt_template is not None and CustomizeAgent._outputs_require_json_mode(self.outputs, self.parse_func) and parse_mode != "json": raise ValueError( f"Cannot set parse_mode='{parse_mode}': current outputs contain object/array types or json_schema. " f"Set parse_mode='json', or provide a custom parse_func first." @@ -298,7 +303,7 @@ def _outputs_require_json_mode(outputs: List[Parameter], parse_func: Optional[Ca if parse_func is not None: return False return ( - any(p.type in {"object", "array"} for p in outputs) + any(p.type in COMPLEX_PARAM_TYPES for p in outputs) or CustomizeAgent.contain_json_schema(outputs) ) @@ -386,9 +391,11 @@ def validate_data( """Validate and normalize agent configuration, auto-correcting parse_mode where needed. Converts `inputs` and `outputs` to `Parameter` objects, validates all - parsing-related options, and auto-corrects `parse_mode` to `"json"` when the - output schema contains `object`/`array` types or a `json_schema` without a - custom parse function. + parsing-related options, and (for `prompt_template`-based agents only) + auto-corrects `parse_mode` to `"json"` when the output schema contains + `object`/`array` types or a `json_schema` without a custom parse function. + Raw-`prompt` agents keep their `parse_mode`, since the prompt is sent verbatim + and dictates its own output format. Returns: A tuple containing: @@ -420,8 +427,12 @@ def validate_data( if parse_mode not in PARSER_VALID_MODE: raise ValueError(f"'{parse_mode}' is an invalid value for `parse_mode`. Available choices: {PARSER_VALID_MODE}.") - # Auto-correct parse_mode to "json" when outputs require it and no custom parse_func is provided - if CustomizeAgent._outputs_require_json_mode(valid_outputs, parse_func) and parse_mode != "json": + # Auto-correct parse_mode to "json" when outputs require it and no custom parse_func is provided. + # Only do this for prompt_template-based agents: the template renders the output-format section + # (and injects the JSON schema) so the model is actually instructed to emit JSON. A raw `prompt` + # is sent verbatim, so the model follows whatever format the prompt itself specifies; forcing + # json here would make the parser disagree with the prompt's requested format. + if prompt_template is not None and CustomizeAgent._outputs_require_json_mode(valid_outputs, parse_func) and parse_mode != "json": logger.warning( f"parse_mode='{parse_mode}' is not compatible with the current outputs (object/array types or " f"json_schema). Auto-correcting to parse_mode='json'. To suppress this warning, explicitly set " @@ -542,7 +553,7 @@ def _create_action_parser(params: List[Union[dict, Parameter]], action_name: str action_parser_type = ActionInput if type == "input" else ActionOutput action_fields = CustomizeAgent._prepare_action_info(params) - if CustomizeAgent.contain_json_schema(params): + if CustomizeAgent._requires_model_json_schema(params): json_schema = CustomizeAgent.create_json_schema(params) else: json_schema = None @@ -604,6 +615,11 @@ def contain_json_schema(params: List[Union[dict, Parameter]]) -> bool: params: List[Parameter] = to_params(params) return any(param.json_schema for param in params) + @staticmethod + def _requires_model_json_schema(params: List[Union[dict, Parameter]]) -> bool: + params: List[Parameter] = to_params(params) + return any(param.json_schema or param.type in COMPLEX_PARAM_TYPES for param in params) + def _check_output_parser(self, outputs: List[Parameter], output_parser: Type[ActionOutput]): if output_parser is not None: @@ -736,4 +752,3 @@ def from_dict( break return cls(**agent_data, **kwargs) - \ No newline at end of file diff --git a/evoagentx/core/base_config.py b/evoagentx/core/base_config.py index 9159f1c2..e258753c 100644 --- a/evoagentx/core/base_config.py +++ b/evoagentx/core/base_config.py @@ -60,10 +60,10 @@ class Parameter(BaseModule): Attributes: name: Parameter name - type: Parameter type, support json & python type. if type is `object` or `array`, then schema is required. + type: Parameter type, support json & python type. description: Parameter description required: Whether the parameter is required, defaults to True - json_schema: the json schema of the parameter, required when type is `object` or `array`. + json_schema: the optional json schema of the parameter. Recommended when type is `object` or `array`. """ name: str type: str @@ -73,16 +73,19 @@ class Parameter(BaseModule): @model_validator(mode="after") def _validate_type_and_schema(self): - from ..utils.utils import string_to_python_type + from ..utils.utils import string_to_json_schema_type, string_to_python_type if self.type not in string_to_python_type: raise ValueError(f"Invalid `type`: {self.type}. Allowed: {list(string_to_python_type.keys())}") - if self.type in {"object", "array"} and not self.json_schema: - raise ValueError("`json_schema` is required when `type` is `object` or `array`.") if self.json_schema is not None: try: Draft7Validator.check_schema(self.json_schema) except Exception as e: raise ValueError(f"Invalid `json_schema` for '{self.name}': {self.json_schema}.") from e - assert self.type == self.json_schema.get("type"), f"`type` and `json_schema.type` must be the same if `json_schema` is provided. But got `type`: {self.type}, `json_schema.type`: {self.json_schema.get('type')}" + expected_schema_type = string_to_json_schema_type[self.type] + actual_schema_type = self.json_schema.get("type") + if expected_schema_type != actual_schema_type: + raise ValueError( + "`type` and `json_schema.type` must be the same if `json_schema` is provided. " + f"But got `type`: {self.type}, `json_schema.type`: {actual_schema_type}" + ) return self - diff --git a/evoagentx/prompts/template.py b/evoagentx/prompts/template.py index 3e90bfd5..8f57ad92 100644 --- a/evoagentx/prompts/template.py +++ b/evoagentx/prompts/template.py @@ -167,7 +167,7 @@ def render_input_example( if field_description is not None: description = f"({field_description})" - if isinstance(value, dict): + if isinstance(value, (dict, list)): value = json.dumps(value, indent=2, ensure_ascii=False) inputs_str += template.format(name=name, description=description, value=value) return inputs_str diff --git a/examples/workflow/arxiv_workflow.py b/examples/workflow/arxiv_workflow.py index d5831c4a..66371259 100644 --- a/examples/workflow/arxiv_workflow.py +++ b/examples/workflow/arxiv_workflow.py @@ -1,25 +1,25 @@ import os from dotenv import load_dotenv -from evoagentx.models import OpenAILLMConfig, OpenAILLM +from evoagentx.models import OpenRouterConfig, OpenRouterLLM from evoagentx.workflow import WorkFlowGenerator, WorkFlowGraph, WorkFlow from evoagentx.agents import AgentManager from evoagentx.tools.file_tool import FileToolkit -from evoagentx.tools import ArxivToolkit +from evoagentx.tools import ArxivToolkit -load_dotenv() -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +load_dotenv() +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") def main(): - openai_config = OpenAILLMConfig( - model="gpt-4o", - openai_key=OPENAI_API_KEY, + openrouter_config = OpenRouterConfig( + model="deepseek/deepseek-v4-pro", + openrouter_key=OPENROUTER_API_KEY, stream=True, output_response=True, max_tokens=16000 ) - llm = OpenAILLM(config=openai_config) + llm = OpenRouterLLM(config=openrouter_config) keywords = "medical, multiagent" max_results = 10 @@ -47,7 +47,7 @@ def main(): daily_paper_digest """ - target_directory = "EvoAgentX/examples/output/paper_push" + target_directory = "examples/output/paper_push" module_save_path = os.path.join(target_directory, "paper_push_workflow.json") result_path = os.path.join(target_directory, "daily_paper_digest.md") os.makedirs(target_directory, exist_ok=True) @@ -60,10 +60,10 @@ def main(): workflow_graph.save_module(module_save_path) - workflow_graph.display() + # workflow_graph.display() - agent_manager = AgentManager(tools=tools) - agent_manager.add_agents_from_workflow(workflow_graph, llm_config=openai_config) + agent_manager = AgentManager() + agent_manager.add_agents_from_workflow(workflow_graph, llm_config=openrouter_config, tools=tools) workflow = WorkFlow(graph=workflow_graph, agent_manager=agent_manager, llm=llm) output = workflow.execute() diff --git a/tests/src/agents/test_customize_action.py b/tests/src/agents/test_customize_action.py index 33f9ce89..3af88f9c 100644 --- a/tests/src/agents/test_customize_action.py +++ b/tests/src/agents/test_customize_action.py @@ -4,6 +4,7 @@ called.""" import asyncio +import json import unittest from typing import Any, Dict, List, Optional from unittest.mock import AsyncMock, patch @@ -153,6 +154,59 @@ async def test_action_tools_override_prompt_template_tools(self): self.assertIn("add_numbers", prompt_text) self.assertNotIn("prompt_only", prompt_text) + async def test_prompt_path_renders_array_input_as_json(self): + agent = CustomizeAgent( + name="PromptArrayInput", description="d", + prompt="Summarize these records: {records}", + llm_config=make_config(), + inputs=[ + {"name": "records", "type": "array", "description": "Records to summarize."}, + ], + ) + + cm = ContextManager(llm=make_llm()) + await agent.action.prepare_context( + llm=make_llm(), + inputs={"records": [{"title": "Alpha", "score": 1}]}, + context_manager=cm, + ) + + user_text = _user_messages(cm.context) + self.assertIn(json.dumps([{"title": "Alpha", "score": 1}], indent=2), user_text) + self.assertNotIn("[{'title': 'Alpha', 'score': 1}]", user_text) + + async def test_no_schema_array_input_and_object_output_prompt(self): + agent = CustomizeAgent( + name="NoSchemaComplex", description="d", + prompt_template=ChatTemplate(instruction="Summarize the provided records."), + llm_config=make_config(), + inputs=[ + {"name": "records", "type": "array", "description": "Records to summarize."}, + ], + outputs=[ + {"name": "summary", "type": "object", "description": "Structured summary."}, + ], + parse_mode="title", + ) + + self.assertEqual(agent.parse_mode, "json") + + cm = ContextManager(llm=make_llm()) + await agent.action.prepare_context( + llm=make_llm(), + inputs={"records": [{"title": "Alpha", "score": 1}]}, + context_manager=cm, + ) + + messages_text = _all_message_text(cm.context) + user_text = _user_messages(cm.context) + output_schema = agent.action.outputs_format.model_config.get("json_schema_extra") + + self.assertEqual(output_schema["properties"]["summary"]["type"], "object") + self.assertIn("strictly follows the following JSON schema", messages_text) + self.assertIn(json.dumps([{"title": "Alpha", "score": 1}], indent=2), user_text) + self.assertNotIn("[{'title': 'Alpha', 'score': 1}]", user_text) + class TestExtractHelpers(unittest.TestCase): """A4: tool-call / answer extraction helpers.""" diff --git a/tests/src/agents/test_customize_agent_config.py b/tests/src/agents/test_customize_agent_config.py index 96d6f785..69188c5f 100644 --- a/tests/src/agents/test_customize_agent_config.py +++ b/tests/src/agents/test_customize_agent_config.py @@ -237,8 +237,11 @@ def test_custom_parse_mode_without_func_raises(self): ) def test_object_output_auto_corrects_to_json(self): + # Auto-correction only applies to prompt_template agents: the template injects the + # JSON-schema output instruction, so json parsing is what the model is told to produce. agent = CustomizeAgent( - name="N", description="d", prompt="p", + name="N", description="d", + prompt_template=ChatTemplate(instruction="Do the task"), llm_config=make_config(), outputs=[{ "name": "data", "type": "object", "description": "obj", @@ -248,6 +251,30 @@ def test_object_output_auto_corrects_to_json(self): ) self.assertEqual(agent.parse_mode, "json") + def test_object_output_without_schema_builds_minimal_schema(self): + agent = CustomizeAgent( + name="N", description="d", + prompt_template=ChatTemplate(instruction="Do the task"), + llm_config=make_config(), + outputs=[{"name": "data", "type": "object", "description": "obj"}], + parse_mode="title", + ) + + schema = agent.action.outputs_format.model_config.get("json_schema_extra") + self.assertEqual(agent.parse_mode, "json") + self.assertEqual(schema["properties"]["data"], {"type": "object", "description": "obj"}) + + def test_raw_prompt_object_output_keeps_parse_mode(self): + # A raw `prompt` is sent verbatim, so the model follows the format the prompt requests. + # parse_mode must NOT be force-corrected to json (it would disagree with the prompt). + agent = CustomizeAgent( + name="N", description="d", prompt="p", + llm_config=make_config(), + outputs=[{"name": "data", "type": "object", "description": "obj"}], + parse_mode="title", + ) + self.assertEqual(agent.parse_mode, "title") + def test_invalid_input_item_type_raises(self): with self.assertRaises(ValueError): CustomizeAgent( @@ -271,7 +298,10 @@ def _agent(self, **overrides): return CustomizeAgent(**kwargs) def test_parse_mode_setter_rejects_non_json_for_object_outputs(self): + # Setter enforcement only applies to prompt_template agents (see validate_data). agent = self._agent( + prompt=None, + prompt_template=ChatTemplate(instruction="Do the task"), outputs=[{ "name": "data", "type": "object", "description": "obj", "json_schema": {"type": "object", "properties": {"k": {"type": "string"}}}, @@ -281,6 +311,14 @@ def test_parse_mode_setter_rejects_non_json_for_object_outputs(self): with self.assertRaises(ValueError): agent.parse_mode = "title" + def test_parse_mode_setter_allows_non_json_for_raw_prompt_object_outputs(self): + agent = self._agent( + outputs=[{"name": "data", "type": "object", "description": "obj"}], + parse_mode="title", + ) + agent.parse_mode = "str" + self.assertEqual(agent.parse_mode, "str") + def test_parse_func_none_while_custom_raises(self): agent = self._agent(parse_mode="custom", parse_func=_cfg_parse_func) with self.assertRaises(ValueError): diff --git a/tests/src/core/test_base_config.py b/tests/src/core/test_base_config.py index 00ae3e6c..0dfc03bc 100644 --- a/tests/src/core/test_base_config.py +++ b/tests/src/core/test_base_config.py @@ -46,13 +46,13 @@ def test_invalid_type_raises(self): with self.assertRaises(Exception): Parameter(name="p", type="invalid_type", description="bad type") - def test_object_type_requires_json_schema(self): - with self.assertRaises(Exception): - Parameter(name="p", type="object", description="missing schema") + def test_object_type_allows_missing_json_schema(self): + param = Parameter(name="p", type="object", description="missing schema") + self.assertIsNone(param.json_schema) - def test_array_type_requires_json_schema(self): - with self.assertRaises(Exception): - Parameter(name="p", type="array", description="missing schema") + def test_array_type_allows_missing_json_schema(self): + param = Parameter(name="p", type="array", description="missing schema") + self.assertIsNone(param.json_schema) def test_object_with_valid_json_schema(self): schema = {"type": "object", "properties": {"key": {"type": "string"}}} @@ -64,6 +64,11 @@ def test_array_with_valid_json_schema(self): param = Parameter(name="p", type="array", description="an array", json_schema=schema) self.assertEqual(param.json_schema, schema) + def test_python_alias_with_valid_json_schema(self): + schema = {"type": "array", "items": {"type": "string"}} + param = Parameter(name="p", type="list", description="an array", json_schema=schema) + self.assertEqual(param.json_schema, schema) + def test_json_schema_type_mismatch_raises(self): schema = {"type": "object", "properties": {}} with self.assertRaises(Exception): From 1d79a07c7f5c8073bac49496516f01b04ddf3a38 Mon Sep 17 00:00:00 2001 From: jinyuan Date: Sat, 27 Jun 2026 15:09:04 +0100 Subject: [PATCH 9/9] update examples to be compatible with new agent scheme --- evoagentx/tools/mcp.py | 2 +- examples/customize_agent.py | 12 ++--- examples/hitl/hitl_example2.py | 15 ++++-- .../hitl/hitl_multi_conversation_example.py | 15 +++--- examples/mcp_agent.py | 14 ++--- examples/models/openrouter_example.py | 6 +-- examples/sequential_workflow.py | 15 +++--- examples/workflow/arxiv_workflow.py | 2 +- examples/workflow/workflow_demo.py | 26 ++++----- examples/workflow/workflow_direction.py | 11 ++-- examples/workflow_demo_with_tools.py | 54 +++++++------------ 11 files changed, 75 insertions(+), 97 deletions(-) diff --git a/evoagentx/tools/mcp.py b/evoagentx/tools/mcp.py index 6edbd8c2..f1787024 100644 --- a/evoagentx/tools/mcp.py +++ b/evoagentx/tools/mcp.py @@ -87,7 +87,7 @@ def _convert_result(self, result: Any) -> Any: if hasattr(result, 'text'): return result.text elif hasattr(result, 'content'): - return result.content + return self._convert_result(result.content) # Handle objects with __dict__ (convert to dictionary) if hasattr(result, '__dict__'): diff --git a/examples/customize_agent.py b/examples/customize_agent.py index f8507b27..3c44c407 100644 --- a/examples/customize_agent.py +++ b/examples/customize_agent.py @@ -1,19 +1,17 @@ import os from dotenv import load_dotenv -from evoagentx.core import Message -from evoagentx.models import OpenAILLMConfig +from evoagentx.core import Message +from evoagentx.models import OpenRouterConfig, OpenRouterLLM from evoagentx.agents import CustomizeAgent from evoagentx.prompts import StringTemplate, ChatTemplate from evoagentx.core.module_utils import extract_code_blocks as util_extract_code_blocks from evoagentx.core.registry import register_parse_function -from evoagentx.tools.file_tool import FileToolkit +from evoagentx.tools.file_tool import FileToolkit from evoagentx.tools.mcp import MCPToolkit load_dotenv() -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") -model_config = OpenAILLMConfig(model="gpt-4o-mini", openai_key=OPENAI_API_KEY, stream=True, output_response=True) -# model_config = LiteLLMConfig(model="anthropic/claude-3-7-sonnet-20250219", anthropic_key=ANTHROPIC_API_KEY, stream=True, output_response=True, max_tokens=20000) +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") +model_config = OpenRouterConfig(model="openai/gpt-5.4-mini", openrouter_key=OPENROUTER_API_KEY, stream=True, output_response=True) @register_parse_function diff --git a/examples/hitl/hitl_example2.py b/examples/hitl/hitl_example2.py index 181a38dc..dc800946 100644 --- a/examples/hitl/hitl_example2.py +++ b/examples/hitl/hitl_example2.py @@ -14,10 +14,12 @@ HITLUserInputCollectorAgent, HITLManager ) -from evoagentx.models import OpenAILLMConfig, OpenAILLM +from evoagentx.models import OpenRouterLLM +from evoagentx.models.model_configs import OpenRouterConfig +from evoagentx.prompts import ChatTemplate load_dotenv() -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") class UserProfileInput(ActionInput): user_name: str = Field(description="User's name") @@ -33,8 +35,8 @@ async def main(): print("🚀 EvoAgentX HITL user input collection example") print("=" * 60) - llm_config = OpenAILLMConfig(model="gpt-4o", openai_key=OPENAI_API_KEY, stream=True, output_response=True) - llm = OpenAILLM(llm_config) + llm_config = OpenRouterConfig(model="openai/gpt-5.4-mini", openrouter_key=OPENROUTER_API_KEY, stream=True, output_response=True) + llm = OpenRouterLLM(llm_config) # define user input fields user_input_fields = { @@ -69,6 +71,9 @@ async def main(): input_fields=user_input_fields, ) + profile_processor_template = ChatTemplate( + instruction="Generate a profile summary and personalized recommendations based on the user information provided in the inputs.", + ) profile_processor = CustomizeAgent( name="ProfileProcessor", description="process user's profile and generate recommendations", @@ -82,7 +87,7 @@ async def main(): {"name": "profile_summary", "type": "string", "description": "profile summary based on user's information"}, {"name": "recommendations", "type": "string", "description": "Personalized recommendations based on user information"} ], - prompt="Generate profile summary and personalized recommendations based on the following user information:\nName: {user_name}\nAge: {user_age}\nEmail: {user_email}\nPreferences: {user_preferences}\n\nPlease provide profile summary and personalized recommendations. The results should be presented in json format and have field of 'profile_summary' and 'recommendations'", + prompt_template=profile_processor_template, llm_config=llm_config, parse_mode="json" ) diff --git a/examples/hitl/hitl_multi_conversation_example.py b/examples/hitl/hitl_multi_conversation_example.py index 6f619e79..58786293 100644 --- a/examples/hitl/hitl_multi_conversation_example.py +++ b/examples/hitl/hitl_multi_conversation_example.py @@ -1,7 +1,8 @@ import asyncio import os from dotenv import load_dotenv -from evoagentx.models import OpenAILLMConfig, OpenAILLM +from evoagentx.models import OpenRouterLLM +from evoagentx.models.model_configs import OpenRouterConfig from evoagentx.hitl import HITLOutsideConversationAgent, HITLManager # from evoagentx.workflow import WorkFlow, WorkFlowGraph # from evoagentx.workflow.workflow_graph import WorkFlowNode, WorkFlowEdge @@ -9,7 +10,7 @@ # from evoagentx.core.base_config import Parameter load_dotenv() -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") async def main(): @@ -20,13 +21,13 @@ async def main(): print("=" * 80) # configure the LLM - llm_config = OpenAILLMConfig( - model="gpt-4o", - openai_key=OPENAI_API_KEY, - stream=True, + llm_config = OpenRouterConfig( + model="openai/gpt-5.4-mini", + openrouter_key=OPENROUTER_API_KEY, + stream=True, output_response=True ) - llm = OpenAILLM(llm_config) + llm = OpenRouterLLM(llm_config) # create the HITLOutsideConversationAgent conversation_agent = HITLOutsideConversationAgent( diff --git a/examples/mcp_agent.py b/examples/mcp_agent.py index 7fc75be1..bae1137a 100644 --- a/examples/mcp_agent.py +++ b/examples/mcp_agent.py @@ -2,18 +2,14 @@ import os from dotenv import load_dotenv -from evoagentx.models import OpenAILLMConfig +from evoagentx.models import OpenRouterConfig, OpenRouterLLM from evoagentx.agents import CustomizeAgent -from evoagentx.prompts import StringTemplate +from evoagentx.prompts import StringTemplate from evoagentx.tools.mcp import MCPToolkit -from evoagentx.tools.image_analysis import ImageAnalysisTool -from evoagentx.tools.images_flux_generation import FluxImageGenerationTool load_dotenv() -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -OPENAI_ORGANIZATION_ID = os.getenv("OPENAI_ORGANIZATION_ID") OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") -openai_config = OpenAILLMConfig(model="gpt-4o-mini", openai_key=OPENAI_API_KEY, stream=True, output_response=True) +openrouter_config = OpenRouterConfig(model="openai/gpt-5.4-mini", openrouter_key=OPENROUTER_API_KEY, stream=True, output_response=True) def test_MCP_server(): @@ -26,7 +22,7 @@ def test_MCP_server(): prompt_template= StringTemplate( instruction="Do some operations based on the user's instruction." ), - llm_config=openai_config, + llm_config=openrouter_config, inputs=[ {"name": "instruction", "type": "string", "description": "The goal you need to achieve"} ], @@ -36,7 +32,7 @@ def test_MCP_server(): tools=tools ) mcp_agent.save_module("examples/output/mcp_agent/mcp_agent.json") - mcp_agent.load_module("examples/output/mcp_agent/mcp_agent.json", llm_config=openai_config, tools=tools) + mcp_agent.load_module("examples/output/mcp_agent/mcp_agent.json", llm_config=openrouter_config, tools=tools) message = mcp_agent( inputs={"instruction": "Summarize all the tools."} diff --git a/examples/models/openrouter_example.py b/examples/models/openrouter_example.py index ba85bc13..3c828a4e 100644 --- a/examples/models/openrouter_example.py +++ b/examples/models/openrouter_example.py @@ -10,10 +10,10 @@ load_dotenv() OPEN_ROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") openrouter_config = OpenRouterConfig( - model="deepseek/deepseek-r1-0528-qwen3-8b:free", - openrouter_key=OPEN_ROUTER_API_KEY, + model="openai/gpt-5.4-mini", + openrouter_key=OPEN_ROUTER_API_KEY, output_response=True, - max_tokens=1, + max_tokens=512, temperature=0.5 ) diff --git a/examples/sequential_workflow.py b/examples/sequential_workflow.py index f9d8f1a0..37768c02 100644 --- a/examples/sequential_workflow.py +++ b/examples/sequential_workflow.py @@ -5,11 +5,11 @@ from evoagentx.core.module_utils import extract_code_blocks from evoagentx.workflow import SequentialWorkFlowGraph, WorkFlow from evoagentx.agents import AgentManager -from evoagentx.models import OpenAILLMConfig, OpenAILLM +from evoagentx.models import OpenRouterConfig, OpenRouterLLM from evoagentx.tools import FileToolkit load_dotenv() -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") @register_parse_function @@ -19,9 +19,9 @@ def custom_parse_func(content: str) -> str: def build_sequential_workflow(): - # configure the LLM - llm_config = OpenAILLMConfig(model="gpt-4o-mini", openai_key=OPENAI_API_KEY, stream=True, output_response=True) - llm = OpenAILLM(llm_config) + # configure the LLM + llm_config = OpenRouterConfig(model="openai/gpt-5.4-mini", openrouter_key=OPENROUTER_API_KEY, stream=True, output_response=True) + llm = OpenRouterLLM(config=llm_config) # Define two sequential tasks: Planning and Coding tasks = [ @@ -69,8 +69,9 @@ def build_sequential_workflow(): # create agent instance from the workflow graph agent_manager = AgentManager(tools = [FileToolkit()]) agent_manager.add_agents_from_workflow( - graph, - llm_config=llm_config, # will be used for all tasks without `llm_config`. + graph, + llm_config=llm_config, # will be used for all tasks without `llm_config`. + tools=[FileToolkit()] ) # create a workflow instance for execution diff --git a/examples/workflow/arxiv_workflow.py b/examples/workflow/arxiv_workflow.py index 66371259..420d16fe 100644 --- a/examples/workflow/arxiv_workflow.py +++ b/examples/workflow/arxiv_workflow.py @@ -13,7 +13,7 @@ def main(): openrouter_config = OpenRouterConfig( - model="deepseek/deepseek-v4-pro", + model="openai/gpt-5.4-mini", openrouter_key=OPENROUTER_API_KEY, stream=True, output_response=True, diff --git a/examples/workflow/workflow_demo.py b/examples/workflow/workflow_demo.py index 5ed5e778..0d0da18f 100644 --- a/examples/workflow/workflow_demo.py +++ b/examples/workflow/workflow_demo.py @@ -1,22 +1,21 @@ -import os -from dotenv import load_dotenv -from evoagentx.models import OpenAILLMConfig, OpenAILLM, LiteLLMConfig, LiteLLM +import os +from dotenv import load_dotenv +from evoagentx.models import OpenRouterConfig, OpenRouterLLM from evoagentx.workflow import WorkFlowGenerator, WorkFlowGraph, WorkFlow from evoagentx.agents import AgentManager from evoagentx.actions.code_extraction import CodeExtraction -from evoagentx.actions.code_verification import CodeVerification +from evoagentx.actions.code_verification import CodeVerification from evoagentx.core.module_utils import extract_code_blocks load_dotenv() # Loads environment variables from .env file -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") def main(): # LLM configuration - openai_config = OpenAILLMConfig(model="gpt-4o-mini", openai_key=OPENAI_API_KEY, stream=True, output_response=True, max_tokens=16000) + openrouter_config = OpenRouterConfig(model="openai/gpt-5.4-mini", openrouter_key=OPENROUTER_API_KEY, stream=True, output_response=True, max_tokens=16000) # Initialize the language model - llm = OpenAILLM(config=openai_config) + llm = OpenRouterLLM(config=openrouter_config) goal = "Generate html code for the Tetris game that can be played in the browser." target_directory = "examples/output/tetris_game" @@ -32,18 +31,15 @@ def main(): # workflow_graph: WorkFlowGraph = WorkFlowGraph.from_file(f"{target_directory}/workflow_demo_4o_mini.json") agent_manager = AgentManager() - agent_manager.add_agents_from_workflow(workflow_graph, llm_config=openai_config) + agent_manager.add_agents_from_workflow(workflow_graph, llm_config=openrouter_config) workflow = WorkFlow(graph=workflow_graph, agent_manager=agent_manager, llm=llm) output = workflow.execute() - - # verfiy the code - verification_llm_config = LiteLLMConfig(model="anthropic/claude-3-7-sonnet-20250219", anthropic_key=ANTHROPIC_API_KEY, stream=True, output_response=True, max_tokens=20000) - verification_llm = LiteLLM(config=verification_llm_config) - + + # verify the code code_verifier = CodeVerification() output = code_verifier.execute( - llm = verification_llm, + llm=llm, inputs={ "requirements": goal, "code": output diff --git a/examples/workflow/workflow_direction.py b/examples/workflow/workflow_direction.py index f3430734..6fce2558 100644 --- a/examples/workflow/workflow_direction.py +++ b/examples/workflow/workflow_direction.py @@ -5,14 +5,14 @@ from dotenv import load_dotenv import sys -from evoagentx.models import OpenAILLMConfig, OpenAILLM +from evoagentx.models import OpenRouterConfig, OpenRouterLLM from evoagentx.workflow import WorkFlowGraph, WorkFlow # from evoagentx.workflow.workflow_generator import WorkFlowGenerator from evoagentx.agents import AgentManager from evoagentx.tools.mcp import MCPToolkit from evoagentx.tools.file_tool import FileToolkit load_dotenv() # Loads environment variables from .env file -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") output_file = "debug/output/direction/output.md" mcp_config_path = "examples/output/direction/mcp_direction.config" @@ -21,9 +21,9 @@ def main(goal=None): # LLM configuration - openai_config = OpenAILLMConfig(model="gpt-4o-mini", openai_key=OPENAI_API_KEY, stream=True, output_response=True, max_tokens=16000) + openrouter_config = OpenRouterConfig(model="openai/gpt-5.4-mini", openrouter_key=OPENROUTER_API_KEY, stream=True, output_response=True, max_tokens=16000) # Initialize the language model - llm = OpenAILLM(config=openai_config) + llm = OpenRouterLLM(config=openrouter_config) goal = """Read and analyze the candidate's pdf resume at examples/output/direction/test_pdf.pdf, and recommend one future PHD directions based on the resume. You should provide a list of 5 review papers about the topic for the candidate to learn more about this direction as well.""" # goal = making_goal(openai_config, goal) @@ -56,8 +56,7 @@ def main(goal=None): # [optional] display workflow # workflow_graph.display() agent_manager = AgentManager(tools=tools) - agent_manager.add_agents_from_workflow(workflow_graph, llm_config=openai_config) - # from pdb import set_trace; set_trace() + agent_manager.add_agents_from_workflow(workflow_graph, llm_config=openrouter_config, tools=tools) workflow = WorkFlow(graph=workflow_graph, agent_manager=agent_manager, llm=llm) output = workflow.execute() diff --git a/examples/workflow_demo_with_tools.py b/examples/workflow_demo_with_tools.py index 6d9af943..f991d0f0 100644 --- a/examples/workflow_demo_with_tools.py +++ b/examples/workflow_demo_with_tools.py @@ -5,37 +5,19 @@ """ import os -import sys from dotenv import load_dotenv -# Add the EvoAgentX project to the path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'EvoAgentX-clean_tools')) - -from evoagentx.models import OpenAILLMConfig, OpenAILLM +from evoagentx.models import OpenRouterConfig, OpenRouterLLM from evoagentx.workflow import WorkFlowGenerator, WorkFlowGraph, WorkFlow from evoagentx.agents import AgentManager from evoagentx.tools import CMDToolkit def load_api_key(): - """Load OpenAI API key from various sources""" + """Load OpenRouter API key from environment""" load_dotenv() - - # Try to get from environment - api_key = os.getenv("OPENAI_API_KEY") - - # Try to get from local file - if not api_key and os.path.exists("openai_api_key.txt"): - with open("openai_api_key.txt", "r") as f: - content = f.read().strip() - # Extract the key if it's in the format "OPENAI_API_KEY=..." - if "=" in content: - api_key = content.split("=", 1)[1].strip() - else: - api_key = content - + api_key = os.getenv("OPENROUTER_API_KEY") if not api_key: - raise ValueError("OpenAI API key not found. Please set OPENAI_API_KEY environment variable or create openai_api_key.txt file") - + raise ValueError("OpenRouter API key not found. Please set OPENROUTER_API_KEY environment variable.") return api_key def demo_basic_workflow(): @@ -45,14 +27,14 @@ def demo_basic_workflow(): # Setup LLM configuration api_key = load_api_key() - openai_config = OpenAILLMConfig( - model="gpt-4o-mini", - openai_key=api_key, + openai_config = OpenRouterConfig( + model="openai/gpt-5.4-mini", + openrouter_key=api_key, stream=True, output_response=True, max_tokens=8000 ) - llm = OpenAILLM(config=openai_config) + llm = OpenRouterLLM(config=openai_config) # Define the goal goal = "Create a simple Python calculator application" @@ -87,14 +69,14 @@ def demo_toolkit_workflow(): # Setup LLM configuration api_key = load_api_key() - openai_config = OpenAILLMConfig( - model="gpt-4o-mini", - openai_key=api_key, + openai_config = OpenRouterConfig( + model="openai/gpt-5.4-mini", + openrouter_key=api_key, stream=True, output_response=True, max_tokens=8000 ) - llm = OpenAILLM(config=openai_config) + llm = OpenRouterLLM(config=openai_config) # Define the goal and tools goal = "Create a folder structure for a Python project and show the file tree" @@ -102,8 +84,8 @@ def demo_toolkit_workflow(): print(f"Goal: {goal}") print(f"Tools: {[tool.__class__.__name__ for tool in tools]}") - # Generate workflow with tools - wf_generator = WorkFlowGenerator(llm=llm, tools=tools) + # Generate workflow (tools are passed to AgentManager, not WorkFlowGenerator) + wf_generator = WorkFlowGenerator(llm=llm) workflow_graph: WorkFlowGraph = wf_generator.generate_workflow(goal=goal) # Display workflow structure @@ -131,14 +113,14 @@ def demo_workflow_save_load(): # Setup LLM configuration api_key = load_api_key() - openai_config = OpenAILLMConfig( - model="gpt-4o-mini", - openai_key=api_key, + openai_config = OpenRouterConfig( + model="openai/gpt-5.4-mini", + openrouter_key=api_key, stream=True, output_response=True, max_tokens=8000 ) - llm = OpenAILLM(config=openai_config) + llm = OpenRouterLLM(config=openai_config) # Generate a simple workflow goal = "Create a simple Python calculator application"