drop list_documents/get_document from default RAG skill

This commit is contained in:
Yiorgis Gozadinos 2026-05-26 11:14:05 +03:00
parent 14d9951016
commit 74221b1447
No known key found for this signature in database
6 changed files with 88 additions and 64 deletions

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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