drop list_documents/get_document from default RAG skill
This commit is contained in:
parent
14d9951016
commit
74221b1447
6 changed files with 88 additions and 64 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue