-
Notifications
You must be signed in to change notification settings - Fork 8
Add generic memory tool #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
KavyaSree2610
wants to merge
20
commits into
main
Choose a base branch
from
kkaitepalli/genericmemorytool
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,440
−0
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
1c9b809
Implement generic Memory Tool and Anthropic-native memory tool
315cc03
Refactor MemoryTool error handling to return CmdReturn instead of rai…
0260226
Initial plan
Copilot ae8e01e
Add workflow_dispatch trigger to dockerBuildPush workflow
Copilot abba902
Modify comments in AnthropicMemoryTool implementation
213aa9e
Add unit tests for MemoryTool and AnthropicMemoryTool functionality
b4d753e
Add tests for coverage
579e6a3
move tool dispatch out of AnthropicApi into MicroBot run loop
c7d2657
enhance MemoryTool path validation and logging
3067b59
enhance path validation
1ec3251
Add generic memory tool
364be95
Add test in MemoryTool
61704c9
Refactor AnthropicApi and LLMInterface: remove unused parameters and …
7d6c782
Update comment
2de3844
Refactor test files: remove unnecessary imports and clean up code
ee0b080
Refactor MemoryTool integration tests: streamline mock responses and …
fc45c0e
Enhance MemoryTool: integrate argparse for command parsing and improv…
3a9592d
resolve copilot comments
8904e34
Remove unused import
KavyaSree2610 77578bd
Refactor MemoryTool documentation and validation: improve path valida…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from microbots.tools.tool_definitions.memory_tool import MemoryTool |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,311 @@ | ||
| import argparse | ||
| import logging | ||
| import os | ||
| import shlex | ||
| import shutil | ||
| from pathlib import Path | ||
| from typing import Optional | ||
|
|
||
| from pydantic.dataclasses import dataclass, Field | ||
|
|
||
| from microbots.environment.Environment import CmdReturn | ||
| from microbots.tools.external_tool import ExternalTool | ||
|
|
||
| logger = logging.getLogger(" 🧠 MemoryTool") | ||
|
|
||
|
|
||
| class _NoExitArgumentParser(argparse.ArgumentParser): | ||
| """ArgumentParser that raises ``ValueError`` instead of calling ``sys.exit``.""" | ||
|
|
||
| def error(self, message: str) -> None: # type: ignore[override] | ||
| raise ValueError(message) | ||
|
|
||
| INSTRUCTIONS_TO_LLM = """ | ||
| Use this tool to persist information to files across steps. | ||
| All paths must be under /memories/. | ||
|
|
||
| MEMORY PROTOCOL: | ||
| 1. ALWAYS run `memory view /memories` BEFORE doing anything else to check for | ||
| earlier progress. | ||
| 2. Record status, findings and intermediate results as you go. | ||
| 3. Before completing a task, save your final results to memory. | ||
| 4. Keep the memory folder organised — rename or delete stale files. | ||
|
|
||
| ## Commands | ||
|
|
||
| View a file or list a directory: | ||
| memory view <path> | ||
| memory view <path> --start <line> --end <line> | ||
|
|
||
| Create a file: | ||
| memory create <path> <content> | ||
|
|
||
| Replace a unique string in a file: | ||
| memory str_replace <path> --old "<old_text>" --new "<new_text>" | ||
|
|
||
| Insert a line into a file (0 = prepend): | ||
| memory insert <path> --line <line_number> --text "<text>" | ||
|
|
||
| Delete a file or directory: | ||
| memory delete <path> | ||
|
|
||
| Rename / move a file: | ||
| memory rename <old_path> <new_path> | ||
|
|
||
| Clear all memory: | ||
| memory clear | ||
|
|
||
| ## Examples | ||
|
|
||
| memory view /memories | ||
| memory create /memories/progress.md "## Progress\\n- Found bug in src/foo.py line 42" | ||
| memory str_replace /memories/progress.md --old "line 42" --new "line 45" | ||
| memory insert /memories/progress.md --line 0 --text "# Task Notes" | ||
| memory view /memories/progress.md --start 1 --end 10 | ||
| memory delete /memories/old_notes.md | ||
| memory rename /memories/draft.md /memories/final.md | ||
|
|
||
| ## Notes | ||
| - Paths must start with /memories/. | ||
| - memory create overwrites if the file already exists. | ||
| - memory str_replace requires the old text to appear exactly once. | ||
| - In memory view, use --end -1 to read through the end of the file. | ||
| """ | ||
|
|
||
|
|
||
| @dataclass | ||
| class MemoryTool(ExternalTool): | ||
| """ | ||
| File-backed memory tool that dispatches through the text command loop and | ||
| works consistently across providers. | ||
|
|
||
| Subclass of ``ExternalTool`` — all command lists are empty so | ||
| ``install_tool``, ``setup_tool``, ``verify_tool_installation``, and | ||
| ``uninstall_tool`` are all effective no-ops inherited from ``ExternalTool``. | ||
|
|
||
| All files are stored under ``memory_dir`` on the host (default | ||
| ``~/.microbots/memory``). The LLM uses paths like ``/memories/notes.md`` | ||
| which are resolved relative to ``memory_dir``. | ||
| """ | ||
|
|
||
| name: str = Field(default="memory") | ||
| description: str = Field( | ||
| default="File-backed memory store — view, create, edit, delete files under /memories/." | ||
| ) | ||
| usage_instructions_to_llm: str = Field(default=INSTRUCTIONS_TO_LLM) | ||
| memory_dir: Optional[str] = Field(default=None) | ||
|
|
||
| def __post_init__(self): | ||
| base = Path(self.memory_dir) if self.memory_dir else Path.home() / ".microbots" / "memory" | ||
| self._memory_dir = base | ||
| self._memory_dir.mkdir(parents=True, exist_ok=True) | ||
| self._parser = self._build_parser() | ||
|
|
||
| def _build_parser(self) -> _NoExitArgumentParser: | ||
| """Build the argparse parser with subparsers for each memory subcommand.""" | ||
| parser = _NoExitArgumentParser(prog="memory", add_help=False) | ||
| subs = parser.add_subparsers(dest="subcommand") | ||
|
|
||
| p_view = subs.add_parser("view", add_help=False) | ||
| p_view.add_argument("path") | ||
| p_view.add_argument("--start", type=int, default=None) | ||
| p_view.add_argument("--end", type=int, default=None) | ||
|
|
||
| p_create = subs.add_parser("create", add_help=False) | ||
| p_create.add_argument("path") | ||
| p_create.add_argument("content", nargs=argparse.REMAINDER) | ||
|
|
||
| p_str = subs.add_parser("str_replace", add_help=False) | ||
| p_str.add_argument("path") | ||
| p_str.add_argument("--old", required=True) | ||
| p_str.add_argument("--new", required=True) | ||
|
|
||
| p_ins = subs.add_parser("insert", add_help=False) | ||
| p_ins.add_argument("path") | ||
| p_ins.add_argument("--line", type=int, required=True) | ||
| p_ins.add_argument("--text", required=True) | ||
|
|
||
| p_del = subs.add_parser("delete", add_help=False) | ||
| p_del.add_argument("path") | ||
|
|
||
| p_ren = subs.add_parser("rename", add_help=False) | ||
| p_ren.add_argument("old_path") | ||
| p_ren.add_argument("new_path") | ||
|
|
||
| subs.add_parser("clear", add_help=False) | ||
|
|
||
| return parser | ||
|
|
||
| # ---------------------------------------------------------------------- # | ||
| # Path helpers | ||
| # ---------------------------------------------------------------------- # | ||
|
|
||
| def _resolve(self, path: str) -> Path: | ||
| """Resolve a /memories/… path to an absolute host path.""" | ||
| if not path.startswith("/"): | ||
| raise ValueError( | ||
| f"Invalid memory path: {path!r}. Paths must start with /memories/." | ||
| ) | ||
|
|
||
| stripped = path.lstrip("/") | ||
|
|
||
| # Reject any path containing '..' components before resolving | ||
| if ".." in Path(stripped).parts: | ||
| raise ValueError(f"Path traversal not allowed: {path!r}") | ||
|
|
||
| if stripped != "memories" and not stripped.startswith("memories/"): | ||
| raise ValueError( | ||
| f"Invalid memory path: {path!r}. Paths must start with /memories/." | ||
| ) | ||
|
|
||
| if stripped == "memories": | ||
| rel = "" | ||
| else: | ||
| rel = stripped[len("memories/"):] | ||
|
|
||
| resolved = (self._memory_dir / rel).resolve() if rel else self._memory_dir.resolve() | ||
| # Use trailing separator to prevent prefix confusion with sibling dirs | ||
| memory_root = str(self._memory_dir.resolve()) | ||
| if resolved != self._memory_dir.resolve() and not str(resolved).startswith(memory_root + os.sep): | ||
KavyaSree2610 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| raise ValueError(f"Path traversal not allowed: {path!r}") | ||
| return resolved | ||
|
|
||
| # ---------------------------------------------------------------------- # | ||
| # ToolAbstract interface | ||
| # ---------------------------------------------------------------------- # | ||
|
|
||
| def is_invoked(self, command: str) -> bool: | ||
| cmd = command.strip() | ||
| return cmd == "memory" or cmd.startswith("memory ") | ||
|
|
||
| def invoke(self, command: str, parent_bot) -> CmdReturn: | ||
| try: | ||
| tokens = shlex.split(command) | ||
| except ValueError as exc: | ||
| return CmdReturn(stdout="", stderr=f"Parse error: {exc}", return_code=1) | ||
|
|
||
| try: | ||
| args = self._parser.parse_args(tokens[1:]) # skip "memory" | ||
| except ValueError as exc: | ||
| return CmdReturn(stdout="", stderr=str(exc), return_code=1) | ||
|
|
||
| if args.subcommand is None: | ||
| return CmdReturn(stdout="", stderr="Usage: memory <subcommand> ...", return_code=1) | ||
|
|
||
| dispatch = { | ||
| "view": self._view, | ||
| "create": self._create, | ||
| "str_replace": self._str_replace, | ||
| "insert": self._insert, | ||
| "delete": self._delete, | ||
| "rename": self._rename, | ||
| } | ||
|
|
||
| try: | ||
| if args.subcommand == "clear": | ||
| return self._clear() | ||
| return dispatch[args.subcommand](args) | ||
| except (OSError, ValueError, RuntimeError, UnicodeDecodeError) as exc: | ||
| logger.error("🧠 MemoryTool error: %s", exc) | ||
KavyaSree2610 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return CmdReturn(stdout="", stderr=str(exc), return_code=1) | ||
|
|
||
| # ---------------------------------------------------------------------- # | ||
| # Subcommand handlers | ||
| # ---------------------------------------------------------------------- # | ||
|
|
||
| def _view(self, args: argparse.Namespace) -> CmdReturn: | ||
| resolved = self._resolve(args.path) | ||
| if not resolved.exists(): | ||
| return CmdReturn(stdout="", stderr=f"Path not found: {args.path!r}", return_code=1) | ||
|
|
||
| if resolved.is_dir(): | ||
| items = [ | ||
| (f"{item.name}/" if item.is_dir() else item.name) | ||
| for item in sorted(resolved.iterdir()) | ||
| if not item.name.startswith(".") | ||
| ] | ||
| result = f"Directory: {args.path}\n" + "\n".join(f"- {i}" for i in items) | ||
| return CmdReturn(stdout=result, stderr="", return_code=0) | ||
|
|
||
| lines = resolved.read_text(encoding="utf-8").splitlines() | ||
| if args.start is not None or args.end is not None: | ||
| s = max(0, (args.start or 1) - 1) | ||
| e = len(lines) if (args.end is None or args.end == -1) else args.end | ||
| lines = lines[s:e] | ||
| base_num = s + 1 | ||
| else: | ||
| base_num = 1 | ||
| numbered = "\n".join(f"{i + base_num:4d}: {line}" for i, line in enumerate(lines)) | ||
| return CmdReturn(stdout=numbered, stderr="", return_code=0) | ||
|
|
||
| def _create(self, args: argparse.Namespace) -> CmdReturn: | ||
| if not args.content: | ||
| return CmdReturn(stdout="", stderr="Usage: memory create <path> <content>", return_code=1) | ||
| content = " ".join(args.content) | ||
| resolved = self._resolve(args.path) | ||
| resolved.parent.mkdir(parents=True, exist_ok=True) | ||
| resolved.write_text(content, encoding="utf-8") | ||
| logger.info("🧠 Memory file created: %s", args.path) | ||
| return CmdReturn(stdout=f"File created: {args.path}", stderr="", return_code=0) | ||
|
|
||
| def _str_replace(self, args: argparse.Namespace) -> CmdReturn: | ||
| resolved = self._resolve(args.path) | ||
| if not resolved.is_file(): | ||
| return CmdReturn(stdout="", stderr=f"File not found: {args.path!r}", return_code=1) | ||
| content = resolved.read_text(encoding="utf-8") | ||
| count = content.count(args.old) | ||
| if count == 0: | ||
| return CmdReturn(stdout="", stderr=f"Text not found in {args.path!r}", return_code=1) | ||
| if count > 1: | ||
| return CmdReturn(stdout="", stderr=f"Text appears {count} times in {args.path!r} - must be unique", return_code=1) | ||
| resolved.write_text(content.replace(args.old, args.new, 1), encoding="utf-8") | ||
| return CmdReturn(stdout=f"File {args.path} has been edited.", stderr="", return_code=0) | ||
|
|
||
| def _insert(self, args: argparse.Namespace) -> CmdReturn: | ||
| resolved = self._resolve(args.path) | ||
| if not resolved.is_file(): | ||
| return CmdReturn(stdout="", stderr=f"File not found: {args.path!r}", return_code=1) | ||
| file_lines = resolved.read_text(encoding="utf-8").splitlines() | ||
| if args.line < 0 or args.line > len(file_lines): | ||
| return CmdReturn(stdout="", stderr=f"Invalid line number {args.line}. Must be 0 - {len(file_lines)}.", return_code=1) | ||
| file_lines.insert(args.line, args.text.rstrip("\n")) | ||
| resolved.write_text("\n".join(file_lines) + "\n", encoding="utf-8") | ||
| return CmdReturn(stdout=f"Text inserted at line {args.line} in {args.path}.", stderr="", return_code=0) | ||
|
|
||
| def _delete(self, args: argparse.Namespace) -> CmdReturn: | ||
| resolved = self._resolve(args.path) | ||
| if resolved == self._memory_dir.resolve(): | ||
| return CmdReturn(stdout="", stderr="Cannot delete the /memories root directory", return_code=1) | ||
| if resolved.is_file(): | ||
| resolved.unlink() | ||
| logger.info("🧠 Memory file deleted: %s", args.path) | ||
| return CmdReturn(stdout=f"Deleted: {args.path}", stderr="", return_code=0) | ||
| if resolved.is_dir(): | ||
| shutil.rmtree(resolved) | ||
| logger.info("🧠 Memory directory deleted: %s", args.path) | ||
| return CmdReturn(stdout=f"Deleted directory: {args.path}", stderr="", return_code=0) | ||
| return CmdReturn(stdout="", stderr=f"Path not found: {args.path!r}", return_code=1) | ||
|
|
||
| def _rename(self, args: argparse.Namespace) -> CmdReturn: | ||
| old_resolved = self._resolve(args.old_path) | ||
| new_resolved = self._resolve(args.new_path) | ||
| memory_root = self._memory_dir.resolve() | ||
| if old_resolved == memory_root: | ||
| return CmdReturn(stdout="", stderr="Cannot rename the /memories root directory", return_code=1) | ||
| if new_resolved == memory_root: | ||
| return CmdReturn(stdout="", stderr="Cannot overwrite the /memories root directory", return_code=1) | ||
| if not old_resolved.exists(): | ||
| return CmdReturn(stdout="", stderr=f"Source not found: {args.old_path!r}", return_code=1) | ||
| if new_resolved.exists(): | ||
| return CmdReturn(stdout="", stderr=f"Destination already exists: {args.new_path!r}", return_code=1) | ||
| new_resolved.parent.mkdir(parents=True, exist_ok=True) | ||
| old_resolved.rename(new_resolved) | ||
| logger.info("🧠 Memory renamed: %s → %s", args.old_path, args.new_path) | ||
| return CmdReturn(stdout=f"Renamed {args.old_path} to {args.new_path}.", stderr="", return_code=0) | ||
|
|
||
| def _clear(self) -> CmdReturn: | ||
| if self._memory_dir.exists(): | ||
| shutil.rmtree(self._memory_dir) | ||
| self._memory_dir.mkdir(parents=True, exist_ok=True) | ||
| logger.info("🧠 Memory cleared.") | ||
| return CmdReturn(stdout="Memory cleared.", stderr="", return_code=0) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.