From 3aa0b36a6c695c86a69eafaaa898258b568854f5 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Tue, 7 Apr 2026 16:49:26 +0200 Subject: [PATCH] fix(langfuse): use compile() instead of .format() for prompt variable injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Langfuse uses {{variable}} syntax in its prompt management UI, while the hardcoded fallbacks use {variable} (Python str.format). The previous code always called .format() which silently failed/errored when a real Langfuse prompt was fetched. - langfuse_client.py: add compile_prompt(template, prompt_obj, **vars) → uses prompt_obj.compile(**vars) when Langfuse is available → falls back to template.format(**vars) when using the hardcoded fallback - agent_runner.py: replace .format() with compile_prompt() for unified_processing (V2 local) and batch_cloud_processing (cloud agent) - agent_setup.py: replace .format() with compile_prompt() for journey_system deep_agent.py prompts have no variables, so no change needed there. Co-Authored-By: Claude Sonnet 4.6 --- app/api/routes/agent_setup.py | 6 ++++-- app/core/agent_runner.py | 10 ++++++--- app/core/langfuse_client.py | 39 ++++++++++++++++++++++++++++++++--- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/app/api/routes/agent_setup.py b/app/api/routes/agent_setup.py index 1314e05..2efe891 100644 --- a/app/api/routes/agent_setup.py +++ b/app/api/routes/agent_setup.py @@ -32,7 +32,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, Tool from app.agents.filesystem_agent import FILESYSTEM_TOOLS from app.config.settings import settings -from app.core.langfuse_client import extract_usage, get_langfuse, get_prompt_or_fallback +from app.core.langfuse_client import compile_prompt, extract_usage, get_langfuse, get_prompt_or_fallback from app.core.llm import get_llm logger = logging.getLogger(__name__) @@ -160,7 +160,9 @@ def _build_system_prompt( template, prompt_obj = get_prompt_or_fallback( "journey_system", _JOURNEY_SYSTEM_PROMPT ) - compiled = template.format( + compiled = compile_prompt( + template, + prompt_obj, directory=directory, data_types=", ".join(data_types), template_start=_TEMPLATE_START, diff --git a/app/core/agent_runner.py b/app/core/agent_runner.py index 4adf1cb..f1d3e76 100644 --- a/app/core/agent_runner.py +++ b/app/core/agent_runner.py @@ -45,7 +45,7 @@ from app.agents.task_agent import TASK_TOOLS from app.agents.timeline_agent import TIMELINE_TOOLS from app.config.settings import settings from app.core.device_manager import DeviceConnectionManager -from app.core.langfuse_client import extract_usage, get_langfuse, get_prompt_or_fallback +from app.core.langfuse_client import compile_prompt, extract_usage, get_langfuse, get_prompt_or_fallback from app.core.llm import get_llm from app.core.preprocessors import detect_content_type, preprocess from app.core.ws_context import clear_client_executor, execute_on_client, set_client_executor @@ -661,7 +661,9 @@ async def run_local_agent( ) metadata_section = _format_metadata(preprocessed.metadata) - system_prompt = unified_template.format( + system_prompt = compile_prompt( + unified_template, + prompt_obj, filename=filename, metadata_section=metadata_section, projects_list=projects_block, @@ -893,7 +895,9 @@ async def run_cloud_agent( cloud_template, cloud_prompt_obj = get_prompt_or_fallback( "batch_cloud_processing", _BATCH_CLOUD_PROCESSING_PROMPT ) - processing_prompt = cloud_template.format( + processing_prompt = compile_prompt( + cloud_template, + cloud_prompt_obj, data_types=", ".join(config.data_types), project_context="Determine the appropriate project from the message context.", file_list=f"Message from {config.provider} (id: {msg.id})", diff --git a/app/core/langfuse_client.py b/app/core/langfuse_client.py index 745f649..1a92827 100644 --- a/app/core/langfuse_client.py +++ b/app/core/langfuse_client.py @@ -80,10 +80,11 @@ def get_langfuse() -> Any | None: def get_prompt_or_fallback(name: str, fallback: str) -> tuple[str, Any]: """Fetch a text prompt from Langfuse; fall back to ``fallback`` on any error. - Returns ``(prompt_text, prompt_obj_or_None)``. + Returns ``(raw_template, prompt_obj_or_None)``. - * ``prompt_text`` — the raw template string (variables not yet substituted). - Callers perform variable substitution with Python's ``.format()``. + * ``raw_template`` — the uncompiled template string. Do NOT call ``.format()`` + on it directly; use :func:`compile_prompt` instead so the correct variable + syntax is applied (``{{var}}`` for Langfuse, ``{var}`` for the fallback). * ``prompt_obj`` — the Langfuse prompt object, or ``None`` when Langfuse is unavailable / the fetch failed. Pass this to generation observations so Langfuse links the generation to the exact prompt version in the UI. @@ -102,6 +103,38 @@ def get_prompt_or_fallback(name: str, fallback: str) -> tuple[str, Any]: return fallback, None +def compile_prompt(template: str, prompt_obj: Any, **variables: Any) -> str: + """Compile *template* with *variables*, choosing the right syntax. + + * When *prompt_obj* is a real Langfuse prompt object, calls + ``prompt_obj.compile(**variables)`` which handles ``{{variable}}`` + substitution as defined in the Langfuse UI. + * When *prompt_obj* is ``None`` (Langfuse unavailable or fetch failed), + falls back to ``template.format(**variables)`` which handles the + ``{variable}`` syntax used in the hardcoded fallback strings. + + This keeps callers oblivious to which syntax is in use. + """ + if prompt_obj is not None: + try: + compiled = prompt_obj.compile(**variables) + # compile() returns a string for text prompts. + if isinstance(compiled, str): + return compiled + # Chat prompts return a list of dicts — join text parts. + if isinstance(compiled, list): + return "\n".join( + m.get("content", "") for m in compiled if isinstance(m, dict) + ) + except Exception as exc: + logger.warning( + "langfuse: compile failed for prompt %r: %s — falling back to .format()", + getattr(prompt_obj, "name", "?"), + exc, + ) + return template.format(**variables) + + def extract_usage(response: Any) -> dict[str, int]: """Extract token usage from a LangChain AI message into Langfuse format.""" meta = getattr(response, "usage_metadata", None)