Skip to content

Commit 4817a5e

Browse files
mjnoviceclaude
andcommitted
feat: add Agent Episodic Memory support (recall node, tracing)
- Add MemoryConfig with field_weights, threshold, result_count, folder_path, memory_space_name - Add memory_recall node that queries UiPath Memory service before INIT - Emit OTel trace spans (Find previous memories, Apply dynamic few shot) - Extract OTel helpers to _utils/_otel.py for reuse across tools - Prefix key_path with field type (agent-input) matching backend - Add definition_system_prompt to memory search request - Filter empty string values in _build_search_fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b40328d commit 4817a5e

10 files changed

Lines changed: 469 additions & 9 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ requires-python = ">=3.11"
77
dependencies = [
88
"uipath>=2.10.53, <2.11.0",
99
"uipath-core>=0.5.2, <0.6.0",
10-
"uipath-platform>=0.1.35, <0.2.0",
10+
"uipath-platform>=0.1.36, <0.2.0",
1111
"uipath-runtime>=0.10.0, <0.11.0",
1212
"langgraph>=1.1.8, <2.0.0",
1313
"langchain-core>=1.2.11, <2.0.0",
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
from ._environment import get_execution_folder_path
2+
from ._otel import set_span_attribute
23
from ._request_mixin import UiPathRequestMixin
34

4-
__all__ = ["UiPathRequestMixin", "get_execution_folder_path"]
5+
__all__ = [
6+
"UiPathRequestMixin",
7+
"get_execution_folder_path",
8+
"set_span_attribute",
9+
]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""OpenTelemetry utilities for span and trace context."""
2+
3+
from typing import Any
4+
5+
6+
def set_span_attribute(name: str, value: Any) -> None:
7+
"""Set an attribute on the current OTel span (no-op if unavailable)."""
8+
try:
9+
from opentelemetry import trace
10+
11+
span = trace.get_current_span()
12+
if span.is_recording():
13+
span.set_attribute(name, value)
14+
except ImportError:
15+
pass

src/uipath_langchain/agent/react/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""UiPath ReAct Agent implementation"""
22

33
from .agent import create_agent
4-
from .types import AgentGraphConfig, AgentGraphNode, AgentGraphState
4+
from .types import AgentGraphConfig, AgentGraphNode, AgentGraphState, MemoryConfig
55
from .utils import resolve_input_model, resolve_output_model
66

77
__all__ = [
@@ -11,4 +11,5 @@
1111
"AgentGraphNode",
1212
"AgentGraphState",
1313
"AgentGraphConfig",
14+
"MemoryConfig",
1415
]

src/uipath_langchain/agent/react/agent.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .llm_node import (
2525
create_llm_node,
2626
)
27+
from .memory_node import create_memory_recall_node
2728
from .router import (
2829
create_route_agent,
2930
)
@@ -36,6 +37,7 @@
3637
AgentGraphConfig,
3738
AgentGraphNode,
3839
AgentGraphState,
40+
MemoryConfig,
3941
)
4042
from .utils import create_state_with_input
4143

@@ -53,6 +55,7 @@ def create_agent(
5355
output_schema: Type[OutputT] | None = None,
5456
config: AgentGraphConfig | None = None,
5557
guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None = None,
58+
memory: MemoryConfig | None = None,
5659
) -> StateGraph[AgentGraphState, None, InputT, OutputT]:
5760
"""Build agent graph with INIT -> AGENT (subgraph) <-> TOOLS loop, terminated by control flow tools.
5861
@@ -122,7 +125,13 @@ def create_agent(
122125
)
123126
builder.add_node(AgentGraphNode.TERMINATE, terminate_with_guardrails_subgraph)
124127

125-
builder.add_edge(START, AgentGraphNode.INIT)
128+
if memory:
129+
memory_recall = create_memory_recall_node(memory, input_schema=input_schema)
130+
builder.add_node(AgentGraphNode.MEMORY_RECALL, memory_recall)
131+
builder.add_edge(START, AgentGraphNode.MEMORY_RECALL)
132+
builder.add_edge(AgentGraphNode.MEMORY_RECALL, AgentGraphNode.INIT)
133+
else:
134+
builder.add_edge(START, AgentGraphNode.INIT)
126135

127136
llm_node = create_llm_node(
128137
model,

src/uipath_langchain/agent/react/init_node.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ def graph_state_init(state: Any) -> Any:
2525
resolved_messages = list(messages(state))
2626
else:
2727
resolved_messages = list(messages)
28+
29+
# Append memory injection from the MEMORY_RECALL node (if present)
30+
memory_injection = ""
31+
if hasattr(state, "inner_state") and hasattr(
32+
state.inner_state, "memory_injection"
33+
):
34+
memory_injection = state.inner_state.memory_injection or ""
35+
if memory_injection and resolved_messages:
36+
first = resolved_messages[0]
37+
if isinstance(first, SystemMessage):
38+
resolved_messages[0] = SystemMessage(
39+
content=str(first.content) + memory_injection
40+
)
2841
if is_conversational:
2942
# For conversational agents we need to reorder the messages so that the system message is first, followed by
3043
# the initial user message. When resuming the conversation, the state will have the entire message history,
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Memory recall node for Agent Episodic Memory.
2+
3+
Queries the UiPath Memory service (via LLMOps) for similar past episodes
4+
and stores the server-generated systemPromptInjection in graph state so
5+
the INIT node can append it to the system prompt.
6+
"""
7+
8+
import logging
9+
from contextlib import contextmanager
10+
from typing import Any
11+
12+
from pydantic import BaseModel
13+
from uipath.platform import UiPath
14+
from uipath.platform.errors import EnrichedException
15+
from uipath.platform.memory import (
16+
FieldSettings,
17+
MemorySearchRequest,
18+
SearchField,
19+
SearchMode,
20+
SearchSettings,
21+
)
22+
23+
from .types import MemoryConfig
24+
from .utils import extract_input_data_from_state
25+
26+
logger = logging.getLogger(__name__)
27+
28+
29+
@contextmanager
30+
def _noop_context():
31+
"""No-op context manager when OTel is unavailable."""
32+
yield None
33+
34+
35+
def create_memory_recall_node(
36+
memory_config: MemoryConfig,
37+
input_schema: type[BaseModel] | None = None,
38+
):
39+
"""Create an async graph node that retrieves memory injection.
40+
41+
The node queries ``sdk.memory.search_async()`` and writes the
42+
``systemPromptInjection`` string into ``inner_state.memory_injection``.
43+
On failure it logs a warning and continues with an empty injection.
44+
45+
Args:
46+
memory_config: Memory configuration with space ID and search settings.
47+
48+
Returns:
49+
An async callable suitable for ``builder.add_node()``.
50+
"""
51+
52+
async def memory_recall_node(state: Any) -> dict[str, Any]:
53+
input_model = input_schema if input_schema is not None else BaseModel
54+
input_arguments = extract_input_data_from_state(state, input_model)
55+
if not input_arguments:
56+
logger.debug("Memory recall: no user inputs found in state")
57+
return {}
58+
59+
fields = _build_search_fields(
60+
input_arguments, field_weights=memory_config.field_weights or None
61+
)
62+
if not fields:
63+
logger.debug(
64+
"Memory recall: no search fields after filtering (inputs=%s, weights=%s)",
65+
list(input_arguments.keys()),
66+
memory_config.field_weights,
67+
)
68+
return {}
69+
70+
request = MemorySearchRequest(
71+
fields=fields,
72+
settings=SearchSettings(
73+
threshold=memory_config.threshold,
74+
result_count=memory_config.result_count,
75+
search_mode=SearchMode.Hybrid,
76+
),
77+
definition_system_prompt="",
78+
)
79+
80+
results_count = 0
81+
# Wrap the search in OTel spans so "Find previous memories" and
82+
# "Apply dynamic few shot" appear in the Execution Trace with
83+
# correct timing. The LlmOpsHttpExporter picks these up.
84+
injection = ""
85+
try:
86+
from opentelemetry import trace as otel_trace
87+
88+
tracer = otel_trace.get_tracer("uipath_langchain.memory")
89+
except ImportError:
90+
tracer = None
91+
otel_trace = None # type: ignore[assignment]
92+
93+
# Span attribute keys matching what the LlmOpsHttpExporter and
94+
# Studio UI expect. "openinference.span.kind" sets SpanType.
95+
lookup_span_ctx = (
96+
tracer.start_as_current_span(
97+
"Find previous memories",
98+
attributes={
99+
"openinference.span.kind": "agentMemoryLookup",
100+
"type": "agentMemoryLookup",
101+
"span_type": "agentMemoryLookup",
102+
"uipath.custom_instrumentation": True,
103+
"memorySpaceName": memory_config.memory_space_name or "",
104+
"memorySpaceId": memory_config.memory_space_id,
105+
"strategy": "DynamicFewShotPrompt",
106+
},
107+
)
108+
if tracer
109+
else _noop_context()
110+
)
111+
112+
with lookup_span_ctx as lookup_span:
113+
fewshot_span_ctx = (
114+
tracer.start_as_current_span(
115+
"Apply dynamic few shot",
116+
attributes={
117+
"openinference.span.kind": "applyDynamicFewShot",
118+
"type": "applyDynamicFewShot",
119+
"span_type": "applyDynamicFewShot",
120+
"uipath.custom_instrumentation": True,
121+
"memorySpaceName": memory_config.memory_space_name or "",
122+
"memorySpaceId": memory_config.memory_space_id,
123+
},
124+
)
125+
if tracer
126+
else _noop_context()
127+
)
128+
129+
with fewshot_span_ctx as fewshot_span:
130+
try:
131+
sdk = UiPath()
132+
folder_key = memory_config.folder_key
133+
if not folder_key and memory_config.folder_path:
134+
folder_key = await sdk.folders.retrieve_folder_key_async(
135+
memory_config.folder_path
136+
)
137+
response = await sdk.memory.search_async(
138+
memory_space_id=memory_config.memory_space_id,
139+
request=request,
140+
folder_key=folder_key,
141+
)
142+
injection = response.system_prompt_injection
143+
results_count = len(response.results)
144+
logger.info(
145+
"Memory recall returned %d results for space '%s'",
146+
results_count,
147+
memory_config.memory_space_id,
148+
)
149+
# Set request/response on fewshot span as JSON strings.
150+
# The exporter parses JSON strings back to objects.
151+
# The UI reads "response" to display matched memory items.
152+
if fewshot_span and hasattr(fewshot_span, "set_attribute"):
153+
import json
154+
155+
fewshot_span.set_attribute(
156+
"request",
157+
json.dumps(
158+
request.model_dump(by_alias=True, exclude_none=True)
159+
),
160+
)
161+
fewshot_span.set_attribute(
162+
"response",
163+
json.dumps(
164+
response.model_dump(by_alias=True, exclude_none=True)
165+
),
166+
)
167+
except Exception as e:
168+
if isinstance(e, EnrichedException):
169+
error_detail = (
170+
f"{e} | status={e.status_code} body={e.response_content}"
171+
)
172+
else:
173+
error_detail = repr(e)
174+
logger.warning(
175+
"Memory recall failed for space '%s': %s",
176+
memory_config.memory_space_id,
177+
error_detail,
178+
)
179+
if otel_trace:
180+
if fewshot_span and hasattr(fewshot_span, "set_status"):
181+
fewshot_span.set_status(
182+
otel_trace.StatusCode.ERROR, error_detail
183+
)
184+
if lookup_span and hasattr(lookup_span, "set_status"):
185+
lookup_span.set_status(
186+
otel_trace.StatusCode.ERROR, error_detail
187+
)
188+
189+
# Set result attributes after search completes
190+
if lookup_span and hasattr(lookup_span, "set_attribute"):
191+
lookup_span.set_attribute("memoryItemsMatched", results_count)
192+
if injection:
193+
lookup_span.set_attribute("result", injection)
194+
195+
if not injection:
196+
return {}
197+
198+
return {"inner_state": {"memory_injection": injection}}
199+
200+
return memory_recall_node
201+
202+
203+
def _build_search_fields(
204+
input_arguments: dict[str, Any],
205+
field_weights: dict[str, float] | None = None,
206+
field_type: str = "agent-input",
207+
) -> list[SearchField]:
208+
"""Convert agent input arguments to SearchField objects.
209+
210+
The key_path must be prefixed with the field type:
211+
keyPath = [fieldType, fieldName]
212+
e.g. ["agent-input", "a"] for episodic memory.
213+
"""
214+
fields: list[SearchField] = []
215+
for name, value in input_arguments.items():
216+
value_str = str(value) if value is not None else ""
217+
if not value_str or name.startswith("uipath__"):
218+
continue
219+
# When field_weights is specified, only include fields with configured weights
220+
if field_weights and name not in field_weights:
221+
continue
222+
settings = FieldSettings()
223+
if field_weights and name in field_weights:
224+
settings = FieldSettings(weight=field_weights[name])
225+
fields.append(
226+
SearchField(key_path=[field_type, name], value=value_str, settings=settings)
227+
)
228+
return fields

src/uipath_langchain/agent/react/types.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from langchain_core.messages import AnyMessage
55
from langgraph.graph.message import add_messages
6-
from pydantic import BaseModel, Field
6+
from pydantic import BaseModel, Field, model_validator
77
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
88
from uipath.platform.attachments import Attachment
99

@@ -19,6 +19,7 @@ class InnerAgentGraphState(BaseModel):
1919
job_attachments: Annotated[dict[str, Attachment], merge_dicts] = {}
2020
initial_message_count: int | None = None
2121
tools_storage: Annotated[dict[Hashable, Any], merge_dicts] = {}
22+
memory_injection: str = ""
2223

2324

2425
class InnerAgentGuardrailsGraphState(InnerAgentGraphState):
@@ -55,6 +56,43 @@ class AgentGraphNode(StrEnum):
5556
TOOLS = "tools"
5657
TERMINATE = "terminate"
5758
GUARDED_TERMINATE = "guarded-terminate"
59+
MEMORY_RECALL = "memory_recall"
60+
61+
62+
class MemoryConfig(BaseModel):
63+
"""Configuration for Agent Episodic Memory.
64+
65+
When passed to ``create_agent()``, a MEMORY_RECALL node is added before
66+
INIT that queries the memory service and stores the server-generated
67+
systemPromptInjection in ``inner_state.memory_injection``.
68+
"""
69+
70+
memory_space_id: str = Field(description="GUID of the memory space to query.")
71+
memory_space_name: str = Field(
72+
default="", description="Name of the memory space (for tracing)."
73+
)
74+
folder_key: str | None = Field(
75+
default=None, description="Folder key for the memory resource."
76+
)
77+
folder_path: str | None = Field(
78+
default=None,
79+
description="Folder path for the memory resource. Resolved to folder_key at runtime if folder_key is not set.",
80+
)
81+
# Defaults match FE episodic memory settings (agentEditor.ts:324-328)
82+
result_count: int = Field(default=3, ge=1, le=10)
83+
threshold: float = Field(default=0.0, ge=0.0, le=1.0)
84+
field_weights: dict[str, float] = Field(
85+
description=(
86+
"Per-field search weights. Keys are input field names, values are "
87+
"weights between 0.0 and 1.0. At least one field must be specified."
88+
),
89+
)
90+
91+
@model_validator(mode="after")
92+
def _validate_field_weights(self) -> "MemoryConfig":
93+
if not self.field_weights:
94+
raise ValueError("field_weights must contain at least one field")
95+
return self
5896

5997

6098
class AgentGraphConfig(BaseModel):

0 commit comments

Comments
 (0)