diff --git a/src/rlm/prompt.py b/src/rlm/prompt.py index 8f72012..ab4ddef 100644 --- a/src/rlm/prompt.py +++ b/src/rlm/prompt.py @@ -47,9 +47,41 @@ "benchmark, or API may have its own environment and normal interface. " "Evaluate external systems through their own interface, then use IPython " "to coordinate the process and analyze what comes back.\n\n" - "When running shell commands from IPython, use `%%bash` cells. Avoid " - "`!cmd` shell escapes for project commands so shell behavior is explicit " - "and multi-line commands share one shell context.\n\n" + "The IPython `code` argument is executed literally as one code cell. It " + "must contain only raw Python/IPython syntax, not chat-template wrappers, " + "native tool-call tags, JSON fields, or prose prefixes. Wrong: " + "`%%bash\\npytest -q` or `Now inspect the file`. " + "Correct: `%%bash\\npytest -q` or `print(path.read_text())`.\n\n" + "When running shell commands from IPython, use `%%bash` cells. This is a " + "hard syntax rule: if an IPython tool call contains `%%bash`, the `code` " + "argument must begin with exactly `%%bash\n` as its first seven " + "characters. No leading blank line, no leading spaces, no comments, no " + "imports, and no Python statements may appear before it. If a cell starts " + "with `# note\n%%bash`, IPython parses the whole cell as Python and the " + "shell command fails with `SyntaxError`.\n\n" + "Before every IPython tool call, silently check: does `code` contain " + "`%%bash`? If yes, the first line must be exactly `%%bash`. If not, " + "rewrite the tool call before sending it.\n\n" + "Correct:\n" + "```python\n" + "%%bash\n" + "# explanation belongs here, after the magic\n" + "cd /workspace/project && pytest -q\n" + "```\n" + "Wrong:\n" + "```python\n" + "# explanation before the magic breaks IPython\n" + "%%bash\n" + "cd /workspace/project && pytest -q\n" + "```\n" + "Wrong:\n" + "```python\n" + "\n" + "%%bash\n" + "cd /workspace/project && pytest -q\n" + "```\n\n" + "Avoid `!cmd` shell escapes for project commands so shell behavior is " + "explicit and multi-line commands share one shell context.\n\n" "Important: do not install dependencies into the IPython kernel just to " "make an external project import or run there. If a project import, test, " "script, CLI, or dependency check is needed, run it through that project's " @@ -58,10 +90,61 @@ "or the active project interpreter from the repo root. Treat failures from " "that native environment as the relevant result." "\n\n" - "Use Python for reading, searching, and editing files — it gives you " - "reusable variables you can slice, filter, and act on without re-reading. " - "Always assign read/search results to named variables so you can revisit " - "them later." + "Use Python for reading and searching files — it gives you reusable " + "variables you can slice, filter, and act on without re-reading. Always " + "assign read/search results to named variables so you can revisit them " + "later." +) +EDIT_SKILL_PROMPT = ( + "For targeted modifications to existing files, you must use the " + "pre-imported `edit` skill from IPython instead of manual Python file " + "writes. Read and inspect files with normal Python, then make the change " + 'with `await edit(path="relative/file.py", old_str=old, new_str=new)`, ' + "where `old` and `new` are exact strings. Inline string literals are fine " + "when they are valid Python. The target `old_str` must appear exactly " + "once. Inside IPython `code`, never write native tool-call markup like " + "``, ``, or ``; use Python " + 'instead, for example: `old = "..."; new = "..."; await ' + 'edit(path="pkg/module.py", old_str=old, new_str=new)`. ' + "The supported keyword arguments are `path`, `old_str`, `new_str`, " + "and optional `cwd`; do not use `file`, `old`, `new`, line numbers, " + "`after`, or `insert`. If an edit call fails because the string is not " + "found or not unique, inspect the file and retry with a smaller exact " + "snippet before falling back. Only use normal Python file I/O for creating " + "new files or for broad generated rewrites that cannot be expressed as " + "one or more exact replacements.\n\n" + "Before calling `edit`, make sure the Python string syntax is valid. Do " + 'not wrap text that contains `"""` inside a `"""..."""` string; ' + "that creates a SyntaxError before `edit` runs. If the target text " + "contains triple double quotes, use triple single quotes (`'''...'''`) or " + "assign `old`/`new` from inspected file slices. If the text contains both " + "triple quote styles or quoting becomes complex, build `old` and `new` as " + "variables first, then pass those variables to `await edit(...)`.\n\n" + "Good targeted edit pattern:\n" + "```python\n" + "from pathlib import Path\n" + 'text = Path("pkg/module.py").read_text()\n' + 'print(text[text.index("def broken") : text.index("def next_func")])\n' + "old = '''def broken():\n" + ' """Docstring with triple double quotes."""\n' + " return False\n" + "'''\n" + "new = '''def broken():\n" + ' """Docstring with triple double quotes."""\n' + " return True\n" + "'''\n" + 'await edit(path="pkg/module.py", old_str=old, new_str=new)\n' + "```\n" + "Do not do this for targeted edits:\n" + "```python\n" + 'await edit(path="pkg/module.py", old_str="""def broken():\n' + ' """Docstring closes the outer string early."""\n' + " return False\n" + '""", new_str="""...\n' + '""")\n' + 'Path("pkg/module.py").write_text(text.replace(old, new))\n' + 'open("pkg/module.py", "w").write(new_contents)\n' + "```" ) @@ -108,6 +191,8 @@ def build_system_prompt( "Each skill is also available as a shell command by the same name: ` ...`. " "Discover its CLI usage with ` --help`." ) + if "edit" in installed_skills: + skill_lines.append(EDIT_SKILL_PROMPT) if skill_lines: parts.extend(["", *skill_lines]) diff --git a/src/rlm/tools/ipython.py b/src/rlm/tools/ipython.py index 24a1831..babd5a3 100644 --- a/src/rlm/tools/ipython.py +++ b/src/rlm/tools/ipython.py @@ -4,11 +4,11 @@ import copy import os -from queue import Empty import re import sys import threading import time +from queue import Empty from typing import TYPE_CHECKING, Any from rlm.tools.base import ToolContext, ToolOutcome @@ -30,7 +30,10 @@ "Use !command for shell commands (e.g. !ls -la, !cat file.py, !pip install foo). " "Use !python3 to run code with the project's own packages " "(e.g. !python3 -m pytest, !python3 -c 'import numpy'). " - "Use %%bash for multi-line shell scripts." + "Use %%bash for multi-line shell scripts. If code contains %%bash, " + "the code string must begin exactly with %%bash followed by a " + "newline. Do not put a blank line, spaces, comments, imports, or " + "Python code before %%bash." ), "parameters": { "type": "object", @@ -65,8 +68,7 @@ def schema(self) -> dict[str, Any]: ) schema = copy.deepcopy(IPYTHON_SCHEMA) schema["function"]["parameters"]["properties"]["timeout"]["description"] = ( - "Optional timeout in seconds. " - f"Default: {timeout}s. Maximum: {IPYTHON_TIMEOUT_MAX_SECONDS}s." + f"Optional timeout in seconds. Default: {timeout}s. Maximum: {IPYTHON_TIMEOUT_MAX_SECONDS}s." ) return schema diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 72bb761..7b1f1da 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from rlm.prompt import ( + EDIT_SKILL_PROMPT, GIT_HISTORY_GUARD_PROMPT, IPYTHON_CONTROL_PROMPT, build_system_prompt, @@ -16,11 +17,15 @@ class _Tool: name: str -def _prompt(active_tools: list[_Tool]) -> str: +def _prompt( + active_tools: list[_Tool], + *, + installed_skills: list[str] | None = None, +) -> str: return build_system_prompt( "/repo", None, - [], + installed_skills or [], "/repo/.rlm/messages.jsonl", allow_recursion=False, active_tools=active_tools, @@ -57,8 +62,33 @@ def test_ipython_control_prompt_included_for_ipython_tool(): assert "long-lived notebook" in prompt assert "native runtime" in prompt assert "use `%%bash` cells" in prompt + assert "first seven characters" in prompt + assert "Before every IPython tool call" in prompt assert "do not install dependencies into the IPython kernel" in prompt + assert "Use Python for reading and searching files" in prompt + assert "Use Python for reading, searching, and editing files" not in prompt def test_ipython_control_prompt_omitted_without_ipython_tool(): assert IPYTHON_CONTROL_PROMPT not in _prompt([_Tool("bash")]) + + +def test_edit_skill_prompt_included_when_edit_is_installed(): + prompt = _prompt([_Tool("ipython")], installed_skills=["edit"]) + + assert EDIT_SKILL_PROMPT in prompt + assert 'await edit(path="relative/file.py", old_str=old, new_str=new)' in prompt + assert "pre-imported `edit` skill from IPython" in prompt + assert "`old_str` must appear exactly once" in prompt + assert "do not use `file`, `old`, `new`, line numbers" in prompt + assert "inspect the file and retry with a smaller exact snippet" in prompt + assert "Only use normal Python file I/O for creating new files" in prompt + assert 'Do not wrap text that contains `"""`' in prompt + assert "use triple single quotes" in prompt + assert "Good targeted edit pattern" in prompt + + +def test_edit_skill_prompt_omitted_without_edit_skill(): + prompt = _prompt([_Tool("ipython")], installed_skills=["search_docs"]) + + assert EDIT_SKILL_PROMPT not in prompt