diff --git a/agents/conditional/src/hyperforge_conditional/conditional.py b/agents/conditional/src/hyperforge_conditional/conditional.py index a5e42de..c3b3b33 100644 --- a/agents/conditional/src/hyperforge_conditional/conditional.py +++ b/agents/conditional/src/hyperforge_conditional/conditional.py @@ -4,6 +4,7 @@ from hyperforge.agent import Agent, AgentConfig from hyperforge.configure import get_agent_config_klass, get_agent_klass +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.manager import Manager from hyperforge.memory.memory import QuestionMemory from hyperforge.trace import trace_agent @@ -93,11 +94,10 @@ class ConditionalAgentConfig(AgentConfig): title="Evaluate condition on", description="Source to evaluate the condition on. ", ) - model: str = Field( - default="chatgpt-o3-mini", + model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.reasoning), title="Generative model", description="Model used to assess the condition", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) @field_serializer("then", "else_") diff --git a/agents/external/src/hyperforge_external/config.py b/agents/external/src/hyperforge_external/config.py index 8d1d684..a9a7512 100644 --- a/agents/external/src/hyperforge_external/config.py +++ b/agents/external/src/hyperforge_external/config.py @@ -2,6 +2,7 @@ from typing import Any, Dict, Literal, Optional from hyperforge.agent import AgentConfig +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.utils import WidgetType, sync_dns_validation from pydantic import Field, field_validator from pydantic.config import ConfigDict @@ -49,11 +50,10 @@ class ExternalCallAgentConfig(AgentConfig): {}, title="Headers to use on the API call ", ) - model: str = Field( - default="chatgpt-o3-mini", + model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.reasoning), title="Generative model", description="Model used to extract the parameters to call the URL", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) context: bool = Field( False, diff --git a/agents/generate/src/hyperforge_generate/agent.py b/agents/generate/src/hyperforge_generate/agent.py index 3eb163b..f3d622b 100644 --- a/agents/generate/src/hyperforge_generate/agent.py +++ b/agents/generate/src/hyperforge_generate/agent.py @@ -3,7 +3,7 @@ from hyperforge.agent import Agent from hyperforge.configure import agent -from hyperforge.manager import Manager +from hyperforge.manager import Manager, build_reasoning from hyperforge.memory import QuestionMemory from hyperforge.trace import trace_agent from nuclia.lib.nua_responses import ChatModel, UserPrompt @@ -69,7 +69,8 @@ async def __call__( question="", user_prompt=UserPrompt(prompt=prompt), format_prompt=False, - generative_model=self.config.model, + generative_model=self.config.model.model_id, + reasoning=build_reasoning(self.config.model), max_tokens=2000, tracking=memory.get_tracking_info(), ) diff --git a/agents/generate/src/hyperforge_generate/config.py b/agents/generate/src/hyperforge_generate/config.py index 04d81cc..b48b0cc 100644 --- a/agents/generate/src/hyperforge_generate/config.py +++ b/agents/generate/src/hyperforge_generate/config.py @@ -1,6 +1,7 @@ from typing import Literal, Optional from hyperforge.agent import AgentConfig +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.utils import WidgetType from pydantic import Field from pydantic.config import ConfigDict @@ -16,11 +17,10 @@ class GenerateAgentConfig(AgentConfig): "widget": WidgetType.EXPANDABLE_TEXTAREA, }, ) - model: str = Field( - default="chatgpt-azure-4o-mini", + model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default), title="Generative model", description="Model used to generate the response", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) images: bool = False generate_image: bool = False diff --git a/agents/mcp/src/hyperforge_mcp/agent.py b/agents/mcp/src/hyperforge_mcp/agent.py index be84615..5e0404c 100644 --- a/agents/mcp/src/hyperforge_mcp/agent.py +++ b/agents/mcp/src/hyperforge_mcp/agent.py @@ -19,7 +19,7 @@ PromptFeedbackSchema, ValidationFeedbackSchema, ) -from hyperforge.manager import Manager +from hyperforge.manager import Manager, build_reasoning from hyperforge.memory import QuestionMemory from hyperforge.models import Chunk, Context, Prompt, TrackingInfo from hyperforge.utils import iterate_tools_resp @@ -331,7 +331,8 @@ async def choose_tool( question="", user_id="mcp_agent", query_context_images=images, - generative_model=self.config.tool_choice_model, + generative_model=self.config.tool_choice_model.model_id, + reasoning=build_reasoning(self.config.tool_choice_model), tools=tools, user_prompt=UserPrompt( prompt="Choose the best tool or tools for the task, select task_complete if no more tools are needed according to the user request and previous interactions" @@ -915,7 +916,8 @@ async def sampling_callback( question="", user_id=self.config.id or "mcp_agent", query_context_images=images, - generative_model=self.config.sampling_model, + generative_model=self.config.sampling_model.model_id, + reasoning=build_reasoning(self.config.sampling_model), format_prompt=False, system=params.systemPrompt, context=new_messages, @@ -940,7 +942,7 @@ async def sampling_callback( return CreateMessageResult( role="user", content=types.TextContent(type="text", text=resp.answer), - model=self.config.sampling_model, + model=self.config.sampling_model.model_id, ) async def elicitation_callback( diff --git a/agents/mcp/src/hyperforge_mcp/config.py b/agents/mcp/src/hyperforge_mcp/config.py index b7b6da8..2002ab8 100644 --- a/agents/mcp/src/hyperforge_mcp/config.py +++ b/agents/mcp/src/hyperforge_mcp/config.py @@ -2,7 +2,7 @@ from typing import Dict, List, Literal from hyperforge.context.config import ContextAgentConfig -from hyperforge.utils import WidgetType +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from pydantic import Field from pydantic.config import ConfigDict @@ -22,11 +22,10 @@ class MCPAgentConfig(ContextAgentConfig): "show_in_node": True, }, ) - tool_choice_model: str = Field( - default="chatgpt-4.1", + tool_choice_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.smart), title="Tool choice model", description="Model used to choose the tool to use", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) valid_headers: List[str] = Field( default_factory=list, title="Valid headers to forward to the agent" @@ -44,11 +43,10 @@ class MCPAgentConfig(ContextAgentConfig): max_turns: int = Field( default=5, title="Maximum number of tool calls before stopping" ) - sampling_model: str = Field( - default="gemini-2.5-flash", + sampling_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.fast), title="Sampling model", description="Model used for sampling", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) include_mcp_prompts: bool = Field( default=False, @@ -70,16 +68,19 @@ class MultiMCPAgentConfig(ContextAgentConfig): configs: List[MCPAgentConfig] = Field( default_factory=list, title="List of MCP agent configurations" ) - summarize_model: str = "gemini-2.5-flash" + summarize_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default), + title="Summarize model", + description="Model used to summarize results", + ) feedback_timeout: int = Field( default=10000, title="Feedback timeout in milliseconds" ) interaction: bool = Field(default=True, title="Enable interaction with the user") - tool_choice_model: str = Field( - default="chatgpt-4.1", + tool_choice_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.smart), title="Tool choice model", description="Model used to choose the tool to use", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) work_chain: bool = Field(default=True, title="Enable loop on tool") max_turns: int = Field( diff --git a/agents/nucliadb/src/hyperforge_nucliadb/advanced_ask_agent.py b/agents/nucliadb/src/hyperforge_nucliadb/advanced_ask_agent.py index 5faba7f..7898c69 100644 --- a/agents/nucliadb/src/hyperforge_nucliadb/advanced_ask_agent.py +++ b/agents/nucliadb/src/hyperforge_nucliadb/advanced_ask_agent.py @@ -190,7 +190,7 @@ def build_ask_request( filter_expression = parse_filter_expression(agent, driver) ask_request = AskRequest( query=question, - generative_model=agent.generative_model, + generative_model=agent.generative_model.model_id, citations=True, filters=driver.filters, ) diff --git a/agents/nucliadb/src/hyperforge_nucliadb/advanced_ask_config.py b/agents/nucliadb/src/hyperforge_nucliadb/advanced_ask_config.py index c1ae79e..9189df1 100644 --- a/agents/nucliadb/src/hyperforge_nucliadb/advanced_ask_config.py +++ b/agents/nucliadb/src/hyperforge_nucliadb/advanced_ask_config.py @@ -1,6 +1,7 @@ from typing import Any, List, Literal, Optional, Union from hyperforge.context.config import ContextAgentConfig +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.utils import WidgetType from nucliadb_models.filters import FilterExpression from nucliadb_models.search import ( @@ -30,11 +31,10 @@ class AdvancedAskAgentConfig(ContextAgentConfig): "show_in_node": True, }, ) - generative_model: str = Field( - default="chatgpt-azure-4o-mini", + generative_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default), title="Generative model", description="Model used to generate answers", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) features: Optional[list[ChatOptions]] = Field( default=None, diff --git a/agents/nucliadb/src/hyperforge_nucliadb/ask/config.py b/agents/nucliadb/src/hyperforge_nucliadb/ask/config.py index 372cb2d..b20a758 100644 --- a/agents/nucliadb/src/hyperforge_nucliadb/ask/config.py +++ b/agents/nucliadb/src/hyperforge_nucliadb/ask/config.py @@ -1,6 +1,7 @@ from typing import List, Literal, Optional from hyperforge.context.config import ContextAgentConfig +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.utils import WidgetType from nucliadb_models.search import KnowledgeGraphEntity from pydantic import Field @@ -40,11 +41,10 @@ class AskAgentConfig(ContextAgentConfig): "show_in_node": True, }, ) - configuration_model: str = Field( - default="gemini-2.5-flash", + configuration_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.fast), title="Generative model", description="Model used to generate the configuration", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) fast_answer: bool = True # Setting this to True so that this PR will not break the current behavior diff --git a/agents/nucliadb/src/hyperforge_nucliadb/basic_ask_agent.py b/agents/nucliadb/src/hyperforge_nucliadb/basic_ask_agent.py index a7441a2..b9b4ded 100644 --- a/agents/nucliadb/src/hyperforge_nucliadb/basic_ask_agent.py +++ b/agents/nucliadb/src/hyperforge_nucliadb/basic_ask_agent.py @@ -709,7 +709,7 @@ async def do_nucliadb_query( # Use full resource strategy by requesting title and summary fields ask_request = AskRequest( query=question, - generative_model=self.config.generative_model, + generative_model=self.config.generative_model.model_id, # TODO: Consider what to do when multiple resources match filter_expression=filter_expression, rag_strategies=[ @@ -1132,7 +1132,7 @@ async def inner_rag( query=question, show=[ResourceProperties.BASIC, ResourceProperties.ORIGIN], citations=CitationsType.LLM_FOOTNOTES, - generative_model=self.config.generative_model, + generative_model=self.config.generative_model.model_id, filter_expression=filter_expression, rag_strategies=rag_strategies, ) @@ -1244,7 +1244,7 @@ async def rag( ask_request = AskRequest( query=question, citations=True, - generative_model=self.config.generative_model, + generative_model=self.config.generative_model.model_id, filters=nucliadb_driver.config.filters, ) paragraphs = await nucliadb_driver.ask( diff --git a/agents/nucliadb/src/hyperforge_nucliadb/basic_ask_config.py b/agents/nucliadb/src/hyperforge_nucliadb/basic_ask_config.py index 22b9b04..15bd940 100644 --- a/agents/nucliadb/src/hyperforge_nucliadb/basic_ask_config.py +++ b/agents/nucliadb/src/hyperforge_nucliadb/basic_ask_config.py @@ -1,6 +1,7 @@ from typing import List, Literal, Optional, Tuple from hyperforge.context.agent import ContextAgentConfig +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.utils import WidgetType from pydantic import Field from pydantic.config import ConfigDict @@ -15,11 +16,10 @@ class BasicAskAgentConfig(ContextAgentConfig): "show_in_node": True, }, ) - generative_model: str = Field( - default="chatgpt-azure-4o-mini", + generative_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default), title="Generative model", description="Model used to generate answers", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) published_functions: Optional[Tuple[str, ...]] = Field( default=( diff --git a/agents/related/src/hyperforge_related/agent.py b/agents/related/src/hyperforge_related/agent.py index e477433..4dbf1ef 100644 --- a/agents/related/src/hyperforge_related/agent.py +++ b/agents/related/src/hyperforge_related/agent.py @@ -1,11 +1,11 @@ from time import time +from hyperforge import PROMPT_ENVIRONMENT from hyperforge.agent import Agent from hyperforge.manager import Manager from hyperforge.memory import QuestionMemory from hyperforge.trace import trace_agent -from hyperforge import PROMPT_ENVIRONMENT from hyperforge_related.config import RelatedAgentConfig ASK_JSON_SCHEMA = { diff --git a/agents/related/src/hyperforge_related/config.py b/agents/related/src/hyperforge_related/config.py index a856487..6313563 100644 --- a/agents/related/src/hyperforge_related/config.py +++ b/agents/related/src/hyperforge_related/config.py @@ -1,6 +1,8 @@ from typing import Literal, Optional from hyperforge.agent import AgentConfig +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults +from pydantic import Field from pydantic.config import ConfigDict @@ -8,5 +10,9 @@ class RelatedAgentConfig(AgentConfig): model_config = ConfigDict(title="Related") module: Literal["related"] = "related" prompt: Optional[str] = None - model: str = "chatgpt-azure-4o-mini" + model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default), + title="Generative model", + description="Model used to generate related questions", + ) images: bool = False diff --git a/agents/related/tests/test_related.py b/agents/related/tests/test_related.py index 62f02ed..03f6d40 100644 --- a/agents/related/tests/test_related.py +++ b/agents/related/tests/test_related.py @@ -1,9 +1,11 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from hyperforge.llm_config import LLMConfig from hyperforge.manager import Manager from hyperforge.memory.memory import EphemeralSessionMemory from hyperforge.models import MemoryConfig, Rules + from hyperforge_related.agent import RelatedAgent from hyperforge_related.config import RelatedAgentConfig @@ -11,7 +13,9 @@ def make_agent( model: str = "chatgpt-azure-4o-mini", prompt: str | None = None ) -> RelatedAgent: - config = RelatedAgentConfig(id="test-related", model=model, prompt=prompt) + config = RelatedAgentConfig( + id="test-related", model=LLMConfig(model_id=model), prompt=prompt + ) return RelatedAgent(config=config) @@ -33,22 +37,6 @@ def make_manager(related: list[str] | None = None, return_none: bool = False): return manager -# --- Config tests --- - - -def test_config_defaults(): - config = RelatedAgentConfig() - assert config.module == "related" - assert config.model == "chatgpt-azure-4o-mini" - assert config.prompt is None - assert config.images is False - - -def test_step_title(): - agent = make_agent() - assert agent.step_title("Related questions") == "Related: Related questions" - - # --- Behaviour tests --- @@ -119,7 +107,7 @@ async def test_execute_json_called_with_correct_model(): await agent(memory=memory, manager=manager) call_kwargs = manager.execute_json.call_args.kwargs - assert call_kwargs["model"] == "my-custom-model" + assert call_kwargs["model"].model_id == "my-custom-model" @pytest.mark.asyncio diff --git a/agents/rephrase/src/hyperforge_rephrase/config.py b/agents/rephrase/src/hyperforge_rephrase/config.py index 6b0cde0..ab1a5a2 100644 --- a/agents/rephrase/src/hyperforge_rephrase/config.py +++ b/agents/rephrase/src/hyperforge_rephrase/config.py @@ -1,7 +1,7 @@ from typing import Dict, List, Literal, Optional from hyperforge.agent import AgentConfig -from hyperforge.utils import WidgetType +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from pydantic import Field from pydantic.config import ConfigDict @@ -18,11 +18,10 @@ class RephraseAgentConfig(AgentConfig): extend: bool = True session_info: bool = False history: bool = False - model: str = Field( - default="gemini-2.5-flash-lite", + model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default), title="Generative model", description="Model used to generate the rephrased question", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) module: Literal["rephrase"] = "rephrase" split_question: bool = False diff --git a/agents/restart/src/hyperforge_restart/config.py b/agents/restart/src/hyperforge_restart/config.py index d17a066..b956db7 100644 --- a/agents/restart/src/hyperforge_restart/config.py +++ b/agents/restart/src/hyperforge_restart/config.py @@ -1,11 +1,16 @@ from typing import Literal from hyperforge.agent import AgentConfig +from hyperforge.llm_config import LLMField +from pydantic import Field from pydantic.config import ConfigDict class RestartAgentConfig(AgentConfig): model_config = ConfigDict(title="Restart") module: Literal["restart"] = "restart" - model: str + model: LLMField = Field( + title="Generative model", + description="Model used by the restart agent", + ) retries: int = 2 diff --git a/agents/restricted/src/hyperforge_restricted/config.py b/agents/restricted/src/hyperforge_restricted/config.py index 9c40217..5bcc2ac 100644 --- a/agents/restricted/src/hyperforge_restricted/config.py +++ b/agents/restricted/src/hyperforge_restricted/config.py @@ -3,6 +3,7 @@ from hyperforge.agent import AgentConfig from hyperforge.configure import get_agent_config_klass from hyperforge.context.config import ContextAgentConfig +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.utils import WidgetType from pydantic import BaseModel, Field, field_serializer, field_validator from pydantic.config import ConfigDict @@ -49,11 +50,10 @@ class PythonAgentConfig(ContextAgentConfig): }, ) - decision_model: str = Field( - default="chatgpt-o3-mini", + decision_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.reasoning), title="Generative model", description="Model used to assess the condition", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) needs_rephrase: bool = Field( diff --git a/agents/smart/src/hyperforge_smart/agent.py b/agents/smart/src/hyperforge_smart/agent.py index ba4e666..79dfafb 100644 --- a/agents/smart/src/hyperforge_smart/agent.py +++ b/agents/smart/src/hyperforge_smart/agent.py @@ -10,7 +10,7 @@ from hyperforge.context.agent import ContextAgent, build_context_agent from hyperforge.definition import FunctionDefinition from hyperforge.interaction import Feedback -from hyperforge.manager import Manager +from hyperforge.manager import Manager, build_reasoning from hyperforge.memory.memory import QuestionMemory from hyperforge.models import Chunk, Context, TrackingInfo from hyperforge.utils import iterate_tools_resp @@ -311,7 +311,8 @@ async def choose_tools( item = ChatModel( question="", user_id=f"smart_planner-{self.config.module}", - generative_model=model, + generative_model=model.model_id, + reasoning=build_reasoning(model), tools=tools, user_prompt=UserPrompt( prompt=f"{system}\n\nChoose the best tool or tools for the task. Call task_complete when you have enough information." diff --git a/agents/smart/src/hyperforge_smart/config.py b/agents/smart/src/hyperforge_smart/config.py index 3592919..1d8119a 100644 --- a/agents/smart/src/hyperforge_smart/config.py +++ b/agents/smart/src/hyperforge_smart/config.py @@ -2,6 +2,7 @@ from hyperforge.configure import get_agent_config_klass from hyperforge.context.config import ContextAgentConfig +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.utils import WidgetType from pydantic import BaseModel, Field, field_serializer, field_validator from pydantic.config import ConfigDict @@ -68,17 +69,15 @@ class SmartAgentConfig(ContextAgentConfig): "widget": WidgetType.NOT_SHOWN, }, ) - planner_model: str = Field( - default="chatgpt-4.1", + planner_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.smart), title="Planner model", description="Model used to plan the actions to take", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) - executor_model: str = Field( - default="chatgpt-4.1", + executor_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.smart), title="Executor model", description=("Model used to select and execute the tools."), - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) max_iterations: int = Field( default=5, diff --git a/agents/summarize/src/hyperforge_summarize/agent.py b/agents/summarize/src/hyperforge_summarize/agent.py index 891cc05..d4d0525 100644 --- a/agents/summarize/src/hyperforge_summarize/agent.py +++ b/agents/summarize/src/hyperforge_summarize/agent.py @@ -4,7 +4,7 @@ from hyperforge.agent import Agent from hyperforge.configure import agent from hyperforge.context.agent import generate_ctx_block_id -from hyperforge.manager import Manager +from hyperforge.manager import Manager, build_reasoning from hyperforge.memory import QuestionMemory from hyperforge.models import AnswerCitations, CitationMetadata, Context from hyperforge.trace import trace_agent @@ -230,7 +230,8 @@ async def __call__( if self.config.system_prompt else DEFAULT_SYSTEM_PROMPT, format_prompt=False, - generative_model=self.config.model, + generative_model=self.config.model.model_id, + reasoning=build_reasoning(self.config.model), query_context_images=images, max_tokens=5000, chat_history=await memory.get_chat_history(), diff --git a/agents/summarize/src/hyperforge_summarize/config.py b/agents/summarize/src/hyperforge_summarize/config.py index bc82f26..475af4e 100644 --- a/agents/summarize/src/hyperforge_summarize/config.py +++ b/agents/summarize/src/hyperforge_summarize/config.py @@ -1,6 +1,7 @@ from typing import Literal, Optional from hyperforge.agent import AgentConfig +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.utils import WidgetType from pydantic import Field from pydantic.config import ConfigDict @@ -25,11 +26,10 @@ class SummarizeAgentConfig(AgentConfig): "widget": WidgetType.EXPANDABLE_TEXTAREA, }, ) - model: str = Field( - default="chatgpt-azure-4o-mini", + model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default), title="Generative model", description="Model used to generate the response", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) images: bool = False conversational: bool = False diff --git a/hyperforge/src/hyperforge/context/agent.py b/hyperforge/src/hyperforge/context/agent.py index 6e4bf2d..1cbe710 100644 --- a/hyperforge/src/hyperforge/context/agent.py +++ b/hyperforge/src/hyperforge/context/agent.py @@ -8,7 +8,7 @@ from hyperforge.configure import get_agent_klass from hyperforge.context.config import ContextAgentConfig from hyperforge.definition import FunctionDefinition -from hyperforge.manager import Manager +from hyperforge.manager import Manager, ModelParam from hyperforge.memory import Context, QuestionMemory from hyperforge.prompts import ( NEXT_REPHRASE_JSON_SCHEMA, @@ -197,7 +197,7 @@ async def rephrase( ident: str, question: str, question_uuid: str, - model: str = "", + model: ModelParam = "", module: str = "agent", user_id: str = "next", title: Optional[str] = None, diff --git a/hyperforge/src/hyperforge/context/config.py b/hyperforge/src/hyperforge/context/config.py index 6580cbc..1db3e41 100644 --- a/hyperforge/src/hyperforge/context/config.py +++ b/hyperforge/src/hyperforge/context/config.py @@ -12,6 +12,7 @@ from hyperforge.agent import AgentConfig from hyperforge.configure import get_agent_config_klass +from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults from hyperforge.utils import WidgetType @@ -42,19 +43,17 @@ class ContextAgentConfig(AgentConfig): "widget": WidgetType.NOT_SHOWN, }, ) - context_validation_model: str = Field( - default="chatgpt-azure-4o-mini", + context_validation_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default), title="Context validation model", description="Model used to validate the agent's generated context and generate an answer attempt to the user's question.", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, # Backward compatibility with frontend and stored configurations validation_alias=AliasChoices("context_validation_model", "summarize_model"), ) - rephrase_model: str = Field( - default="chatgpt-azure-4o-mini", + rephrase_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default), title="Rephrase model", description="Model used to rephrase the question based on context (only used in agents that follow others in a chain)", - json_schema_extra={"widget": WidgetType.MODEL_SELECT}, ) context_aware_rephrasing_prompt: Optional[str] = Field( default=None, diff --git a/hyperforge/src/hyperforge/db/llm_migration_utils.py b/hyperforge/src/hyperforge/db/llm_migration_utils.py new file mode 100644 index 0000000..be89d2b --- /dev/null +++ b/hyperforge/src/hyperforge/db/llm_migration_utils.py @@ -0,0 +1,114 @@ +"""Utilities for migrating LLMConfig model identifiers in stored JSONB configs. + +This module provides helpers for Alembic data migrations that need to rename +model identifiers across all stored agent configurations. It recursively walks +JSONB structures and only modifies objects carrying the `_type: "llm_config"` +discriminator, making it safe for arbitrarily nested configs. + +Usage in Alembic migrations: + + from hyperforge.db.llm_migration_utils import migrate_llm_models + + def upgrade(): + migrate_llm_models(op.get_bind(), { + "chatgpt-4.1": "chatgpt-4.5", + "gemini-2.5-flash": "gemini-3.0-flash", + }) +""" + +import json +from typing import Any + +from sqlalchemy import text +from sqlalchemy.engine import Connection + +from hyperforge.llm_config import LLM_CONFIG_TYPE + +# Tables and JSONB columns known to contain agent configurations with LLMConfig +# instances. Update this list when new tables are added. +HYPERFORGE_LLM_TABLES: list[tuple[str, str]] = [ + ("retrieval_agent_preprocess", "preprocess"), + ("retrieval_agent_context", "context"), + ("retrieval_agent_generation", "generation"), + ("retrieval_agent_postprocess", "postprocess"), + ("retrieval_agents_drivers", "config"), +] + + +def walk_and_replace(obj: Any, replacements: dict[str, str]) -> bool: + """Recursively walk a JSON structure, replacing model IDs only in LLMConfig objects. + + Handles the {model}/{uuid} suffix format by matching on the base model ID + and preserving the UUID suffix. + + Args: + obj: A deserialized JSON structure (dict, list, or scalar). + replacements: Mapping of old_model_id -> new_model_id. + + Returns: + True if any modification was made. + """ + modified = False + if isinstance(obj, dict): + if obj.get("_type") == LLM_CONFIG_TYPE and "model_id" in obj: + model_value = obj["model_id"] + if isinstance(model_value, str): + base_model, _, suffix = model_value.partition("/") + if base_model in replacements: + new_value = replacements[base_model] + if suffix: + new_value = f"{new_value}/{suffix}" + obj["model_id"] = new_value + modified = True + # Recurse into all values regardless (there may be nested LLMConfigs) + for value in obj.values(): + if walk_and_replace(value, replacements): + modified = True + elif isinstance(obj, list): + for item in obj: + if walk_and_replace(item, replacements): + modified = True + return modified + + +def migrate_llm_models(conn: Connection, replacements: dict[str, str]) -> int: + """Replace model identifiers in all stored LLMConfig instances. + + Recursively walks stored JSONB configs and only modifies objects + carrying the _type="llm_config" discriminator. Safe for arbitrarily + nested structures and multi-config columns. + + Handles the {model}/{uuid} format automatically - only the base model + is matched and replaced, the UUID suffix is preserved. + + Args: + conn: Database connection (from op.get_bind()). + replacements: Mapping of old_model_id -> new_model_id. + + Returns: + Number of rows modified. + """ + total_modified = 0 + for table, column in HYPERFORGE_LLM_TABLES: + # Only fetch rows that contain at least one LLMConfig (pre-filter) + rows = conn.execute( + text( + f"SELECT id, {column} FROM {table} " + f'WHERE {column}::text LIKE \'%"_type": "{LLM_CONFIG_TYPE}"%\'' + ) + ).fetchall() + for row_id, config in rows: + if config is None: + continue + # Some drivers return JSONB as a string; ensure we have a dict + if isinstance(config, str): + config = json.loads(config) + if walk_and_replace(config, replacements): + conn.execute( + text( + f"UPDATE {table} SET {column} = CAST(:config AS jsonb) WHERE id = :id" + ), + {"config": json.dumps(config), "id": row_id}, + ) + total_modified += 1 + return total_modified diff --git a/hyperforge/src/hyperforge/llm_config.py b/hyperforge/src/hyperforge/llm_config.py new file mode 100644 index 0000000..258dadc --- /dev/null +++ b/hyperforge/src/hyperforge/llm_config.py @@ -0,0 +1,171 @@ +"""Structured generative model configuration. + +This module defines the `LLMConfig` model and the `LLMField` annotated type +that provides backwards-compatible coercion from plain model ID strings to +structured configuration objects. + +Usage in agent configs: + + from hyperforge.llm_config import LLMConfig, LLMField, llm_defaults + + class MyAgentConfig(AgentConfig): + planner_model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.smart) + ) +""" + +from enum import Enum +from typing import Annotated, Any, Optional + +from pydantic import BaseModel, BeforeValidator, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from hyperforge.utils import WidgetType + +# --------------------------------------------------------------------------- +# Reasoning configuration +# --------------------------------------------------------------------------- + + +class ReasoningEffort(str, Enum): + """Reasoning effort levels supported by model providers.""" + + NONE = "none" + MINIMAL = "minimal" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + XHIGH = "xhigh" + + +class ReasoningConfig(BaseModel): + """Advanced reasoning configuration for models that support it.""" + + effort: Optional[ReasoningEffort] = None + budget_tokens: Optional[int] = None + + +class SimpleReasoning(str, Enum): + """Simplified reasoning toggle. + + When set, this is internally mapped to a ReasoningConfig: + - DISABLED -> ReasoningConfig(effort=ReasoningEffort.NONE) + - ENABLED -> ReasoningConfig(effort=ReasoningEffort.HIGH) + """ + + DISABLED = "disabled" + ENABLED = "enabled" + + +# --------------------------------------------------------------------------- +# LLMConfig +# --------------------------------------------------------------------------- + +# Discriminator value used for locating LLMConfig instances in stored JSONB. +# Migration scripts can locate all LLMConfig instances by querying for +# fields containing `{"_type": "llm_config"}`. +LLM_CONFIG_TYPE = "llm_config" + + +class LLMConfig(BaseModel): + """Structured generative model configuration. + + The `_type` field serves as a discriminator/marker that makes instances + of this model identifiable when stored as JSONB in the database. + Migration scripts can locate all LLMConfig instances by querying for + fields containing `{"_type": "llm_config"}`. + """ + + model_config = {"populate_by_name": True, "serialize_by_alias": True} + + type: str = Field(default=LLM_CONFIG_TYPE, alias="_type") + model_id: str = Field( + title="Model", + description="The model identifier (e.g. 'chatgpt-azure-4o-mini', 'chatgpt-4.1')", + json_schema_extra={"widget": WidgetType.MODEL_SELECT}, + ) + reasoning: Optional[SimpleReasoning] = Field( + default=None, + title="Reasoning", + description="Simplified reasoning toggle. Set to 'enabled' for reasoning models.", + ) + advanced_reasoning: Optional[ReasoningConfig] = Field( + default=None, + title="Advanced reasoning", + description="Fine-grained reasoning configuration (effort level, budget tokens). Takes precedence over 'reasoning' if both are set.", + ) + + def get_effective_reasoning(self) -> Optional[ReasoningConfig]: + """Resolve the effective reasoning configuration. + + `advanced_reasoning` takes precedence over `reasoning` if both are set. + """ + if self.advanced_reasoning is not None: + return self.advanced_reasoning + if self.reasoning is not None: + if self.reasoning == SimpleReasoning.ENABLED: + return ReasoningConfig(effort=ReasoningEffort.HIGH) + else: + return ReasoningConfig(effort=ReasoningEffort.NONE) + return None + + +# --------------------------------------------------------------------------- +# LLMField: Annotated type with backwards-compatible coercion +# --------------------------------------------------------------------------- + + +def _coerce_llm_config(value: Any) -> Any: + """BeforeValidator that accepts str, dict, or LLMConfig and normalizes to LLMConfig. + + - str: Treated as a bare model ID (legacy format). Converted to LLMConfig(model_id=value). + - dict: Passed through to pydantic for normal LLMConfig validation. + - LLMConfig: Returned as-is. + """ + if isinstance(value, str): + return LLMConfig(model_id=value) + if isinstance(value, LLMConfig): + return value + # dict or other mapping - let pydantic handle it + return value + + +LLMField = Annotated[LLMConfig, BeforeValidator(_coerce_llm_config)] +"""Annotated type for agent config fields that hold an LLMConfig. + +Accepts: +- A plain model ID string (backwards compatible with legacy configs) +- A dict with LLMConfig fields (structured format) +- An LLMConfig instance + +Always validates/serializes as a full LLMConfig object. +""" + + +# --------------------------------------------------------------------------- +# Centralized defaults (environment-configurable) +# --------------------------------------------------------------------------- + + +class LLMDefaults(BaseSettings): + """Central registry for model defaults. + + All values are overridable via environment variables with the + HYPERFORGE_LLM_ prefix. For example: + HYPERFORGE_LLM_DEFAULT=chatgpt-4.5 + HYPERFORGE_LLM_SMART=chatgpt-4.5 + """ + + model_config = SettingsConfigDict(env_prefix="HYPERFORGE_LLM_") + + # General purpose (low cost, fast) + default: str = "chatgpt-azure-4o-mini" + # Smart tier (complex planning, tool use) + smart: str = "chatgpt-4.1" + # Fast tier (high throughput, configuration) + fast: str = "gemini-2.5-flash" + # Reasoning tier (decision making, conditions) + reasoning: str = "chatgpt-o3-mini" + + +llm_defaults = LLMDefaults() diff --git a/hyperforge/src/hyperforge/manager.py b/hyperforge/src/hyperforge/manager.py index c945fc8..d2b1657 100644 --- a/hyperforge/src/hyperforge/manager.py +++ b/hyperforge/src/hyperforge/manager.py @@ -1,11 +1,12 @@ import json -from typing import Any, AsyncIterator, Dict, List, Optional, Protocol, Tuple +from typing import Any, AsyncIterator, Dict, List, Optional, Protocol, Tuple, Union from nuclia.lib.nua import AsyncNuaClient from nuclia.lib.nua_responses import ( ChatModel, Image, Message, + Reasoning, RerankModel, RerankResponse, Tokens, @@ -31,8 +32,48 @@ from hyperforge.configure import get_driver_klass from hyperforge.driver import Driver, DriverConfig from hyperforge.interaction import StreamingChunk +from hyperforge.llm_config import LLMConfig from hyperforge.models import TrackingInfo +# Type alias for parameters that accept either a plain model ID string +# or a structured LLMConfig object. This keeps backwards compatibility +# with agents that still pass raw strings. +ModelParam = Union[str, LLMConfig] + + +def _resolve_model_id(model: ModelParam) -> str: + """Extract the model identifier string from a ModelParam. + + If `model` is already a string, return it as-is. + If it's an LLMConfig, return its `model_id` field. + """ + if isinstance(model, str): + return model + return model.model_id + + +def build_reasoning(model: ModelParam) -> Union[Reasoning, bool]: + """Build a NUA Reasoning object from a ModelParam. + + If `model` is a plain string or has no reasoning configured, returns False + (reasoning disabled). Otherwise, builds a `Reasoning` object from the + LLMConfig's effective reasoning settings. NUA handles any necessary + effort/budget_tokens normalization server-side. + """ + if isinstance(model, str): + return False + effective = model.get_effective_reasoning() + if effective is None: + return False + kwargs: dict[str, Any] = {} + if effective.effort is not None: + kwargs["effort"] = effective.effort.value + if effective.budget_tokens is not None: + kwargs["budget_tokens"] = effective.budget_tokens + if not kwargs: + return False + return Reasoning(**kwargs) + class StreamCallback(Protocol): """Protocol for objects that can receive streaming chunks (e.g. memory).""" @@ -223,7 +264,7 @@ async def execute_stream( self, prompt: str, user_id: str, - model: str, + model: ModelParam, query_context_images: Dict[str, Image] = {}, system: Optional[str] = None, max_tokens: int = 2000, @@ -242,7 +283,8 @@ async def execute_stream( question="", user_prompt=UserPrompt(prompt=prompt), format_prompt=False, - generative_model=model, + generative_model=_resolve_model_id(model), + reasoning=build_reasoning(model), query_context_images=query_context_images, max_tokens=max_tokens, chat_history=chat_history, @@ -255,7 +297,7 @@ async def execute( self, prompt: str, user_id: str, - model: str, + model: ModelParam, query_context_images: Dict[str, Image] = {}, system: Optional[str] = None, max_tokens: int = 2000, @@ -270,7 +312,8 @@ async def execute( question="", user_prompt=UserPrompt(prompt=prompt), format_prompt=False, - generative_model=model, + generative_model=_resolve_model_id(model), + reasoning=build_reasoning(model), query_context_images=query_context_images, max_tokens=max_tokens, chat_history=chat_history, @@ -300,7 +343,7 @@ async def execute_from_context( self, prompt: str, user_id: str, - model: str, + model: ModelParam, images: Dict[str, Image], system: Optional[str] = None, tracking: TrackingInfo | None = None, @@ -312,7 +355,8 @@ async def execute_from_context( user_id=user_id, user_prompt=UserPrompt(prompt=prompt), format_prompt=False, - generative_model=model, + generative_model=_resolve_model_id(model), + reasoning=build_reasoning(model), query_context_images=images, system=system, ), @@ -342,7 +386,7 @@ async def execute_json( prompt: str, user_id: str, schema: Dict[str, Any], - model: str, + model: ModelParam, images: Dict[str, Image] = {}, system: Optional[str] = None, max_tokens: int = 2000, @@ -354,7 +398,8 @@ async def execute_json( user_id=user_id, question="", user_prompt=UserPrompt(prompt=prompt), - generative_model=model, + generative_model=_resolve_model_id(model), + reasoning=build_reasoning(model), format_prompt=False, query_context_images=images, json_schema=schema, @@ -390,7 +435,7 @@ async def execute_json_citation( question: str, user_id: str, schema: Dict[str, Any], - model: str, + model: ModelParam, contexts: List[str] = [], images: Dict[str, Image] = {}, system: Optional[str] = None, @@ -402,7 +447,8 @@ async def execute_json_citation( user_id=user_id, question=question, query_context=contexts, - generative_model=model, + generative_model=_resolve_model_id(model), + reasoning=build_reasoning(model), format_prompt=True, query_context_images=images, json_schema=schema, diff --git a/hyperforge/src/hyperforge/standalone/run.py b/hyperforge/src/hyperforge/standalone/run.py index 5e7c34b..627e7ef 100644 --- a/hyperforge/src/hyperforge/standalone/run.py +++ b/hyperforge/src/hyperforge/standalone/run.py @@ -26,9 +26,8 @@ def run( # Register all built-in agents and drivers (same as the base initialize, # but without start_health_check() — the FastAPI app handles /health/*). - scan("nuclia_agents.agents.agents") - scan("nuclia_agents.drivers.drivers") - load_all_configurations("nuclia_agents") + scan("hyperforge_restricted") + load_all_configurations("hyperforge_restricted") for load_module in settings.load_modules: try: diff --git a/hyperforge/tests/unit/arag/test_llm_config.py b/hyperforge/tests/unit/arag/test_llm_config.py new file mode 100644 index 0000000..6adf870 --- /dev/null +++ b/hyperforge/tests/unit/arag/test_llm_config.py @@ -0,0 +1,68 @@ +"""Tests for hyperforge.llm_config: backwards-compatible coercion and reasoning resolution.""" + +from hyperforge.llm_config import ( + LLMConfig, + LLMField, + ReasoningConfig, + ReasoningEffort, + SimpleReasoning, + llm_defaults, +) +from pydantic import BaseModel, Field + + +class SampleConfig(BaseModel): + model: LLMField = Field( + default_factory=lambda: LLMConfig(model_id=llm_defaults.default) + ) + + +class TestLLMFieldBackwardsCompat: + """Tests that the LLMField validator correctly coerces legacy plain strings.""" + + def test_coerce_from_plain_string(self): + config = SampleConfig.model_validate({"model": "gpt-5"}) + assert config.model.model_id == "gpt-5" + + def test_coerce_from_structured_dict(self): + config = SampleConfig.model_validate( + {"model": {"_type": "llm_config", "model_id": "gpt-5"}} + ) + assert config.model.model_id == "gpt-5" + + def test_round_trip_from_legacy_string(self): + """String in -> serialize -> deserialize preserves the value.""" + config = SampleConfig.model_validate({"model": "gpt-5"}) + data = config.model_dump() + config2 = SampleConfig.model_validate(data) + assert config2.model.model_id == "gpt-5" + + +class TestSimpleReasoningResolution: + """Tests that SimpleReasoning maps correctly to ReasoningConfig.""" + + def test_enabled_maps_to_high_effort(self): + cfg = LLMConfig(model_id="chatgpt-o3", reasoning=SimpleReasoning.ENABLED) + effective = cfg.get_effective_reasoning() + assert effective.effort == ReasoningEffort.HIGH + + def test_disabled_maps_to_none_effort(self): + cfg = LLMConfig(model_id="chatgpt-o3", reasoning=SimpleReasoning.DISABLED) + effective = cfg.get_effective_reasoning() + assert effective.effort == ReasoningEffort.NONE + + def test_advanced_takes_precedence_over_simple(self): + cfg = LLMConfig( + model_id="chatgpt-o3", + reasoning=SimpleReasoning.DISABLED, + advanced_reasoning=ReasoningConfig( + effort=ReasoningEffort.MEDIUM, budget_tokens=10000 + ), + ) + effective = cfg.get_effective_reasoning() + assert effective.effort == ReasoningEffort.MEDIUM + assert effective.budget_tokens == 10000 + + def test_no_reasoning_returns_none(self): + cfg = LLMConfig(model_id="chatgpt-4.1") + assert cfg.get_effective_reasoning() is None diff --git a/hyperforge/tests/unit/arag/test_llm_migration_utils.py b/hyperforge/tests/unit/arag/test_llm_migration_utils.py new file mode 100644 index 0000000..f21b2d1 --- /dev/null +++ b/hyperforge/tests/unit/arag/test_llm_migration_utils.py @@ -0,0 +1,134 @@ +"""Tests for hyperforge.db.llm_migration_utils.""" + +import json +import uuid + +import pytest +from hyperforge.db.llm_migration_utils import migrate_llm_models, walk_and_replace +from hyperforge.llm_config import LLM_CONFIG_TYPE +from sqlalchemy import text + + +class TestWalkAndReplace: + def test_replaces_matching_model_id(self): + config = { + "planner_model": {"_type": LLM_CONFIG_TYPE, "model_id": "chatgpt-4.1"}, + "executor_model": { + "_type": LLM_CONFIG_TYPE, + "model_id": "gemini-2.5-flash", + }, + "unrelated": "hello", + } + modified = walk_and_replace( + config, + {"chatgpt-4.1": "chatgpt-4.5", "gemini-2.5-flash": "gemini-3.0-flash"}, + ) + assert modified is True + assert config["planner_model"]["model_id"] == "chatgpt-4.5" + assert config["executor_model"]["model_id"] == "gemini-3.0-flash" + assert config["unrelated"] == "hello" + + def test_preserves_uuid_suffix(self): + custom_uuid = str(uuid.uuid4()) + config = {"_type": LLM_CONFIG_TYPE, "model_id": f"chatgpt-4.1/{custom_uuid}"} + walk_and_replace(config, {"chatgpt-4.1": "chatgpt-4.5"}) + assert config["model_id"] == f"chatgpt-4.5/{custom_uuid}" + + def test_ignores_dicts_without_discriminator(self): + config = { + "model_id": "chatgpt-4.1", # no _type + "nested": {"_type": "other", "model_id": "chatgpt-4.1"}, # wrong _type + } + modified = walk_and_replace(config, {"chatgpt-4.1": "chatgpt-4.5"}) + assert modified is False + + def test_recurses_into_nested_structures(self): + config = { + "agents": [ + {"config": {"_type": LLM_CONFIG_TYPE, "model_id": "chatgpt-4.1"}} + ] + } + modified = walk_and_replace(config, {"chatgpt-4.1": "chatgpt-4.5"}) + assert modified is True + assert config["agents"][0]["config"]["model_id"] == "chatgpt-4.5" + + +class TestMigrateLLMModelsDB: + """Integration test exercising migrate_llm_models against a real PostgreSQL.""" + + @pytest.fixture(autouse=True) + def _seed_data(self, test_db): + """Insert test rows into retrieval_agent_generation.""" + self.row_id = str(uuid.uuid4()) + generation_config = { + "module": "summarize", + "model": {"_type": LLM_CONFIG_TYPE, "model_id": "chatgpt-4.1"}, + "nested_agents": [ + { + "planner_model": { + "_type": LLM_CONFIG_TYPE, + "model_id": "gemini-2.5-flash", + } + } + ], + } + # Insert parent rows to satisfy FK chain: + # retrieval_agent_config -> retrieval_agent_workflow -> retrieval_agent_generation + test_db.execute( + text( + "INSERT INTO retrieval_agent_config (account, agent_id, rules, memory) " + "VALUES (:account, :agent_id, '[]'::jsonb, '{}'::jsonb) " + "ON CONFLICT DO NOTHING" + ), + {"account": "test-account", "agent_id": "test-agent"}, + ) + test_db.execute( + text( + "INSERT INTO retrieval_agent_workflow (account, agent_id, workflow_id, name) " + "VALUES (:account, :agent_id, :workflow_id, :name) " + "ON CONFLICT DO NOTHING" + ), + { + "account": "test-account", + "agent_id": "test-agent", + "workflow_id": "test-workflow", + "name": "test", + }, + ) + test_db.execute( + text( + "INSERT INTO retrieval_agent_generation " + "(id, account, agent_id, workflow_id, generation) " + "VALUES (:id, :account, :agent_id, :workflow_id, :generation)" + ), + { + "id": self.row_id, + "account": "test-account", + "agent_id": "test-agent", + "workflow_id": "test-workflow", + "generation": json.dumps(generation_config), + }, + ) + test_db.commit() + + def test_migrate_replaces_models_in_db(self, test_db): + total = migrate_llm_models( + test_db, + {"chatgpt-4.1": "chatgpt-4.5", "gemini-2.5-flash": "gemini-3.0-flash"}, + ) + test_db.commit() + + assert total >= 1 + + # Read back and verify + row = test_db.execute( + text("SELECT generation FROM retrieval_agent_generation WHERE id = :id"), + {"id": self.row_id}, + ).fetchone() + config = row[0] if isinstance(row[0], dict) else json.loads(row[0]) + + assert config["model"]["model_id"] == "chatgpt-4.5" + assert ( + config["nested_agents"][0]["planner_model"]["model_id"] + == "gemini-3.0-flash" + ) diff --git a/uv.lock b/uv.lock index b24cfb1..845a65d 100644 --- a/uv.lock +++ b/uv.lock @@ -1251,6 +1251,7 @@ dependencies = [ { name = "databases", extra = ["asyncpg"] }, { name = "fastapi" }, { name = "httpx" }, + { name = "hyperforge-restricted" }, { name = "jinja2" }, { name = "lru-dict" }, { name = "mcp" }, @@ -1298,6 +1299,7 @@ requires-dist = [ { name = "databases", extras = ["asyncpg"] }, { name = "fastapi" }, { name = "httpx" }, + { name = "hyperforge-restricted", editable = "agents/restricted" }, { name = "jinja2" }, { name = "lru-dict" }, { name = "mcp", specifier = ">=1.26.0" },