diff --git a/src/conductor/config/schema.py b/src/conductor/config/schema.py index e56fe92..ae2a88a 100644 --- a/src/conductor/config/schema.py +++ b/src/conductor/config/schema.py @@ -385,6 +385,7 @@ class AgentDef(BaseModel): 'claude-3-haiku-20240307' Supports environment variables: ${MODEL:-default_value} + Supports Jinja2 templates: {{ workflow.input.model_name }} """ input: list[str] = Field(default_factory=list) diff --git a/src/conductor/executor/agent.py b/src/conductor/executor/agent.py index 3682703..e68c22d 100644 --- a/src/conductor/executor/agent.py +++ b/src/conductor/executor/agent.py @@ -145,6 +145,11 @@ async def execute( ProviderError: If agent execution fails. ValidationError: If output doesn't match schema or tools are invalid. """ + # Render model field if it contains template expressions + if agent.model and ("{{" in agent.model or "{%" in agent.model): + rendered_model = self.renderer.render(agent.model, context) + agent = agent.model_copy(update={"model": rendered_model}) + # Render prompt with context rendered_prompt = self.renderer.render(agent.prompt, context) diff --git a/tests/test_executor/test_agent.py b/tests/test_executor/test_agent.py index bf6c94f..0f65a14 100644 --- a/tests/test_executor/test_agent.py +++ b/tests/test_executor/test_agent.py @@ -208,6 +208,75 @@ def test_render_prompt_helper(self, simple_agent: AgentDef) -> None: assert "Test question?" in rendered +class TestAgentExecutorModelRendering: + """Tests for model field template rendering.""" + + @pytest.mark.asyncio + async def test_model_template_is_rendered(self) -> None: + """Test that model field with Jinja2 template is resolved.""" + agent = AgentDef( + name="test", + model="{{ workflow.input.selected_model }}", + prompt="Do something", + output=None, + ) + + def mock_handler(agent, prompt, context): + return {"result": "ok"} + + provider = CopilotProvider(mock_handler=mock_handler) + executor = AgentExecutor(provider) + + context = {"workflow": {"input": {"selected_model": "claude-opus-4.6-1m"}}} + await executor.execute(agent, context) + + call_history = provider.get_call_history() + assert call_history[0]["model"] == "claude-opus-4.6-1m" + + @pytest.mark.asyncio + async def test_static_model_is_unchanged(self) -> None: + """Test that a static model string passes through unchanged.""" + agent = AgentDef( + name="test", + model="gpt-4", + prompt="Do something", + output=None, + ) + + def mock_handler(agent, prompt, context): + return {"result": "ok"} + + provider = CopilotProvider(mock_handler=mock_handler) + executor = AgentExecutor(provider) + + await executor.execute(agent, {}) + + call_history = provider.get_call_history() + assert call_history[0]["model"] == "gpt-4" + + @pytest.mark.asyncio + async def test_model_template_does_not_mutate_original(self) -> None: + """Test that rendering model creates a copy, not mutating the original.""" + agent = AgentDef( + name="test", + model="{{ workflow.input.model_name }}", + prompt="Do something", + output=None, + ) + + def mock_handler(agent, prompt, context): + return {"result": "ok"} + + provider = CopilotProvider(mock_handler=mock_handler) + executor = AgentExecutor(provider) + + context = {"workflow": {"input": {"model_name": "gpt-5.4"}}} + await executor.execute(agent, context) + + # Original agent should still have the template + assert agent.model == "{{ workflow.input.model_name }}" + + class TestAgentExecutorWithTools: """Tests for agent execution with tools."""