22
33import json
44import logging
5+ from contextlib import contextmanager
56from typing import Any
67from uuid import UUID
78
2930
3031MEMORY_CACHE_HIT_METRIC = "MemoryCacheHit"
3132MEMORY_CACHE_MISS_METRIC = "MemoryCacheMiss"
33+ ESCALATION_MEMORY_STRATEGY = "EscalationMemoryCache"
3234
3335_metric_counters : dict [str , Any ] = {}
3436_MISSING_VALUE = object ()
3537
3638
39+ @contextmanager
40+ def _noop_context ():
41+ """No-op context manager when OTel is unavailable."""
42+ yield None
43+
44+
3745class EscalationMemoryFieldSetting (BaseModel ):
3846 """Per-field search configuration for escalation memory."""
3947
@@ -81,11 +89,13 @@ def __init__(
8189 self ,
8290 memory_space_id : str ,
8391 * ,
92+ memory_space_name : str | None = None ,
8493 folder_path : str | None = None ,
8594 memory_settings : EscalationMemorySettings | None = None ,
8695 uipath_sdk : UiPath | None = None ,
8796 ) -> None :
8897 self .memory_space_id = memory_space_id
98+ self .memory_space_name = memory_space_name or ""
8999 self .folder_path = folder_path
90100 self .memory_settings = memory_settings or EscalationMemorySettings ()
91101 self ._uipath_sdk = uipath_sdk
@@ -97,16 +107,116 @@ async def aretrieve(
97107 """Search escalation memory and return the first cached answer."""
98108 request = self ._build_search_request (serialized_input )
99109 sdk = self ._uipath_sdk if self ._uipath_sdk is not None else UiPath ()
110+
111+ results_count = 0
112+ cached_result : EscalationMemoryCachedResult | None = None
100113 try :
101- response = await sdk .memory .escalation_search_async (
102- memory_space_id = self .memory_space_id ,
103- request = request ,
104- folder_path = self .folder_path ,
114+ # Keep the OTel import local to match episodic memory and keep this
115+ # module importable in runtimes where tracing is not installed.
116+ from opentelemetry import trace as otel_trace
117+
118+ tracer = otel_trace .get_tracer ("uipath_langchain.memory" )
119+ except ImportError :
120+ tracer = None
121+ otel_trace = None # type: ignore[assignment]
122+
123+ # Span attribute keys matching what the LlmOpsHttpExporter and
124+ # Studio UI expect. "openinference.span.kind" sets SpanType.
125+ lookup_span_ctx = (
126+ tracer .start_as_current_span (
127+ "Find previous memories" ,
128+ attributes = {
129+ "openinference.span.kind" : "agentMemoryLookup" ,
130+ "type" : "agentMemoryLookup" ,
131+ "span_type" : "agentMemoryLookup" ,
132+ "uipath.custom_instrumentation" : True ,
133+ "memorySpaceName" : self .memory_space_name ,
134+ "memorySpaceId" : self .memory_space_id ,
135+ "strategy" : ESCALATION_MEMORY_STRATEGY ,
136+ },
105137 )
106- except ValidationError :
107- response = await self ._raw_escalation_search (sdk , request )
138+ if tracer
139+ else _noop_context ()
140+ )
108141
109- return _cached_result_from_search_response (response )
142+ with lookup_span_ctx as lookup_span :
143+ fewshot_span_ctx = (
144+ tracer .start_as_current_span (
145+ "Apply escalation memory" ,
146+ attributes = {
147+ # LlmOps/Studio still key memory rendering off this
148+ # exported span type; rename it when that contract changes.
149+ "openinference.span.kind" : "applyDynamicFewShot" ,
150+ "type" : "applyDynamicFewShot" ,
151+ "span_type" : "applyDynamicFewShot" ,
152+ "uipath.custom_instrumentation" : True ,
153+ "memorySpaceName" : self .memory_space_name ,
154+ "memorySpaceId" : self .memory_space_id ,
155+ "strategy" : ESCALATION_MEMORY_STRATEGY ,
156+ },
157+ )
158+ if tracer
159+ else _noop_context ()
160+ )
161+
162+ with fewshot_span_ctx as fewshot_span :
163+ try :
164+ try :
165+ response = await sdk .memory .escalation_search_async (
166+ memory_space_id = self .memory_space_id ,
167+ request = request ,
168+ folder_path = self .folder_path ,
169+ )
170+ except ValidationError :
171+ # Some existing escalation memories store `answer` as a
172+ # JSON string that the SDK response model rejects. The
173+ # raw API payload is still usable and parsed below.
174+ response = await self ._raw_escalation_search (sdk , request )
175+
176+ results = _read_value (response , "results" ) or []
177+ results_count = _safe_len (results )
178+ cached_result = _cached_result_from_search_response (response )
179+ # Set request/response on fewshot span as JSON strings.
180+ # The exporter parses JSON strings back to objects.
181+ # The UI reads "response" to display matched memory items.
182+ if fewshot_span and hasattr (fewshot_span , "set_attribute" ):
183+ fewshot_span .set_attribute (
184+ "request" ,
185+ _json_dumps (
186+ request .model_dump (by_alias = True , exclude_none = True )
187+ ),
188+ )
189+ fewshot_span .set_attribute (
190+ "response" ,
191+ _serialize_search_response_for_trace (response ),
192+ )
193+ fewshot_span .set_attribute (
194+ "fromMemory" , cached_result is not None
195+ )
196+ except Exception as error :
197+ error_detail = repr (error )
198+ if otel_trace :
199+ if fewshot_span and hasattr (fewshot_span , "set_status" ):
200+ fewshot_span .set_status (
201+ otel_trace .StatusCode .ERROR , error_detail
202+ )
203+ if lookup_span and hasattr (lookup_span , "set_status" ):
204+ lookup_span .set_status (
205+ otel_trace .StatusCode .ERROR , error_detail
206+ )
207+ raise
208+
209+ if lookup_span and hasattr (lookup_span , "set_attribute" ):
210+ lookup_span .set_attribute ("memoryItemsMatched" , results_count )
211+ if cached_result is not None :
212+ lookup_span .set_attribute (
213+ "result" ,
214+ _json_dumps (
215+ cached_result .model_dump (by_alias = True , exclude_none = True )
216+ ),
217+ )
218+
219+ return cached_result
110220
111221 def _build_search_request (
112222 self ,
@@ -211,6 +321,32 @@ def _get_escalation_memory_folder_path(
211321 )
212322
213323
324+ def _get_escalation_memory_space_name (
325+ resource : AgentEscalationResourceConfig ,
326+ agent : Any | None = None ,
327+ ) -> str | None :
328+ """Resolve memory space name from escalation resource or agent memory feature."""
329+ if not _is_escalation_memory_enabled (resource ):
330+ return None
331+
332+ memory = _get_escalation_memory_properties (resource )
333+ memory_space_name = _read_first_value (
334+ (resource , memory ),
335+ "memorySpaceName" ,
336+ "memory_space_name" ,
337+ )
338+ if memory_space_name :
339+ return str (memory_space_name )
340+
341+ feature = _get_agent_memory_space_feature (agent )
342+ memory_space_name = _read_value (
343+ feature ,
344+ "memorySpaceName" ,
345+ "memory_space_name" ,
346+ )
347+ return str (memory_space_name ) if memory_space_name else None
348+
349+
214350def _get_escalation_memory_settings (
215351 resource : AgentEscalationResourceConfig ,
216352) -> EscalationMemorySettings | None :
@@ -390,10 +526,12 @@ async def _check_escalation_memory_cache(
390526 serialized_input : dict [str , Any ],
391527 folder_path : str | None = None ,
392528 memory_settings : EscalationMemorySettings | None = None ,
529+ memory_space_name : str | None = None ,
393530) -> EscalationMemoryCachedResult | None :
394531 """Check escalation memory for a cached answer."""
395532 retriever = EscalationMemoryRetriever (
396533 memory_space_id ,
534+ memory_space_name = memory_space_name ,
397535 folder_path = folder_path ,
398536 memory_settings = memory_settings ,
399537 )
@@ -537,6 +675,23 @@ def _record_custom_metric(metric_name: str, memory_space_id: str) -> None:
537675 logger .debug ("Failed to record metric '%s'" , metric_name , exc_info = True )
538676
539677
678+ def _serialize_search_response_for_trace (response : Any ) -> str :
679+ if isinstance (response , BaseModel ):
680+ response = response .model_dump (by_alias = True , exclude_none = True )
681+ return _json_dumps (response )
682+
683+
684+ def _safe_len (value : Any ) -> int :
685+ try :
686+ return len (value )
687+ except Exception :
688+ return 0
689+
690+
691+ def _json_dumps (payload : Any ) -> str :
692+ return json .dumps (payload , default = str )
693+
694+
540695def _cached_result_from_search_response (
541696 response : Any ,
542697) -> EscalationMemoryCachedResult | None :
0 commit comments