Skip to content

Commit 6c38bf3

Browse files
mjnoviceclaude
andcommitted
feat: add Agent Episodic Memory support (recall node + escalation memory)
Hybrid architecture for memory spaces with two integration points: 1. Pre-execution injection (graph node): - New MEMORY_RECALL node added before INIT when MemoryConfig is provided - Queries sdk.memory.search_async() and stores the server-generated systemPromptInjection in inner_state.memory_injection - INIT node reads memory_injection from state and appends to system prompt - Visible in LangGraph traces, resume-safe (node already ran) - Graph: START → MEMORY_RECALL → INIT → AGENT ↔ TOOLS → TERMINATE → END 2. Escalation memory (tool-level): - Before creating HITL task: escalation_search_async() checks for cached answer - Cache hit → returns cached result immediately, skips human escalation - After human resolution: escalation_ingest_async() persists outcome - Gated by isAgentMemoryEnabled + memorySpaceId on the escalation resource New types: - MemoryConfig: configuration dataclass (memory_space_id, folder_key, etc.) - AgentGraphNode.MEMORY_RECALL: new graph node enum value - InnerAgentGraphState.memory_injection: carries injection through state Dependency: bumps uipath-platform >= 0.1.31 for MemoryService types (PR #1467) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e55f59a commit 6c38bf3

10 files changed

Lines changed: 576 additions & 4 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.49, <2.11.0",
99
"uipath-core>=0.5.2, <0.6.0",
10-
"uipath-platform>=0.1.30, <0.2.0",
10+
"uipath-platform>=0.1.31, <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",

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)
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
@@ -24,6 +24,19 @@ def graph_state_init(state: Any) -> Any:
2424
resolved_messages = list(messages(state))
2525
else:
2626
resolved_messages = list(messages)
27+
28+
# Append memory injection from the MEMORY_RECALL node (if present)
29+
memory_injection = ""
30+
if hasattr(state, "inner_state") and hasattr(
31+
state.inner_state, "memory_injection"
32+
):
33+
memory_injection = state.inner_state.memory_injection or ""
34+
if memory_injection and resolved_messages:
35+
first = resolved_messages[0]
36+
if isinstance(first, SystemMessage):
37+
resolved_messages[0] = SystemMessage(
38+
content=str(first.content) + memory_injection
39+
)
2740
if is_conversational:
2841
# For conversational agents we need to reorder the messages so that the system message is first, followed by
2942
# the initial user message. When resuming the conversation, the state will have the entire message history,
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 typing import Any
10+
11+
from uipath.platform import UiPath
12+
from uipath.platform.memory import (
13+
FieldSettings,
14+
MemorySearchRequest,
15+
SearchField,
16+
SearchMode,
17+
SearchSettings,
18+
)
19+
20+
from .types import AgentGraphState, MemoryConfig
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
def create_memory_recall_node(
26+
memory_config: MemoryConfig,
27+
):
28+
"""Create an async graph node that retrieves memory injection.
29+
30+
The node queries ``sdk.memory.search_async()`` and writes the
31+
``systemPromptInjection`` string into ``inner_state.memory_injection``.
32+
On failure it logs a warning and continues with an empty injection.
33+
34+
Args:
35+
memory_config: Memory configuration with space ID and search settings.
36+
37+
Returns:
38+
An async callable suitable for ``builder.add_node()``.
39+
"""
40+
41+
async def memory_recall_node(state: AgentGraphState) -> dict[str, Any]:
42+
input_arguments = _extract_user_inputs(state)
43+
if not input_arguments:
44+
return {}
45+
46+
fields = _build_search_fields(input_arguments)
47+
if not fields:
48+
return {}
49+
50+
request = MemorySearchRequest(
51+
fields=fields,
52+
settings=SearchSettings(
53+
threshold=memory_config.threshold,
54+
result_count=memory_config.result_count,
55+
search_mode=SearchMode.Hybrid,
56+
),
57+
)
58+
59+
try:
60+
sdk = UiPath()
61+
response = await sdk.memory.search_async(
62+
memory_space_id=memory_config.memory_space_id,
63+
request=request,
64+
folder_key=memory_config.folder_key,
65+
)
66+
injection = response.system_prompt_injection
67+
logger.info(
68+
"Memory recall returned %d results for space '%s'",
69+
len(response.results),
70+
memory_config.memory_space_id,
71+
)
72+
except Exception:
73+
logger.warning(
74+
"Memory recall failed for space '%s', continuing without injection",
75+
memory_config.memory_space_id,
76+
exc_info=True,
77+
)
78+
injection = ""
79+
80+
if not injection:
81+
return {}
82+
83+
return {"inner_state": {"memory_injection": injection}}
84+
85+
return memory_recall_node
86+
87+
88+
def _extract_user_inputs(state: AgentGraphState) -> dict[str, Any]:
89+
"""Extract user-defined input fields from graph state, excluding internal fields."""
90+
internal_fields = set(AgentGraphState.model_fields.keys())
91+
if isinstance(state, dict):
92+
return {k: v for k, v in state.items() if k not in internal_fields}
93+
return {
94+
k: v
95+
for k, v in state.model_dump().items()
96+
if k not in internal_fields and v is not None
97+
}
98+
99+
100+
def _build_search_fields(
101+
input_arguments: dict[str, Any],
102+
field_weights: dict[str, float] | None = None,
103+
) -> list[SearchField]:
104+
"""Convert agent input arguments to SearchField objects."""
105+
fields: list[SearchField] = []
106+
for name, value in input_arguments.items():
107+
if value is None or name.startswith("uipath__"):
108+
continue
109+
settings = FieldSettings()
110+
if field_weights and name in field_weights:
111+
settings = FieldSettings(weight=field_weights[name])
112+
fields.append(SearchField(key_path=[name], value=str(value), settings=settings))
113+
return fields

src/uipath_langchain/agent/react/types.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,23 @@ 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+
folder_key: str | None = Field(
72+
default=None, description="Folder key for the memory resource."
73+
)
74+
result_count: int = Field(default=5, ge=1, le=10)
75+
threshold: float = Field(default=0.0, ge=0.0, le=1.0)
5876

5977

6078
class AgentGraphConfig(BaseModel):

src/uipath_langchain/agent/tools/escalation_tool.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Escalation tool creation for Action Center integration."""
22

3+
import json
4+
import logging
35
from enum import Enum
46
from typing import Any, Literal
57

@@ -39,6 +41,8 @@
3941
sanitize_tool_name,
4042
)
4143

44+
_escalation_logger = logging.getLogger(__name__)
45+
4246

4347
class EscalationAction(str, Enum):
4448
"""Actions that can be taken after an escalation completes."""
@@ -161,6 +165,17 @@ def _parse_task_data(
161165
return filtered_fields
162166

163167

168+
def _get_escalation_memory_space_id(
169+
resource: AgentEscalationResourceConfig,
170+
) -> str | None:
171+
"""Resolve memory space ID from escalation resource extra fields."""
172+
if not resource.is_agent_memory_enabled:
173+
return None
174+
return getattr(resource, "memorySpaceId", None) or getattr(
175+
resource, "memory_space_id", None
176+
)
177+
178+
164179
def create_escalation_tool(
165180
resource: AgentEscalationResourceConfig,
166181
) -> StructuredTool:
@@ -178,6 +193,7 @@ class EscalationToolOutput(BaseModel):
178193
is_deleted: bool = False
179194

180195
_bts_context: dict[str, Any] = {}
196+
_memory_space_id: str | None = _get_escalation_memory_space_id(resource)
181197

182198
async def escalation_tool_fn(**kwargs: Any) -> dict[str, Any]:
183199
agent_input: dict[str, Any] = (
@@ -198,6 +214,13 @@ async def escalation_tool_fn(**kwargs: Any) -> dict[str, Any]:
198214

199215
serialized_data = input_model.model_validate(kwargs).model_dump(mode="json")
200216

217+
# --- Escalation memory: check cache before creating HITL task ---
218+
cached_result = await _check_escalation_memory_cache(
219+
_memory_space_id, serialized_data
220+
)
221+
if cached_result is not None:
222+
return cached_result
223+
201224
@mockable(
202225
name=tool_name.lower(),
203226
description=resource.description,
@@ -262,6 +285,13 @@ async def create_escalation_task():
262285
EscalationAction(outcome_str) if outcome_str else EscalationAction.CONTINUE
263286
)
264287

288+
# --- Escalation memory: persist outcome for future recall ---
289+
await _ingest_escalation_memory(
290+
_memory_space_id,
291+
answer=json.dumps(escalation_output),
292+
attributes=json.dumps(serialized_data),
293+
)
294+
265295
return {
266296
"action": escalation_action,
267297
"output": escalation_output,
@@ -333,3 +363,98 @@ async def escalation_wrapper(
333363
tool.set_tool_wrappers(awrapper=escalation_wrapper)
334364

335365
return tool
366+
367+
368+
# --- Escalation memory helpers ---
369+
370+
371+
async def _check_escalation_memory_cache(
372+
memory_space_id: str | None,
373+
serialized_input: dict[str, Any],
374+
) -> dict[str, Any] | None:
375+
"""Check escalation memory for a cached answer.
376+
377+
Returns the cached result dict if found, None otherwise.
378+
"""
379+
if not memory_space_id:
380+
return None
381+
382+
try:
383+
from uipath.platform.memory import (
384+
MemorySearchRequest,
385+
SearchField,
386+
SearchMode,
387+
SearchSettings,
388+
)
389+
390+
fields = [
391+
SearchField(key_path=[k], value=str(v))
392+
for k, v in serialized_input.items()
393+
if v is not None
394+
]
395+
if not fields:
396+
return None
397+
398+
request = MemorySearchRequest(
399+
fields=fields,
400+
settings=SearchSettings(
401+
threshold=0.0, result_count=1, search_mode=SearchMode.Hybrid
402+
),
403+
)
404+
sdk = UiPath()
405+
response = await sdk.memory.escalation_search_async(
406+
memory_space_id=memory_space_id, request=request
407+
)
408+
if response.results and response.results[0].answer:
409+
cached = response.results[0].answer
410+
_escalation_logger.info(
411+
"Escalation memory cache hit for space '%s'", memory_space_id
412+
)
413+
return {
414+
"action": EscalationAction.CONTINUE,
415+
"output": cached.output,
416+
"outcome": cached.outcome or "cached",
417+
}
418+
except Exception:
419+
_escalation_logger.warning(
420+
"Escalation memory search failed for space '%s'",
421+
memory_space_id,
422+
exc_info=True,
423+
)
424+
425+
return None
426+
427+
428+
async def _ingest_escalation_memory(
429+
memory_space_id: str | None,
430+
answer: str,
431+
attributes: str,
432+
span_id: str = "",
433+
trace_id: str = "",
434+
) -> None:
435+
"""Persist a resolved escalation outcome into memory."""
436+
if not memory_space_id:
437+
return
438+
439+
try:
440+
from uipath.platform.memory import EscalationMemoryIngestRequest
441+
442+
request = EscalationMemoryIngestRequest(
443+
span_id=span_id or "unknown",
444+
trace_id=trace_id or "unknown",
445+
answer=answer,
446+
attributes=attributes,
447+
)
448+
sdk = UiPath()
449+
await sdk.memory.escalation_ingest_async(
450+
memory_space_id=memory_space_id, request=request
451+
)
452+
_escalation_logger.info(
453+
"Ingested escalation outcome into memory space '%s'", memory_space_id
454+
)
455+
except Exception:
456+
_escalation_logger.warning(
457+
"Failed to ingest escalation outcome into memory space '%s'",
458+
memory_space_id,
459+
exc_info=True,
460+
)

0 commit comments

Comments
 (0)