diff --git a/manuals/scratchpad.md b/manuals/scratchpad.md new file mode 100644 index 0000000..961fd1f --- /dev/null +++ b/manuals/scratchpad.md @@ -0,0 +1,80 @@ +# scratchpad + +Working memory for the current session. Use this to save facts discovered mid-task +(file paths, config values, error messages) so they are not lost across tool calls. + +## When to use + +- You found a file path during exploration and need to reference it later. +- An error occurred and you want to record what was tried before attempting a fix. +- You are in a multi-step task and need to remember the overall goal. +- You want to collect findings before composing the final answer. + +## When NOT to use + +- Do NOT use for progress tracking — use `todo` for that. +- Do NOT use for final answers — the final answer goes in the assistant message. + +## Parameters + +| Field | Type | Required | Description | +|----------|--------|----------|-------------| +| action | string | **yes** | One of: `write`, `append`, `read`, `clear` | +| section | string | no | Section name: `goal`, `findings`, `artifacts`, `errors`, `main`. Defaults to `main` on write/append. | +| content | string | no | **Required for `write` and `append`**. The text to save. | + +## Actions + +### write +Overwrite a section with new content. + +```json +{"action": "write", "section": "findings", "content": "The config file is at /etc/app/config.yml"} +``` + +### append +Add text to the end of an existing section. + +```json +{"action": "append", "section": "findings", "content": "Port is 8080"} +``` + +### read +Return the contents of one section, or all sections if `section` is omitted. + +```json +{"action": "read", "section": "findings"} +``` + +### clear +Erase one section, or the entire scratchpad if `section` is omitted. + +```json +{"action": "clear", "section": "errors"} +``` + +## Common mistakes + +1. **Missing `content` on write/append** — The call will fail. Always provide `content`. +2. **Putting text in `section` instead of `content`** — `section` is the category name (e.g. `"findings"`), `content` is the actual text. +3. **Using `op` instead of `action`** — The parameter is called `action`, not `op`. +4. **Passing text as the action value** — `{"action": "your text"}` is wrong. Use `{"action": "write", "content": "your text"}`. + +## Full example workflow + +``` +# Step 1: Set the goal +{"action": "write", "section": "goal", "content": "Fix the database connection error"} + +# Step 2: Record what you found +{"action": "write", "section": "findings", "content": "Error says 'connection refused on port 5432'"} + +# Step 3: Add more info +{"action": "append", "section": "findings", "content": "PostgreSQL service is stopped"} + +# Step 4: Check what you saved +{"action": "read"} + +# Step 5: After fixing, clear errors +{"action": "clear", "section": "errors"} +``` diff --git a/navi/tools/scratchpad.py b/navi/tools/scratchpad.py index 401be1a..b925ae9 100644 --- a/navi/tools/scratchpad.py +++ b/navi/tools/scratchpad.py @@ -36,15 +36,27 @@ name = "scratchpad" description = ( "Working memory for the current session — for facts discovered mid-task, not for progress tracking. " - "Sections: goal (one-sentence objective), findings (key facts from tool results), " - "artifacts (file paths, URLs), errors (failures and what was tried). " - "Write 'goal' at the start of any multi-step task. " - "Read before composing a final answer — findings may contain facts needed for the response." + "Use this to save intermediate findings (file paths, URLs, error details) so they are not lost across tool calls. " + "Read before composing a final answer — saved findings may contain facts needed for the response.\n\n" + "JSON schema:\n" + " action: 'write' | 'append' | 'read' | 'clear'\n" + " section: string — which section to target (goal, findings, artifacts, errors, main). Defaults to 'main'.\n" + " content: string — text to write or append. REQUIRED for write and append.\n\n" + "Examples (copy this structure exactly):\n" + " {\"action\": \"write\", \"section\": \"findings\", \"content\": \"The config file is at /etc/app/config.yml\"}\n" + " {\"action\": \"append\", \"section\": \"findings\", \"content\": \"Port is 8080\"}\n" + " {\"action\": \"read\", \"section\": \"findings\"}\n" + " {\"action\": \"clear\", \"section\": \"errors\"}\n\n" + "Common mistakes to avoid:\n" + " - Do NOT omit 'content' on write/append — the call will fail.\n" + " - Do NOT pass the text as the action value (e.g. {\"action\": \"your text here\"}). " + " The action MUST be exactly one of: write, append, read, clear.\n" + " - Do NOT put the text inside 'section' — section is the category name, content is the text." ) parameters = { "type": "object", "properties": { - "op": { + "action": { "type": "string", "enum": ["write", "append", "read", "clear"], "description": ( @@ -66,12 +78,12 @@ "content": { "type": "string", "description": ( - "Text to write or append. REQUIRED for 'write' and 'append' — " - "the call will fail without it." + "Text to write or append. REQUIRED for 'write' and 'append'. " + "The call will fail if this is missing or empty." ), }, }, - "required": ["op"], + "required": ["action"], } def __init__(self, kv_store=None) -> None: @@ -80,21 +92,49 @@ async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult: sid = _sid(ctx.session_id if ctx else None) - op = params.get("op") + # Support both 'action' (new) and 'op' (legacy) for backward compatibility + action = params.get("action") or params.get("op") section: str | None = params.get("section") or None content: str = params.get("content", "") - if op == "write": + if not action: + return ToolResult( + success=False, + output="", + error=( + "Missing required parameter 'action'. " + "Expected one of: write, append, read, clear. " + "Example: {\"action\": \"write\", \"section\": \"findings\", \"content\": \"...\"}" + ), + ) + + if action == "write": if not content: - return ToolResult(success=False, output="", error="'content' is required for 'write'") + return ToolResult( + success=False, + output="", + error=( + "'content' is required for 'write'. " + "You must provide the text to save in the 'content' field. " + "Example: {\"action\": \"write\", \"section\": \"findings\", \"content\": \"The result is 42\"}" + ), + ) key = section or "main" if _kv_store is not None: await _kv_store.set(_uid(ctx.user_id if ctx else None), sid, "scratchpad", key, content) return ToolResult(success=True, output=f"[{key}] written ({len(content)} chars).") - if op == "append": + if action == "append": if not content: - return ToolResult(success=False, output="", error="'content' is required for 'append'") + return ToolResult( + success=False, + output="", + error=( + "'content' is required for 'append'. " + "You must provide the text to append in the 'content' field. " + "Example: {\"action\": \"append\", \"section\": \"findings\", \"content\": \"Additional note\"}" + ), + ) key = section or "main" if _kv_store is not None: existing = await _kv_store.get(_uid(ctx.user_id if ctx else None), sid, "scratchpad", key) or "" @@ -103,7 +143,7 @@ return ToolResult(success=True, output=f"[{key}] updated ({len(new)} chars total).") return ToolResult(success=True, output=f"[{key}] updated.") - if op == "read": + if action == "read": if _kv_store is None: return ToolResult(success=True, output="Scratchpad is empty.") if section is not None: @@ -118,7 +158,7 @@ parts = [f"[{k}]:\n{v}" for k, v in all_data.items()] return ToolResult(success=True, output="\n\n".join(parts)) - if op == "clear": + if action == "clear": if _kv_store is None: return ToolResult(success=True, output="Scratchpad cleared.") if section is not None: @@ -131,4 +171,12 @@ await _kv_store.clear_scope(_uid(ctx.user_id if ctx else None), sid, "scratchpad") return ToolResult(success=True, output="Scratchpad cleared.") - return ToolResult(success=False, output="", error=f"Unknown op: {op!r}") + return ToolResult( + success=False, + output="", + error=( + f"Unknown action: {action!r}. " + f"Expected one of: write, append, read, clear. " + f"Example: {{\"action\": \"write\", \"section\": \"findings\", \"content\": \"...\"}}" + ), + ) diff --git a/tests/unit/tools/test_scratchpad.py b/tests/unit/tools/test_scratchpad.py index 26ad10a..4772186 100644 --- a/tests/unit/tools/test_scratchpad.py +++ b/tests/unit/tools/test_scratchpad.py @@ -2,7 +2,7 @@ import pytest -from navi.tools.scratchpad import ScratchpadTool, get_section, _kv_store +from navi.tools.scratchpad import ScratchpadTool, get_section from navi.tools._internal.base import ToolContext from navi.store import KvStore from tests.conftest_factory import FakePool @@ -49,10 +49,10 @@ @pytest.mark.asyncio async def test_write_then_read(_fake_kv): tool = ScratchpadTool() - result = await tool.execute({"op": "write", "section": "findings", "content": "found X"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + result = await tool.execute({"action": "write", "section": "findings", "content": "found X"}, ctx=ToolContext(session_id="sess1", user_id="user1")) assert result.success is True - result = await tool.execute({"op": "read", "section": "findings"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + result = await tool.execute({"action": "read", "section": "findings"}, ctx=ToolContext(session_id="sess1", user_id="user1")) assert result.success is True assert "found X" in result.output @@ -60,11 +60,11 @@ @pytest.mark.asyncio async def test_append(_fake_kv): tool = ScratchpadTool() - await tool.execute({"op": "write", "section": "findings", "content": "line1"}, ctx=ToolContext(session_id="sess1", user_id="user1")) - result = await tool.execute({"op": "append", "section": "findings", "content": "line2"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + await tool.execute({"action": "write", "section": "findings", "content": "line1"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + result = await tool.execute({"action": "append", "section": "findings", "content": "line2"}, ctx=ToolContext(session_id="sess1", user_id="user1")) assert result.success is True - result = await tool.execute({"op": "read", "section": "findings"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + result = await tool.execute({"action": "read", "section": "findings"}, ctx=ToolContext(session_id="sess1", user_id="user1")) assert "line1" in result.output assert "line2" in result.output @@ -72,9 +72,9 @@ @pytest.mark.asyncio async def test_read_all_sections(_fake_kv): tool = ScratchpadTool() - await tool.execute({"op": "write", "section": "goal", "content": "do thing"}, ctx=ToolContext(session_id="sess1", user_id="user1")) - await tool.execute({"op": "write", "section": "findings", "content": "found"}, ctx=ToolContext(session_id="sess1", user_id="user1")) - result = await tool.execute({"op": "read"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + await tool.execute({"action": "write", "section": "goal", "content": "do thing"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + await tool.execute({"action": "write", "section": "findings", "content": "found"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + result = await tool.execute({"action": "read"}, ctx=ToolContext(session_id="sess1", user_id="user1")) assert result.success is True assert "goal" in result.output assert "findings" in result.output @@ -83,26 +83,64 @@ @pytest.mark.asyncio async def test_clear_section(_fake_kv): tool = ScratchpadTool() - await tool.execute({"op": "write", "section": "goal", "content": "do thing"}, ctx=ToolContext(session_id="sess1", user_id="user1")) - result = await tool.execute({"op": "clear", "section": "goal"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + await tool.execute({"action": "write", "section": "goal", "content": "do thing"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + result = await tool.execute({"action": "clear", "section": "goal"}, ctx=ToolContext(session_id="sess1", user_id="user1")) assert result.success is True - result = await tool.execute({"op": "read", "section": "goal"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + result = await tool.execute({"action": "read", "section": "goal"}, ctx=ToolContext(session_id="sess1", user_id="user1")) assert "empty" in result.output.lower() @pytest.mark.asyncio async def test_scope_isolation(_fake_kv): tool = ScratchpadTool() - await tool.execute({"op": "write", "section": "main", "content": "sess1 data"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + await tool.execute({"action": "write", "section": "main", "content": "sess1 data"}, ctx=ToolContext(session_id="sess1", user_id="user1")) - result = await tool.execute({"op": "read", "section": "main"}, ctx=ToolContext(session_id="sess2", user_id="user1")) + result = await tool.execute({"action": "read", "section": "main"}, ctx=ToolContext(session_id="sess2", user_id="user1")) assert "empty" in result.output.lower() @pytest.mark.asyncio async def test_get_section(_fake_kv): tool = ScratchpadTool() - await tool.execute({"op": "write", "section": "artifacts", "content": "path/to/file"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + await tool.execute({"action": "write", "section": "artifacts", "content": "path/to/file"}, ctx=ToolContext(session_id="sess1", user_id="user1")) text = await get_section("sess1", "artifacts", user_id="user1") assert text == "path/to/file" + + +@pytest.mark.asyncio +async def test_legacy_op_fallback(_fake_kv): + """Backward compatibility: old calls using 'op' instead of 'action' still work.""" + tool = ScratchpadTool() + result = await tool.execute({"op": "write", "section": "legacy", "content": "old format"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + assert result.success is True + + result = await tool.execute({"action": "read", "section": "legacy"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + assert "old format" in result.output + + +@pytest.mark.asyncio +async def test_write_missing_content_gives_helpful_error(_fake_kv): + tool = ScratchpadTool() + result = await tool.execute({"action": "write", "section": "findings"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + assert result.success is False + assert "content" in result.error.lower() + assert "example" in result.error.lower() + + +@pytest.mark.asyncio +async def test_append_missing_content_gives_helpful_error(_fake_kv): + tool = ScratchpadTool() + result = await tool.execute({"action": "append", "section": "findings"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + assert result.success is False + assert "content" in result.error.lower() + assert "example" in result.error.lower() + + +@pytest.mark.asyncio +async def test_unknown_action_gives_helpful_error(_fake_kv): + tool = ScratchpadTool() + result = await tool.execute({"action": "delete", "section": "findings"}, ctx=ToolContext(session_id="sess1", user_id="user1")) + assert result.success is False + assert "expected one of" in result.error.lower() + assert "example" in result.error.lower()