diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8261b617..436e6dd7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 14d7f795..031b238e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/backend/main.py b/app/backend/main.py index f527ec10..b49beeca 100644 --- a/app/backend/main.py +++ b/app/backend/main.py @@ -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, ) diff --git a/docs/agents/index.md b/docs/agents/index.md index e8d00caf..095165c2 100644 --- a/docs/agents/index.md +++ b/docs/agents/index.md @@ -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], ) ``` diff --git a/docs/python.md b/docs/python.md index bbd58cdd..30b1b35d 100644 --- a/docs/python.md +++ b/docs/python.md @@ -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. diff --git a/docs/tools.md b/docs/tools.md index a12fb550..3a77830d 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -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" ) diff --git a/haiku_rag_slim/haiku/rag/agents/chat/__init__.py b/haiku_rag_slim/haiku/rag/agents/chat/__init__.py index fb1892eb..03b1c395 100644 --- a/haiku_rag_slim/haiku/rag/agents/chat/__init__.py +++ b/haiku_rag_slim/haiku/rag/agents/chat/__init__.py @@ -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", diff --git a/haiku_rag_slim/haiku/rag/agents/chat/agent.py b/haiku_rag_slim/haiku/rag/agents/chat/agent.py index 45606318..51bcb317 100644 --- a/haiku_rag_slim/haiku/rag/agents/chat/agent.py +++ b/haiku_rag_slim/haiku/rag/agents/chat/agent.py @@ -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", diff --git a/haiku_rag_slim/haiku/rag/agents/qa/agent.py b/haiku_rag_slim/haiku/rag/agents/qa/agent.py index 493e2ec4..f395fb85 100644 --- a/haiku_rag_slim/haiku/rag/agents/qa/agent.py +++ b/haiku_rag_slim/haiku/rag/agents/qa/agent.py @@ -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 diff --git a/haiku_rag_slim/haiku/rag/chat/app.py b/haiku_rag_slim/haiku/rag/chat/app.py index 9b49c12a..c6ddc441 100644 --- a/haiku_rag_slim/haiku/rag/chat/app.py +++ b/haiku_rag_slim/haiku/rag/chat/app.py @@ -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, ) diff --git a/haiku_rag_slim/haiku/rag/tools/__init__.py b/haiku_rag_slim/haiku/rag/tools/__init__.py index 58e24fe4..00e703d0 100644 --- a/haiku_rag_slim/haiku/rag/tools/__init__.py +++ b/haiku_rag_slim/haiku/rag/tools/__init__.py @@ -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", diff --git a/haiku_rag_slim/haiku/rag/tools/analysis.py b/haiku_rag_slim/haiku/rag/tools/analysis.py index 54e8ef5f..dfd97343 100644 --- a/haiku_rag_slim/haiku/rag/tools/analysis.py +++ b/haiku_rag_slim/haiku/rag/tools/analysis.py @@ -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) diff --git a/haiku_rag_slim/haiku/rag/tools/context.py b/haiku_rag_slim/haiku/rag/tools/context.py index 782b6a1c..dc108c32 100644 --- a/haiku_rag_slim/haiku/rag/tools/context.py +++ b/haiku_rag_slim/haiku/rag/tools/context.py @@ -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) diff --git a/haiku_rag_slim/haiku/rag/tools/document.py b/haiku_rag_slim/haiku/rag/tools/document.py index 18c819c2..ac28000c 100644 --- a/haiku_rag_slim/haiku/rag/tools/document.py +++ b/haiku_rag_slim/haiku/rag/tools/document.py @@ -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: diff --git a/haiku_rag_slim/haiku/rag/tools/qa.py b/haiku_rag_slim/haiku/rag/tools/qa.py index 387436b9..d9482443 100644 --- a/haiku_rag_slim/haiku/rag/tools/qa.py +++ b/haiku_rag_slim/haiku/rag/tools/qa.py @@ -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: diff --git a/haiku_rag_slim/haiku/rag/tools/search.py b/haiku_rag_slim/haiku/rag/tools/search.py index a019c35b..e290a5f1 100644 --- a/haiku_rag_slim/haiku/rag/tools/search.py +++ b/haiku_rag_slim/haiku/rag/tools/search.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 7dd439e5..218ca63f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/agents/chat/test_chat_agent.py b/tests/agents/chat/test_chat_agent.py index b7c4d644..a051ee48 100644 --- a/tests/agents/chat/test_chat_agent.py +++ b/tests/agents/chat/test_chat_agent.py @@ -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, ) diff --git a/tests/agents/chat/test_features.py b/tests/agents/chat/test_features.py index 82a36ab7..fbda785d 100644 --- a/tests/agents/chat/test_features.py +++ b/tests/agents/chat/test_features.py @@ -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 diff --git a/tests/agents/qa/test_qa.py b/tests/agents/qa/test_qa.py index d2c60647..a12c8c45 100644 --- a/tests/agents/qa/test_qa.py +++ b/tests/agents/qa/test_qa.py @@ -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() diff --git a/tests/tools/test_analysis.py b/tests/tools/test_analysis.py index 22643e63..30dd6bbb 100644 --- a/tests/tools/test_analysis.py +++ b/tests/tools/test_analysis.py @@ -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 diff --git a/tests/tools/test_document.py b/tests/tools/test_document.py index 61fc406e..b7501f85 100644 --- a/tests/tools/test_document.py +++ b/tests/tools/test_document.py @@ -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" diff --git a/tests/tools/test_qa.py b/tests/tools/test_qa.py index 7ecfdd1d..8a961119 100644 --- a/tests/tools/test_qa.py +++ b/tests/tools/test_qa.py @@ -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 diff --git a/tests/tools/test_search.py b/tests/tools/test_search.py index 569f3736..e5532b4e 100644 --- a/tests/tools/test_search.py +++ b/tests/tools/test_search.py @@ -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) diff --git a/uv.lock b/uv.lock index 097b1952..298d7892 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]