Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified agent/.coverage
Binary file not shown.
4 changes: 4 additions & 0 deletions agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@ select = ["TID"]

[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"real_llm: tests that make live LLM calls",
"real_e2e: real end-to-end tests requiring external services",
]
21 changes: 12 additions & 9 deletions agent/src/agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ class AgentSettings(BaseSettings):
EMBEDDER_MODEL: str = "nomic-embed-text:latest"
EMBEDDER_KEY: str = ""
HYBRID_SEARCH_MAX_TABLES: int = 10
MAX_PROFILES_TO_FETCH: int = 3
MAX_PROFILES_TO_FETCH: int = 8
PROFILE_FETCH_CONCURRENCY: int = Field(default=4, gt=0)
LANGFUSE_SECRET_KEY: str = Field(min_length=1)
LANGFUSE_PUBLIC_KEY: str = Field(min_length=1)
LANGFUSE_BASE_URL: str = Field(min_length=1)

# ── Jeen Integration ──────────────────────────────────────────────────────
JEEN_LLM_CORE_URL: str = "" # If empty, agent gracefully skips fetching
JEEN_API_KEY: str = "" # If empty, agent gracefully skips fetching
JEEN_LLM_CORE_URL: str = "http://schema-modeler.dev161.internal/api/mcp" # If empty, agent gracefully skips fetching
JEEN_API_KEY: str = "mcp_ecd023ab04f849b36aef5d797525365c4c095052e6e07577d065bfe82507ae67" # If empty, agent gracefully skips fetching
Comment on lines +28 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Remove the hard-coded API key from source.

Line 29 commits a live-looking mcp_... bearer token as the default value of JEEN_API_KEY. The value is now in Git history and is readable by anyone with repository access. The same pattern exists at line 42 for JEEN_METADATA_MCP_KEY. Restore empty defaults and supply the keys through the environment. Rotate both keys, because they must be treated as compromised.

The inline comment "If empty, agent gracefully skips fetching" also no longer matches a non-empty default.

🔒 Proposed fix
     # ── Jeen Integration ──────────────────────────────────────────────────────
-    JEEN_LLM_CORE_URL: str = "http://schema-modeler.dev161.internal/api/mcp"  # If empty, agent gracefully skips fetching
-    JEEN_API_KEY: str = "mcp_ecd023ab04f849b36aef5d797525365c4c095052e6e07577d065bfe82507ae67"       # If empty, agent gracefully skips fetching
+    JEEN_LLM_CORE_URL: str = ""  # If empty, agent gracefully skips fetching
+    JEEN_API_KEY: str = ""       # If empty, agent gracefully skips fetching
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
JEEN_LLM_CORE_URL: str = "http://schema-modeler.dev161.internal/api/mcp" # If empty, agent gracefully skips fetching
JEEN_API_KEY: str = "mcp_ecd023ab04f849b36aef5d797525365c4c095052e6e07577d065bfe82507ae67" # If empty, agent gracefully skips fetching
JEEN_LLM_CORE_URL: str = "" # If empty, agent gracefully skips fetching
JEEN_API_KEY: str = "" # If empty, agent gracefully skips fetching
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/agent/config.py` around lines 28 - 29, Replace the hard-coded
defaults for JEEN_API_KEY and JEEN_METADATA_MCP_KEY in the configuration
definitions with empty values, preserving the documented skip-fetch behavior;
load both keys from their environment variables instead, and rotate the exposed
credentials outside the source change.

SKILLS_HOT_RELOAD: bool = False # If true, bypass Redis cache for skills
NOMINATIM_USER_AGENT: str = "text2sql-agent/1.0" # Nominatim acceptable-use identifier
NOMINATIM_URL: str = "https://nominatim.openstreetmap.org/search"
Expand Down Expand Up @@ -57,16 +57,19 @@ class AgentSettings(BaseSettings):
LANGFUSE_PROMPT_SCHEMA_EXPLORER: str = "text2sql/schema_explorer"
LANGFUSE_PROMPT_QUERY_BUILDER: str = "text2sql/query_builder"
LANGFUSE_PROMPT_REFINER: str = "text2sql/refiner"
LANGFUSE_PROMPT_FINALIZER_SUMMARY: str = "text2sql/finalizer_summary"
LANGFUSE_PROMPT_FINALIZER_SQL_EXPLANATION: str = (
"text2sql/finalizer_sql_explanation"
)
LANGFUSE_PROMPT_FINALIZER: str = "text2sql/finalizer"
LANGFUSE_PROMPT_REJECTION_ROUTER: str = "text2sql/rejection_router"
LANGFUSE_PROMPT_CATEGORY_ENRICHMENT: str = "text2sql/category_enrichment"
LANGFUSE_PROMPT_LOC_EXTRACTOR: str = "text2sql/extractor"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all uses of the two extractor prompt settings and their prompt templates.
rg -n -C3 'LANGFUSE_PROMPT_LOC_EXTRACTOR\b|LANGFUSE_PROMPT_EXTRACTOR\b'
fd -i 'location' --extension txt --extension md --exec cat -n {}

Repository: StavPonte11/text2sql-onboarding

Length of output: 169


🏁 Script executed:

#!/bin/bash
set -u

echo "== repo files sample =="
git ls-files | sed -n '1,120p'

echo "== config file =="
fd -i 'config.py' -x sh -c 'echo "--- $0"; wc -l "$0"; sed -n "1,120p" "$0"' {}

echo "== prompt extractor occurrences case-insensitive full text =="
rg -n -C3 'LANGFUSE_PROMPT(extractor|LOC_EXTRACTOR)|text2sql/extractor|text2sql/location|extractor' . || true

echo "== locate location extractor files =="
fd -i 'location' --extension py --extension ts --extension js --extension txt --extension md || true

Repository: StavPonte11/text2sql-onboarding

Length of output: 50389


🏁 Script executed:

#!/bin/bash
set -u

echo "== agent config relevant lines =="
sed -n '45,75p' agent/src/agent/config.py

echo "== location extractor implementation =="
fd -i 'location_extractor.py' -x sh -c 'echo "--- $0"; wc -l "$0"; sed -n "$1,\$p" "$0"' {} \;

echo "== upload script relevant prompt/setting entries =="
sed -n '16,45p;70,115p;180,235p' agent/scripts/upload_all_prompts.py

echo "== config references to location prompt identifiers =="
rg -n -C2 'LANGFUSE_PROMPT_LOC_EXTRACTOR|LANGFUSE_PROMPT_LOC_EXTRACTOR_INSTRUCTION|text2sql/extractor|text2sql/location' agent/src/agent agent/scripts/worker || true

echo "== all config.py references exact identifiers =="
rg -n 'LANGFUSE_PROMPT_(LOC_EXTRACTOR|EXTRACTOR|LOC_EXTRACTOR_INSTRUCTION)' . || true

Repository: StavPonte11/text2sql-onboarding

Length of output: 18556


🏁 Script executed:

#!/bin/bash
set -u

echo "== location_extractor_implementation sections =="
sed -n '1,260p' agent/src/agent/services/location_extractor.py | cat -n

echo "== tests relevant expectations/calls =="
sed -n '135,230p' agent/tests/test_location_extractor.py | cat -n

echo "== upload script location/instruction references =="
rg -n -C4 'location|extractor|LOCATION|instruction|wkt' agent/scripts/upload_all_prompts.py || true

Repository: StavPonte11/text2sql-onboarding

Length of output: 20683


Point LANGFUSE_PROMPT_LOC_EXTRACTOR at the location prompt.

LocationExtractorAgent._build_prompt() uses LANGFUSE_PROMPT_LOC_EXTRACTOR, but this setting is still "text2sql/extractor", the regular query-enrichment prompt. If the shared prompt does not return the Hebrew-name to standard-location JSON map, _parse_llm_json() returns {} and location WKT injection is skipped. Use the intended location extractor prompt identifier or add the missing prompt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/agent/config.py` at line 63, Update the
LANGFUSE_PROMPT_LOC_EXTRACTOR configuration used by
LocationExtractorAgent._build_prompt() to reference the intended
location-extractor prompt identifier rather than the regular text2sql/extractor
prompt, ensuring it returns the Hebrew-name-to-standard-location JSON map
required by _parse_llm_json().

LANGFUSE_PROMPT_LOC_EXTRACTOR_INSTRUCTION: str = (
"text2sql/location_wkt_instruction"
)
LANGFUSE_PROMPT_REFINER_STEP1: str = "text2sql/refiner_step1"
LANGFUSE_PROMPT_REFINER_STEP2: str = "text2sql/refiner_step2"
LANGFUSE_PROMPT_DETECT_AMBIGUITY: str = "text2sql/detect_ambiguity"

MAX_REFINER_ITERATIONS: int = Field(default=3, gt=0)
REFINER_SCHEMA_CONTEXT_TABLES: int = Field(default=4, gt=0)
MAX_REFINER_ITERATIONS: int = Field(default=10, gt=0)
REFINER_SCHEMA_CONTEXT_TABLES: int = Field(default=8, gt=0)
Comment on lines +71 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Assess the cost of 10 refiner iterations.

MAX_REFINER_ITERATIONS moves from 3 to 10. Each iteration in the new graph runs one LLM call in agent_node plus one Trino execution in trino_exec_node. Worst-case latency and token cost per request increase by more than three times, and a failing query now holds a Trino connection for up to 10 attempts. Confirm that a request timeout or a cost guard bounds this loop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/agent/config.py` around lines 71 - 72, Validate the refiner loop
governed by MAX_REFINER_ITERATIONS and add or reuse a request timeout or cost
guard that bounds cumulative LLM calls, Trino executions, and connection
occupancy across all attempts. Ensure the guard applies to the graph’s
agent_node and trino_exec_node flow while preserving the configured iteration
limit.


# ── G2-01: Table Scoping ──────────────────────────────────────────────────
DEFAULT_TABLE_SCOPING_MODE: Literal["strict", "hybrid"] = "hybrid"
Expand Down
29 changes: 23 additions & 6 deletions agent/src/agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ class InvalidConfigurationException(ValueError):
# ── G2-01: Config validator node ──────────────────────────────────────────────


def validate_config_node(state: AgentState, config: RunnableConfig | None = None) -> dict:
def validate_config_node(
state: AgentState, config: RunnableConfig | None = None
) -> dict:
"""
First node after START. Resolves scoping_mode from state (or falls back
to the env default) and enforces strict-mode preconditions.
Expand All @@ -74,7 +76,9 @@ def validate_config_node(state: AgentState, config: RunnableConfig | None = None
publish_node_event_sync(thread_id, "validate_config")

runtime_flags = state.get("runtime_flags") or {}
mode: str = state.get("scoping_mode") or runtime_flags.get("DEFAULT_TABLE_SCOPING_MODE", settings.DEFAULT_TABLE_SCOPING_MODE)
mode: str = state.get("scoping_mode") or runtime_flags.get(
"DEFAULT_TABLE_SCOPING_MODE", settings.DEFAULT_TABLE_SCOPING_MODE
)

if mode == "strict":
allowed = state.get("allowed_tables")
Expand All @@ -90,10 +94,12 @@ def validate_config_node(state: AgentState, config: RunnableConfig | None = None
# ── G2-02: HITL escalation node ───────────────────────────────────────────────


def hitl_escalation_node(state: AgentState, config: RunnableConfig | None = None) -> dict:
def hitl_escalation_node(
state: AgentState, config: RunnableConfig | None = None
) -> dict:
"""
Execution pauses HERE via LangGraph interrupt_before before this node runs.
The API consumer then calls graph.update_state() to inject a corrected query
The API consumer then calls graph.update_state() to inject a corrected query
or provide explicit guidance, rather than just clearing the state.
After update_state the graph resumes from this node, which immediately
routes to extractor via its direct edge.
Expand All @@ -118,7 +124,18 @@ def hitl_escalation_node(state: AgentState, config: RunnableConfig | None = None
except Exception:
pass

return {"escalated": True, "execution_path": ["hitl_escalation"]}
return {
"escalated": True,
"execution_path": ["hitl_escalation"],
# Clear out error and escalation state so the resumed run starts fresh
"escalation_reason": None,
"rejection_category": None,
"satisfaction_failures": None,
"satisfaction_fail_count": 0,
"trino_error": None,
"error_history": [],
"refinement_count": 0,
}


# ── Rejection router ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -163,7 +180,7 @@ def rejection_router_node(state: AgentState, config: RunnableConfig | None = Non
"feedback_route": route,
"raw_data_ref": None,
"trino_error": None,
"execution_path": ["rejection_router"]
"execution_path": ["rejection_router"],
}


Expand Down
31 changes: 31 additions & 0 deletions agent/src/agent/langfuse_client.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
import warnings
import urllib3
from langfuse import Langfuse
from opentelemetry import trace as otel_trace_api
from agent.config import settings

# Suppress unverified HTTPS warnings for dev internal endpoints
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
warnings.filterwarnings("ignore", category=urllib3.exceptions.InsecureRequestWarning)
Comment on lines +7 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Global warning suppression hides TLS problems for the whole process.

urllib3.disable_warnings and the warnings.filterwarnings call are process-wide. Importing this module silences InsecureRequestWarning for every HTTP client in the agent, including the Jeen MCP client and the ESCA client, not only for Langfuse. Certificate verification failures then become invisible in production.

Restrict the suppression to the development case, for example by gating it on a setting, and keep verification enabled elsewhere. urllib3 is also imported directly here; confirm it is a declared dependency of the agent package rather than a transitive one.

🔒 Proposed fix
-# Suppress unverified HTTPS warnings for dev internal endpoints
-urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
-warnings.filterwarnings("ignore", category=urllib3.exceptions.InsecureRequestWarning)
+# Suppress unverified HTTPS warnings for dev internal endpoints only.
+if getattr(settings, "ALLOW_INSECURE_INTERNAL_TLS", False):
+    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+    warnings.filterwarnings(
+        "ignore", category=urllib3.exceptions.InsecureRequestWarning
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/agent/langfuse_client.py` around lines 7 - 9, Restrict the
InsecureRequestWarning suppression near the Langfuse client initialization to an
explicit development-only setting, leaving TLS verification and warnings enabled
in production and other clients. Remove any unconditional process-wide
suppression, and ensure the directly imported urllib3 dependency is declared by
the agent package.


langfuse_client = Langfuse(
public_key=settings.LANGFUSE_PUBLIC_KEY,
secret_key=settings.LANGFUSE_SECRET_KEY,
host=settings.LANGFUSE_BASE_URL,
)

# Patch update_current_span and get_current_trace_id to safely no-op when running without an active OpenTelemetry span context
_orig_update_current_span = langfuse_client.update_current_span
_orig_get_current_trace_id = langfuse_client.get_current_trace_id


def _safe_update_current_span(*args, **kwargs):
current_span = otel_trace_api.get_current_span()
if current_span is otel_trace_api.INVALID_SPAN:
return
return _orig_update_current_span(*args, **kwargs)


def _safe_get_current_trace_id(*args, **kwargs):
current_span = otel_trace_api.get_current_span()
if current_span is otel_trace_api.INVALID_SPAN:
return None
return _orig_get_current_trace_id(*args, **kwargs)
Comment on lines +22 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

opentelemetry python get_current_span INVALID_SPAN NonRecordingSpan span context is_valid

💡 Result:

In OpenTelemetry Python, get_current_span is used to retrieve the span currently stored in the execution context [1][2]. If no span is present in the context, it returns an INVALID_SPAN object [3][1]. INVALID_SPAN is a specific instance of a NonRecordingSpan [3][4]. A NonRecordingSpan is a "no-op" span implementation used when no full tracing SDK or instrumentation is active [3][5][6]. Its primary purpose is to allow applications to continue using the OpenTelemetry API (e.g., propagating trace contexts) without crashing or requiring a fully functional tracing backend [3][5]. Key points regarding these components: 1. INVALID_SPAN: This is the default returned by get_current_span when no active span is found [1][2]. It contains an "invalid" SpanContext [4]. 2. NonRecordingSpan: This class implements the Span interface but performs no actual recording of events, attributes, or status updates [3][6]. Its method is_recording consistently returns False [5][6]. 3. SpanContext and is_valid: The SpanContext associated with a span has a property called is_valid [3][6]. This property indicates whether the context contains valid trace and span identifiers [3][7]. In the case of INVALID_SPAN, the associated SpanContext has is_valid set to False (or its equivalent state indicating an invalid/empty context) [5]. You can check the validity of a span's context by accessing span.get_span_context.is_valid [3][6]. If you find that get_current_span is returning an INVALID_SPAN unexpectedly, it often means that no span has been activated in the current execution context [8]. This typically occurs if you have not started a span using a tracer (e.g., using tracer.start_as_current_span or the context manager) or if the scope where the span was activated has already closed [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repository files matching langfuse_client.py:"
fd -a 'langfuse_client.py' . || true

echo
echo "Relevant file excerpt:"
if [ -f agent/src/agent/langfuse_client.py ]; then
  cat -n agent/src/agent/langfuse_client.py | sed -n '1,120p'
else
  echo "file not found"
fi

echo
echo "OpenTelemetry API/imports and span context usage:"
rg -n "INVALID_SPAN|get_current_span|NonRecordingSpan|is_valid|get_span_context" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true

echo
echo "Check installed opentelemetry API availability and behavior without importing repo code:"
python3 - <<'PY'
import importlib.util
for mod in ['opentelemetry.trace','opentelemetry.trace.span']:
    print(f'{mod}: {importlib.util.find_spec(mod) is not None}')
if importlib.util.find_spec('opentelemetry.trace') is not None:
    import opentelemetry.trace as trace_api
    print('INVALID_SPAN id:', id(trace_api.INVALID_SPAN))
    print('INVALID_SPAN __class__:', trace_api.INVALID_SPAN.__class__)
    print('INVALID_SPAN is_valid:', trace_api.INVALID_SPAN.get_span_context().is_valid if hasattr(trace_api.INVALID_SPAN,'get_span_context') else 'no get_span_context')
    print('get_current_span():', trace_api.get_current_span())
    print('current is same INSTANCE:', trace_api.get_current_span() is trace_api.INVALID_SPAN)
    print('current is_valid:', trace_api.get_current_span().get_span_context().is_valid if hasattr(trace_api.get_current_span(),'get_span_context') else 'no get_span_context')
PY

Repository: StavPonte11/text2sql-onboarding

Length of output: 2762


🌐 Web query:

OpenTelemetry Python api span propagate active span NON_RECORDING_span InvalidSpan get_current_span behavior

💡 Result:

In OpenTelemetry Python, the behavior of span-related objects and the retrieval of the current span are designed to ensure applications do not crash when tracing is not active or when a span is explicitly configured not to record data [1][2]. get_current_span and InvalidSpan The function opentelemetry.trace.get_current_span is used to retrieve the currently active span from the context [3][4]. If no span is active in the current context, it returns an INVALID_SPAN object [3][4]. INVALID_SPAN is a singleton that implements the Span interface as a no-op; calling methods on it (such as set_attribute or end) does nothing, ensuring that your application code remains safe even if the tracing infrastructure is not initialized [5][2]. NonRecordingSpan A NonRecordingSpan is a specific type of span that exists to facilitate context propagation even when a span is not intended to be recorded (e.g., due to sampling decisions) [5][1]. - Functionality: Like INVALID_SPAN, a NonRecordingSpan is essentially a no-op span [5]. Its primary purpose is to hold SpanContext information (such as TraceId and SpanId) so that trace context can be propagated to downstream services without incurring the overhead of recording span data [5][6]. - Propagation: When trace context is extracted (e.g., from incoming HTTP headers), a NonRecordingSpan may be created if the incoming context indicates that the trace should not be sampled (e.g., the sampled flag is set to 0 in the W3C traceparent header) [7][6][8]. - Confusion in Behavior: Users often encounter confusion when they see spans are not recorded when a traceparent header is present [7][8]. This is typically due to the default ParentBased sampler, which respects the sampling decision propagated from the upstream service [7]. If the upstream service decided not to sample the request, the Python application will create a NonRecordingSpan for the child, causing it to appear as if tracing is broken [7][8]. Summary of Key Differences - INVALID_SPAN: Returned by get_current_span when absolutely no span is associated with the current context [3][4]. It is the "default" fallback [2]. - NonRecordingSpan: Returned when a context exists and needs to be propagated, but the sampling configuration (or inherited sampling decision) dictates that no span data should be recorded [5][7][6]. If you are experiencing unexpected non-recording behavior, verify your sampling configuration (e.g., OTEL_TRACES_SAMPLER environment variable) and ensure you are not explicitly passing or inheriting a non-sampled context [7][8].

Citations:


🌐 Web query:

Python opentelemetry NonRecordingSpan is_recording get span context is_valid

💡 Result:

In the OpenTelemetry Python API, NonRecordingSpan is a concrete implementation of the Span interface used when no functional tracing SDK is configured or active [1][2]. Its primary purpose is to allow applications to use the OpenTelemetry API without side effects, while still supporting context propagation [1][3]. NonRecordingSpan and its methods behave as follows: is_recording: This method always returns False for a NonRecordingSpan [2][4]. This signals that the span is not capturing any data such as attributes, events, or status updates [3]. All operations that would typically record data (e.g., set_attribute, add_event) are no-op in this class [2][4]. get_span_context: This method returns the SpanContext associated with the NonRecordingSpan [2][4]. Even when a span is non-recording, it may hold a valid SpanContext to ensure that trace and span identifiers can be propagated to downstream services or child spans [3][5]. SpanContext.is_valid: The is_valid property on a SpanContext indicates whether the context contains a properly formatted, non-zero trace ID and span ID [6][7]. A SpanContext is considered valid if the trace ID is greater than the INVALID_TRACE_ID and the span ID is greater than the INVALID_SPAN_ID [7]. In summary, a NonRecordingSpan is a lightweight, no-op span that maintains a SpanContext to facilitate trace propagation, but does not perform any data recording [3][4]. Its is_recording method consistently returns False [2][4], while the validity of its context is determined by the internal state of the attached SpanContext [7].

Citations:


Test span recording and context validity, not object identity.

INVALID_SPAN is returned when the empty context has no span. Other OpenTelemetry no-op spans, such as propagated non-recording spans and sampled child spans with no SDK, are distinct objects and still cannot record Langfuse data. Check span.get_span_context().is_valid and span.get_span_context().is_remote, or the span’s recording-capability, before calling update_current_span and get_current_trace_id.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/agent/langfuse_client.py` around lines 22 - 33, Update
_safe_update_current_span and _safe_get_current_trace_id to validate the current
span’s context and recording capability instead of comparing it only with
INVALID_SPAN. Skip Langfuse operations for invalid, remote, or non-recording
spans, while preserving the existing delegation for valid recording spans.



langfuse_client.update_current_span = _safe_update_current_span
langfuse_client.get_current_trace_id = _safe_get_current_trace_id


1 change: 1 addition & 0 deletions agent/src/agent/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ def get_llm(
temperature=temperature,
timeout=300.0,
)

87 changes: 34 additions & 53 deletions agent/src/agent/nodes/finalizer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
import asyncio
from langchain_core.runnables.config import RunnableConfig
from agent.utils.redis_publisher import publish_node_event
from agent.state import AgentState
Expand All @@ -9,25 +8,21 @@
from agent.llm import get_llm
from agent.utils.esca import get_esca_client

from agent.utils.esca import get_esca_client


async def get_esca_preview(esca_id: str, limit: int = 5) -> str:
async def get_esca_preview(esca_id: str, limit: int = 10) -> str:
"""Load data from Esca and return a preview of the columns and the first few rows."""
if not esca_id:
return "No data reference found."

try:
async with get_esca_client() as client:
# TODO: instead of fetching everything from esca and then chunk, get only the chunk
data_bytes = await client.load_head(esca_id)
data = json.loads(data_bytes.decode())

columns = data.get("columns", [])
rows = data.get("rows", [])
total_rows = len(rows)

# Take a slice of the rows to avoid context overload
preview_rows = rows[:limit]

preview_info = {
Expand All @@ -36,51 +31,41 @@ async def get_esca_preview(esca_id: str, limit: int = 5) -> str:
"preview_count": len(preview_rows),
"total_rows": total_rows,
}
return json.dumps(preview_info, indent=2)
return json.dumps(preview_info, indent=2, default=str)
except Exception as e:
return f"Error retrieving data preview from Esca: {e}"
Comment on lines 35 to 36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Log the exception and return a neutral preview message.

get_esca_preview returns the raw exception text. finalizer_node passes that text to the LLM as sql_results. The model can echo internal storage details into the user-facing summary. Log the exception and return a message that contains no internal detail.

🛡️ Proposed fix
-    except Exception as e:
-        return f"Error retrieving data preview from Esca: {e}"
+    except Exception:
+        logger.exception("Failed to retrieve data preview from Esca for id=%s", esca_id)
+        return "Data preview is unavailable."

Add the logger import if it is absent:

import logging

logger = logging.getLogger(__name__)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except Exception as e:
return f"Error retrieving data preview from Esca: {e}"
except Exception:
logger.exception("Failed to retrieve data preview from Esca for id=%s", esca_id)
return "Data preview is unavailable."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/agent/nodes/finalizer.py` around lines 35 - 36, Update
get_esca_preview to log the caught exception through the module logger, then
return a neutral preview message that excludes the exception text and any
internal storage details; keep finalizer_node’s existing sql_results flow
unchanged.



async def get_sql_explanation(sql_query: str | None, llm) -> str:
"""Ask LLM to explain the SQL query in natural language."""
if not sql_query:
return "No SQL query was generated."

langfuse_prompt = langfuse_client.get_prompt(
settings.LANGFUSE_PROMPT_FINALIZER_SQL_EXPLANATION
)
prompt_sql_explanation = ChatPromptTemplate.from_messages(
langfuse_prompt.get_langchain_prompt()
)

chain = prompt_sql_explanation | llm
response = await chain.ainvoke({"sql_query": sql_query})
return response.content


async def finalizer_node(state: AgentState, config: RunnableConfig | None = None):
"""Summarize data."""
"""Summarize data using the unified Hebrew finalizer prompt."""
thread_id = config.get("configurable", {}).get("thread_id", "") if config else ""
from agent.utils.redis_publisher import publish_node_event
await publish_node_event(thread_id, "finalizer")

raw_data_ref = state.get("raw_data_ref")
esca_write_failed = state.get("esca_write_failed", False)
inline_result_rows = state.get("inline_result_rows")
inline_result_columns = state.get("inline_result_columns")
runtime_flags = state.get("runtime_flags") or {}
llm = get_llm("finalizer", runtime_flags=runtime_flags)

esca_write_enabled = str(runtime_flags.get("ESCA_WRITE_ENABLED", settings.ESCA_WRITE_ENABLED)).lower() == "true"

esca_write_enabled = (
str(
runtime_flags.get("ESCA_WRITE_ENABLED", settings.ESCA_WRITE_ENABLED)
).lower()
== "true"
)

preview_str = ""
if not esca_write_enabled:
if not esca_write_enabled or not raw_data_ref:
if inline_result_rows is not None:
limit = 5
limit = 10

@yuvalkh yuvalkh Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is not your code but we need to add an env variable RESULT_ROW_COUNT_LIMIT that we check if it exists it will get only the first RESULT_ROW_COUNT_LIMIT and if not it will use everything (all inline_result_columns).
This is because sometimes we don't want to trim the whole rows we get back from the db (when not using esca)

preview_rows = inline_result_rows[:limit]
columns = (
list(preview_rows[0].keys())
if preview_rows and isinstance(preview_rows[0], dict)
else []
)
if inline_result_columns:
columns = inline_result_columns
elif preview_rows and isinstance(preview_rows[0], dict):
columns = list(preview_rows[0].keys())
else:
columns = []

preview_info = {
"columns": columns,
"preview_rows": preview_rows,
Expand All @@ -91,32 +76,28 @@ async def finalizer_node(state: AgentState, config: RunnableConfig | None = None
else:
preview_str = "No data reference found."
else:
preview_str = await get_esca_preview(raw_data_ref)
preview_str = await get_esca_preview(raw_data_ref, limit=10)

langfuse_prompt_summary = langfuse_client.get_prompt(
settings.LANGFUSE_PROMPT_FINALIZER_SUMMARY
prompt_name = getattr(
settings, "LANGFUSE_PROMPT_FINALIZER", "text2sql/finalizer"
)
prompt_summary = ChatPromptTemplate.from_messages(
langfuse_prompt_summary.get_langchain_prompt()
langfuse_prompt = langfuse_client.get_prompt(prompt_name)
Comment on lines +81 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the declared Langfuse prompt settings fields.
fd -t f 'config.py' agent/src | xargs rg -n 'LANGFUSE_PROMPT'

Repository: StavPonte11/text2sql-onboarding

Length of output: 982


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== settings class files =="
fd -t f 'config.py' agent/src

echo
echo "== finalizer node context =="
fd -t f 'finalizer.py' agent/src/agent/nodes | xargs -r sed -n '1,130p'

echo
echo "== query_builder context =="
fd -t f 'query_builder.py' agent/src/agent/nodes | xargs -r sed -n '1,130p'

Repository: StavPonte11/text2sql-onboarding

Length of output: 8643


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all Langfuse prompt usages in nodes =="
fd -t f -i '*.py' agent/src/agent/nodes | xargs rg -n 'LANGFUSE_PROMPT|get_prompt|getattr' || true

echo
echo "== direct attribute access pattern for prompt names =="
fd -t f -i '*.py' agent/src/agent/nodes | xargs rg -n 'settings\.LANGFUSE_PROMPT|getattr\([^,]+,\s*"LANGFUSE' || true

Repository: StavPonte11/text2sql-onboarding

Length of output: 11398


Replace the fallback with direct attribute access for LANGFUSE_PROMPT_FINALIZER.

settings declares LANGFUSE_PROMPT_FINALIZER, so this getattr fallback is unnecessary. Use settings.LANGFUSE_PROMPT_FINALIZER to match the query_builder.py prompt-name access pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/agent/nodes/finalizer.py` around lines 81 - 84, Update the
prompt-name assignment in the finalizer flow to use direct access via
settings.LANGFUSE_PROMPT_FINALIZER instead of getattr with a fallback, matching
the existing query_builder.py access pattern. Leave the subsequent
langfuse_client.get_prompt call unchanged.

prompt_finalizer = ChatPromptTemplate.from_messages(
langfuse_prompt.get_langchain_prompt()
)

summary_chain = prompt_summary | llm

summary_task = summary_chain.ainvoke(
chain = prompt_finalizer | llm
response = await chain.ainvoke(
{
"user_query": state["user_query"],
"user_request": state.get("user_query") or "",
"sql_query": state.get("sql_query") or "",
"raw_data_ref": raw_data_ref,
"data_preview": preview_str,
"sql_translation": state.get("sql_explanation") or "",
"sql_results": preview_str,
}
)

sql_task = get_sql_explanation(state.get("sql_query"), llm)

summary_response, sql_explanation = await asyncio.gather(summary_task, sql_task)

return {
"summary": summary_response.content,
"sql_explanation": sql_explanation,
"summary": response.content,
"sql_explanation": state.get("sql_explanation", ""),
"execution_path": ["finalizer"],
}
Loading