Move client and tool_context from toolset factories to RunContext.deps

This commit is contained in:
Yiorgis Gozadinos 2026-02-12 15:49:14 +02:00
parent 467bcff94d
commit 05ebd781d4
No known key found for this signature in database
25 changed files with 402 additions and 325 deletions

View file

@ -20,7 +20,7 @@ repos:
hooks:
- id: ty
name: ty check
entry: uvx ty check
entry: uv run ty check
language: system
types: [python]
pass_filenames: false

View file

@ -16,6 +16,9 @@
### Changed
- **Toolset factories decoupled from runtime dependencies**: `create_search_toolset()`, `create_qa_toolset()`, `create_document_toolset()`, `create_analysis_toolset()`, and `create_chat_agent()` no longer take `client` or `context` parameters. Instead, tool functions receive these via pydantic-ai's `RunContext.deps`. This enables toolset and agent creation at configuration time (cacheable, created once), with only lightweight deps created per-request. Deps must satisfy the `RAGDeps` protocol (`client: HaikuRAG`, `tool_context: ToolContext | None`)
- **`ChatDeps` now includes `client`**: `ChatDeps(config=..., client=..., tool_context=...)` — the `client` field was added since it's no longer captured by the agent factory
- **`prepare_chat_context()` helper**: Extracted from `create_chat_agent()` for idempotent namespace registration, since the agent factory no longer has access to the context
- **Chat agent architecture**: Rebuilt on composable toolsets instead of monolithic tool definitions. Chat agent is now a thin wrapper around `create_search_toolset`, `create_document_toolset`, `create_qa_toolset`, and `create_analysis_toolset`
- **State management simplified**: Removed `session_id`, `incoming_session_id`, and `incoming_session_context` from the state layer. `ToolContextCache` preserves all state (embeddings, summaries, QA history) on cached `ToolContext` instances, eliminating the need for module-level caches
- **AG-UI state sync**: `ask` tool now emits `StateSnapshotEvent` instead of `StateDeltaEvent`, ensuring background summarization results are reliably delivered to clients

View file

@ -16,6 +16,7 @@ from haiku.rag.agents.chat import (
AGUI_STATE_KEY,
ChatDeps,
create_chat_agent,
prepare_chat_context,
)
from haiku.rag.client import HaikuRAG
from haiku.rag.config import load_yaml_config
@ -68,6 +69,10 @@ def get_client() -> HaikuRAG:
return _client
# Agent is created once at module level (no runtime deps needed)
agent = create_chat_agent(Config)
async def stream_chat(request: Request) -> Response:
"""Chat streaming endpoint with AG-UI protocol.
@ -79,11 +84,13 @@ async def stream_chat(request: Request) -> Response:
run_input = AGUIAdapter.build_run_input(body)
thread_id = getattr(run_input, "thread_id", None) or "default"
context, _is_new = context_cache.get_or_create(thread_id)
agent = create_chat_agent(Config, get_client(), context)
context, is_new = context_cache.get_or_create(thread_id)
if is_new:
prepare_chat_context(context)
deps = ChatDeps(
config=Config,
client=get_client(),
tool_context=context,
state_key=AGUI_STATE_KEY,
)

View file

@ -73,14 +73,15 @@ See [Applications](../apps.md#chat-tui) for the full TUI interface guide.
```python
from haiku.rag.client import HaikuRAG
from haiku.rag.agents.chat import create_chat_agent, ChatDeps
from haiku.rag.agents.chat import create_chat_agent, prepare_chat_context, ChatDeps
from haiku.rag.tools import ToolContext
agent = create_chat_agent(config)
async with HaikuRAG(path_to_db) as client:
# Create agent with composed toolsets
context = ToolContext()
agent = create_chat_agent(config, client, context)
deps = ChatDeps(config=config, tool_context=context)
prepare_chat_context(context)
deps = ChatDeps(config=config, client=client, tool_context=context)
# First question
result = await agent.run("What is haiku.rag?", deps=deps)
@ -105,11 +106,11 @@ from haiku.rag.agents.chat import (
)
# Search-only agent
agent = create_chat_agent(config, client, context, features=[FEATURE_SEARCH])
agent = create_chat_agent(config, features=[FEATURE_SEARCH])
# All features including code analysis
agent = create_chat_agent(
config, client, context,
config,
features=[FEATURE_SEARCH, FEATURE_DOCUMENTS, FEATURE_QA, FEATURE_ANALYSIS],
)
```

View file

@ -432,20 +432,28 @@ See [RLM Agent](agents/rlm.md) for details on capabilities and configuration.
haiku.rag provides composable toolset factories that can be mixed into any pydantic-ai agent. This lets you build custom agents with exactly the capabilities you need — search, document management, Q&A, or code analysis — sharing state across tools via `ToolContext`.
```python
from dataclasses import dataclass
from pydantic_ai import Agent
from haiku.rag.tools import ToolContext, create_search_toolset, create_qa_toolset
from haiku.rag.tools import ToolContext, RAGDeps, create_search_toolset, create_qa_toolset
@dataclass
class MyDeps:
client: HaikuRAG
tool_context: ToolContext | None = None
search = create_search_toolset(config)
qa = create_qa_toolset(config)
agent = Agent(
"openai:gpt-4o",
deps_type=MyDeps,
instructions="You are a helpful assistant.",
toolsets=[search, qa],
)
async with HaikuRAG("path/to/db.lancedb") as client:
context = ToolContext()
agent = Agent(
"openai:gpt-4o",
instructions="You are a helpful assistant.",
toolsets=[
create_search_toolset(client, config, context=context),
create_qa_toolset(client, config, context=context),
],
)
result = await agent.run("What are the main findings?")
deps = MyDeps(client=client, tool_context=ToolContext())
result = await agent.run("What are the main findings?", deps=deps)
```
See [Toolsets](tools.md) for the full API reference and composition guide.

View file

@ -50,19 +50,16 @@ context.load_namespace("my_namespace", MyState, data["my_namespace"])
`create_search_toolset()` provides hybrid search (vector + full-text) with context expansion and citation tracking.
```python
from haiku.rag.tools import ToolContext, create_search_toolset
from haiku.rag.tools import create_search_toolset
context = ToolContext()
search = create_search_toolset(client, config, context=context)
search = create_search_toolset(config)
```
**Parameters:**
| Parameter | Default | Description |
|-----------|---------|-------------|
| `client` | required | HaikuRAG client |
| `config` | required | AppConfig |
| `context` | `None` | ToolContext for state accumulation |
| `expand_context` | `True` | Expand results with surrounding chunks |
| `base_filter` | `None` | SQL WHERE clause applied to all searches |
| `tool_name` | `"search"` | Name of the tool exposed to the agent |
@ -78,19 +75,16 @@ Searches the knowledge base and returns formatted results. When a `ToolContext`
`create_document_toolset()` provides document browsing, retrieval, and summarization.
```python
from haiku.rag.tools import ToolContext, create_document_toolset
from haiku.rag.tools import create_document_toolset
context = ToolContext()
docs = create_document_toolset(client, config, context=context)
docs = create_document_toolset(config)
```
**Parameters:**
| Parameter | Default | Description |
|-----------|---------|-------------|
| `client` | required | HaikuRAG client |
| `config` | required | AppConfig (used for summarization LLM) |
| `context` | `None` | ToolContext for session filtering |
| `base_filter` | `None` | SQL WHERE clause for list operations |
**Tools:**
@ -104,23 +98,18 @@ docs = create_document_toolset(client, config, context=context)
`create_qa_toolset()` provides question answering via the research graph, with prior answer recall and background summarization.
```python
from haiku.rag.tools import ToolContext, create_qa_toolset
from haiku.rag.tools import create_qa_toolset
context = ToolContext()
qa = create_qa_toolset(client, config, context=context)
qa = create_qa_toolset(config)
```
**Parameters:**
| Parameter | Default | Description |
|-----------|---------|-------------|
| `client` | required | HaikuRAG client |
| `config` | required | AppConfig |
| `context` | `None` | ToolContext for state accumulation |
| `base_filter` | `None` | SQL WHERE clause applied to searches |
| `tool_name` | `"ask"` | Name of the tool exposed to the agent |
| `session_context` | `None` | Session context for the research graph |
| `prior_answers` | `None` | Prior answers for context |
**Tool: `ask(question, document_name?)`**
@ -162,16 +151,14 @@ for citation in result.citations:
```python
from haiku.rag.tools import create_analysis_toolset
analysis = create_analysis_toolset(client, config, context=context)
analysis = create_analysis_toolset(config)
```
**Parameters:**
| Parameter | Default | Description |
|-----------|---------|-------------|
| `client` | required | HaikuRAG client |
| `config` | required | AppConfig |
| `context` | `None` | ToolContext for session filtering |
| `base_filter` | `None` | SQL WHERE clause applied to searches |
| `tool_name` | `"analyze"` | Name of the tool exposed to the agent |
@ -184,41 +171,52 @@ Executes a computational task via code execution and returns an `AnalysisResult`
Toolsets are designed to be composed into custom pydantic-ai agents:
```python
from dataclasses import dataclass
from pydantic_ai import Agent
from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.tools import (
ToolContext,
RAGDeps,
create_search_toolset,
create_qa_toolset,
create_document_toolset,
)
# Toolsets are created once at configuration time
search = create_search_toolset(Config)
qa = create_qa_toolset(Config)
docs = create_document_toolset(Config)
@dataclass
class MyDeps:
"""Must satisfy the RAGDeps protocol (client + tool_context)."""
client: HaikuRAG
tool_context: ToolContext | None = None
agent = Agent(
"openai:gpt-4o",
deps_type=MyDeps,
instructions="You are a helpful research assistant.",
toolsets=[search, qa, docs],
)
async with HaikuRAG("path/to/db.lancedb") as client:
# Shared context across all toolsets
context = ToolContext()
deps = MyDeps(client=client, tool_context=context)
# Pick the toolsets you need
search = create_search_toolset(client, Config, context=context)
qa = create_qa_toolset(client, Config, context=context)
docs = create_document_toolset(client, Config, context=context)
agent = Agent(
"openai:gpt-4o",
instructions="You are a helpful research assistant.",
toolsets=[search, qa, docs],
)
result = await agent.run("What documents do we have about climate?")
result = await agent.run("What documents do we have about climate?", deps=deps)
print(result.output)
# Access accumulated state
from haiku.rag.tools import SearchState, SEARCH_NAMESPACE
from haiku.rag.tools.search import SearchState, SEARCH_NAMESPACE
search_state = context.get(SEARCH_NAMESPACE, SearchState)
if search_state:
print(f"Total search results: {len(search_state.results)}")
```
Tool functions access `client` and `tool_context` via pydantic-ai's `RunContext.deps`, so toolsets can be created once and reused across requests. Your deps type just needs to satisfy the `RAGDeps` protocol (have `client` and `tool_context` attributes).
All toolsets respect session-level document filters when a `SessionState` is registered in the context. This means setting `SessionState.document_filter` restricts all tools simultaneously.
## AG-UI State Management
@ -226,16 +224,22 @@ All toolsets respect session-level document filters when a `SessionState` is reg
When using the chat agent with [AG-UI](https://docs.ag-ui.com) streaming, `ChatDeps` implements the `StateHandler` protocol. State is emitted under a namespaced key via `state_key`:
```python
from haiku.rag.agents.chat import AGUI_STATE_KEY, ChatDeps, create_chat_agent
from haiku.rag.agents.chat import (
AGUI_STATE_KEY, ChatDeps, create_chat_agent, prepare_chat_context,
)
from haiku.rag.tools import ToolContext, ToolContextCache
# Agent can be created once at startup
agent = create_chat_agent(config)
# For multi-session apps, cache ToolContext per thread
cache = ToolContextCache()
context, _is_new = cache.get_or_create(thread_id)
prepare_chat_context(context) # idempotent namespace registration
agent = create_chat_agent(config, client, context)
deps = ChatDeps(
config=config,
client=client,
tool_context=context,
state_key=AGUI_STATE_KEY, # "haiku.rag.chat"
)

View file

@ -6,6 +6,7 @@ from haiku.rag.agents.chat.agent import (
FEATURE_SEARCH,
ChatDeps,
create_chat_agent,
prepare_chat_context,
run_chat_agent,
trigger_background_summarization,
)
@ -25,6 +26,7 @@ __all__ = [
"FEATURE_SEARCH",
"build_chat_prompt",
"create_chat_agent",
"prepare_chat_context",
"run_chat_agent",
"trigger_background_summarization",
"ChatDeps",

View file

@ -38,10 +38,11 @@ DEFAULT_FEATURES = [FEATURE_SEARCH, FEATURE_DOCUMENTS, FEATURE_QA]
class ChatDeps:
"""Dependencies for chat agent.
Implements StateHandler protocol for AG-UI state management.
Implements RAGDeps protocol and StateHandler protocol for AG-UI state management.
"""
config: AppConfig
client: HaikuRAG
tool_context: ToolContext
state_key: str | None = None
@ -112,20 +113,39 @@ class ChatDeps:
qa_session_state.session_context = initial
def prepare_chat_context(
context: ToolContext,
features: list[str] | None = None,
) -> None:
"""Register required namespaces in a ToolContext for chat agent use.
Idempotent safe to call multiple times on the same context.
Args:
context: ToolContext to prepare.
features: List of enabled features. Defaults to DEFAULT_FEATURES.
"""
if features is None:
features = DEFAULT_FEATURES
if context.get(SESSION_NAMESPACE, SessionState) is None:
context.register(SESSION_NAMESPACE, SessionState())
if context.state_key is None:
context.state_key = AGUI_STATE_KEY
if FEATURE_QA in features:
if context.get(QA_SESSION_NAMESPACE, QASessionState) is None:
context.register(QA_SESSION_NAMESPACE, QASessionState())
def create_chat_agent(
config: AppConfig,
client: HaikuRAG,
context: ToolContext,
features: list[str] | None = None,
) -> Agent[ChatDeps, str]:
"""Create the chat agent with composed toolsets.
Args:
config: Application configuration.
client: HaikuRAG client for database operations.
context: ToolContext for shared state across toolsets.
SessionState is always registered. QASessionState is
registered only when the QA feature is active.
features: List of features to enable. Defaults to DEFAULT_FEATURES
(search, documents, qa). Available features: "search",
"documents", "qa", "analysis".
@ -136,34 +156,25 @@ def create_chat_agent(
Example:
async with HaikuRAG(db_path, create=True) as client:
context = ToolContext()
agent = create_chat_agent(config, client, context)
deps = ChatDeps(config=config, tool_context=context)
prepare_chat_context(context)
agent = create_chat_agent(config)
deps = ChatDeps(config=config, client=client, tool_context=context)
result = await agent.run("Search for X", deps=deps)
"""
if features is None:
features = DEFAULT_FEATURES
existing = context.get(SESSION_NAMESPACE, SessionState)
if existing is None:
context.register(SESSION_NAMESPACE, SessionState())
if context.state_key is None:
context.state_key = AGUI_STATE_KEY
if FEATURE_QA in features:
if context.get(QA_SESSION_NAMESPACE, QASessionState) is None:
context.register(QA_SESSION_NAMESPACE, QASessionState())
toolsets = []
if FEATURE_SEARCH in features:
toolsets.append(create_search_toolset(client, config, context=context))
toolsets.append(create_search_toolset(config))
if FEATURE_DOCUMENTS in features:
toolsets.append(create_document_toolset(client, config, context=context))
toolsets.append(create_document_toolset(config))
if FEATURE_QA in features:
toolsets.append(create_qa_toolset(client, config, context=context))
toolsets.append(create_qa_toolset(config))
if FEATURE_ANALYSIS in features:
from haiku.rag.tools.analysis import create_analysis_toolset
toolsets.append(create_analysis_toolset(client, config, context=context))
toolsets.append(create_analysis_toolset(config))
model = get_model(config.qa.model, config)
@ -220,6 +231,7 @@ async def run_chat_agent(
__all__ = [
"create_chat_agent",
"prepare_chat_context",
"run_chat_agent",
"trigger_background_summarization",
"ChatDeps",

View file

@ -1,5 +1,6 @@
from dataclasses import dataclass
from pydantic_ai import Agent
from pydantic_ai.output import ToolOutput
from haiku.rag.agents.qa.prompts import QA_SYSTEM_PROMPT
from haiku.rag.agents.research.models import (
@ -15,6 +16,12 @@ from haiku.rag.tools.search import SEARCH_NAMESPACE, SearchState, create_search_
from haiku.rag.utils import get_model
@dataclass
class _QARunDeps:
client: HaikuRAG
tool_context: ToolContext | None = None
class QuestionAnswerAgent:
def __init__(
self,
@ -25,12 +32,8 @@ class QuestionAnswerAgent:
):
self._client = client
self._config = config or Config
self._agent: Agent[None, RawSearchAnswer] = Agent(
model=get_model(model_config, self._config),
output_type=ToolOutput(RawSearchAnswer, max_retries=3),
instructions=system_prompt or QA_SYSTEM_PROMPT,
retries=3,
)
self._model_config = model_config
self._system_prompt = system_prompt or QA_SYSTEM_PROMPT
async def answer(
self, question: str, filter: str | None = None
@ -44,17 +47,27 @@ class QuestionAnswerAgent:
Returns:
Tuple of (answer text, list of resolved citations)
"""
# Create context and search toolset for this run
context = ToolContext()
search_toolset = create_search_toolset(
self._client,
self._config,
context=context,
base_filter=filter,
tool_name="search_documents",
)
result = await self._agent.run(question, toolsets=[search_toolset])
# Agent created per-call: toolset varies with filter, and Agent
# construction is pure Python (no IO).
agent = Agent(
model=get_model(self._model_config, self._config),
deps_type=_QARunDeps,
output_type=RawSearchAnswer,
output_retries=3,
instructions=self._system_prompt,
toolsets=[search_toolset], # ty: ignore[invalid-argument-type]
retries=3,
)
deps = _QARunDeps(client=self._client, tool_context=context)
result = await agent.run(question, deps=deps) # ty: ignore[invalid-argument-type]
output = result.output
# Get search results from context for citation resolution

View file

@ -18,6 +18,7 @@ from pydantic_ai.messages import ModelMessage
from haiku.rag.agents.chat.agent import (
ChatDeps,
create_chat_agent,
prepare_chat_context,
trigger_background_summarization,
)
from haiku.rag.client import HaikuRAG
@ -152,7 +153,8 @@ class ChatApp(App):
# Create tool context and agent
self.tool_context = ToolContext()
self.agent = create_chat_agent(self.config, self.client, self.tool_context)
prepare_chat_context(self.tool_context)
self.agent = create_chat_agent(self.config)
# Sync document filter to tool context
session_state = self.tool_context.get(SESSION_NAMESPACE, SessionState)
@ -250,6 +252,7 @@ class ChatApp(App):
deps = ChatDeps(
config=self.config,
client=self.client,
tool_context=self.tool_context,
)

View file

@ -1,5 +1,5 @@
from haiku.rag.tools.analysis import create_analysis_toolset
from haiku.rag.tools.context import ToolContext, ToolContextCache
from haiku.rag.tools.context import RAGDeps, ToolContext, ToolContextCache
from haiku.rag.tools.document import (
DocumentInfo,
DocumentListResponse,
@ -29,6 +29,7 @@ from haiku.rag.tools.session import (
)
__all__ = [
"RAGDeps",
"ToolContext",
"ToolContextCache",
"QAResult",

View file

@ -1,11 +1,10 @@
from pydantic_ai import FunctionToolset
from pydantic_ai import FunctionToolset, RunContext
from haiku.rag.agents.rlm.agent import create_rlm_agent
from haiku.rag.agents.rlm.dependencies import RLMContext, RLMDeps
from haiku.rag.agents.rlm.docker_sandbox import DockerSandbox
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig
from haiku.rag.tools.context import ToolContext
from haiku.rag.tools.context import RAGDeps
from haiku.rag.tools.filters import (
build_document_filter,
combine_filters,
@ -15,20 +14,14 @@ from haiku.rag.tools.models import AnalysisResult
def create_analysis_toolset(
client: HaikuRAG,
config: AppConfig,
context: ToolContext | None = None,
base_filter: str | None = None,
tool_name: str = "analyze",
) -> FunctionToolset:
"""Create a toolset with code analysis capabilities via RLM agent.
Args:
client: HaikuRAG client for document operations.
config: Application configuration.
context: Optional ToolContext for state accumulation.
If SessionState is registered, it will be used for dynamic
document filtering.
base_filter: Optional base SQL WHERE clause applied to searches.
tool_name: Name for the analyze tool. Defaults to "analyze".
@ -37,6 +30,7 @@ def create_analysis_toolset(
"""
async def analyze(
ctx: RunContext[RAGDeps],
task: str,
document_name: str | None = None,
) -> AnalysisResult:
@ -52,9 +46,12 @@ def create_analysis_toolset(
Returns:
AnalysisResult with answer and execution metadata.
"""
client = ctx.deps.client
tool_context = ctx.deps.tool_context
doc_filter = build_document_filter(document_name) if document_name else None
effective_filter = combine_filters(
get_session_filter(context, base_filter), doc_filter
get_session_filter(tool_context, base_filter), doc_filter
)
rlm_context = RLMContext(filter=effective_filter)

View file

@ -1,11 +1,26 @@
from datetime import datetime, timedelta
from typing import Any, TypeVar, overload
from typing import TYPE_CHECKING, Any, Protocol, TypeVar, overload, runtime_checkable
from pydantic import BaseModel, PrivateAttr
if TYPE_CHECKING:
from haiku.rag.client import HaikuRAG
T = TypeVar("T", bound=BaseModel)
@runtime_checkable
class RAGDeps(Protocol):
"""Contract for toolset dependencies injected via RunContext.
Any deps object passed to an agent using haiku.rag toolsets must
provide these attributes.
"""
client: "HaikuRAG"
tool_context: "ToolContext | None"
class ToolContext(BaseModel):
"""Generic state container for haiku.rag toolsets.
@ -24,17 +39,17 @@ class ToolContext(BaseModel):
SEARCH_NAMESPACE = "haiku.rag.search"
# In toolset factory
def create_search_toolset(client, config, context=None):
if context:
state = context.get_or_create(SEARCH_NAMESPACE, SearchState)
...
def create_search_toolset(config):
async def search(ctx: RunContext[RAGDeps], query: str):
tool_context = ctx.deps.tool_context
if tool_context:
state = tool_context.get_or_create(SEARCH_NAMESPACE, SearchState)
...
# Usage
context = ToolContext()
search_tools = create_search_toolset(client, config, context=context)
search_tools = create_search_toolset(config)
agent = Agent(..., toolsets=[search_tools])
await agent.run("...")
await agent.run("...", deps=my_deps)
# Access accumulated state
search_state = context.get(SEARCH_NAMESPACE)

View file

@ -1,9 +1,9 @@
from pydantic import BaseModel
from pydantic_ai import Agent, FunctionToolset
from pydantic_ai import Agent, FunctionToolset, RunContext
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig
from haiku.rag.tools.context import ToolContext
from haiku.rag.tools.context import RAGDeps
from haiku.rag.tools.filters import get_session_filter
from haiku.rag.utils import get_model
@ -66,26 +66,22 @@ async def find_document(client: HaikuRAG, query: str):
def create_document_toolset(
client: HaikuRAG,
config: AppConfig,
context: ToolContext | None = None,
base_filter: str | None = None,
) -> FunctionToolset:
"""Create a toolset with document management capabilities.
Args:
client: HaikuRAG client for document operations.
config: Application configuration (used for summarization LLM).
context: Optional ToolContext for state tracking.
If SessionState is registered, it will be used for dynamic
document filtering.
base_filter: Optional base SQL WHERE clause applied to list operations.
Returns:
FunctionToolset with list_documents, get_document, summarize_document tools.
"""
async def list_documents(page: int = 1) -> DocumentListResponse:
async def list_documents(
ctx: RunContext[RAGDeps], page: int = 1
) -> DocumentListResponse:
"""List available documents in the knowledge base.
Args:
@ -94,10 +90,13 @@ def create_document_toolset(
Returns:
Paginated list of documents with metadata.
"""
client = ctx.deps.client
tool_context = ctx.deps.tool_context
page_size = 50
offset = (page - 1) * page_size
effective_filter = get_session_filter(context, base_filter)
effective_filter = get_session_filter(tool_context, base_filter)
docs = await client.list_documents(
limit=page_size, offset=offset, filter=effective_filter
@ -119,7 +118,7 @@ def create_document_toolset(
total_documents=total,
)
async def get_document(query: str) -> str:
async def get_document(ctx: RunContext[RAGDeps], query: str) -> str:
"""Retrieve a specific document by title or URI.
Args:
@ -128,6 +127,8 @@ def create_document_toolset(
Returns:
Document content and metadata, or not found message.
"""
client = ctx.deps.client
doc = await find_document(client, query)
if doc is None:
@ -141,7 +142,7 @@ def create_document_toolset(
f"**Content:**\n{doc.content}"
)
async def summarize_document(query: str) -> str:
async def summarize_document(ctx: RunContext[RAGDeps], query: str) -> str:
"""Generate a summary of a specific document.
Args:
@ -150,6 +151,8 @@ def create_document_toolset(
Returns:
Generated summary or not found message.
"""
client = ctx.deps.client
doc = await find_document(client, query)
if doc is None:

View file

@ -2,7 +2,7 @@ import math
from ag_ui.core import EventType, StateSnapshotEvent
from pydantic import BaseModel, Field
from pydantic_ai import FunctionToolset, ToolReturn
from pydantic_ai import FunctionToolset, RunContext, ToolReturn
from haiku.rag.agents.chat.context import trigger_background_summarization
from haiku.rag.agents.chat.state import build_chat_state_snapshot
@ -13,7 +13,7 @@ from haiku.rag.agents.research.state import ResearchDeps, ResearchState
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig
from haiku.rag.embeddings import get_embedder
from haiku.rag.tools.context import ToolContext
from haiku.rag.tools.context import RAGDeps, ToolContext
from haiku.rag.tools.filters import (
build_document_filter,
combine_filters,
@ -212,34 +212,23 @@ async def run_qa_core(
def create_qa_toolset(
client: HaikuRAG,
config: AppConfig,
context: ToolContext | None = None,
base_filter: str | None = None,
tool_name: str = "ask",
session_context: str | None = None,
prior_answers: list[SearchAnswer] | None = None,
) -> FunctionToolset:
"""Create a toolset with Q&A capabilities using research graph.
Args:
client: HaikuRAG client for search operations.
config: Application configuration.
context: Optional ToolContext for state accumulation.
If SessionState is registered, it will be used for dynamic
document filtering and citation indexing.
base_filter: Optional base SQL WHERE clause applied to searches.
tool_name: Name for the ask tool. Defaults to "ask".
session_context: Optional session context for the research graph.
Overridden by QASessionState.session_context if available.
prior_answers: Optional list of prior answers for context.
Overridden by similarity-matched answers from QASessionState if available.
Returns:
FunctionToolset with an ask tool.
"""
async def ask(
ctx: RunContext[RAGDeps],
question: str,
document_name: str | None = None,
) -> ToolReturn | QAResult:
@ -254,24 +243,25 @@ def create_qa_toolset(
Returns:
QAResult with answer, confidence, and citations.
"""
client = ctx.deps.client
tool_context = ctx.deps.tool_context
session_state: SessionState | None = None
qa_session_state: QASessionState | None = None
state_key: str | None = None
if context is not None:
session_state = context.get(SESSION_NAMESPACE, SessionState)
qa_session_state = context.get(QA_SESSION_NAMESPACE, QASessionState)
state_key = context.state_key
if tool_context is not None:
session_state = tool_context.get(SESSION_NAMESPACE, SessionState)
qa_session_state = tool_context.get(QA_SESSION_NAMESPACE, QASessionState)
state_key = tool_context.state_key
qa_result = await run_qa_core(
client=client,
config=config,
question=question,
document_name=document_name,
context=context,
context=tool_context,
base_filter=base_filter,
session_context=session_context,
prior_answers=prior_answers,
)
if session_state is not None:

View file

@ -1,11 +1,10 @@
from pydantic import BaseModel
from pydantic_ai import FunctionToolset, ToolReturn
from pydantic_ai import FunctionToolset, RunContext, ToolReturn
from haiku.rag.agents.research.models import Citation
from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig
from haiku.rag.store.models import SearchResult
from haiku.rag.tools.context import ToolContext
from haiku.rag.tools.context import RAGDeps
from haiku.rag.tools.filters import combine_filters, get_session_filter
from haiku.rag.tools.session import SESSION_NAMESPACE, SessionState, compute_state_delta
@ -22,9 +21,7 @@ class SearchState(BaseModel):
def create_search_toolset(
client: HaikuRAG,
config: AppConfig,
context: ToolContext | None = None,
expand_context: bool = True,
base_filter: str | None = None,
tool_name: str = "search",
@ -32,12 +29,7 @@ def create_search_toolset(
"""Create a toolset with search capabilities.
Args:
client: HaikuRAG client for search operations.
config: Application configuration.
context: Optional ToolContext for state accumulation.
If provided, search results are accumulated in SearchState.
If SessionState is registered, it will be used for dynamic
document filtering and citation indexing.
expand_context: Whether to expand search results with surrounding context.
Defaults to True.
base_filter: Optional base SQL WHERE clause applied to all searches.
@ -47,11 +39,9 @@ def create_search_toolset(
Returns:
FunctionToolset with a search tool.
"""
search_state: SearchState | None = None
if context is not None:
search_state = context.get_or_create(SEARCH_NAMESPACE, SearchState)
async def search(
ctx: RunContext[RAGDeps],
query: str,
limit: int | None = None,
filter: str | None = None,
@ -66,18 +56,25 @@ def create_search_toolset(
Returns:
Formatted search results with content and metadata.
"""
client = ctx.deps.client
tool_context = ctx.deps.tool_context
search_state: SearchState | None = None
if tool_context is not None:
search_state = tool_context.get_or_create(SEARCH_NAMESPACE, SearchState)
session_state: SessionState | None = None
old_session_state: SessionState | None = None
state_key: str | None = None
if context is not None:
session_state = context.get(SESSION_NAMESPACE, SessionState)
state_key = context.state_key
if tool_context is not None:
session_state = tool_context.get(SESSION_NAMESPACE, SessionState)
state_key = tool_context.state_key
if session_state is not None:
old_session_state = session_state.model_copy(deep=True)
# Combine all filters: base_filter AND session_filter AND tool filter
effective_filter = combine_filters(
get_session_filter(context, base_filter), filter
get_session_filter(tool_context, base_filter), filter
)
effective_limit = limit or config.search.limit

View file

@ -75,7 +75,7 @@ dev = [
"pydantic-ai-slim[bedrock]",
"pydantic-ai-slim[google]",
"pydantic-ai-slim[groq]",
"ty>=0.0.14",
"ty>=0.0.16",
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",

View file

@ -8,6 +8,7 @@ from haiku.rag.agents.chat import (
ChatDeps,
ChatSessionState,
create_chat_agent,
prepare_chat_context,
)
from haiku.rag.agents.chat.context import _summarization_tasks
from haiku.rag.agents.research.models import Citation
@ -49,22 +50,22 @@ def vcr_cassette_dir():
def test_create_chat_agent(temp_db_path):
"""Test that create_chat_agent returns a properly configured agent."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
agent = create_chat_agent(Config)
assert agent is not None
assert agent.name == "chat_agent" or agent.name is None
client.close()
def test_chat_deps_initialization(temp_db_path):
"""Test ChatDeps can be initialized with required fields."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
deps = ChatDeps(config=Config, tool_context=context)
deps = ChatDeps(config=Config, client=client, tool_context=context)
assert deps.config is Config
assert deps.client is client
assert deps.tool_context is context
assert deps.state_key is None
client.close()
def test_agui_state_key_constant():
@ -72,33 +73,42 @@ def test_agui_state_key_constant():
assert AGUI_STATE_KEY == "haiku.rag.chat"
def test_chat_deps_with_state_key():
def test_chat_deps_with_state_key(temp_db_path):
"""Test ChatDeps can be initialized with state_key for keyed state emission."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
deps = ChatDeps(config=Config, tool_context=context, state_key="my_state")
deps = ChatDeps(
config=Config, client=client, tool_context=context, state_key="my_state"
)
assert deps.config is Config
assert deps.state_key == "my_state"
client.close()
def test_chat_deps_state_key_default_none():
def test_chat_deps_state_key_default_none(temp_db_path):
"""Test ChatDeps state_key defaults to None."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
deps = ChatDeps(config=Config, tool_context=context)
deps = ChatDeps(config=Config, client=client, tool_context=context)
assert deps.state_key is None
client.close()
def test_chat_deps_state_setter_handles_initial_context():
def test_chat_deps_state_setter_handles_initial_context(temp_db_path):
"""Test ChatDeps.state setter transfers initial_context to qa_session_state."""
from haiku.rag.tools.qa import QA_SESSION_NAMESPACE, QASessionState
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
# Register QASessionState (normally done by create_chat_agent)
# Register QASessionState (normally done by prepare_chat_context)
context.register(QA_SESSION_NAMESPACE, QASessionState())
context.register(SESSION_NAMESPACE, SessionState())
deps = ChatDeps(config=Config, tool_context=context, state_key=AGUI_STATE_KEY)
deps = ChatDeps(
config=Config, client=client, tool_context=context, state_key=AGUI_STATE_KEY
)
# Client sends initial_context with no session_context
incoming_state = {
@ -118,17 +128,21 @@ def test_chat_deps_state_setter_handles_initial_context():
qa_session_state = context.get(QA_SESSION_NAMESPACE)
assert isinstance(qa_session_state, QASessionState)
assert qa_session_state.session_context == "Background info about the project"
client.close()
def test_chat_deps_state_setter_parses_session_context_dict():
def test_chat_deps_state_setter_parses_session_context_dict(temp_db_path):
"""Test ChatDeps.state setter parses session_context dict and extracts summary."""
from haiku.rag.tools.qa import QA_SESSION_NAMESPACE, QASessionState
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
context.register(QA_SESSION_NAMESPACE, QASessionState())
context.register(SESSION_NAMESPACE, SessionState())
deps = ChatDeps(config=Config, tool_context=context, state_key=AGUI_STATE_KEY)
deps = ChatDeps(
config=Config, client=client, tool_context=context, state_key=AGUI_STATE_KEY
)
# Client sends session_context as a dict (as it comes from JSON)
incoming_state = {
@ -150,19 +164,23 @@ def test_chat_deps_state_setter_parses_session_context_dict():
qa_session_state = context.get(QA_SESSION_NAMESPACE)
assert isinstance(qa_session_state, QASessionState)
assert qa_session_state.session_context == "Previous conversation summary"
client.close()
def test_chat_deps_state_setter_preserves_server_session_context():
def test_chat_deps_state_setter_preserves_server_session_context(temp_db_path):
"""Test that server's session_context is preferred over client's stale value."""
from haiku.rag.tools.qa import QA_SESSION_NAMESPACE, QASessionState
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
qa_state = QASessionState()
qa_state.session_context = "Fresh summary from background summarizer"
context.register(QA_SESSION_NAMESPACE, qa_state)
context.register(SESSION_NAMESPACE, SessionState())
deps = ChatDeps(config=Config, tool_context=context, state_key=AGUI_STATE_KEY)
deps = ChatDeps(
config=Config, client=client, tool_context=context, state_key=AGUI_STATE_KEY
)
# Client sends stale session_context
incoming_state = {
@ -186,6 +204,7 @@ def test_chat_deps_state_setter_preserves_server_session_context():
assert (
qa_session_state.session_context == "Fresh summary from background summarizer"
)
client.close()
def test_chat_session_state():
@ -345,9 +364,11 @@ async def test_chat_agent_search_tool(allow_model_requests, temp_db_path):
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
@ -379,9 +400,11 @@ async def test_chat_agent_search_tool_with_filter(allow_model_requests, temp_db_
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
@ -407,9 +430,11 @@ async def test_chat_agent_get_document_tool(allow_model_requests, temp_db_path):
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
@ -430,9 +455,11 @@ async def test_chat_agent_get_document_not_found(allow_model_requests, temp_db_p
"""Test the chat agent's get_document tool when document is not found."""
async with HaikuRAG(temp_db_path, create=True) as client:
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
@ -458,9 +485,11 @@ async def test_chat_agent_ask_adds_citations(allow_model_requests, temp_db_path)
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
state_key=AGUI_STATE_KEY,
)
@ -513,9 +542,11 @@ async def test_chat_agent_ask_triggers_background_summarization(
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
state_key=AGUI_STATE_KEY,
)
@ -577,9 +608,11 @@ async def test_chat_agent_multi_turn_with_context(allow_model_requests, temp_db_
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
state_key=AGUI_STATE_KEY,
)
@ -676,9 +709,11 @@ async def test_chat_agent_ask_with_prior_answer_retrieval(
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps1 = ChatDeps(
config=Config,
client=client,
tool_context=context,
state_key=AGUI_STATE_KEY,
)
@ -779,7 +814,8 @@ async def test_chat_agent_search_with_session_filter(
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
# Set session filter to only include the labels document
session_state = context.get(SESSION_NAMESPACE)
@ -788,6 +824,7 @@ async def test_chat_agent_search_with_session_filter(
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
@ -1086,9 +1123,11 @@ async def test_list_documents_basic(allow_model_requests, temp_db_path):
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
@ -1126,9 +1165,11 @@ async def test_list_documents_with_session_filter(allow_model_requests, temp_db_
SESSION_NAMESPACE,
SessionState(document_filter=["DocLayNet Class Labels"]),
)
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
@ -1166,9 +1207,11 @@ async def test_list_documents_pagination(allow_model_requests, temp_db_path):
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
@ -1199,9 +1242,11 @@ async def test_summarize_document_found(allow_model_requests, temp_db_path):
)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)
@ -1222,9 +1267,11 @@ async def test_summarize_document_not_found(allow_model_requests, temp_db_path):
"""Test that summarize_document handles not found documents gracefully."""
async with HaikuRAG(temp_db_path, create=True) as client:
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
deps = ChatDeps(
config=Config,
client=client,
tool_context=context,
)

View file

@ -8,6 +8,7 @@ from haiku.rag.agents.chat.agent import (
FEATURE_SEARCH,
ChatDeps,
create_chat_agent,
prepare_chat_context,
)
from haiku.rag.agents.chat.prompts import build_chat_prompt
from haiku.rag.client import HaikuRAG
@ -29,9 +30,9 @@ def _count_function_toolsets(agent) -> int:
def test_default_features(temp_db_path):
"""Default features create search + document + qa toolsets and register both states."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
agent = create_chat_agent(Config, client, context)
prepare_chat_context(context)
agent = create_chat_agent(Config)
# Should have 3 toolsets (search, document, qa)
assert _count_function_toolsets(agent) == 3
@ -39,46 +40,42 @@ def test_default_features(temp_db_path):
# Both SessionState and QASessionState should be registered
assert context.get(SESSION_NAMESPACE, SessionState) is not None
assert context.get(QA_SESSION_NAMESPACE, QASessionState) is not None
client.close()
def test_search_only(temp_db_path):
"""features=["search"] creates only search toolset, no QASessionState."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
agent = create_chat_agent(Config, client, context, features=[FEATURE_SEARCH])
prepare_chat_context(context, features=[FEATURE_SEARCH])
agent = create_chat_agent(Config, features=[FEATURE_SEARCH])
assert _count_function_toolsets(agent) == 1
# SessionState always registered, but QASessionState should NOT be
assert context.get(SESSION_NAMESPACE, SessionState) is not None
assert context.get(QA_SESSION_NAMESPACE, QASessionState) is None
client.close()
def test_search_and_documents(temp_db_path):
"""features=["search", "documents"] creates both toolsets, no QASessionState."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
agent = create_chat_agent(
Config, client, context, features=[FEATURE_SEARCH, FEATURE_DOCUMENTS]
)
prepare_chat_context(context, features=[FEATURE_SEARCH, FEATURE_DOCUMENTS])
agent = create_chat_agent(Config, features=[FEATURE_SEARCH, FEATURE_DOCUMENTS])
assert _count_function_toolsets(agent) == 2
assert context.get(SESSION_NAMESPACE, SessionState) is not None
assert context.get(QA_SESSION_NAMESPACE, QASessionState) is None
client.close()
def test_all_features(temp_db_path):
"""All four features create four toolsets."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
prepare_chat_context(
context,
features=[FEATURE_SEARCH, FEATURE_DOCUMENTS, FEATURE_QA, FEATURE_ANALYSIS],
)
agent = create_chat_agent(
Config,
client,
context,
features=[FEATURE_SEARCH, FEATURE_DOCUMENTS, FEATURE_QA, FEATURE_ANALYSIS],
)
@ -86,28 +83,23 @@ def test_all_features(temp_db_path):
assert context.get(SESSION_NAMESPACE, SessionState) is not None
assert context.get(QA_SESSION_NAMESPACE, QASessionState) is not None
client.close()
def test_no_qa_skips_qa_session_state(temp_db_path):
"""Without QA feature, QASessionState is not registered."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
create_chat_agent(
Config, client, context, features=[FEATURE_SEARCH, FEATURE_DOCUMENTS]
)
prepare_chat_context(context, features=[FEATURE_SEARCH, FEATURE_DOCUMENTS])
assert context.get(QA_SESSION_NAMESPACE, QASessionState) is None
client.close()
def test_chat_deps_state_without_qa(temp_db_path):
"""ChatDeps.state getter omits qa_history/session_context when QASessionState absent."""
client = HaikuRAG(temp_db_path, create=True)
context = ToolContext()
create_chat_agent(Config, client, context, features=[FEATURE_SEARCH])
prepare_chat_context(context, features=[FEATURE_SEARCH])
deps = ChatDeps(config=Config, tool_context=context)
deps = ChatDeps(config=Config, client=client, tool_context=context)
state = deps.state
# SessionState fields should be present

View file

@ -40,9 +40,7 @@ def test_get_qa_agent_with_custom_prompt(temp_db_path):
assert agent is not None
assert isinstance(agent, QuestionAnswerAgent)
# The internal pydantic-ai agent should have instructions set
# (pydantic-ai wraps the string in an Instructions object)
assert agent._agent.instructions is not None
assert agent._system_prompt == custom_prompt
client.close()

View file

@ -6,25 +6,21 @@ from haiku.rag.tools.analysis import create_analysis_toolset
class TestAnalysisToolset:
"""Tests for create_analysis_toolset."""
def test_create_analysis_toolset_returns_function_toolset(
self, analysis_client, analysis_config
):
def test_create_analysis_toolset_returns_function_toolset(self, analysis_config):
"""create_analysis_toolset returns a FunctionToolset."""
from pydantic_ai import FunctionToolset
toolset = create_analysis_toolset(analysis_client, analysis_config)
toolset = create_analysis_toolset(analysis_config)
assert isinstance(toolset, FunctionToolset)
def test_analysis_toolset_has_analyze_tool(self, analysis_client, analysis_config):
def test_analysis_toolset_has_analyze_tool(self, analysis_config):
"""The toolset includes an 'analyze' tool."""
toolset = create_analysis_toolset(analysis_client, analysis_config)
toolset = create_analysis_toolset(analysis_config)
assert "analyze" in toolset.tools
def test_analysis_toolset_custom_tool_name(self, analysis_client, analysis_config):
def test_analysis_toolset_custom_tool_name(self, analysis_config):
"""Toolset supports custom tool name."""
toolset = create_analysis_toolset(
analysis_client, analysis_config, tool_name="run_code"
)
toolset = create_analysis_toolset(analysis_config, tool_name="run_code")
assert "run_code" in toolset.tools
assert "analyze" not in toolset.tools

View file

@ -1,3 +1,5 @@
from types import SimpleNamespace
import pytest
from haiku.rag.tools.document import (
@ -7,6 +9,11 @@ from haiku.rag.tools.document import (
)
def make_ctx(client, context=None):
"""Create a lightweight RunContext-like object for direct tool function calls."""
return SimpleNamespace(deps=SimpleNamespace(client=client, tool_context=context))
class TestDocumentModels:
"""Tests for document models."""
@ -38,18 +45,16 @@ class TestDocumentModels:
class TestDocumentToolset:
"""Tests for create_document_toolset."""
def test_create_document_toolset_returns_function_toolset(
self, doc_client, doc_config
):
def test_create_document_toolset_returns_function_toolset(self, doc_config):
"""create_document_toolset returns a FunctionToolset."""
from pydantic_ai import FunctionToolset
toolset = create_document_toolset(doc_client, doc_config)
toolset = create_document_toolset(doc_config)
assert isinstance(toolset, FunctionToolset)
def test_document_toolset_has_expected_tools(self, doc_client, doc_config):
def test_document_toolset_has_expected_tools(self, doc_config):
"""The toolset includes list_documents, get_document, summarize_document."""
toolset = create_document_toolset(doc_client, doc_config)
toolset = create_document_toolset(doc_config)
assert "list_documents" in toolset.tools
assert "get_document" in toolset.tools
@ -65,10 +70,11 @@ class TestDocumentToolExecution:
self, doc_client, doc_config
):
"""list_documents returns DocumentListResponse."""
toolset = create_document_toolset(doc_client, doc_config)
toolset = create_document_toolset(doc_config)
list_tool = toolset.tools["list_documents"]
result = await list_tool.function()
ctx = make_ctx(doc_client)
result = await list_tool.function(ctx)
assert isinstance(result, DocumentListResponse)
assert result.total_documents == 2
@ -78,10 +84,11 @@ class TestDocumentToolExecution:
@pytest.mark.asyncio
async def test_list_documents_pagination(self, doc_client, doc_config):
"""list_documents supports pagination."""
toolset = create_document_toolset(doc_client, doc_config)
toolset = create_document_toolset(doc_config)
list_tool = toolset.tools["list_documents"]
result = await list_tool.function(page=2)
ctx = make_ctx(doc_client)
result = await list_tool.function(ctx, page=2)
# With only 2 documents and page_size=50, page 2 should be empty
assert result.page == 2
@ -90,10 +97,11 @@ class TestDocumentToolExecution:
@pytest.mark.asyncio
async def test_get_document_by_title(self, doc_client, doc_config):
"""get_document finds document by title."""
toolset = create_document_toolset(doc_client, doc_config)
toolset = create_document_toolset(doc_config)
get_tool = toolset.tools["get_document"]
result = await get_tool.function("Python Guide")
ctx = make_ctx(doc_client)
result = await get_tool.function(ctx, "Python Guide")
assert "Python Guide" in result
assert "Python is a programming language" in result
@ -101,20 +109,22 @@ class TestDocumentToolExecution:
@pytest.mark.asyncio
async def test_get_document_by_uri(self, doc_client, doc_config):
"""get_document finds document by URI."""
toolset = create_document_toolset(doc_client, doc_config)
toolset = create_document_toolset(doc_config)
get_tool = toolset.tools["get_document"]
result = await get_tool.function("test://python")
ctx = make_ctx(doc_client)
result = await get_tool.function(ctx, "test://python")
assert "Python Guide" in result
@pytest.mark.asyncio
async def test_get_document_not_found(self, doc_client, doc_config):
"""get_document returns appropriate message when not found."""
toolset = create_document_toolset(doc_client, doc_config)
toolset = create_document_toolset(doc_config)
get_tool = toolset.tools["get_document"]
result = await get_tool.function("nonexistent")
ctx = make_ctx(doc_client)
result = await get_tool.function(ctx, "nonexistent")
assert "Document not found" in result
@ -122,11 +132,12 @@ class TestDocumentToolExecution:
async def test_list_documents_with_base_filter(self, doc_client, doc_config):
"""list_documents respects base_filter."""
toolset = create_document_toolset(
doc_client, doc_config, base_filter="title LIKE '%Python%'"
doc_config, base_filter="title LIKE '%Python%'"
)
list_tool = toolset.tools["list_documents"]
result = await list_tool.function()
ctx = make_ctx(doc_client)
result = await list_tool.function(ctx)
assert result.total_documents == 1
assert result.documents[0].title == "Python Guide"

View file

@ -6,25 +6,21 @@ from haiku.rag.tools.qa import create_qa_toolset
class TestQAToolset:
"""Tests for create_qa_toolset."""
def test_create_qa_toolset_returns_function_toolset(
self, qa_client_simple, qa_config
):
def test_create_qa_toolset_returns_function_toolset(self, qa_config):
"""create_qa_toolset returns a FunctionToolset."""
from pydantic_ai import FunctionToolset
toolset = create_qa_toolset(qa_client_simple, qa_config)
toolset = create_qa_toolset(qa_config)
assert isinstance(toolset, FunctionToolset)
def test_qa_toolset_has_ask_tool(self, qa_client_simple, qa_config):
def test_qa_toolset_has_ask_tool(self, qa_config):
"""The toolset includes an 'ask' tool."""
toolset = create_qa_toolset(qa_client_simple, qa_config)
toolset = create_qa_toolset(qa_config)
assert "ask" in toolset.tools
def test_qa_toolset_custom_tool_name(self, qa_client_simple, qa_config):
def test_qa_toolset_custom_tool_name(self, qa_config):
"""Toolset supports custom tool name."""
toolset = create_qa_toolset(
qa_client_simple, qa_config, tool_name="answer_question"
)
toolset = create_qa_toolset(qa_config, tool_name="answer_question")
assert "answer_question" in toolset.tools
assert "ask" not in toolset.tools

View file

@ -1,9 +1,16 @@
from types import SimpleNamespace
import pytest
from haiku.rag.tools import ToolContext
from haiku.rag.tools.search import SEARCH_NAMESPACE, SearchState, create_search_toolset
def make_ctx(client, context=None):
"""Create a lightweight RunContext-like object for direct tool function calls."""
return SimpleNamespace(deps=SimpleNamespace(client=client, tool_context=context))
class TestSearchState:
"""Tests for SearchState model."""
@ -51,51 +58,20 @@ class TestSearchState:
class TestSearchToolset:
"""Tests for create_search_toolset."""
def test_create_search_toolset_returns_function_toolset(
self, search_client, search_config
):
def test_create_search_toolset_returns_function_toolset(self, search_config):
"""create_search_toolset returns a FunctionToolset."""
from pydantic_ai import FunctionToolset
context = ToolContext()
toolset = create_search_toolset(search_client, search_config, context)
toolset = create_search_toolset(search_config)
assert isinstance(toolset, FunctionToolset)
def test_search_toolset_has_search_tool(self, search_client, search_config):
def test_search_toolset_has_search_tool(self, search_config):
"""The toolset includes a 'search' tool."""
context = ToolContext()
toolset = create_search_toolset(search_client, search_config, context)
toolset = create_search_toolset(search_config)
# toolset.tools is a dict with tool names as keys
assert "search" in toolset.tools
def test_search_toolset_registers_state(self, search_client, search_config):
"""Toolset registers SearchState under SEARCH_NAMESPACE."""
context = ToolContext()
create_search_toolset(search_client, search_config, context)
state = context.get(SEARCH_NAMESPACE)
assert state is not None
assert isinstance(state, SearchState)
def test_search_toolset_uses_existing_state(self, search_client, search_config):
"""Toolset uses existing state if already registered."""
from haiku.rag.store.models import SearchResult
context = ToolContext()
existing_state = SearchState()
existing_state.results.append(
SearchResult(content="pre-existing", score=0.5, chunk_id="pre1")
)
context.register(SEARCH_NAMESPACE, existing_state)
create_search_toolset(search_client, search_config, context)
state = context.get(SEARCH_NAMESPACE)
assert isinstance(state, SearchState)
assert len(state.results) == 1
assert state.results[0].chunk_id == "pre1"
@pytest.mark.vcr()
class TestSearchToolExecution:
@ -105,11 +81,12 @@ class TestSearchToolExecution:
async def test_search_returns_formatted_results(self, search_client, search_config):
"""Search tool returns formatted results."""
context = ToolContext()
toolset = create_search_toolset(search_client, search_config, context)
toolset = create_search_toolset(search_config)
# Get the search function
search_tool = toolset.tools["search"]
result = await search_tool.function("Python")
ctx = make_ctx(search_client, context)
result = await search_tool.function(ctx, "Python")
assert "Python" in result or "programming" in result
assert "No results found" not in result
@ -118,11 +95,12 @@ class TestSearchToolExecution:
async def test_search_accumulates_in_state(self, search_client, search_config):
"""Search tool accumulates results in SearchState."""
context = ToolContext()
toolset = create_search_toolset(search_client, search_config, context)
toolset = create_search_toolset(search_config)
# Run search
search_tool = toolset.tools["search"]
await search_tool.function("Python")
ctx = make_ctx(search_client, context)
await search_tool.function(ctx, "Python")
# Check state was updated
state = context.get(SEARCH_NAMESPACE)
@ -138,10 +116,11 @@ class TestSearchToolExecution:
# Use empty database
async with HaikuRAG(temp_db_path, create=True) as empty_client:
context = ToolContext()
toolset = create_search_toolset(empty_client, search_config, context)
toolset = create_search_toolset(search_config)
search_tool = toolset.tools["search"]
result = await search_tool.function("anything")
ctx = make_ctx(empty_client, context)
result = await search_tool.function(ctx, "anything")
assert result == "No results found."
@ -149,11 +128,12 @@ class TestSearchToolExecution:
async def test_search_with_filter(self, search_client, search_config):
"""Search tool respects filter parameter."""
context = ToolContext()
toolset = create_search_toolset(search_client, search_config, context)
toolset = create_search_toolset(search_config)
search_tool = toolset.tools["search"]
ctx = make_ctx(search_client, context)
# Filter to only Python documents
await search_tool.function("programming", filter="title LIKE '%Python%'")
await search_tool.function(ctx, "programming", filter="title LIKE '%Python%'")
# Should find Python but not JavaScript
state = context.get(SEARCH_NAMESPACE)
@ -164,10 +144,11 @@ class TestSearchToolExecution:
@pytest.mark.asyncio
async def test_search_without_context(self, search_client, search_config):
"""Search tool works without ToolContext."""
toolset = create_search_toolset(search_client, search_config, context=None)
toolset = create_search_toolset(search_config)
search_tool = toolset.tools["search"]
result = await search_tool.function("Python")
ctx = make_ctx(search_client, None)
result = await search_tool.function(ctx, "Python")
# Should still return results
assert "Python" in result or "programming" in result
@ -176,15 +157,16 @@ class TestSearchToolExecution:
async def test_search_multiple_accumulates(self, search_client, search_config):
"""Multiple searches accumulate results in state."""
context = ToolContext()
toolset = create_search_toolset(search_client, search_config, context)
toolset = create_search_toolset(search_config)
search_tool = toolset.tools["search"]
await search_tool.function("Python")
ctx = make_ctx(search_client, context)
await search_tool.function(ctx, "Python")
state = context.get(SEARCH_NAMESPACE)
assert isinstance(state, SearchState)
first_count = len(state.results)
await search_tool.function("JavaScript")
await search_tool.function(ctx, "JavaScript")
state = context.get(SEARCH_NAMESPACE)
assert isinstance(state, SearchState)
second_count = len(state.results)
@ -197,14 +179,13 @@ class TestSearchToolExecution:
context = ToolContext()
# Create toolset with base_filter for Python documents only
toolset = create_search_toolset(
search_client,
search_config,
context,
base_filter="title LIKE '%Python%'",
)
search_tool = toolset.tools["search"]
await search_tool.function("programming")
ctx = make_ctx(search_client, context)
await search_tool.function(ctx, "programming")
# Should only find Python documents
state = context.get(SEARCH_NAMESPACE)

38
uv.lock
View file

@ -1335,7 +1335,7 @@ dev = [
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pytest-recording", specifier = ">=0.13.4" },
{ name = "ruff", specifier = ">=0.14.13" },
{ name = "ty", specifier = ">=0.0.14" },
{ name = "ty", specifier = ">=0.0.16" },
]
[[package]]
@ -4986,26 +4986,26 @@ wheels = [
[[package]]
name = "ty"
version = "0.0.14"
version = "0.0.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/18/77f84d89db54ea0d1d1b09fa2f630ac4c240c8e270761cb908c06b6e735c/ty-0.0.16.tar.gz", hash = "sha256:a999b0db6aed7d6294d036ebe43301105681e0c821a19989be7c145805d7351c", size = 5129637, upload-time = "2026-02-10T20:24:16.48Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" },
{ url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" },
{ url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" },
{ url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" },
{ url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" },
{ url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" },
{ url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" },
{ url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" },
{ url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" },
{ url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" },
{ url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" },
{ url = "https://files.pythonhosted.org/packages/67/b9/909ebcc7f59eaf8a2c18fb54bfcf1c106f99afb3e5460058d4b46dec7b20/ty-0.0.16-py3-none-linux_armv6l.whl", hash = "sha256:6d8833b86396ed742f2b34028f51c0e98dbf010b13ae4b79d1126749dc9dab15", size = 10113870, upload-time = "2026-02-10T20:24:11.864Z" },
{ url = "https://files.pythonhosted.org/packages/c3/2c/b963204f3df2fdbf46a4a1ea4a060af9bb676e065d59c70ad0f5ae0dbae8/ty-0.0.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934c0055d3b7f1cf3c8eab78c6c127ef7f347ff00443cef69614bda6f1502377", size = 9936286, upload-time = "2026-02-10T20:24:08.695Z" },
{ url = "https://files.pythonhosted.org/packages/ef/4d/3d78294f2ddfdded231e94453dea0e0adef212b2bd6536296039164c2a3e/ty-0.0.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b55e8e8733b416d914003cd22e831e139f034681b05afed7e951cc1a5ea1b8d4", size = 9442660, upload-time = "2026-02-10T20:24:02.704Z" },
{ url = "https://files.pythonhosted.org/packages/15/40/ce48c0541e3b5749b0890725870769904e6b043e077d4710e5325d5cf807/ty-0.0.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feccae8f4abd6657de111353bd604f36e164844466346eb81ffee2c2b06ea0f0", size = 9934506, upload-time = "2026-02-10T20:24:35.818Z" },
{ url = "https://files.pythonhosted.org/packages/84/16/3b29de57e1ec6e56f50a4bb625ee0923edb058c5f53e29014873573a00cd/ty-0.0.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cad5e29d8765b92db5fa284940ac57149561f3f89470b363b9aab8a6ce553b0", size = 9933099, upload-time = "2026-02-10T20:24:43.003Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a1/e546995c25563d318c502b2f42af0fdbed91e1fc343708241e2076373644/ty-0.0.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86f28797c7dc06f081238270b533bf4fc8e93852f34df49fb660e0b58a5cda9a", size = 10438370, upload-time = "2026-02-10T20:24:33.44Z" },
{ url = "https://files.pythonhosted.org/packages/11/c1/22d301a4b2cce0f75ae84d07a495f87da193bcb68e096d43695a815c4708/ty-0.0.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be971a3b42bcae44d0e5787f88156ed2102ad07558c05a5ae4bfd32a99118e66", size = 10992160, upload-time = "2026-02-10T20:24:25.574Z" },
{ url = "https://files.pythonhosted.org/packages/6f/40/f1892b8c890db3f39a1bab8ec459b572de2df49e76d3cad2a9a239adcde9/ty-0.0.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c9f982b7c4250eb91af66933f436b3a2363c24b6353e94992eab6551166c8b7", size = 10717892, upload-time = "2026-02-10T20:24:05.914Z" },
{ url = "https://files.pythonhosted.org/packages/2f/1b/caf9be8d0c738983845f503f2e92ea64b8d5fae1dd5ca98c3fca4aa7dadc/ty-0.0.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d122edf85ce7bdf6f85d19158c991d858fc835677bd31ca46319c4913043dc84", size = 10510916, upload-time = "2026-02-10T20:24:00.252Z" },
{ url = "https://files.pythonhosted.org/packages/60/ea/28980f5c7e1f4c9c44995811ea6a36f2fcb205232a6ae0f5b60b11504621/ty-0.0.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:497ebdddbb0e35c7758ded5aa4c6245e8696a69d531d5c9b0c1a28a075374241", size = 9908506, upload-time = "2026-02-10T20:24:28.133Z" },
{ url = "https://files.pythonhosted.org/packages/f7/80/8672306596349463c21644554f935ff8720679a14fd658fef658f66da944/ty-0.0.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e1e0ac0837bde634b030243aeba8499383c0487e08f22e80f5abdacb5b0bd8ce", size = 9949486, upload-time = "2026-02-10T20:24:18.62Z" },
{ url = "https://files.pythonhosted.org/packages/8b/8a/d8747d36f30bd82ea157835f5b70d084c9bb5d52dd9491dba8a149792d6a/ty-0.0.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1216c9bcca551d9f89f47a817ebc80e88ac37683d71504e5509a6445f24fd024", size = 10145269, upload-time = "2026-02-10T20:24:38.249Z" },
{ url = "https://files.pythonhosted.org/packages/6f/4c/753535acc7243570c259158b7df67e9c9dd7dab9a21ee110baa4cdcec45d/ty-0.0.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:221bbdd2c6ee558452c96916ab67fcc465b86967cf0482e19571d18f9c831828", size = 10608644, upload-time = "2026-02-10T20:24:40.565Z" },
{ url = "https://files.pythonhosted.org/packages/3e/05/8e8db64cf45a8b16757e907f7a3bfde8d6203e4769b11b64e28d5bdcd79a/ty-0.0.16-py3-none-win32.whl", hash = "sha256:d52c4eb786be878e7514cab637200af607216fcc5539a06d26573ea496b26512", size = 9582579, upload-time = "2026-02-10T20:24:30.406Z" },
{ url = "https://files.pythonhosted.org/packages/25/bc/45759faea132cd1b2a9ff8374e42ba03d39d076594fbb94f3e0e2c226c62/ty-0.0.16-py3-none-win_amd64.whl", hash = "sha256:f572c216aa8ecf79e86589c6e6d4bebc01f1f3cb3be765c0febd942013e1e73a", size = 10436043, upload-time = "2026-02-10T20:23:57.51Z" },
{ url = "https://files.pythonhosted.org/packages/7f/02/70a491802e7593e444137ed4e41a04c34d186eb2856f452dd76b60f2e325/ty-0.0.16-py3-none-win_arm64.whl", hash = "sha256:430eadeb1c0de0c31ef7bef9d002bdbb5f25a31e3aad546f1714d76cd8da0a87", size = 9915122, upload-time = "2026-02-10T20:24:14.285Z" },
]
[[package]]