1313
1414import asyncio
1515import logging
16- from typing import Annotated , Any
16+ from contextlib import contextmanager
17+ from typing import Annotated , Any , Iterator
1718
1819from langchain_core .language_models import BaseChatModel
1920from langchain_core .messages import (
3031from langgraph .graph .state import CompiledStateGraph
3132from pydantic import BaseModel
3233from uipath .platform .entities import EntitiesService , Entity
34+ from uipath .platform .errors import DataFabricError , EnrichedException
3335
3436from ..datafabric_query_tool import DataFabricQueryTool
3537from . import datafabric_prompt_builder
3638from .models import DataFabricExecuteSqlInput
3739
3840logger = logging .getLogger (__name__ )
41+ CATEGORY_MARKER = "(category: "
42+
43+
44+ @contextmanager
45+ def _noop_context () -> Iterator [None ]:
46+ yield None
3947
4048
4149class DataFabricSubgraphState (BaseModel ):
@@ -44,33 +52,124 @@ class DataFabricSubgraphState(BaseModel):
4452 messages : Annotated [list [AnyMessage ], add_messages ] = []
4553 iteration_count : int = 0
4654 last_tool_success : bool = False
55+ last_error_category : str = ""
56+ last_error_detail : str = ""
4757
4858
4959class QueryExecutor :
5060 """Executes SQL queries against Data Fabric."""
5161
52- def __init__ (self , entities_service : EntitiesService ) -> None :
62+ def __init__ (
63+ self , entities_service : EntitiesService , entities : list [Entity ]
64+ ) -> None :
5365 self ._entities = entities_service
66+ native = [e .name for e in entities if not e .external_fields ]
67+ federated = [e .name for e in entities if e .external_fields ]
68+ self ._entity_attrs : dict [str , str | int ] = {
69+ "df.entity_count" : len (entities ),
70+ "df.native_entity_count" : len (native ),
71+ "df.federated_entity_count" : len (federated ),
72+ "df.native_entities" : ", " .join (native ) if native else "" ,
73+ "df.federated_entities" : ", " .join (federated ) if federated else "" ,
74+ }
5475
5576 async def __call__ (self , sql_query : str ) -> dict [str , Any ]:
5677 logger .debug ("execute_sql called with SQL: %s" , sql_query )
78+
5779 try :
58- records = await self ._entities .query_entity_records_async (
59- sql_query = sql_query ,
80+ from opentelemetry import trace as otel_trace
81+
82+ tracer = otel_trace .get_tracer ("uipath_langchain.datafabric" )
83+ except ImportError :
84+ tracer = None
85+
86+ span_ctx = (
87+ tracer .start_as_current_span (
88+ "Data Fabric SQL query" ,
89+ attributes = {
90+ "openinference.span.kind" : "TOOL" ,
91+ "span_type" : "datafabricQuery" ,
92+ "uipath.custom_instrumentation" : True ,
93+ "df.sql_query" : sql_query ,
94+ ** self ._entity_attrs ,
95+ },
6096 )
61- return {
62- "records" : records ,
63- "total_count" : len (records ),
64- "sql_query" : sql_query ,
65- }
66- except Exception as e :
67- logger .error ("SQL query failed: %s" , e )
68- return {
69- "records" : [],
70- "total_count" : 0 ,
71- "error" : str (e ),
72- "sql_query" : sql_query ,
73- }
97+ if tracer
98+ else _noop_context ()
99+ )
100+
101+ with span_ctx as span :
102+ try :
103+ records = await self ._entities .query_entity_records_async (
104+ sql_query = sql_query ,
105+ )
106+ if span is not None :
107+ span .set_attribute ("df.record_count" , len (records ))
108+ span .set_attribute ("df.success" , True )
109+ return {
110+ "records" : records ,
111+ "total_count" : len (records ),
112+ "sql_query" : sql_query ,
113+ }
114+ except Exception as e :
115+ return self ._handle_query_error (e , span , sql_query )
116+
117+ def _handle_query_error (
118+ self , e : Exception , span : Any , sql_query : str
119+ ) -> dict [str , Any ]:
120+ """Handle a failed SQL query: log, record span attributes, return error dict."""
121+ logger .error ("SQL query failed: %s" , e )
122+
123+ df_error = None
124+ if isinstance (e , EnrichedException ):
125+ df_error = DataFabricError .from_enriched_exception (e )
126+
127+ if span is not None :
128+ self ._record_error_span (span , e , df_error )
129+
130+ return {
131+ "records" : [],
132+ "total_count" : 0 ,
133+ "error" : self ._build_error_detail (e , df_error ),
134+ "sql_query" : sql_query ,
135+ }
136+
137+ @staticmethod
138+ def _record_error_span (
139+ span : Any , e : Exception , df_error : "DataFabricError | None"
140+ ) -> None :
141+ """Set error attributes on an OTEL span."""
142+ span .set_attribute ("df.success" , False )
143+ span .set_attribute ("df.error.raw" , str (e )[:500 ])
144+ if df_error :
145+ if df_error .code :
146+ span .set_attribute ("df.error.code" , df_error .code )
147+ if df_error .message :
148+ span .set_attribute ("df.error.message" , df_error .message )
149+ if df_error .trace_id :
150+ span .set_attribute ("df.error.trace_id" , df_error .trace_id )
151+ span .set_attribute ("df.error.category" , df_error .category .value )
152+
153+ from opentelemetry .trace import Status , StatusCode
154+
155+ span .record_exception (e )
156+ span .set_status (Status (StatusCode .ERROR , str (e )[:200 ]))
157+
158+ @staticmethod
159+ def _build_error_detail (exc : Exception , df_error : "DataFabricError | None" ) -> str :
160+ """Build a structured error string for the inner LLM."""
161+ if df_error and df_error .code :
162+ parts = [f"[{ df_error .code } ]" ]
163+ if df_error .category .value != "unknown" :
164+ parts .append (f"(category: { df_error .category .value } )" )
165+ if df_error .message :
166+ parts .append (df_error .message )
167+ if df_error .is_retryable :
168+ parts .append ("— This error is transient, retry the same query." )
169+ elif df_error .is_bad_sql :
170+ parts .append ("— Fix the SQL syntax and retry." )
171+ return " " .join (parts )
172+ return str (exc )
74173
75174
76175class DataFabricGraph :
@@ -130,16 +229,33 @@ async def tool_node(self, state: DataFabricSubgraphState) -> dict[str, Any]:
130229 results = await asyncio .gather (
131230 * [self ._execute_tool_call (tc ) for tc in last .tool_calls ]
132231 )
133- tool_messages = [msg for msg , _ in results ]
134- all_succeeded = bool (results ) and all (success for _ , success in results )
232+ tool_messages = [msg for msg , _ , _ , _ in results ]
233+ all_succeeded = bool (results ) and all (ok for _ , ok , _ , _ in results )
234+
235+ # Capture last error info from the most recent failed call
236+ last_category = ""
237+ last_detail = ""
238+ for _ , ok , cat , detail in reversed (results ):
239+ if not ok and detail :
240+ last_category = cat
241+ last_detail = detail
242+ break
243+
135244 return {
136245 "messages" : tool_messages ,
137246 "iteration_count" : state .iteration_count + len (last .tool_calls ),
138247 "last_tool_success" : all_succeeded ,
248+ "last_error_category" : last_category or state .last_error_category ,
249+ "last_error_detail" : last_detail or state .last_error_detail ,
139250 }
140251
141- async def _execute_tool_call (self , tool_call : ToolCall ) -> tuple [ToolMessage , bool ]:
142- """Execute a single tool call and report whether it succeeded."""
252+ async def _execute_tool_call (
253+ self , tool_call : ToolCall
254+ ) -> tuple [ToolMessage , bool , str , str ]:
255+ """Execute a single tool call and report whether it succeeded.
256+
257+ Returns (message, succeeded, error_category, error_detail).
258+ """
143259 args = tool_call .get ("args" , {})
144260 try :
145261 result = await self ._execute_sql_tool .ainvoke (args )
@@ -150,33 +266,42 @@ async def _execute_tool_call(self, tool_call: ToolCall) -> tuple[ToolMessage, bo
150266 "error" : str (e ),
151267 "sql_query" : args .get ("sql_query" , "" ),
152268 }
269+ error_str = result .get ("error" , "" ) if isinstance (result , dict ) else ""
153270 succeeded = (
154271 isinstance (result , dict )
155- and not result . get ( "error" )
272+ and not error_str
156273 and result .get ("total_count" , 0 ) > 0
157274 )
275+ # Extract category from structured error like "[SQL_VALIDATION] (category: bad_sql) ..."
276+ error_category = ""
277+ if error_str and CATEGORY_MARKER in error_str :
278+ start = error_str .index (CATEGORY_MARKER ) + len (CATEGORY_MARKER )
279+ end = error_str .find (")" , start )
280+ if end != - 1 :
281+ error_category = error_str [start :end ]
158282 return (
159283 ToolMessage (
160284 content = str (result ),
161285 tool_call_id = tool_call ["id" ],
162286 name = "execute_sql" ,
163287 ),
164288 succeeded ,
289+ error_category ,
290+ error_str ,
165291 )
166292
167293 async def termination_node (self , state : DataFabricSubgraphState ) -> dict [str , Any ]:
168294 """Produce a clear message when max iterations is reached."""
169- return {
170- "messages" : [
171- AIMessage (
172- content = (
173- "I was unable to resolve the query after "
174- f"{ state .iteration_count } SQL attempts. "
175- "Please try rephrasing the question or narrowing the scope."
176- )
177- )
178- ]
179- }
295+ parts = [
296+ f"I was unable to resolve the query after "
297+ f"{ state .iteration_count } SQL attempts." ,
298+ ]
299+ if state .last_error_category :
300+ parts .append (f"Last error category: { state .last_error_category } ." )
301+ if state .last_error_detail :
302+ parts .append (f"Last error: { state .last_error_detail [:300 ]} " )
303+ parts .append ("Please try rephrasing the question or narrowing the scope." )
304+ return {"messages" : [AIMessage (content = " " .join (parts ))]}
180305
181306 def router (self , state : DataFabricSubgraphState ) -> str :
182307 """Route from ``inner_llm`` to tool, termination, or END."""
@@ -214,7 +339,7 @@ def _create_execute_sql_tool(
214339 "tables and columns. Retry with a corrected query on errors."
215340 ),
216341 args_schema = DataFabricExecuteSqlInput ,
217- coroutine = QueryExecutor (entities_service ),
342+ coroutine = QueryExecutor (entities_service , entities ),
218343 metadata = {"tool_type" : "datafabric_sql" },
219344 )
220345
0 commit comments