Skip to content

Commit 31242c3

Browse files
authored
Add spans for escalation memory lookup (#865)
1 parent e4f27ed commit 31242c3

6 files changed

Lines changed: 533 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.11.1"
3+
version = "0.11.2"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
@@ -169,4 +169,3 @@ name = "testpypi"
169169
url = "https://test.pypi.org/simple/"
170170
publish-url = "https://test.pypi.org/legacy/"
171171
explicit = true
172-

src/uipath_langchain/agent/tools/escalation_memory.py

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
from contextlib import contextmanager
56
from typing import Any
67
from uuid import UUID
78

@@ -29,11 +30,18 @@
2930

3031
MEMORY_CACHE_HIT_METRIC = "MemoryCacheHit"
3132
MEMORY_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+
3745
class 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+
214350
def _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+
540695
def _cached_result_from_search_response(
541696
response: Any,
542697
) -> EscalationMemoryCachedResult | None:

src/uipath_langchain/agent/tools/escalation_tool.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
_get_escalation_memory_folder_path,
4646
_get_escalation_memory_settings,
4747
_get_escalation_memory_space_id,
48+
_get_escalation_memory_space_name,
4849
_ingest_escalation_memory,
4950
_resolve_user_id,
5051
)
@@ -272,6 +273,7 @@ class EscalationToolOutput(BaseModel):
272273
_memory_folder_path: str | None = _get_escalation_memory_folder_path(
273274
resource, agent
274275
)
276+
_memory_space_name: str | None = _get_escalation_memory_space_name(resource, agent)
275277
_memory_settings: EscalationMemorySettings | None = _get_escalation_memory_settings(
276278
resource
277279
)
@@ -302,6 +304,7 @@ async def escalation_tool_fn(**kwargs: Any) -> dict[str, Any]:
302304
serialized_data,
303305
folder_path=_memory_folder_path or folder_path,
304306
memory_settings=_memory_settings,
307+
memory_space_name=_memory_space_name,
305308
)
306309
if cached_result is not None:
307310
return {

0 commit comments

Comments
 (0)