diff --git a/CHANGELOG.md b/CHANGELOG.md index ca3e3505..48f73071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Changed + +- Drop `list_documents` and `get_document` from the default RAG skill's tool set; the skill now exposes only `search` and `cite`. Both tools dumped unbounded content into the agent's context (full document lists, full document bodies) and `get_document` returned no chunk_ids so its output was structurally uncitable. The analysis skill already covers these uses programmatically — `await list_documents()` and `Path('/documents/{id}/content.txt').read_text()` inside `execute_code`. The tool branches remain in `create_skill_tools` and the `skill_generator` `AVAILABLE_TOOLS` set so users can still opt in when building custom skills. + ## [0.48.1] - 2026-05-21 ### Changed diff --git a/docs/skills/rag.md b/docs/skills/rag.md index 14ee8418..01a76624 100644 --- a/docs/skills/rag.md +++ b/docs/skills/rag.md @@ -15,10 +15,10 @@ If the question requires *computation* over the corpus (counts, aggregates, comp | Tool | Purpose | |------|---------| | `search(query, limit?)` | Hybrid search (vector + full-text) with section-aware context expansion. Returns `chunk_id`, content, `doc_item_refs`, `picture_refs`, `picture_captions`, source metadata. | -| `list_documents()` | List all documents in the knowledge base. | -| `get_document(query)` | Fetch a document by ID, title, or URI. Partial matches work. | | `cite(chunk_ids)` | Register chunk IDs as citations for the current answer. The agent calls this before writing the final response. | +For corpus enumeration or full-document reads, reach for the [Analysis skill](analysis.md), which exposes `await list_documents()` and a `/documents/{id}/content.txt` virtual filesystem inside `execute_code`. Both are also available as opt-in tools when building a [custom skill](custom.md). + ## State The skill manages a `RAGState` under the `"rag"` namespace: @@ -33,7 +33,7 @@ class RAGState(BaseModel): - **citation_index** — All citations indexed by chunk ID. Accumulates across invocations so historical chunk IDs stay resolvable in UI scrollback. - **citations** — Chunk IDs registered via `cite` during the current invocation. Deduplicated, cleared at the start of each invocation. -- **document_filter** — SQL WHERE clause applied to `search` and `list_documents`. Persists across invocations. +- **document_filter** — SQL WHERE clause applied to `search`. Persists across invocations. - **searches** — Search results keyed by query string. Cleared at the start of each invocation. ## `create_skill(db_path?, config?)` @@ -99,7 +99,7 @@ state.document_filter = "uri LIKE '%helios/v4/%'" result = await agent.run("What's the maintenance interval for the inverters?") ``` -The filter applies to every `search` and `list_documents` call for the rest of the session, including the model can't bypass it from inside. +The filter applies to every `search` call for the rest of the session, and the model can't bypass it from inside. ### Combining with the analysis skill diff --git a/haiku_rag_slim/haiku/rag/skills/rag.py b/haiku_rag_slim/haiku/rag/skills/rag.py index 74ec28fb..cbde84df 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag.py +++ b/haiku_rag_slim/haiku/rag/skills/rag.py @@ -18,7 +18,7 @@ CRITICAL RULES: 3. When a skill returns citations, always include them in your response """ -_RAG_TOOLS = ["search", "list_documents", "get_document", "cite"] +_RAG_TOOLS = ["search", "cite"] def get_agent_preamble(config: AppConfig) -> str: diff --git a/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md b/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md index a39b918a..18b2ff48 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md +++ b/haiku_rag_slim/haiku/rag/skills/rag/SKILL.md @@ -21,12 +21,6 @@ Each result includes: When a result's Type is `picture`, the corresponding figure may also be attached to the tool response as an image alongside the text. Use the image directly to answer questions about figures, diagrams, charts, screenshots. -### list_documents -List available documents in the knowledge base. Use when the user wants to browse what's available. - -### get_document -Retrieve a document by ID, title, or URI. Partial matches work. Use when the user wants the full content of a specific document. - ### cite Register the chunk IDs that ground your answer. Call this BEFORE writing your final answer, with the `chunk_id` values from search results that support each claim. Every answer that uses search results must be backed by `cite`. @@ -52,12 +46,6 @@ You MUST call `cite` with at least one chunk ID before producing your final answ - If the retrieved documents do not directly address the question, say: "I cannot find enough information in the knowledge base to answer this question." Do not guess or infer from tangentially related content. In this refusal case do **not** call `cite` — there is nothing to cite. - Do NOT include chunk IDs or UUIDs in your answer text — your answer should read naturally. Use the `cite` tool separately to register citations. -## When the user mentions a specific document - -If the user says "search in [doc]", "find in [doc]", or "answer from [doc]": -- Use `get_document` or `list_documents` first to identify the document -- Then search for the topic - ## When search returns irrelevant results If your first search returns results that clearly don't match the question: diff --git a/tests/skills/test_rag.py b/tests/skills/test_rag.py index 26dbb413..e72eaec3 100644 --- a/tests/skills/test_rag.py +++ b/tests/skills/test_rag.py @@ -112,7 +112,7 @@ class TestRAGSkillCreation: skill = create_skill(config=test_app_config, db_path=temp_db_path) tool_names = {getattr(t, "__name__") for t in skill.tools if callable(t)} - assert tool_names == {"search", "list_documents", "get_document", "cite"} + assert tool_names == {"search", "cite"} def test_create_skill_has_state(self, test_app_config, temp_db_path): from haiku.rag.skills.rag import RAGState, create_skill @@ -220,52 +220,6 @@ class TestSearchTool: assert len(state.searches) == 2 -class TestListDocumentsTool: - async def test_list_documents_returns_results(self, rag_db, rag_client): - from haiku.rag.skills.rag import create_skill - - skill = create_skill(db_path=rag_db) - list_docs = _get_tool(skill, "list_documents") - ctx = _make_ctx(rag=rag_client) - results = await list_docs(ctx) - assert isinstance(results, list) - assert len(results) == 2 - - async def test_list_documents_applies_document_filter_from_state( - self, rag_db, rag_client - ): - from haiku.rag.skills.rag import RAGState, create_skill - - skill = create_skill(db_path=rag_db) - list_docs = _get_tool(skill, "list_documents") - state = RAGState(document_filter="title = 'AI Overview'") - ctx = _make_ctx(state, rag=rag_client) - results = await list_docs(ctx) - assert len(results) == 1 - assert results[0]["title"] == "AI Overview" - - -class TestGetDocumentTool: - async def test_get_document_by_title(self, rag_db, rag_client): - from haiku.rag.skills.rag import create_skill - - skill = create_skill(db_path=rag_db) - get_doc = _get_tool(skill, "get_document") - ctx = _make_ctx(rag=rag_client) - result = await get_doc(ctx, query="AI Overview") - assert result is not None - assert result["title"] == "AI Overview" - - async def test_get_document_not_found(self, rag_db, rag_client): - from haiku.rag.skills.rag import create_skill - - skill = create_skill(db_path=rag_db) - get_doc = _get_tool(skill, "get_document") - ctx = _make_ctx(rag=rag_client) - result = await get_doc(ctx, query="nonexistent document xyz") - assert result is None - - class TestCiteTool: async def test_cite_registers_citations(self, rag_db, rag_client): from haiku.rag.skills.rag import RAGState, create_skill diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py index 8364d8ed..3e882c50 100644 --- a/tests/test_skill_tools.py +++ b/tests/test_skill_tools.py @@ -24,6 +24,7 @@ from haiku.rag.skills._deps import RAGRunDeps from haiku.rag.skills._tools import create_skill_tools from haiku.rag.skills.rag import RAGState from haiku.rag.store.models.chunk import SearchResult +from haiku.rag.store.models.document import Document def _make_png(color: str = "red") -> bytes: @@ -239,3 +240,80 @@ async def test_skill_search_keeps_same_self_ref_from_different_documents(): payloads = {part.data for part in result.content} # type: ignore[attr-defined] assert PICTURE_BYTES in payloads assert other_bytes in payloads + + +def _build_tool(config: AppConfig, name: str): + tools = create_skill_tools( + db_path=Path("/tmp/unused.lancedb"), + config=config, + state_type=RAGState, + tool_names=[name], + model=config.qa.model, + ) + return tools[name] + + +@pytest.mark.asyncio +async def test_list_documents_tool_returns_shaped_dicts(): + config = AppConfig() + list_documents = _build_tool(config, "list_documents") + rag = AsyncMock() + rag.list_documents = AsyncMock( + return_value=[ + Document(id="d1", content="x", title="AI", uri="test://ai"), + Document(id="d2", content="y", title="ML", uri="test://ml"), + ] + ) + ctx = _make_ctx(rag, RAGState()) + + results = await list_documents(ctx) + + assert [r["title"] for r in results] == ["AI", "ML"] + assert all( + set(r.keys()) == {"id", "title", "uri", "metadata", "created_at", "updated_at"} + for r in results + ) + + +@pytest.mark.asyncio +async def test_list_documents_tool_forwards_document_filter_from_state(): + config = AppConfig() + list_documents = _build_tool(config, "list_documents") + rag = AsyncMock() + rag.list_documents = AsyncMock(return_value=[]) + state = RAGState(document_filter="title = 'AI Overview'") + ctx = _make_ctx(rag, state) + + await list_documents(ctx) + + rag.list_documents.assert_awaited_once_with(filter="title = 'AI Overview'") + + +@pytest.mark.asyncio +async def test_get_document_tool_returns_shaped_dict(): + config = AppConfig() + get_document = _build_tool(config, "get_document") + rag = AsyncMock() + rag.resolve_document = AsyncMock( + return_value=Document(id="d1", content="full text", title="AI", uri="test://ai") + ) + ctx = _make_ctx(rag, RAGState()) + + result = await get_document(ctx, "AI") + + assert result is not None + assert result["content"] == "full text" + assert result["title"] == "AI" + + +@pytest.mark.asyncio +async def test_get_document_tool_returns_none_when_missing(): + config = AppConfig() + get_document = _build_tool(config, "get_document") + rag = AsyncMock() + rag.resolve_document = AsyncMock(return_value=None) + ctx = _make_ctx(rag, RAGState()) + + result = await get_document(ctx, "nonexistent") + + assert result is None