feat: memory apply patch [LET-5548] (#5475)

This commit is contained in:
Kevin Lin
2025-10-21 12:56:50 -07:00
committed by Caren Thomas
parent 35b5383724
commit 4bb54f471c
2 changed files with 162 additions and 0 deletions

View File

@@ -450,6 +450,57 @@ def memory_insert(agent_state: "AgentState", label: str, new_str: str, insert_li
return success_msg
def memory_apply_patch(agent_state: "AgentState", label: str, patch: str) -> str: # type: ignore
"""
Apply a unified-diff style patch to a memory block by anchoring on content and context (not line numbers).
The patch format is a simplified unified diff that supports one or more hunks. Each hunk may optionally
start with a line beginning with `@@` and then contains lines that begin with one of:
- " " (space): context lines that must match the current memory content
- "-": lines to remove (must match exactly in the current content)
- "+": lines to add
Notes:
- Do not include line number prefixes like "Line 12:" anywhere in the patch. Line numbers are for display only.
- Do not include the line-number warning banner. Provide only the text to edit.
- Tabs are normalized to spaces for matching consistency.
Args:
label (str): The memory block to edit, identified by its label.
patch (str): The simplified unified-diff patch text composed of context (" "), deletion ("-"), and addition ("+") lines. Optional
lines beginning with "@@" can be used to delimit hunks. Do not include visual line numbers or warning banners.
Examples:
Simple replacement:
label="human",
patch:
@@
-Their name is Alice
+Their name is Bob
Replacement with surrounding context for disambiguation:
label="persona",
patch:
@@
Persona:
-Friendly and curious
+Friendly, curious, and precise
Likes: Hiking
Insertion (no deletions) between two context lines:
label="todos",
patch:
@@
- [ ] Step 1: Gather requirements
+ [ ] Step 1.5: Clarify stakeholders
- [ ] Step 2: Draft design
Returns:
str: A success message if the patch applied cleanly; raises ValueError otherwise.
"""
raise NotImplementedError("This should never be invoked directly. Contact Letta if you see this error message.")
def memory_rethink(agent_state: "AgentState", label: str, new_memory: str) -> None:
"""
The memory_rethink command allows you to completely rewrite the contents of a memory block. Use this tool to make large sweeping changes (e.g. when you want to condense or reorganize the memory blocks), do NOT use this tool to make small precise edits (e.g. add or remove a line, replace a specific string, etc).

View File

@@ -47,6 +47,7 @@ class LettaCoreToolExecutor(ToolExecutor):
"core_memory_replace": self.core_memory_replace,
"memory_replace": self.memory_replace,
"memory_insert": self.memory_insert,
"memory_apply_patch": self.memory_apply_patch,
"memory_str_replace": self.memory_str_replace,
"memory_str_insert": self.memory_str_insert,
"memory_rethink": self.memory_rethink,
@@ -393,6 +394,116 @@ class LettaCoreToolExecutor(ToolExecutor):
# return None
return success_msg
async def memory_apply_patch(self, agent_state: AgentState, actor: User, label: str, patch: str) -> str:
"""Apply a simplified unified-diff style patch to a memory block, anchored on content and context.
Args:
label: The memory block label to modify.
patch: Patch text with lines starting with " ", "-", or "+" and optional "@@" hunk headers.
Returns:
Success message on clean application; raises ValueError on mismatch/ambiguity.
"""
if agent_state.memory.get_block(label).read_only:
raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}")
# Guardrails: forbid visual line numbers and warning banners
if MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(patch or ""):
raise ValueError(
"Patch contains a line number prefix, which is not allowed. Do not include line numbers (they are for display only)."
)
if CORE_MEMORY_LINE_NUMBER_WARNING in (patch or ""):
raise ValueError("Patch contains the line number warning banner, which is not allowed. Provide only the text to edit.")
current_value = str(agent_state.memory.get_block(label).value).expandtabs()
patch = str(patch).expandtabs()
current_lines = current_value.split("\n")
# Ignore common diff headers
raw_lines = patch.splitlines()
patch_lines = [ln for ln in raw_lines if not ln.startswith("*** ") and not ln.startswith("---") and not ln.startswith("+++")]
# Split into hunks using '@@' as delimiter
hunks: list[list[str]] = []
h: list[str] = []
for ln in patch_lines:
if ln.startswith("@@"):
if h:
hunks.append(h)
h = []
continue
if ln.startswith(" ") or ln.startswith("-") or ln.startswith("+"):
h.append(ln)
elif ln.strip() == "":
# Treat blank line as context for empty string line
h.append(" ")
else:
# Skip unknown metadata lines
continue
if h:
hunks.append(h)
if not hunks:
raise ValueError("No applicable hunks found in patch. Ensure lines start with ' ', '-', or '+'.")
def find_all_subseq(hay: list[str], needle: list[str]) -> list[int]:
out: list[int] = []
n = len(needle)
if n == 0:
return out
for i in range(0, len(hay) - n + 1):
if hay[i : i + n] == needle:
out.append(i)
return out
# Apply each hunk sequentially against the rolling buffer
for hunk in hunks:
expected: list[str] = []
replacement: list[str] = []
for ln in hunk:
if ln.startswith(" "):
line = ln[1:]
expected.append(line)
replacement.append(line)
elif ln.startswith("-"):
line = ln[1:]
expected.append(line)
elif ln.startswith("+"):
line = ln[1:]
replacement.append(line)
if not expected and replacement:
# Pure insertion with no context: append at end
current_lines = current_lines + replacement
continue
matches = find_all_subseq(current_lines, expected)
if len(matches) == 0:
sample = "\n".join(expected[:4])
raise ValueError(
"Failed to apply patch: expected hunk context not found in the memory block. "
f"Verify the target lines exist and try providing more context. Expected start:\n{sample}"
)
if len(matches) > 1:
raise ValueError(
"Failed to apply patch: hunk context matched multiple places in the memory block. "
"Please add more unique surrounding context to disambiguate."
)
idx = matches[0]
end = idx + len(expected)
current_lines = current_lines[:idx] + replacement + current_lines[end:]
new_value = "\n".join(current_lines)
agent_state.memory.update_block_value(label=label, value=new_value)
await self.agent_manager.update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor)
return (
f"The core memory block with label `{label}` has been edited. "
"Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). "
"Edit the memory block again if necessary."
)
async def memory_insert(
self,
agent_state: AgentState,