diff --git a/letta/functions/function_sets/base.py b/letta/functions/function_sets/base.py index 181edd86..188c4a04 100644 --- a/letta/functions/function_sets/base.py +++ b/letta/functions/function_sets/base.py @@ -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). diff --git a/letta/services/tool_executor/core_tool_executor.py b/letta/services/tool_executor/core_tool_executor.py index 4c381fdb..55c54b13 100644 --- a/letta/services/tool_executor/core_tool_executor.py +++ b/letta/services/tool_executor/core_tool_executor.py @@ -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,