From ed49fa62c41c600bf17672f6d2606ac6f9d20b31 Mon Sep 17 00:00:00 2001 From: Muizz Lateef Date: Fri, 19 Jun 2026 01:07:29 +0100 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20prompt=20module=20hardening=20?= =?UTF-8?q?=E2=80=94=20null=20guard,=20token=20preflight,=20dead=20code=20?= =?UTF-8?q?removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise ConfigValidationError on prompt:null for LLM actions (was silent fallback) - Token overflow guard always runs with default 128K limit when model unknown - Wrap skipped-dep re-render in try/except for proper TemplateVariableError - Raise ConfigurationError on seed namespace collision (was warn+overwrite) - Remove dead code: _enrich_source_namespace (zero callers) - Remove dead code: replace_field_references and helpers (zero callers) --- .../prompt/context/scope_application.py | 18 ++-- .../prompt/context/scope_namespace.py | 22 ----- agent_actions/prompt/formatter.py | 21 ++-- agent_actions/prompt/message_builder.py | 30 +++--- agent_actions/prompt/prompt_utils.py | 98 ------------------- agent_actions/prompt/service.py | 12 ++- .../transformers/test_prompt_field_refs.py | 91 ----------------- .../context/test_special_namespaces.py | 68 ------------- .../prompt/context/test_scope_application.py | 31 ++++++ tests/unit/prompt/test_formatter.py | 12 ++- .../test_message_builder_token_guard.py | 20 +++- 11 files changed, 105 insertions(+), 318 deletions(-) delete mode 100644 tests/agents/transformers/test_prompt_field_refs.py diff --git a/agent_actions/prompt/context/scope_application.py b/agent_actions/prompt/context/scope_application.py index 1083d27b..f261efe9 100644 --- a/agent_actions/prompt/context/scope_application.py +++ b/agent_actions/prompt/context/scope_application.py @@ -6,7 +6,7 @@ from copy import deepcopy from typing import Any -from agent_actions.errors import RecordContextError +from agent_actions.errors import ConfigurationError, RecordContextError from agent_actions.logging.core.manager import fire_event from agent_actions.logging.events.io_events import ( ContextFieldSkippedEvent, @@ -144,12 +144,18 @@ def apply_context_scope( logger.debug("[STATIC_DATA] Merging %s static data fields into context", len(static_data)) logger.debug("[STATIC_DATA] Fields: %s", list(static_data.keys())) - # Add under 'seed' namespace in prompt_context (for field reference replacement) - # This allows references like {{seed.exam_syllabus}} in prompts + # Defense-in-depth: DependencyNamespaceBuilder already skips SPECIAL_NAMESPACES, + # but seed is the only framework namespace injected from an external source + # (static files via seed_path), so guard against edge cases where "seed" leaks + # into prompt_context through a non-standard path. if "seed" in prompt_context: - logger.warning( - "Seed data namespace 'seed' conflicts with existing action. " - "Seed data will overwrite it." + raise ConfigurationError( + "Namespace collision: action named 'seed' conflicts with the seed data namespace. " + "Rename the action to avoid overwriting its output with static seed data.", + context={ + "action_name": action_name, + "conflicting_namespace": "seed", + }, ) prompt_context["seed"] = static_data logger.debug("[SEED_DATA] Added to prompt_context under 'seed' namespace") diff --git a/agent_actions/prompt/context/scope_namespace.py b/agent_actions/prompt/context/scope_namespace.py index 24096ae0..760f8d15 100644 --- a/agent_actions/prompt/context/scope_namespace.py +++ b/agent_actions/prompt/context/scope_namespace.py @@ -53,28 +53,6 @@ def _extract_content_data(source_content: Any) -> dict: return {k: v for k, v in source_content.items() if k not in _RECORD_METADATA_KEYS} -def _enrich_source_namespace( - base_namespace: dict[str, Any], current_item: dict[str, Any] | None -) -> dict[str, Any]: - """ - Merge fallback fields into the source namespace from the current item. - - This helps downstream actions get at least one source-like namespace even if the - stored source file was sparse (e.g., only identifiers). - """ - merged = dict(base_namespace or {}) - - if not current_item or not isinstance(current_item, dict): - return merged - - fallback = _extract_content_data(current_item) - for key, value in fallback.items(): - if key not in merged: - merged[key] = value - - return merged - - def _filter_and_store_fields( field_context: dict, name: str, diff --git a/agent_actions/prompt/formatter.py b/agent_actions/prompt/formatter.py index 0ba49e59..9654bfa5 100644 --- a/agent_actions/prompt/formatter.py +++ b/agent_actions/prompt/formatter.py @@ -21,18 +21,21 @@ def get_raw_prompt(agent_config): Raw prompt string Raises: - ConfigValidationError: If prompt is an empty/whitespace string for non-tool/hitl/seed actions + ConfigValidationError: If prompt is empty/whitespace or null for non-tool/hitl/seed/source actions PromptValidationError: If prompt retrieval or loading fails """ raw_prompt = agent_config.get(PROMPT_KEY) - if ( - agent_config.get("kind") not in ("tool", "hitl", "seed", "source") - and isinstance(raw_prompt, str) - and not raw_prompt.strip() - ): - raise ConfigValidationError( - f"prompt cannot be an empty string for action '{agent_config.get('agent_type', 'unknown')}'" - ) + if agent_config.get("kind") not in ("tool", "hitl", "seed", "source"): + if isinstance(raw_prompt, str) and not raw_prompt.strip(): + raise ConfigValidationError( + f"prompt cannot be an empty string for action " + f"'{agent_config.get('agent_type', 'unknown')}'" + ) + if PROMPT_KEY in agent_config and raw_prompt is None: + raise ConfigValidationError( + f"prompt is null for action '{agent_config.get('agent_type', 'unknown')}'. " + f"Remove the prompt key or provide a valid prompt string." + ) try: if PROMPT_KEY not in agent_config: return "Process the following content: {content}" diff --git a/agent_actions/prompt/message_builder.py b/agent_actions/prompt/message_builder.py index da162af8..b37c857a 100644 --- a/agent_actions/prompt/message_builder.py +++ b/agent_actions/prompt/message_builder.py @@ -334,21 +334,21 @@ def build( for m in messages ] - # Token overflow pre-flight guard - if model_name is not None: - estimated_tokens = sum(len(m.content) for m in messages) // 4 - model_limit = _MODEL_CONTEXT_LIMITS.get(model_name, _DEFAULT_CONTEXT_LIMIT) - if estimated_tokens > model_limit: - raise PromptTooLargeError( - f"Estimated prompt size ({estimated_tokens} tokens) exceeds " - f"model context window ({model_limit} tokens)", - context={ - "estimated_tokens": estimated_tokens, - "model_limit": model_limit, - "provider": provider, - "model_name": model_name, - }, - ) + # Token overflow pre-flight guard — always runs. + # When model_name is unknown, falls back to _DEFAULT_CONTEXT_LIMIT. + estimated_tokens = sum(len(m.content) for m in messages) // 4 + model_limit = _MODEL_CONTEXT_LIMITS.get(model_name, _DEFAULT_CONTEXT_LIMIT) + if estimated_tokens > model_limit: + raise PromptTooLargeError( + f"Estimated prompt size ({estimated_tokens} tokens) exceeds " + f"model context window ({model_limit} tokens)", + context={ + "estimated_tokens": estimated_tokens, + "model_limit": model_limit, + "provider": provider, + "model_name": model_name, + }, + ) return LLMMessageEnvelope( messages=messages, diff --git a/agent_actions/prompt/prompt_utils.py b/agent_actions/prompt/prompt_utils.py index f9744c8c..8760df59 100644 --- a/agent_actions/prompt/prompt_utils.py +++ b/agent_actions/prompt/prompt_utils.py @@ -1,6 +1,5 @@ """Module for String Processing Functions""" -import json import re from typing import Any @@ -135,100 +134,3 @@ def inject_function_outputs_into_prompt( else: processed_prompt = prompt_config return (processed_prompt, captured_results) - - @staticmethod - def parse_field_references(prompt: str) -> list: - """ - Parse {reference.field} patterns from prompt. - - Pattern matches: - - {source.field} - - {agent.field} - - {agent.nested.field} - - {agent.items.0} (array index) - - Args: - prompt: Prompt string with field references - - Returns: - List of dicts with 'reference', 'field_path', and 'full_match' - """ - pattern = r"\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)+)\}" - references = [] - for match in re.finditer(pattern, prompt): - full_ref = match.group(1) - parts = full_ref.split(".") - references.append( - { - "reference": parts[0], - "field_path": parts[1:], - "full_match": match.group(0), - "start": match.start(), - "end": match.end(), - } - ) - return references - - @staticmethod - def resolve_field_reference(reference: str, field_path: list, context: dict): - """ - Resolve a field reference to its value in the context. - - Args: - reference: Reference name (e.g., 'source', 'extractor') - field_path: List of field names (e.g., ['metrics', 'count']) - context: Dict with available references - - Returns: - Resolved value - - Raises: - ValueError: If reference or field not found - """ - if reference not in context: - available = ", ".join(context.keys()) - raise ValueError(f"Reference '{reference}' not found. Available: [{available}]") - data = context[reference] - for field in field_path: - if isinstance(data, dict) and field in data: - data = data[field] - elif isinstance(data, list) and field.isdigit(): - idx = int(field) - if 0 <= idx < len(data): - data = data[idx] - else: - raise ValueError(f"Index {idx} out of range for array in '{reference}'") - else: - field_str = ".".join(field_path) - raise ValueError(f"Field '{field_str}' not found in '{reference}'") - return data - - @staticmethod - def replace_field_references(prompt: str, context: dict) -> str: - """ - Replace all {reference.field} patterns with their values. - - Args: - prompt: Prompt string with field references - context: Dict with available references - - Returns: - Prompt with all references replaced - - Raises: - ValueError: If reference or field not found - """ - references = PromptUtils.parse_field_references(prompt) - for ref in reversed(references): - try: - value = PromptUtils.resolve_field_reference( - ref["reference"], ref["field_path"], context - ) - if isinstance(value, dict | list): - value_str = json.dumps(value, indent=2) - else: - value_str = str(value) - prompt = prompt[: ref["start"]] + value_str + prompt[ref["end"] :] - except ValueError as e: - raise ValueError(f"Error resolving {ref['full_match']}: {str(e)}") from e - return prompt diff --git a/agent_actions/prompt/service.py b/agent_actions/prompt/service.py index ef71d680..e5027384 100644 --- a/agent_actions/prompt/service.py +++ b/agent_actions/prompt/service.py @@ -364,11 +364,19 @@ def _render_prompt_template( ue, ) prompt_context[action] = _PermissiveNamespace() - # Re-render with all known skipped deps injected for other in skipped_actions: if other not in prompt_context: prompt_context[other] = _PermissiveNamespace() - formatted_prompt = template.render(**prompt_context) + try: + formatted_prompt = template.render(**prompt_context) + except UndefinedError as ue2: + raise TemplateVariableError( + missing_variables=[str(ue2)], + available_variables=list(prompt_context.keys()), + agent_name=agent_name or "", + mode=mode or "", + cause=ue2, + ) from ue2 break else: raise diff --git a/tests/agents/transformers/test_prompt_field_refs.py b/tests/agents/transformers/test_prompt_field_refs.py deleted file mode 100644 index 14b03d07..00000000 --- a/tests/agents/transformers/test_prompt_field_refs.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Tests for field reference pattern {reference.field} in PromptUtils.""" - -import pytest - -from agent_actions.prompt.prompt_utils import PromptUtils - - -class TestParseFieldReferences: - """Test parsing {reference.field} patterns from prompts.""" - - def test_ignore_single_word_braces(self): - """Should not match single word in braces (no dot).""" - prompt = "Process {content}" - refs = PromptUtils.parse_field_references(prompt) - assert len(refs) == 0 - - def test_ignore_double_braces(self): - """Should not match old source_context{{}} pattern.""" - prompt = "source_context{{['field']}}" - refs = PromptUtils.parse_field_references(prompt) - assert len(refs) == 0 - - -class TestResolveFieldReference: - """Test resolving field references to actual values.""" - - def test_resolve_simple_field(self): - """Should resolve simple field from context.""" - context = {"source": {"content": "hello world"}} - value = PromptUtils.resolve_field_reference("source", ["content"], context) - assert value == "hello world" - - def test_resolve_nested_field(self): - """Should resolve nested field from context.""" - context = {"extractor": {"data": {"metrics": {"count": 5}}}} - value = PromptUtils.resolve_field_reference( - "extractor", ["data", "metrics", "count"], context - ) - assert value == 5 - - def test_resolve_array_index(self): - """Should resolve array index from context.""" - context = {"extractor": {"items": ["a", "b", "c"]}} - value = PromptUtils.resolve_field_reference("extractor", ["items", "1"], context) - assert value == "b" - - def test_resolve_first_array_element(self): - """Should resolve first array element (index 0).""" - context = {"extractor": {"items": ["first", "second"]}} - value = PromptUtils.resolve_field_reference("extractor", ["items", "0"], context) - assert value == "first" - - def test_missing_reference_error(self): - """Should raise error for missing reference with available list.""" - context = {"source": {}} - with pytest.raises(ValueError) as exc_info: - PromptUtils.resolve_field_reference("extractor", ["field"], context) - assert "Reference 'extractor' not found" in str(exc_info.value) - assert "Available: [source]" in str(exc_info.value) - - def test_missing_field_error(self): - """Should raise error for missing field.""" - context = {"extractor": {"summary": "text"}} - with pytest.raises(ValueError) as exc_info: - PromptUtils.resolve_field_reference("extractor", ["metrics"], context) - assert "Field 'metrics' not found in 'extractor'" in str(exc_info.value) - - def test_missing_nested_field_error(self): - """Should raise error for missing nested field.""" - context = {"extractor": {"data": {}}} - with pytest.raises(ValueError) as exc_info: - PromptUtils.resolve_field_reference("extractor", ["data", "metrics", "count"], context) - assert "Field 'data.metrics.count' not found" in str(exc_info.value) - - def test_array_index_out_of_range(self): - """Should raise error for array index out of range.""" - context = {"extractor": {"items": ["a", "b"]}} - with pytest.raises(ValueError) as exc_info: - PromptUtils.resolve_field_reference("extractor", ["items", "5"], context) - assert "Index 5 out of range" in str(exc_info.value) - - -class TestReplaceFieldReferences: - """Test replacing field references in prompts.""" - - def test_no_references_returns_unchanged(self): - """Should return prompt unchanged if no references.""" - prompt = "This is a plain prompt" - context = {"source": {"data": "value"}} - result = PromptUtils.replace_field_references(prompt, context) - assert result == prompt diff --git a/tests/preprocessing/context/test_special_namespaces.py b/tests/preprocessing/context/test_special_namespaces.py index 70e929ed..b80fda94 100644 --- a/tests/preprocessing/context/test_special_namespaces.py +++ b/tests/preprocessing/context/test_special_namespaces.py @@ -9,7 +9,6 @@ from agent_actions.errors import ConfigurationError from agent_actions.prompt.context.scope_inference import infer_dependencies -from agent_actions.prompt.context.scope_namespace import _enrich_source_namespace class TestSpecialNamespaceValidationBypass: @@ -73,70 +72,3 @@ def test_unknown_namespace_still_raises_error(self): assert "unknown_action" in str(exc_info.value) assert "not found in workflow" in str(exc_info.value) - - -class TestEnrichSourceNamespace: - """Test _enrich_source_namespace() fallback logic.""" - - def test_enrich_source_namespace_no_current_item(self): - """Test with no current item returns base namespace unchanged.""" - base_namespace = {"existing": "value"} - current_item = None - - result = _enrich_source_namespace(base_namespace, current_item) - - assert result == {"existing": "value"} - - def test_enrich_source_namespace_empty_current_item(self): - """Test with empty current item returns base namespace unchanged.""" - base_namespace = {"existing": "value"} - current_item = {} - - result = _enrich_source_namespace(base_namespace, current_item) - - assert result == {"existing": "value"} - - def test_enrich_source_namespace_adds_missing_fields(self): - """Test that fallback fields are added from current item.""" - base_namespace = {"source_guid": "guid-123"} - current_item = { - "content": {"page_content": "Full text here", "title": "My Title"}, - "source_guid": "guid-123", - } - - result = _enrich_source_namespace(base_namespace, current_item) - - assert result["source_guid"] == "guid-123" # Original preserved - assert result["page_content"] == "Full text here" # Added from current - assert result["title"] == "My Title" # Added from current - - def test_enrich_source_namespace_does_not_overwrite_existing(self): - """Test that existing fields in base namespace are NOT overwritten.""" - base_namespace = {"page_content": "Original content", "source_guid": "guid-123"} - current_item = {"content": {"page_content": "Different content", "extra": "value"}} - - result = _enrich_source_namespace(base_namespace, current_item) - - assert result["page_content"] == "Original content" # NOT overwritten - assert result["extra"] == "value" # Added - assert result["source_guid"] == "guid-123" # Preserved - - def test_enrich_source_namespace_with_flat_structure(self): - """Test with flat current item structure (no 'content' key).""" - base_namespace = {} - current_item = {"page_content": "Text", "title": "Title", "id": "123"} - - result = _enrich_source_namespace(base_namespace, current_item) - - assert result["page_content"] == "Text" - assert result["title"] == "Title" - assert result["id"] == "123" - - def test_enrich_source_namespace_handles_none_base(self): - """Test with None base namespace (should create new dict).""" - base_namespace = None - current_item = {"content": {"field": "value"}} - - result = _enrich_source_namespace(base_namespace, current_item) - - assert result == {"field": "value"} diff --git a/tests/unit/prompt/context/test_scope_application.py b/tests/unit/prompt/context/test_scope_application.py index a96ec693..67665816 100644 --- a/tests/unit/prompt/context/test_scope_application.py +++ b/tests/unit/prompt/context/test_scope_application.py @@ -647,3 +647,34 @@ def test_framework_namespaces_always_available(self): def test_empty_field_context(self): pc, lc, pt = apply_context_scope({}, {}) assert pc == {} and lc == {} and pt == {} + + +class TestSeedNamespaceCollision: + def test_seed_collision_raises_configuration_error(self): + field_context = {"seed": {"user_data": "value"}, "dep": {"x": 1}} + static_data = {"reference": "data"} + with pytest.raises(ConfigurationError, match="Namespace collision"): + apply_context_scope( + field_context, + {"observe": ["dep.x"]}, + static_data=static_data, + action_name="test_action", + ) + + def test_seed_no_collision_when_no_static_data(self): + field_context = {"seed": {"user_data": "value"}, "dep": {"x": 1}} + pc, _, _ = apply_context_scope( + field_context, {"observe": ["dep.x"]}, action_name="test_action" + ) + assert "dep" in pc + + def test_seed_injected_when_no_collision(self): + field_context = {"dep": {"x": 1}} + static_data = {"reference": "data"} + pc, _, _ = apply_context_scope( + field_context, + {"observe": ["dep.x"]}, + static_data=static_data, + action_name="test_action", + ) + assert pc["seed"] == {"reference": "data"} diff --git a/tests/unit/prompt/test_formatter.py b/tests/unit/prompt/test_formatter.py index 3c92884b..73d78a09 100644 --- a/tests/unit/prompt/test_formatter.py +++ b/tests/unit/prompt/test_formatter.py @@ -27,8 +27,16 @@ def test_newline_only_raises_config_validation_error(self): with pytest.raises(ConfigValidationError, match="empty string"): PromptFormatter.get_raw_prompt({"prompt": "\n"}) - def test_none_prompt_returns_default(self): - result = PromptFormatter.get_raw_prompt({"prompt": None}) + def test_none_prompt_raises_config_validation_error(self): + with pytest.raises(ConfigValidationError, match="prompt is null"): + PromptFormatter.get_raw_prompt({"prompt": None}) + + def test_tool_kind_none_prompt_returns_default(self): + result = PromptFormatter.get_raw_prompt({"kind": "tool", "prompt": None}) + assert result == "Process the following content: {content}" + + def test_seed_kind_none_prompt_returns_default(self): + result = PromptFormatter.get_raw_prompt({"kind": "seed", "prompt": None}) assert result == "Process the following content: {content}" def test_missing_prompt_key_returns_default(self): diff --git a/tests/unit/prompt/test_message_builder_token_guard.py b/tests/unit/prompt/test_message_builder_token_guard.py index 27dbb145..33306a1c 100644 --- a/tests/unit/prompt/test_message_builder_token_guard.py +++ b/tests/unit/prompt/test_message_builder_token_guard.py @@ -40,18 +40,28 @@ def test_small_prompt_passes_guard(self): ) assert len(envelope.messages) > 0 - def test_no_model_name_skips_guard(self): - """When model_name is None (default), no token check runs.""" - giant_prompt = "x" * 500_000 - # Should not raise — guard is skipped when model_name is None + def test_no_model_name_uses_default_limit(self): + """When model_name is None, guard runs with default 128K limit.""" + prompt = "x" * 500_000 # ~125K tokens, under 128K default envelope = MessageBuilder.build( "openai", - giant_prompt, + prompt, "context", json_mode=True, ) assert len(envelope.messages) > 0 + def test_no_model_name_rejects_massive_prompt(self): + """When model_name is None, prompts exceeding 128K default are rejected.""" + prompt = "x" * 600_000 # ~150K tokens, over 128K default + with pytest.raises(PromptTooLargeError): + MessageBuilder.build( + "openai", + prompt, + "context", + json_mode=True, + ) + def test_unknown_model_uses_default_limit(self): """Unknown model names use _DEFAULT_CONTEXT_LIMIT (128K).""" # 128K * 4 = 512K chars needed to exceed default limit From 41f439c710b8f8464122122091b67fe80c35dbba Mon Sep 17 00:00:00 2001 From: Muizz Lateef Date: Fri, 19 Jun 2026 01:09:45 +0100 Subject: [PATCH 2/3] fix: resolve mypy arg-type error in token guard dict.get() requires str key but model_name is str | None. Use conditional expression to handle None explicitly. --- agent_actions/prompt/message_builder.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/agent_actions/prompt/message_builder.py b/agent_actions/prompt/message_builder.py index b37c857a..662afc4a 100644 --- a/agent_actions/prompt/message_builder.py +++ b/agent_actions/prompt/message_builder.py @@ -337,7 +337,11 @@ def build( # Token overflow pre-flight guard — always runs. # When model_name is unknown, falls back to _DEFAULT_CONTEXT_LIMIT. estimated_tokens = sum(len(m.content) for m in messages) // 4 - model_limit = _MODEL_CONTEXT_LIMITS.get(model_name, _DEFAULT_CONTEXT_LIMIT) + model_limit = ( + _MODEL_CONTEXT_LIMITS.get(model_name, _DEFAULT_CONTEXT_LIMIT) + if model_name is not None + else _DEFAULT_CONTEXT_LIMIT + ) if estimated_tokens > model_limit: raise PromptTooLargeError( f"Estimated prompt size ({estimated_tokens} tokens) exceeds " From e58a2ba38e7a4bb24cee6318ee6f88f344426333 Mon Sep 17 00:00:00 2001 From: Muizz Lateef Date: Fri, 19 Jun 2026 17:16:12 +0100 Subject: [PATCH 3/3] fix: remove double-wrapping bug in re-render error path, simplify token guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service.py: Remove try/except around re-render — inner TemplateVariableError was caught by outer except Exception handler, double-wrapping the error. UndefinedError now propagates to the rich diagnostics handler directly. - message_builder.py: Collapse 4-line ternary to single line (dict.get handles None key correctly, ternary only needed for mypy). - scope_application.py: Shorten 4-line comment to 1-line. --- agent_actions/prompt/context/scope_application.py | 5 +---- agent_actions/prompt/service.py | 11 +---------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/agent_actions/prompt/context/scope_application.py b/agent_actions/prompt/context/scope_application.py index f261efe9..00a2f9a8 100644 --- a/agent_actions/prompt/context/scope_application.py +++ b/agent_actions/prompt/context/scope_application.py @@ -144,10 +144,7 @@ def apply_context_scope( logger.debug("[STATIC_DATA] Merging %s static data fields into context", len(static_data)) logger.debug("[STATIC_DATA] Fields: %s", list(static_data.keys())) - # Defense-in-depth: DependencyNamespaceBuilder already skips SPECIAL_NAMESPACES, - # but seed is the only framework namespace injected from an external source - # (static files via seed_path), so guard against edge cases where "seed" leaks - # into prompt_context through a non-standard path. + # 'seed' is reserved for static data injection (SPECIAL_NAMESPACES blocks it upstream too). if "seed" in prompt_context: raise ConfigurationError( "Namespace collision: action named 'seed' conflicts with the seed data namespace. " diff --git a/agent_actions/prompt/service.py b/agent_actions/prompt/service.py index e5027384..5c390c3e 100644 --- a/agent_actions/prompt/service.py +++ b/agent_actions/prompt/service.py @@ -367,16 +367,7 @@ def _render_prompt_template( for other in skipped_actions: if other not in prompt_context: prompt_context[other] = _PermissiveNamespace() - try: - formatted_prompt = template.render(**prompt_context) - except UndefinedError as ue2: - raise TemplateVariableError( - missing_variables=[str(ue2)], - available_variables=list(prompt_context.keys()), - agent_name=agent_name or "", - mode=mode or "", - cause=ue2, - ) from ue2 + formatted_prompt = template.render(**prompt_context) break else: raise