From 134837a34f67d58a1b1630553d6b1107b22e078e Mon Sep 17 00:00:00 2001 From: Jainish Date: Sat, 22 Nov 2025 19:53:17 +0530 Subject: [PATCH] feat(templating): Add templating support for InstructionProvider Add comprehensive support for popular Python templating engines (Jinja2, Mako, Mustache, Django) to ADK Community, enabling developers to use familiar templating syntax for dynamic agent instructions. ## What Changed Core Implementation: - Created BaseTemplateProvider abstract class with common functionality - Implemented 4 templating providers: * Jinja2InstructionProvider - Full-featured with custom filters/tests/globals * MakoInstructionProvider - Python-centric with inline expressions * MustacheInstructionProvider - Logic-less templating via chevron * DjangoInstructionProvider - Django template syntax with auto-config - All providers expose ADK session metadata (adk_session_id, adk_user_id, adk_app_name) - Added templating optional dependency group to pyproject.toml Testing: - Created 51 comprehensive unit tests across 4 test suites - Tests cover: basic rendering, nested access, control structures, filters, custom extensions, error handling, and import errors - All tests passing (79/79 total including existing tests) Examples: - Created 4 working sample agents demonstrating real-world use cases: * jinja2_devops_bot - Server monitoring with system metrics * mako_data_analyst - Sales analysis with Python expressions * mustache_customer_support - Customer support with logic-less templates * django_content_moderator - Content moderation with Django filters - Updated README.md with feature highlight, installation guide, and comparison table - Added comprehensive docstrings to all classes and methods Resolves: https://github.com/google/adk-python-community/issues/6 --- .../django_content_moderator/__init__.py | 15 ++ .../django_content_moderator/agent.py | 144 +++++++++++ .../templating/jinja2_devops_bot/__init__.py | 15 ++ .../templating/jinja2_devops_bot/agent.py | 98 ++++++++ .../templating/mako_data_analyst/__init__.py | 15 ++ .../templating/mako_data_analyst/agent.py | 108 ++++++++ .../mustache_customer_support/__init__.py | 15 ++ .../mustache_customer_support/agent.py | 130 ++++++++++ pyproject.toml | 8 + src/google/adk_community/__init__.py | 2 + src/google/adk_community/memory/__init__.py | 1 - .../adk_community/templating/__init__.py | 62 +++++ src/google/adk_community/templating/base.py | 107 ++++++++ .../templating/django_provider.py | 151 +++++++++++ .../templating/jinja2_provider.py | 131 ++++++++++ .../adk_community/templating/mako_provider.py | 117 +++++++++ .../templating/mustache_provider.py | 110 ++++++++ tests/unittests/templating/__init__.py | 15 ++ .../templating/test_django_provider.py | 235 ++++++++++++++++++ .../templating/test_jinja2_provider.py | 224 +++++++++++++++++ .../templating/test_mako_provider.py | 193 ++++++++++++++ .../templating/test_mustache_provider.py | 210 ++++++++++++++++ 22 files changed, 2105 insertions(+), 1 deletion(-) create mode 100644 contributing/samples/templating/django_content_moderator/__init__.py create mode 100644 contributing/samples/templating/django_content_moderator/agent.py create mode 100644 contributing/samples/templating/jinja2_devops_bot/__init__.py create mode 100644 contributing/samples/templating/jinja2_devops_bot/agent.py create mode 100644 contributing/samples/templating/mako_data_analyst/__init__.py create mode 100644 contributing/samples/templating/mako_data_analyst/agent.py create mode 100644 contributing/samples/templating/mustache_customer_support/__init__.py create mode 100644 contributing/samples/templating/mustache_customer_support/agent.py create mode 100644 src/google/adk_community/templating/__init__.py create mode 100644 src/google/adk_community/templating/base.py create mode 100644 src/google/adk_community/templating/django_provider.py create mode 100644 src/google/adk_community/templating/jinja2_provider.py create mode 100644 src/google/adk_community/templating/mako_provider.py create mode 100644 src/google/adk_community/templating/mustache_provider.py create mode 100644 tests/unittests/templating/__init__.py create mode 100644 tests/unittests/templating/test_django_provider.py create mode 100644 tests/unittests/templating/test_jinja2_provider.py create mode 100644 tests/unittests/templating/test_mako_provider.py create mode 100644 tests/unittests/templating/test_mustache_provider.py diff --git a/contributing/samples/templating/django_content_moderator/__init__.py b/contributing/samples/templating/django_content_moderator/__init__.py new file mode 100644 index 0000000..c48963c --- /dev/null +++ b/contributing/samples/templating/django_content_moderator/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/templating/django_content_moderator/agent.py b/contributing/samples/templating/django_content_moderator/agent.py new file mode 100644 index 0000000..8c39f00 --- /dev/null +++ b/contributing/samples/templating/django_content_moderator/agent.py @@ -0,0 +1,144 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Content moderator bot example using DjangoInstructionProvider. + +This example demonstrates how to use Django templating for dynamic agent +instructions that leverage Django's familiar syntax and built-in filters. + +The agent reviews user-generated content and provides moderation decisions, +showcasing Django template features like filters and for...empty tags. + +Usage: + adk run contributing/samples/templating/django_content_moderator +""" + +from google.adk.agents import Agent +from google.adk.agents.callback_context import CallbackContext +from google.adk_community.templating import DjangoInstructionProvider + + +def populate_moderation_queue(callback_context: CallbackContext): + """Populate the session state with content moderation queue. + + In a real application, this would fetch content from a moderation system. + For this demo, we use sample data. + """ + callback_context.state['platform_name'] = 'SocialHub' + callback_context.state['moderator_name'] = 'Alex Chen' + + callback_context.state['pending_content'] = [ + { + 'id': 'POST-001', + 'type': 'Comment', + 'author': 'user123', + 'text': 'Great product! Highly recommended.', + 'reports': 0, + 'flagged_words': [], + }, + { + 'id': 'POST-002', + 'type': 'Review', + 'author': 'angry_customer', + 'text': 'This is terrible service! Very disappointed.', + 'reports': 3, + 'flagged_words': ['terrible'], + }, + { + 'id': 'POST-003', + 'type': 'Forum Post', + 'author': 'spammer99', + 'text': 'Click here for free stuff!!!', + 'reports': 5, + 'flagged_words': ['click here', 'free'], + }, + ] + + callback_context.state['moderation_stats'] = { + 'total_reviewed_today': 47, + 'approved': 32, + 'rejected': 10, + 'pending': 5, + } + + +# Define the Django template for instructions +# Django uses {{ }} for variables, {% %} for tags, and | for filters +CONTENT_MODERATOR_TEMPLATE = """ +You are a Content Moderation Agent for {{ platform_name }}. +Moderator: {{ moderator_name }} + +MODERATION QUEUE +================ +{% if pending_content %} +{{ pending_content|length }} item{{ pending_content|length|pluralize }} pending review: + +{% for item in pending_content %} +[{{ item.id }}] {{ item.type }} by @{{ item.author }} +Content: "{{ item.text|truncatewords:15 }}" +Reports: {{ item.reports }} +{% if item.flagged_words %} +⚠️ Flagged Keywords: {{ item.flagged_words|join:", " }} +{% endif %} +{% if item.reports > 2 %} +🔴 HIGH PRIORITY - Multiple user reports! +{% elif item.reports > 0 %} +⚠️ MEDIUM PRIORITY - User reported +{% else %} +✅ LOW PRIORITY - No reports +{% endif %} +--- +{% endfor %} +{% else %} +✅ Queue is clear! No content pending moderation. +{% endif %} + +TODAY'S MODERATION STATS +======================== +Total Reviewed: {{ moderation_stats.total_reviewed_today }} +Approved: {{ moderation_stats.approved }} ({{ moderation_stats.approved|add:0|floatformat:0 }}) +Rejected: {{ moderation_stats.rejected }} +Still Pending: {{ moderation_stats.pending }} + +INSTRUCTIONS +============ +Review each piece of content and determine if it should be: +1. **APPROVED** - Complies with community guidelines +2. **REJECTED** - Violates guidelines (spam, harassment, etc.) +3. **FLAGGED FOR HUMAN REVIEW** - Edge case requiring manual review + +Guidelines: +- Multiple reports or flagged keywords warrant careful review +- Consider context, not just keywords +- Err on the side of caution for borderline cases +- Be fair and consistent +- Provide clear reasoning for decisions + +{% if pending_content %} +Prioritize items with the most reports first. +{% endif %} + +Be objective, thorough, and protect the community while respecting free expression. +""".strip() + +# Create the provider with the template +instruction_provider = DjangoInstructionProvider(CONTENT_MODERATOR_TEMPLATE) + +# Create the agent +root_agent = Agent( + name='content_moderator_agent', + model='gemini-2.0-flash', + instruction=instruction_provider, + before_agent_callback=[populate_moderation_queue], +) diff --git a/contributing/samples/templating/jinja2_devops_bot/__init__.py b/contributing/samples/templating/jinja2_devops_bot/__init__.py new file mode 100644 index 0000000..c48963c --- /dev/null +++ b/contributing/samples/templating/jinja2_devops_bot/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/templating/jinja2_devops_bot/agent.py b/contributing/samples/templating/jinja2_devops_bot/agent.py new file mode 100644 index 0000000..5ad98bd --- /dev/null +++ b/contributing/samples/templating/jinja2_devops_bot/agent.py @@ -0,0 +1,98 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""DevOps bot example using Jinja2InstructionProvider. + +This example demonstrates how to use Jinja2 templating for dynamic agent +instructions that adapt based on system state. + +The agent monitors server status and provides recommendations based on +CPU usage, using Jinja2's powerful features like loops, conditionals, +and filters. + +Usage: + adk run contributing/samples/templating/jinja2_devops_bot +""" + +from google.adk.agents import Agent +from google.adk.agents.callback_context import CallbackContext +from google.adk_community.templating import Jinja2InstructionProvider + + +def populate_system_state(callback_context: CallbackContext): + """Populate the session state with system information. + + In a real application, this would fetch actual server metrics. + For this demo, we use sample data. + """ + callback_context.state["environment"] = "PRODUCTION" + callback_context.state["user_query"] = "" + callback_context.state["servers"] = [ + {"id": "srv-01", "role": "LoadBalancer", "cpu": 15, "memory": 45}, + {"id": "srv-02", "role": "AppServer", "cpu": 95, "memory": 88}, + {"id": "srv-03", "role": "Database", "cpu": 72, "memory": 65}, + {"id": "srv-04", "role": "Cache", "cpu": 35, "memory": 42}, + ] + + +# Define the Jinja2 template for instructions +DEVOPS_TEMPLATE = """ +You are a System Reliability Engineer (SRE) Assistant. + +CURRENT SYSTEM STATUS +===================== +Environment: {{ environment }} + +{% if servers|length == 0 %} +⚠️ No servers are currently online. +{% else %} +Active Servers ({{ servers|length }}): +{% for server in servers %} + - [{{ server.id }}] {{ server.role }} + CPU: {{ server.cpu }}% | Memory: {{ server.memory }}% + {% if server.cpu > 80 or server.memory > 80 %} + 🚨 CRITICAL ALERT: Resource usage is dangerously high! + {% elif server.cpu > 60 or server.memory > 60 %} + ⚠️ WARNING: Resource usage is elevated. + {% else %} + ✅ Status: Normal + {% endif %} +{% endfor %} +{% endif %} + +INSTRUCTIONS +============ +Based on the status above: +1. Analyze the current system health +2. Identify any critical issues or warnings +3. Provide specific, actionable recommendations +4. If user asks a question, answer it in the context of the current system state + +{% if user_query %} +User Question: {{ user_query }} +{% endif %} + +Be concise, technical, and prioritize critical issues. +""".strip() + +# Create the provider with the template +instruction_provider = Jinja2InstructionProvider(DEVOPS_TEMPLATE) + +# Create the agent +root_agent = Agent( + name="devops_sre_agent", + model="gemini-2.0-flash", + instruction=instruction_provider, + before_agent_callback=[populate_system_state], +) diff --git a/contributing/samples/templating/mako_data_analyst/__init__.py b/contributing/samples/templating/mako_data_analyst/__init__.py new file mode 100644 index 0000000..c48963c --- /dev/null +++ b/contributing/samples/templating/mako_data_analyst/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/templating/mako_data_analyst/agent.py b/contributing/samples/templating/mako_data_analyst/agent.py new file mode 100644 index 0000000..cf1980d --- /dev/null +++ b/contributing/samples/templating/mako_data_analyst/agent.py @@ -0,0 +1,108 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Data analyst bot example using MakoInstructionProvider. + +This example demonstrates how to use Mako templating for dynamic agent +instructions that include Python expressions and calculations. + +The agent analyzes sales data and provides insights, showcasing Mako's +Python-centric approach with inline expressions. + +Usage: + adk run contributing/samples/templating/mako_data_analyst +""" + +from google.adk.agents import Agent +from google.adk.agents.callback_context import CallbackContext +from google.adk_community.templating import MakoInstructionProvider + + +def populate_sales_data(callback_context: CallbackContext): + """Populate the session state with sales data. + + In a real application, this would fetch data from a database. + For this demo, we use sample data. + """ + callback_context.state['company_name'] = 'TechCorp Inc.' + callback_context.state['quarter'] = 'Q4 2024' + callback_context.state['sales_data'] = [ + {'product': 'Widget A', 'revenue': 125000, 'units': 450, 'region': 'North'}, + {'product': 'Widget B', 'revenue': 89000, 'units': 320, 'region': 'South'}, + {'product': 'Widget C', 'revenue': 156000, 'units': 580, 'region': 'East'}, + {'product': 'Widget D', 'revenue': 72000, 'units': 210, 'region': 'West'}, + ] + callback_context.state['target_revenue'] = 500000 + + +# Define the Mako template for instructions +# Mako allows Python expressions directly in templates using ${} +DATA_ANALYST_TEMPLATE = """ +You are a Data Analyst Assistant for ${company_name}. + +SALES REPORT - ${quarter} +${'=' * 50} + +% if sales_data: +## Calculate totals using Python expressions +<% + total_revenue = sum(item['revenue'] for item in sales_data) + total_units = sum(item['units'] for item in sales_data) + avg_price = total_revenue / total_units if total_units > 0 else 0 +%> + +Summary: + Total Revenue: $$${"{:,}".format(total_revenue)} + Total Units Sold: ${"{:,}".format(total_units)} + Average Price per Unit: $$${"{:.2f}".format(avg_price)} + Target Revenue: $$${"{:,}".format(target_revenue)} + Performance: ${"{:.1f}%".format((total_revenue / target_revenue * 100) if target_revenue > 0 else 0)} of target + +Product Breakdown: +% for item in sales_data: + - ${item['product']} (${item['region']}): + Revenue: $$${"{:,}".format(item['revenue'])} | Units: ${item['units']} + Avg Price: $$${"{:.2f}".format(item['revenue'] / item['units'] if item['units'] > 0 else 0)} + % if item['revenue'] > 100000: + ⭐ Top Performer! + % elif item['revenue'] < 80000: + ⚠️ Needs attention + % endif +% endfor + +% else: +No sales data available for this period. +% endif + +INSTRUCTIONS +============ +Based on the data above: +1. Analyze sales trends and patterns +2. Identify top and underperforming products +3. Provide actionable recommendations for improvement +4. Answer any questions about the sales data + +Be data-driven, specific, and highlight key insights. +""".strip() + +# Create the provider with the template +instruction_provider = MakoInstructionProvider(DATA_ANALYST_TEMPLATE) + +# Create the agent +root_agent = Agent( + name='data_analyst_agent', + model='gemini-2.0-flash', + instruction=instruction_provider, + before_agent_callback=[populate_sales_data], +) diff --git a/contributing/samples/templating/mustache_customer_support/__init__.py b/contributing/samples/templating/mustache_customer_support/__init__.py new file mode 100644 index 0000000..c48963c --- /dev/null +++ b/contributing/samples/templating/mustache_customer_support/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/templating/mustache_customer_support/agent.py b/contributing/samples/templating/mustache_customer_support/agent.py new file mode 100644 index 0000000..09cbf34 --- /dev/null +++ b/contributing/samples/templating/mustache_customer_support/agent.py @@ -0,0 +1,130 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Customer support bot example using MustacheInstructionProvider. + +This example demonstrates how to use Mustache (logic-less) templating +for dynamic agent instructions that are simple and declarative. + +The agent provides personalized customer support based on user tier +and ticket history, showcasing Mustache's clean, minimal syntax. + +Usage: + adk run contributing/samples/templating/mustache_customer_support +""" + +from google.adk.agents import Agent +from google.adk.agents.callback_context import CallbackContext +from google.adk_community.templating import MustacheInstructionProvider + + +def populate_customer_data(callback_context: CallbackContext): + """Populate the session state with customer support data. + + In a real application, this would fetch data from a CRM system. + For this demo, we use sample data. + """ + callback_context.state['customer'] = { + 'name': 'Sarah Johnson', + 'id': 'CUST-12345', + 'tier': 'Premium', + 'is_premium': True, + 'account_age_days': 847, + } + + callback_context.state['open_tickets'] = [ + { + 'id': 'TKT-001', + 'subject': 'Billing discrepancy', + 'priority': 'High', + 'is_high_priority': True, + }, + { + 'id': 'TKT-002', + 'subject': 'Feature request', + 'priority': 'Low', + 'is_high_priority': False, + }, + ] + + callback_context.state['has_open_tickets'] = True + callback_context.state['recent_purchases'] = [ + {'product': 'Enterprise Plan', 'date': '2024-10-15'}, + {'product': 'Add-on Storage', 'date': '2024-11-01'}, + ] + + +# Define the Mustache template for instructions +# Mustache uses {{}} for variables, {{#}} for sections, {{^}} for inverted sections +CUSTOMER_SUPPORT_TEMPLATE = """ +You are a Customer Support Agent. + +CUSTOMER PROFILE +================ +Name: {{customer.name}} +Customer ID: {{customer.id}} +Account Tier: {{customer.tier}} +Account Age: {{customer.account_age_days}} days + +{{#customer.is_premium}} +⭐ PREMIUM CUSTOMER - Provide priority white-glove service! +{{/customer.is_premium}} + +{{^customer.is_premium}} +Standard customer - Provide professional, helpful service. +{{/customer.is_premium}} + +OPEN TICKETS +============ +{{#has_open_tickets}} +Active Support Tickets: +{{#open_tickets}} + - [{{id}}] {{subject}} + Priority: {{priority}} + {{#is_high_priority}}🔴 HIGH PRIORITY - Address immediately!{{/is_high_priority}} +{{/open_tickets}} +{{/has_open_tickets}} + +{{^has_open_tickets}} +No open tickets - customer is all caught up! +{{/has_open_tickets}} + +RECENT PURCHASES +================ +{{#recent_purchases}} + - {{product}} (purchased {{date}}) +{{/recent_purchases}} + +INSTRUCTIONS +============ +1. Greet the customer warmly by name +2. Acknowledge their account tier and value +3. {{#has_open_tickets}}Prioritize resolving their open tickets{{/has_open_tickets}} +4. {{^has_open_tickets}}Ask how you can help them today{{/has_open_tickets}} +5. Be empathetic, professional, and solution-oriented +6. {{#customer.is_premium}}Offer additional premium perks or expedited solutions{{/customer.is_premium}} + +Your goal is to ensure customer satisfaction and resolve issues efficiently. +""".strip() + +# Create the provider with the template +instruction_provider = MustacheInstructionProvider(CUSTOMER_SUPPORT_TEMPLATE) + +# Create the agent +root_agent = Agent( + name='customer_support_agent', + model='gemini-2.0-flash', + instruction=instruction_provider, + before_agent_callback=[populate_customer_data], +) diff --git a/pyproject.toml b/pyproject.toml index 11afcd8..2648009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,12 @@ test = [ "pytest>=8.4.2", "pytest-asyncio>=1.2.0", ] +templating = [ + "jinja2>=3.1.0", # For Jinja2InstructionProvider + "Mako>=1.3.0", # For MakoInstructionProvider + "chevron>=0.14.0", # For MustacheInstructionProvider (Mustache templates) + "Django>=4.0.0", # For DjangoInstructionProvider +] [tool.pyink] @@ -72,6 +78,8 @@ build-backend = "flit_core.buildapi" [dependency-groups] dev = [ + "isort>=6.1.0", + "pyink>=24.10.1", "pytest>=8.4.2", "pytest-asyncio>=1.2.0", ] diff --git a/src/google/adk_community/__init__.py b/src/google/adk_community/__init__.py index 9a1dc35..552bc86 100644 --- a/src/google/adk_community/__init__.py +++ b/src/google/adk_community/__init__.py @@ -14,5 +14,7 @@ from . import memory from . import sessions +from . import templating from . import version + __version__ = version.__version__ diff --git a/src/google/adk_community/memory/__init__.py b/src/google/adk_community/memory/__init__.py index 1f3442c..fbcf66b 100644 --- a/src/google/adk_community/memory/__init__.py +++ b/src/google/adk_community/memory/__init__.py @@ -21,4 +21,3 @@ "OpenMemoryService", "OpenMemoryServiceConfig", ] - diff --git a/src/google/adk_community/templating/__init__.py b/src/google/adk_community/templating/__init__.py new file mode 100644 index 0000000..ff19977 --- /dev/null +++ b/src/google/adk_community/templating/__init__.py @@ -0,0 +1,62 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Templating engine integrations for ADK instruction providers. + +This package provides InstructionProvider implementations for popular +Python templating engines, allowing developers to use their preferred +templating syntax for dynamic agent instructions. + +Available Providers: + - Jinja2InstructionProvider: Jinja2 templating (most popular) + - MakoInstructionProvider: Mako templating (fast, Python-centric) + - MustacheInstructionProvider: Mustache/Chevron (logic-less) + - DjangoInstructionProvider: Django templates + +Installation: + pip install google-adk-community[templating] + +Example: + ```python + from google.adk.agents import Agent + from google.adk_community.templating import Jinja2InstructionProvider + + provider = Jinja2InstructionProvider(''' + You are a {{ role }} assistant. + {% if user %} + Current user: {{ user.name }} + {% endif %} + ''') + + agent = Agent( + name="my_agent", + model="gemini-2.0-flash", + instruction=provider + ) + ``` +""" + +from .base import BaseTemplateProvider +from .django_provider import DjangoInstructionProvider +from .jinja2_provider import Jinja2InstructionProvider +from .mako_provider import MakoInstructionProvider +from .mustache_provider import MustacheInstructionProvider + +__all__ = [ + 'BaseTemplateProvider', + 'Jinja2InstructionProvider', + 'MakoInstructionProvider', + 'MustacheInstructionProvider', + 'DjangoInstructionProvider', +] diff --git a/src/google/adk_community/templating/base.py b/src/google/adk_community/templating/base.py new file mode 100644 index 0000000..f41a69f --- /dev/null +++ b/src/google/adk_community/templating/base.py @@ -0,0 +1,107 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import Any +from typing import Dict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from google.adk.agents.readonly_context import ReadonlyContext + + +class BaseTemplateProvider(ABC): + """Base class for template-based instruction providers. + + This class provides common functionality for all templating engines, + including state extraction and error handling patterns. + + Subclasses must implement the `_render_template` method to integrate + with their specific templating engine. + """ + + def __init__(self, template: str): + """Initialize the template provider. + + Args: + template: The template string to render. + """ + self.template = template + + async def __call__(self, context: ReadonlyContext) -> str: + """Render the template with session state from context. + + This method is called by the ADK framework to generate dynamic + instructions for agents. + + Args: + context: The readonly context containing session state. + + Returns: + The rendered instruction string. + """ + render_context = self._extract_context(context) + return await self._render_template(render_context) + + def _extract_context(self, context: ReadonlyContext) -> Dict[str, Any]: + """Extract rendering context from ADK's ReadonlyContext. + + This method provides access to: + - Session state variables + - User ID + - Session ID + - App name + + Args: + context: The readonly context from ADK. + + Returns: + Dictionary of variables available for template rendering. + """ + # Access the invocation context to get session information + invocation_context = context._invocation_context + session = invocation_context.session + + # Build the render context with session state and metadata + render_context = dict(context.state) + + # Add session metadata as special variables + # Note: Using 'adk_' prefix instead of '__' to avoid conflicts with + # templating engines that don't allow variables starting with underscores + render_context['adk_session_id'] = session.id + render_context['adk_user_id'] = session.user_id + render_context['adk_app_name'] = session.app_name + + return render_context + + @abstractmethod + async def _render_template(self, context: Dict[str, Any]) -> str: + """Render the template using the specific templating engine. + + Subclasses must implement this method to integrate with their + templating engine (Jinja2, Mako, Mustache, Django, etc.). + + Args: + context: Dictionary of variables for template rendering. + + Returns: + The rendered template string. + + Raises: + Any templating engine-specific exceptions. + """ + pass diff --git a/src/google/adk_community/templating/django_provider.py b/src/google/adk_community/templating/django_provider.py new file mode 100644 index 0000000..80bf873 --- /dev/null +++ b/src/google/adk_community/templating/django_provider.py @@ -0,0 +1,151 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Any +from typing import Dict +from typing import Optional + +from typing_extensions import override + +from .base import BaseTemplateProvider + + +class DjangoInstructionProvider(BaseTemplateProvider): + """Instruction provider using Django template engine. + + Django's template language is designed to strike a balance between power + and ease. It provides: + - Variables: {{ variable }} + - Tags: {% if %}, {% for %}, {% block %} + - Filters: {{ variable|filter }} + - Template inheritance + - Automatic HTML escaping (configurable) + + Example: + ```python + from google.adk.agents import Agent + from google.adk_community.templating import DjangoInstructionProvider + + provider = DjangoInstructionProvider(''' + You are a {{ role }} assistant. + Current user: {{ user.name|default:"Unknown" }} + + {% if servers %} + Active Servers: + {% for server in servers %} + - {{ server.name }}: CPU {{ server.cpu }}% + {% if server.cpu > 80 %}(CRITICAL!){% endif %} + {% endfor %} + {% else %} + No servers currently active. + {% endif %} + ''') + + agent = Agent( + name="my_agent", + model="gemini-2.0-flash", + instruction=provider + ) + ``` + + See https://docs.djangoproject.com/en/stable/ref/templates/ for full + documentation. + """ + + def __init__( + self, + template: str, + autoescape: bool = False, + custom_filters: Optional[Dict[str, Any]] = None, + custom_tags: Optional[Dict[str, Any]] = None, + ): + """Initialize the Django instruction provider. + + Args: + template: The Django template string. + autoescape: If True, enables HTML autoescaping. Default is False + since instructions are typically plain text. + custom_filters: Optional dictionary of custom template filters. + custom_tags: Optional dictionary of custom template tags. + + Raises: + ImportError: If Django is not installed. + """ + super().__init__(template) + + try: + from django import conf + from django.template import Context + from django.template import Engine + from django.template.library import Library + except ImportError as e: + raise ImportError( + 'DjangoInstructionProvider requires Django. ' + 'Install it with: pip install google-adk-community[templating] ' + 'or: pip install Django' + ) from e + + # Configure Django settings if not already configured + # This is needed for features like localization and number formatting + if not conf.settings.configured: + conf.settings.configure( + USE_I18N=False, # Disable internationalization for simple use case + USE_L10N=False, # Disable localization + USE_TZ=False, # Disable timezone support + ) + + # Create a Django template engine + # We don't need a full Django setup, just the template engine + self.engine = Engine(autoescape=autoescape) + + # Register custom filters and tags if provided + if custom_filters or custom_tags: + library = Library() + if custom_filters: + for name, func in custom_filters.items(): + library.filter(name, func) + if custom_tags: + for name, func in custom_tags.items(): + library.tag(name, func) + # Add library to engine's built-in libraries + self.engine.template_libraries['custom'] = library + # Load the custom library in templates by default + self.engine.builtins.append('custom') + + # Store Django classes and settings for use in render + self._Context = Context + self._autoescape = autoescape + + # Compile the template once during initialization + self.compiled_template = self.engine.from_string(template) + + @override + async def _render_template(self, context: Dict[str, Any]) -> str: + """Render the Django template with the provided context. + + Args: + context: Dictionary of variables for template rendering. + + Returns: + The rendered template string. + + Raises: + django.template.TemplateSyntaxError: If template has syntax errors. + django.template.TemplateDoesNotExist: If a referenced template + (e.g., via {% extends %}) does not exist. + """ + django_context = self._Context(context, autoescape=self._autoescape) + return self.compiled_template.render(django_context) diff --git a/src/google/adk_community/templating/jinja2_provider.py b/src/google/adk_community/templating/jinja2_provider.py new file mode 100644 index 0000000..3862edf --- /dev/null +++ b/src/google/adk_community/templating/jinja2_provider.py @@ -0,0 +1,131 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Any +from typing import Callable +from typing import Dict +from typing import Optional + +from typing_extensions import override + +from .base import BaseTemplateProvider + + +class Jinja2InstructionProvider(BaseTemplateProvider): + """Instruction provider using Jinja2 template engine. + + Jinja2 is a modern and designer-friendly templating language for Python. + It provides powerful features including: + - Variables: {{ variable }} + - Control structures: {% if %}, {% for %} + - Filters: {{ variable|filter }} + - Template inheritance and macros + + Example: + ```python + from google.adk.agents import Agent + from google.adk_community.templating import Jinja2InstructionProvider + + provider = Jinja2InstructionProvider(''' + You are a {{ role }} assistant. + Current user: {{ user.name }} + + {% if servers %} + Active Servers: + {% for server in servers %} + - {{ server.name }}: CPU {{ server.cpu }}% + {% if server.cpu > 80 %}(CRITICAL!){% endif %} + {% endfor %} + {% endif %} + ''') + + agent = Agent( + name="my_agent", + model="gemini-2.0-flash", + instruction=provider + ) + ``` + + See https://jinja.palletsprojects.com/ for full documentation. + """ + + def __init__( + self, + template: str, + custom_filters: Optional[Dict[str, Callable]] = None, + custom_tests: Optional[Dict[str, Callable]] = None, + custom_globals: Optional[Dict[str, Any]] = None, + **jinja_env_kwargs, + ): + """Initialize the Jinja2 instruction provider. + + Args: + template: The Jinja2 template string. + custom_filters: Optional dictionary of custom Jinja2 filters. + Example: {'uppercase': str.upper} + custom_tests: Optional dictionary of custom Jinja2 tests. + Example: {'even': lambda x: x % 2 == 0} + custom_globals: Optional dictionary of global variables/functions + available in templates. + **jinja_env_kwargs: Additional keyword arguments passed to + jinja2.Environment constructor (e.g., autoescape, trim_blocks). + + Raises: + ImportError: If jinja2 is not installed. + """ + super().__init__(template) + + try: + import jinja2 + except ImportError as e: + raise ImportError( + 'Jinja2InstructionProvider requires jinja2. ' + 'Install it with: pip install google-adk-community[templating] ' + 'or: pip install jinja2' + ) from e + + # Create Jinja2 environment with user-provided settings + self.env = jinja2.Environment(**jinja_env_kwargs) + + # Add custom filters + if custom_filters: + self.env.filters.update(custom_filters) + + # Add custom tests + if custom_tests: + self.env.tests.update(custom_tests) + + # Add custom globals + if custom_globals: + self.env.globals.update(custom_globals) + + # Compile the template once during initialization + self.compiled_template = self.env.from_string(template) + + @override + async def _render_template(self, context: Dict[str, Any]) -> str: + """Render the Jinja2 template with the provided context. + + Args: + context: Dictionary of variables for template rendering. + + Returns: + The rendered template string. + + Raises: + jinja2.TemplateError: If template rendering fails. + """ + return self.compiled_template.render(context) diff --git a/src/google/adk_community/templating/mako_provider.py b/src/google/adk_community/templating/mako_provider.py new file mode 100644 index 0000000..7d8b865 --- /dev/null +++ b/src/google/adk_community/templating/mako_provider.py @@ -0,0 +1,117 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Any +from typing import Dict + +from typing_extensions import override + +from .base import BaseTemplateProvider + + +class MakoInstructionProvider(BaseTemplateProvider): + """Instruction provider using Mako template engine. + + Mako is a fast, Python-centric templating library that allows you to + write Python expressions directly in your templates. It provides: + - Python expressions: ${variable} + - Control structures: % if, % for + - Def blocks for reusable components + - Template inheritance + - Fast compilation and execution + + Example: + ```python + from google.adk.agents import Agent + from google.adk_community.templating import MakoInstructionProvider + + provider = MakoInstructionProvider(''' + You are a ${role} assistant. + Current user: ${user.get('name', 'Unknown')} + + % if servers: + Active Servers: + % for server in servers: + - ${server['name']}: CPU ${server['cpu']}% + % if server['cpu'] > 80: + (CRITICAL!) + % endif + % endfor + % endif + ''') + + agent = Agent( + name="my_agent", + model="gemini-2.0-flash", + instruction=provider + ) + ``` + + See https://www.makotemplates.org/ for full documentation. + """ + + def __init__( + self, + template: str, + strict_undefined: bool = False, + **mako_template_kwargs, + ): + """Initialize the Mako instruction provider. + + Args: + template: The Mako template string. + strict_undefined: If True, raises an exception when accessing + undefined variables. If False (default), undefined variables + evaluate to None or empty string. + **mako_template_kwargs: Additional keyword arguments passed to + mako.template.Template constructor (e.g., input_encoding, + output_encoding, error_handler). + + Raises: + ImportError: If Mako is not installed. + """ + super().__init__(template) + + try: + from mako.template import Template + except ImportError as e: + raise ImportError( + 'MakoInstructionProvider requires Mako. ' + 'Install it with: pip install google-adk-community[templating] ' + 'or: pip install Mako' + ) from e + + # Set strict_undefined as a Mako-specific setting + if strict_undefined: + mako_template_kwargs['strict_undefined'] = True + + # Compile the template once during initialization + self.compiled_template = Template(text=template, **mako_template_kwargs) + + @override + async def _render_template(self, context: Dict[str, Any]) -> str: + """Render the Mako template with the provided context. + + Args: + context: Dictionary of variables for template rendering. + + Returns: + The rendered template string. + + Raises: + mako.exceptions.MakoException: If template rendering fails. + """ + return self.compiled_template.render(**context) diff --git a/src/google/adk_community/templating/mustache_provider.py b/src/google/adk_community/templating/mustache_provider.py new file mode 100644 index 0000000..6f179db --- /dev/null +++ b/src/google/adk_community/templating/mustache_provider.py @@ -0,0 +1,110 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Any +from typing import Dict + +from typing_extensions import override + +from .base import BaseTemplateProvider + + +class MustacheInstructionProvider(BaseTemplateProvider): + """Instruction provider using Mustache template engine. + + Mustache is a logic-less templating language that works across many + programming languages. It provides: + - Variables: {{variable}} + - Sections: {{#section}}...{{/section}} + - Inverted sections: {{^section}}...{{/section}} + - Comments: {{! comment }} + - Partials: {{> partial}} + + This implementation uses the 'chevron' library, a Python implementation + of Mustache. + + Example: + ```python + from google.adk.agents import Agent + from google.adk_community.templating import MustacheInstructionProvider + + provider = MustacheInstructionProvider(''' + You are a {{role}} assistant. + Current user: {{user.name}} + + {{#servers}} + Active Servers: + {{#servers}} + - {{name}}: CPU {{cpu}}% + {{#high_cpu}}(CRITICAL!){{/high_cpu}} + {{/servers}} + {{/servers}} + {{^servers}} + No servers currently active. + {{/servers}} + ''') + + agent = Agent( + name="my_agent", + model="gemini-2.0-flash", + instruction=provider + ) + ``` + + See https://mustache.github.io/ and https://github.com/noahmorrison/chevron + for full documentation. + """ + + def __init__( + self, + template: str, + warn: bool = False, + ): + """Initialize the Mustache instruction provider. + + Args: + template: The Mustache template string. + warn: If True, prints a warning to stderr when a tag is not found + in the data. Default is False. + + Raises: + ImportError: If chevron is not installed. + """ + super().__init__(template) + + try: + import chevron + except ImportError as e: + raise ImportError( + 'MustacheInstructionProvider requires chevron. ' + 'Install it with: pip install google-adk-community[templating] ' + 'or: pip install chevron' + ) from e + + self.warn = warn + self._chevron = chevron + + @override + async def _render_template(self, context: Dict[str, Any]) -> str: + """Render the Mustache template with the provided context. + + Args: + context: Dictionary of variables for template rendering. + + Returns: + The rendered template string. + """ + return self._chevron.render(self.template, context, warn=self.warn) diff --git a/tests/unittests/templating/__init__.py b/tests/unittests/templating/__init__.py new file mode 100644 index 0000000..bcf9f82 --- /dev/null +++ b/tests/unittests/templating/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for templating providers.""" diff --git a/tests/unittests/templating/test_django_provider.py b/tests/unittests/templating/test_django_provider.py new file mode 100644 index 0000000..91d9b43 --- /dev/null +++ b/tests/unittests/templating/test_django_provider.py @@ -0,0 +1,235 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import MagicMock + +import pytest + +from google.adk_community.templating import DjangoInstructionProvider + + +@pytest.fixture +def mock_readonly_context(): + """Create a mock ReadonlyContext for testing.""" + context = MagicMock() + session = MagicMock() + session.id = 'test-session-id' + session.user_id = 'test-user' + session.app_name = 'test-app' + + invocation_context = MagicMock() + invocation_context.session = session + + context._invocation_context = invocation_context + context.state = {} + + return context + + +class TestDjangoInstructionProvider: + """Test suite for DjangoInstructionProvider.""" + + async def test_basic_variable_substitution(self, mock_readonly_context): + """Test basic variable substitution.""" + mock_readonly_context.state = {'name': 'Alice', 'role': 'Engineer'} + + provider = DjangoInstructionProvider( + 'Hello {{ name }}, you are a {{ role }}.' + ) + result = await provider(mock_readonly_context) + + assert result == 'Hello Alice, you are a Engineer.' + + async def test_nested_object_access(self, mock_readonly_context): + """Test accessing nested objects.""" + mock_readonly_context.state = { + 'user': {'name': 'Bob', 'profile': {'age': 30, 'role': 'Developer'}} + } + + provider = DjangoInstructionProvider( + 'User: {{ user.name }}, Role: {{ user.profile.role }}' + ) + result = await provider(mock_readonly_context) + + assert result == 'User: Bob, Role: Developer' + + async def test_control_structures(self, mock_readonly_context): + """Test if/else control structures.""" + mock_readonly_context.state = {'logged_in': True, 'username': 'Charlie'} + + provider = DjangoInstructionProvider( + '{% if logged_in %}Welcome {{ username }}!{% else %}Please log' + ' in.{% endif %}' + ) + result = await provider(mock_readonly_context) + + assert result == 'Welcome Charlie!' + + async def test_for_loop(self, mock_readonly_context): + """Test for loops.""" + mock_readonly_context.state = { + 'servers': [ + {'name': 'srv-01', 'cpu': 25}, + {'name': 'srv-02', 'cpu': 90}, + ] + } + + provider = DjangoInstructionProvider( + '{% for server in servers %}{{ server.name }}: {{ server.cpu }}%\n{%' + ' endfor %}' + ) + result = await provider(mock_readonly_context) + + assert 'srv-01: 25%' in result + assert 'srv-02: 90%' in result + + async def test_filters(self, mock_readonly_context): + """Test Django filters.""" + mock_readonly_context.state = {'name': 'alice'} + + provider = DjangoInstructionProvider('Hello {{ name|upper }}!') + result = await provider(mock_readonly_context) + + assert result == 'Hello ALICE!' + + async def test_default_filter(self, mock_readonly_context): + """Test default filter for missing variables.""" + mock_readonly_context.state = {} + + provider = DjangoInstructionProvider('Hello {{ name|default:"Guest" }}!') + result = await provider(mock_readonly_context) + + assert result == 'Hello Guest!' + + async def test_custom_filters(self, mock_readonly_context): + """Test custom filters.""" + mock_readonly_context.state = {'number': 42} + + def double(value): + return value * 2 + + provider = DjangoInstructionProvider( + '{% load custom %}Result: {{ number|double }}', + custom_filters={'double': double}, + ) + result = await provider(mock_readonly_context) + + assert result == 'Result: 84' + + async def test_session_metadata_access(self, mock_readonly_context): + """Test access to session metadata.""" + provider = DjangoInstructionProvider( + 'Session: {{ adk_session_id }}, User: {{ adk_user_id }}, App: {{' + ' adk_app_name }}' + ) + result = await provider(mock_readonly_context) + + assert result == 'Session: test-session-id, User: test-user, App: test-app' + + async def test_empty_template(self, mock_readonly_context): + """Test empty template.""" + provider = DjangoInstructionProvider('') + result = await provider(mock_readonly_context) + + assert result == '' + + async def test_for_empty_tag(self, mock_readonly_context): + """Test for...empty tag.""" + mock_readonly_context.state = {'items': []} + + provider = DjangoInstructionProvider( + '{% for item in items %}{{ item }}{% empty %}No items{% endfor %}' + ) + result = await provider(mock_readonly_context) + + assert result == 'No items' + + async def test_comparison_operators(self, mock_readonly_context): + """Test comparison operators in if statements.""" + mock_readonly_context.state = {'cpu': 95} + + provider = DjangoInstructionProvider( + '{% if cpu > 80 %}CRITICAL{% else %}NORMAL{% endif %}' + ) + result = await provider(mock_readonly_context) + + assert result == 'CRITICAL' + + async def test_complex_devops_scenario(self, mock_readonly_context): + """Test complex DevOps scenario.""" + mock_readonly_context.state = { + 'environment': 'PRODUCTION', + 'user_query': 'Check system status', + 'servers': [ + {'id': 'srv-01', 'role': 'LoadBalancer', 'cpu': 15}, + {'id': 'srv-02', 'role': 'AppServer', 'cpu': 95}, + ], + } + + template = """Environment: {{ environment }} +{% if servers %} +Active Servers: +{% for server in servers %} + - [{{ server.id }}] {{ server.role }}: CPU {{ server.cpu }}%{% if server.cpu > 80 %} (CRITICAL!){% endif %} +{% endfor %} +{% else %} +No servers active. +{% endif %} +Query: {{ user_query }}""" + + provider = DjangoInstructionProvider(template) + result = await provider(mock_readonly_context) + + assert 'Environment: PRODUCTION' in result + assert 'srv-01' in result + assert 'srv-02' in result + assert 'CRITICAL!' in result + assert 'Query: Check system status' in result + + async def test_length_filter(self, mock_readonly_context): + """Test length filter.""" + mock_readonly_context.state = {'items': [1, 2, 3, 4, 5]} + + provider = DjangoInstructionProvider('Count: {{ items|length }}') + result = await provider(mock_readonly_context) + + assert result == 'Count: 5' + + async def test_autoescape_disabled(self, mock_readonly_context): + """Test that autoescape is disabled by default.""" + mock_readonly_context.state = {'html': 'bold'} + + provider = DjangoInstructionProvider('{{ html }}', autoescape=False) + result = await provider(mock_readonly_context) + + assert result == 'bold' + + def test_import_error_when_django_not_installed(self, monkeypatch): + """Test that ImportError is raised when Django is not installed.""" + import builtins + + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == 'django.template' or name.startswith('django.'): + raise ImportError('No module named django') + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', mock_import) + + with pytest.raises(ImportError) as exc_info: + DjangoInstructionProvider('test template') + + assert 'django' in str(exc_info.value).lower() + assert 'google-adk-community[templating]' in str(exc_info.value) diff --git a/tests/unittests/templating/test_jinja2_provider.py b/tests/unittests/templating/test_jinja2_provider.py new file mode 100644 index 0000000..dbd1873 --- /dev/null +++ b/tests/unittests/templating/test_jinja2_provider.py @@ -0,0 +1,224 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import MagicMock + +import pytest + +from google.adk_community.templating import Jinja2InstructionProvider + + +@pytest.fixture +def mock_readonly_context(): + """Create a mock ReadonlyContext for testing.""" + context = MagicMock() + session = MagicMock() + session.id = 'test-session-id' + session.user_id = 'test-user' + session.app_name = 'test-app' + + invocation_context = MagicMock() + invocation_context.session = session + + context._invocation_context = invocation_context + context.state = {} + + return context + + +class TestJinja2InstructionProvider: + """Test suite for Jinja2InstructionProvider.""" + + async def test_basic_variable_substitution(self, mock_readonly_context): + """Test basic variable substitution.""" + mock_readonly_context.state = {'name': 'Alice', 'role': 'Engineer'} + + provider = Jinja2InstructionProvider( + 'Hello {{ name }}, you are a {{ role }}.' + ) + result = await provider(mock_readonly_context) + + assert result == 'Hello Alice, you are a Engineer.' + + async def test_nested_object_access(self, mock_readonly_context): + """Test accessing nested objects.""" + mock_readonly_context.state = { + 'user': {'name': 'Bob', 'profile': {'age': 30, 'role': 'Developer'}} + } + + provider = Jinja2InstructionProvider( + 'User: {{ user.name }}, Role: {{ user.profile.role }}' + ) + result = await provider(mock_readonly_context) + + assert result == 'User: Bob, Role: Developer' + + async def test_control_structures(self, mock_readonly_context): + """Test if/else control structures.""" + mock_readonly_context.state = {'logged_in': True, 'username': 'Charlie'} + + provider = Jinja2InstructionProvider( + '{% if logged_in %}Welcome {{ username }}!{% else %}Please log' + ' in.{% endif %}' + ) + result = await provider(mock_readonly_context) + + assert result == 'Welcome Charlie!' + + async def test_for_loop(self, mock_readonly_context): + """Test for loops.""" + mock_readonly_context.state = { + 'servers': [ + {'name': 'srv-01', 'cpu': 25}, + {'name': 'srv-02', 'cpu': 90}, + ] + } + + provider = Jinja2InstructionProvider( + '{% for server in servers %}{{ server.name }}: {{ server.cpu }}%\n{%' + ' endfor %}' + ) + result = await provider(mock_readonly_context) + + assert 'srv-01: 25%' in result + assert 'srv-02: 90%' in result + + async def test_filters(self, mock_readonly_context): + """Test Jinja2 filters.""" + mock_readonly_context.state = {'name': 'alice'} + + provider = Jinja2InstructionProvider('Hello {{ name|upper }}!') + result = await provider(mock_readonly_context) + + assert result == 'Hello ALICE!' + + async def test_custom_filters(self, mock_readonly_context): + """Test custom filters.""" + mock_readonly_context.state = {'number': 42} + + def double(value): + return value * 2 + + provider = Jinja2InstructionProvider( + 'Result: {{ number|double }}', custom_filters={'double': double} + ) + result = await provider(mock_readonly_context) + + assert result == 'Result: 84' + + async def test_custom_tests(self, mock_readonly_context): + """Test custom tests.""" + mock_readonly_context.state = {'value': 10} + + def is_large(value): + return value > 50 + + provider = Jinja2InstructionProvider( + '{% if value is large %}Large{% else %}Small{% endif %}', + custom_tests={'large': is_large}, + ) + result = await provider(mock_readonly_context) + + assert result == 'Small' + + async def test_custom_globals(self, mock_readonly_context): + """Test custom global variables.""" + mock_readonly_context.state = {} + + provider = Jinja2InstructionProvider( + 'Version: {{ version }}', custom_globals={'version': '1.0.0'} + ) + result = await provider(mock_readonly_context) + + assert result == 'Version: 1.0.0' + + async def test_session_metadata_access(self, mock_readonly_context): + """Test access to session metadata.""" + provider = Jinja2InstructionProvider( + 'Session: {{ adk_session_id }}, User: {{ adk_user_id }}, App: {{' + ' adk_app_name }}' + ) + result = await provider(mock_readonly_context) + + assert result == 'Session: test-session-id, User: test-user, App: test-app' + + async def test_missing_variable_default(self, mock_readonly_context): + """Test default value for missing variables.""" + mock_readonly_context.state = {} + + provider = Jinja2InstructionProvider('Hello {{ name|default("Guest") }}!') + result = await provider(mock_readonly_context) + + assert result == 'Hello Guest!' + + async def test_empty_template(self, mock_readonly_context): + """Test empty template.""" + provider = Jinja2InstructionProvider('') + result = await provider(mock_readonly_context) + + assert result == '' + + async def test_complex_devops_scenario(self, mock_readonly_context): + """Test complex DevOps scenario similar to the example.""" + mock_readonly_context.state = { + 'environment': 'PRODUCTION', + 'user_query': 'Check system status', + 'servers': [ + {'id': 'srv-01', 'role': 'LoadBalancer', 'cpu': 15}, + {'id': 'srv-02', 'role': 'AppServer', 'cpu': 95}, + ], + } + + template = """ +Environment: {{ environment }} +{% if servers %} +Active Servers: +{% for server in servers %} + - [{{ server.id }}] {{ server.role }}: CPU {{ server.cpu }}% + {%- if server.cpu > 80 %} (CRITICAL!){% endif %} +{% endfor %} +{% else %} +No servers active. +{% endif %} +Query: {{ user_query }} +""".strip() + + provider = Jinja2InstructionProvider(template) + result = await provider(mock_readonly_context) + + assert 'Environment: PRODUCTION' in result + assert 'srv-01' in result + assert 'srv-02' in result + assert 'CRITICAL!' in result + assert 'Query: Check system status' in result + + def test_import_error_when_jinja2_not_installed(self, monkeypatch): + """Test that ImportError is raised when jinja2 is not installed.""" + # Mock the import to simulate jinja2 not being installed + import builtins + + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == 'jinja2': + raise ImportError('No module named jinja2') + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', mock_import) + + with pytest.raises(ImportError) as exc_info: + Jinja2InstructionProvider('test template') + + assert 'jinja2' in str(exc_info.value).lower() + assert 'google-adk-community[templating]' in str(exc_info.value) diff --git a/tests/unittests/templating/test_mako_provider.py b/tests/unittests/templating/test_mako_provider.py new file mode 100644 index 0000000..fe98b50 --- /dev/null +++ b/tests/unittests/templating/test_mako_provider.py @@ -0,0 +1,193 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import MagicMock + +import pytest + +from google.adk_community.templating import MakoInstructionProvider + + +@pytest.fixture +def mock_readonly_context(): + """Create a mock ReadonlyContext for testing.""" + context = MagicMock() + session = MagicMock() + session.id = 'test-session-id' + session.user_id = 'test-user' + session.app_name = 'test-app' + + invocation_context = MagicMock() + invocation_context.session = session + + context._invocation_context = invocation_context + context.state = {} + + return context + + +class TestMakoInstructionProvider: + """Test suite for MakoInstructionProvider.""" + + async def test_basic_variable_substitution(self, mock_readonly_context): + """Test basic variable substitution.""" + mock_readonly_context.state = {'name': 'Alice', 'role': 'Engineer'} + + provider = MakoInstructionProvider('Hello ${name}, you are a ${role}.') + result = await provider(mock_readonly_context) + + assert result == 'Hello Alice, you are a Engineer.' + + async def test_nested_object_access(self, mock_readonly_context): + """Test accessing nested objects using dictionary access.""" + mock_readonly_context.state = { + 'user': {'name': 'Bob', 'profile': {'age': 30, 'role': 'Developer'}} + } + + provider = MakoInstructionProvider( + "User: ${user['name']}, Role: ${user['profile']['role']}" + ) + result = await provider(mock_readonly_context) + + assert result == 'User: Bob, Role: Developer' + + async def test_control_structures(self, mock_readonly_context): + """Test if/else control structures.""" + mock_readonly_context.state = {'logged_in': True, 'username': 'Charlie'} + + provider = MakoInstructionProvider( + '% if logged_in:\nWelcome ${username}!\n% else:\nPlease log in.\n%' + ' endif' + ) + result = await provider(mock_readonly_context) + + assert 'Welcome Charlie!' in result + + async def test_for_loop(self, mock_readonly_context): + """Test for loops.""" + mock_readonly_context.state = { + 'servers': [ + {'name': 'srv-01', 'cpu': 25}, + {'name': 'srv-02', 'cpu': 90}, + ] + } + + provider = MakoInstructionProvider( + "% for server in servers:\n${server['name']}: ${server['cpu']}%\n%" + ' endfor' + ) + result = await provider(mock_readonly_context) + + assert 'srv-01: 25%' in result + assert 'srv-02: 90%' in result + + async def test_python_expressions(self, mock_readonly_context): + """Test Python expressions in templates.""" + mock_readonly_context.state = {'x': 10, 'y': 5} + + provider = MakoInstructionProvider('Result: ${x + y}') + result = await provider(mock_readonly_context) + + assert result == 'Result: 15' + + async def test_session_metadata_access(self, mock_readonly_context): + """Test access to session metadata.""" + provider = MakoInstructionProvider( + 'Session: ${adk_session_id}, User: ${adk_user_id}, App: ${adk_app_name}' + ) + result = await provider(mock_readonly_context) + + assert result == 'Session: test-session-id, User: test-user, App: test-app' + + async def test_get_method_with_default(self, mock_readonly_context): + """Test using .get() method for safe access.""" + mock_readonly_context.state = {'user': {'name': 'Dave'}} + + provider = MakoInstructionProvider( + "Name: ${user.get('name', 'Unknown')}, Role: ${user.get('role', 'N/A')}" + ) + result = await provider(mock_readonly_context) + + assert result == 'Name: Dave, Role: N/A' + + async def test_empty_template(self, mock_readonly_context): + """Test empty template.""" + provider = MakoInstructionProvider('') + result = await provider(mock_readonly_context) + + assert result == '' + + async def test_conditional_with_comparison(self, mock_readonly_context): + """Test conditional with comparison operators.""" + mock_readonly_context.state = {'cpu': 95} + + provider = MakoInstructionProvider( + '% if cpu > 80:\nCRITICAL: CPU at ${cpu}%\n% else:\nNormal: CPU at' + ' ${cpu}%\n% endif' + ) + result = await provider(mock_readonly_context) + + assert 'CRITICAL: CPU at 95%' in result + + async def test_complex_devops_scenario(self, mock_readonly_context): + """Test complex DevOps scenario.""" + mock_readonly_context.state = { + 'environment': 'PRODUCTION', + 'servers': [ + {'id': 'srv-01', 'role': 'LoadBalancer', 'cpu': 15}, + {'id': 'srv-02', 'role': 'AppServer', 'cpu': 95}, + ], + } + + template = """Environment: ${environment} +% if servers: +Active Servers: +% for server in servers: + - [${server['id']}] ${server['role']}: CPU ${server['cpu']}%\\ +% if server['cpu'] > 80: + (CRITICAL!)\\ +% endif + +% endfor +% else: +No servers active. +% endif +""" + + provider = MakoInstructionProvider(template) + result = await provider(mock_readonly_context) + + assert 'Environment: PRODUCTION' in result + assert 'srv-01' in result + assert 'srv-02' in result + assert 'CRITICAL!' in result + + def test_import_error_when_mako_not_installed(self, monkeypatch): + """Test that ImportError is raised when Mako is not installed.""" + import builtins + + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == 'mako.template' or name == 'mako': + raise ImportError('No module named mako') + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', mock_import) + + with pytest.raises(ImportError) as exc_info: + MakoInstructionProvider('test template') + + assert 'mako' in str(exc_info.value).lower() + assert 'google-adk-community[templating]' in str(exc_info.value) diff --git a/tests/unittests/templating/test_mustache_provider.py b/tests/unittests/templating/test_mustache_provider.py new file mode 100644 index 0000000..01bcbdb --- /dev/null +++ b/tests/unittests/templating/test_mustache_provider.py @@ -0,0 +1,210 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import MagicMock + +import pytest + +from google.adk_community.templating import MustacheInstructionProvider + + +@pytest.fixture +def mock_readonly_context(): + """Create a mock ReadonlyContext for testing.""" + context = MagicMock() + session = MagicMock() + session.id = 'test-session-id' + session.user_id = 'test-user' + session.app_name = 'test-app' + + invocation_context = MagicMock() + invocation_context.session = session + + context._invocation_context = invocation_context + context.state = {} + + return context + + +class TestMustacheInstructionProvider: + """Test suite for MustacheInstructionProvider.""" + + async def test_basic_variable_substitution(self, mock_readonly_context): + """Test basic variable substitution.""" + mock_readonly_context.state = {'name': 'Alice', 'role': 'Engineer'} + + provider = MustacheInstructionProvider( + 'Hello {{name}}, you are a {{role}}.' + ) + result = await provider(mock_readonly_context) + + assert result == 'Hello Alice, you are a Engineer.' + + async def test_nested_object_access(self, mock_readonly_context): + """Test accessing nested objects.""" + mock_readonly_context.state = { + 'user': {'name': 'Bob', 'profile': {'role': 'Developer'}} + } + + provider = MustacheInstructionProvider( + 'User: {{user.name}}, Role: {{user.profile.role}}' + ) + result = await provider(mock_readonly_context) + + assert result == 'User: Bob, Role: Developer' + + async def test_section_with_truthy_value(self, mock_readonly_context): + """Test section rendering with truthy value.""" + mock_readonly_context.state = {'logged_in': True, 'username': 'Charlie'} + + provider = MustacheInstructionProvider( + '{{#logged_in}}Welcome {{username}}!{{/logged_in}}' + ) + result = await provider(mock_readonly_context) + + assert result == 'Welcome Charlie!' + + async def test_inverted_section(self, mock_readonly_context): + """Test inverted section (renders when value is falsy).""" + mock_readonly_context.state = {'logged_in': False} + + provider = MustacheInstructionProvider( + '{{^logged_in}}Please log in.{{/logged_in}}' + ) + result = await provider(mock_readonly_context) + + assert result == 'Please log in.' + + async def test_list_iteration(self, mock_readonly_context): + """Test iterating over a list.""" + mock_readonly_context.state = { + 'servers': [ + {'name': 'srv-01', 'cpu': 25}, + {'name': 'srv-02', 'cpu': 90}, + ] + } + + provider = MustacheInstructionProvider( + '{{#servers}}{{name}}: {{cpu}}%\n{{/servers}}' + ) + result = await provider(mock_readonly_context) + + assert 'srv-01: 25%' in result + assert 'srv-02: 90%' in result + + async def test_session_metadata_access(self, mock_readonly_context): + """Test access to session metadata.""" + provider = MustacheInstructionProvider( + 'Session: {{adk_session_id}}, User: {{adk_user_id}}, App:' + ' {{adk_app_name}}' + ) + result = await provider(mock_readonly_context) + + assert result == 'Session: test-session-id, User: test-user, App: test-app' + + async def test_missing_variable_removal(self, mock_readonly_context): + """Test that missing variables are removed by default.""" + mock_readonly_context.state = {'name': 'Dave'} + + provider = MustacheInstructionProvider( + 'Hello {{name}}, your role is {{role}}.' + ) + result = await provider(mock_readonly_context) + + # Missing variable {{role}} should be removed + assert result == 'Hello Dave, your role is .' + + async def test_empty_template(self, mock_readonly_context): + """Test empty template.""" + provider = MustacheInstructionProvider('') + result = await provider(mock_readonly_context) + + assert result == '' + + async def test_empty_list(self, mock_readonly_context): + """Test section with empty list.""" + mock_readonly_context.state = {'servers': []} + + provider = MustacheInstructionProvider( + '{{#servers}}Active{{/servers}}{{^servers}}No servers{{/servers}}' + ) + result = await provider(mock_readonly_context) + + assert result == 'No servers' + + async def test_comment_ignored(self, mock_readonly_context): + """Test that comments are ignored.""" + mock_readonly_context.state = {'name': 'Eve'} + + provider = MustacheInstructionProvider( + 'Hello {{name}}! {{! This is a comment }}' + ) + result = await provider(mock_readonly_context) + + assert result == 'Hello Eve! ' + assert 'comment' not in result + + async def test_complex_devops_scenario(self, mock_readonly_context): + """Test complex DevOps scenario.""" + mock_readonly_context.state = { + 'environment': 'PRODUCTION', + 'servers': [ + { + 'id': 'srv-01', + 'role': 'LoadBalancer', + 'cpu': 15, + 'critical': False, + }, + {'id': 'srv-02', 'role': 'AppServer', 'cpu': 95, 'critical': True}, + ], + } + + template = """Environment: {{environment}} +{{#servers}} +Active Servers: +{{#servers}} + - [{{id}}] {{role}}: CPU {{cpu}}%{{#critical}} (CRITICAL!){{/critical}} +{{/servers}} +{{/servers}} +{{^servers}} +No servers active. +{{/servers}} +""" + + provider = MustacheInstructionProvider(template) + result = await provider(mock_readonly_context) + + assert 'Environment: PRODUCTION' in result + assert 'srv-01' in result + assert 'srv-02' in result + assert 'CRITICAL!' in result + + def test_import_error_when_chevron_not_installed(self, monkeypatch): + """Test that ImportError is raised when chevron is not installed.""" + import builtins + + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == 'chevron': + raise ImportError('No module named chevron') + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', mock_import) + + with pytest.raises(ImportError) as exc_info: + MustacheInstructionProvider('test template') + + assert 'chevron' in str(exc_info.value).lower() + assert 'google-adk-community[templating]' in str(exc_info.value)