diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6bc062a..f82b6e9f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,6 +68,8 @@ jobs: run: uv run python -c "from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('Qwen/Qwen3-Embedding-0.6B')" - name: Run tests with coverage run: uv run pytest -m "not integration" --cov=haiku --cov-report=xml + env: + HF_HUB_OFFLINE: "1" - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 54dfc4aa..f104e9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog ## [Unreleased] +### Added + +- **RAG skill** (`haiku.rag.skills.rag`): haiku.skills integration with search, list_documents, get_document, ask, and research tools plus managed `RAGState` +- **RLM skill** (`haiku.rag.skills.rlm`): haiku.skills integration with analyze tool for computational analysis via code execution +- **`HaikuRAG.research()`**: Client method for multi-agent research +- **haiku.skills entry points**: `rag = "haiku.rag.skills.rag:create_skill"`, `rag-rlm = "haiku.rag.skills.rlm:create_skill"` + +### Changed + +- **Chat TUI**: Rebuilt on RAG skill + haiku.skills `SkillToolset` +- **Web app backend**: Rebuilt on RAG skill + `AGUIAdapter` +- **Toolsets simplified**: Removed `ToolContext`, `SessionState`, `AgentDeps`, `Toolkit`; kept core `FunctionToolset` factories +- **Research graph**: Removed `session_context` and conversational output mode + +### Removed + +- **`agents/chat/`**: Entire chat agent module (replaced by RAG skill) +- **`--deep` flag**: Removed from `ask` CLI (use `research` command instead) +- **`--context`/`--context-file`**: Removed from `ask` CLI +- **`tools/` state machinery**: `ToolContext`, `ToolContextCache`, `SessionState`, `AgentDeps`, `Toolkit`, etc. + ## [0.30.2] - 2026-02-19 ### Fixed diff --git a/README.md b/README.md index 8a63aeac..1f09a20d 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,6 @@ haiku-rag search "attention mechanism" # Ask questions with citations haiku-rag ask "What datasets were used for evaluation?" --cite -# Deep QA — decomposes complex questions into sub-queries -haiku-rag ask "How does the proposed method compare to the baseline on MMLU?" --deep - # Research mode — iterative planning and search haiku-rag research "What are the limitations of the approach?" @@ -140,7 +137,7 @@ Full documentation at: https://ggozad.github.io/haiku.rag/ - [Configuration](https://ggozad.github.io/haiku.rag/configuration/) - YAML configuration - [CLI](https://ggozad.github.io/haiku.rag/cli/) - Command reference - [Python API](https://ggozad.github.io/haiku.rag/python/) - Complete API docs -- [Agents](https://ggozad.github.io/haiku.rag/agents/) - QA, chat, and research agents +- [Agents](https://ggozad.github.io/haiku.rag/agents/) - QA and research agents - [RLM Agent](https://ggozad.github.io/haiku.rag/rlm/) - Complex analytical tasks via code execution - [Applications](https://ggozad.github.io/haiku.rag/apps/) - Chat TUI, web app, and inspector - [Server](https://ggozad.github.io/haiku.rag/server/) - File monitoring and MCP diff --git a/app/README.md b/app/README.md index c3c1648b..efbf30fe 100644 --- a/app/README.md +++ b/app/README.md @@ -46,7 +46,7 @@ A conversational RAG interface built with [CopilotKit](https://copilotkit.ai/) a ### haiku.rag.yaml -Configure the chat agent's LLM, embeddings, and search settings: +Configure the LLM, embeddings, and search settings: ```yaml qa: @@ -100,7 +100,7 @@ docker compose -f docker-compose.dev.yml up -d --build ## Chat Capabilities -The chat agent can: +The chat can: - **Search** your documents with hybrid vector + full-text search - **Answer questions** with citations from your knowledge base diff --git a/app/backend/main.py b/app/backend/main.py index 7bbcf4cf..50876a8c 100644 --- a/app/backend/main.py +++ b/app/backend/main.py @@ -3,8 +3,9 @@ import os from pathlib import Path from dotenv import find_dotenv, load_dotenv +from pydantic_ai import Agent +from pydantic_ai.ag_ui import AGUIAdapter from pydantic_ai.ui import SSE_CONTENT_TYPE -from pydantic_ai.ui.ag_ui import AGUIAdapter from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware @@ -12,22 +13,14 @@ from starlette.requests import Request from starlette.responses import JSONResponse, Response, StreamingResponse from starlette.routing import Route -from haiku.rag.agents.chat import ( - AGUI_STATE_KEY, - ChatDeps, - build_chat_toolkit, - create_chat_agent, -) from haiku.rag.client import HaikuRAG from haiku.rag.config import load_yaml_config from haiku.rag.config.models import AppConfig -from haiku.rag.tools.context import ToolContextCache +from haiku.rag.skills.rag import AGENT_PREAMBLE, create_skill +from haiku.skills import SkillDeps, SkillToolset load_dotenv(find_dotenv(usecwd=True)) -# Cache ToolContext instances by thread_id across requests -context_cache = ToolContextCache() - # Configure logfire (only sends data if LOGFIRE_TOKEN is present) try: import logfire @@ -69,34 +62,26 @@ def get_client() -> HaikuRAG: return _client -# Toolkit and agent are created once at module level -chat_toolkit = build_chat_toolkit(Config) -agent = create_chat_agent(Config, toolkit=chat_toolkit) +# Create skill, toolset, and agent +skill = create_skill(db_path=db_path, config=Config) +toolset = SkillToolset(skills=[skill]) + +agent = Agent( + os.getenv("HAIKU_CHAT_MODEL", "openai:gpt-4o"), + instructions=AGENT_PREAMBLE + toolset.system_prompt, + toolsets=[toolset], + deps_type=SkillDeps, +) async def stream_chat(request: Request) -> Response: - """Chat streaming endpoint with AG-UI protocol. - - Uses ToolContextCache to maintain state across requests for the same thread. - AGUIAdapter restores client-sent state via ChatDeps.state setter. - """ + """Chat streaming endpoint with AG-UI protocol.""" body = await request.body() accept = request.headers.get("accept", SSE_CONTENT_TYPE) 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) - if is_new: - chat_toolkit.prepare(context, state_key=AGUI_STATE_KEY) - - deps = ChatDeps( - config=Config, - client=get_client(), - tool_context=context, - ) - adapter = AGUIAdapter(agent=agent, run_input=run_input, accept=accept) - event_stream = adapter.run_stream(deps=deps) + event_stream = adapter.run_stream(deps=SkillDeps()) sse_event_stream = adapter.encode_stream(event_stream) return StreamingResponse( diff --git a/app/frontend/components/Chat.tsx b/app/frontend/components/Chat.tsx index 3401fc52..7f069a05 100644 --- a/app/frontend/components/Chat.tsx +++ b/app/frontend/components/Chat.tsx @@ -17,27 +17,27 @@ import { useMemo, useState, } from "react"; -import { BrainIcon, FilterIcon } from "../lib/icons"; -import type { ChatSessionState } from "../lib/sessionStorage"; +import { FilterIcon } from "../lib/icons"; +import type { RAGState } from "../lib/sessionStorage"; import { createSession, + deriveCitationsHistory, getActiveSessionId, getSession, - normalizeChatState, + normalizeRAGState, updateSessionMessages, } from "../lib/sessionStorage"; import CitationBlock from "./CitationBlock"; -import ContextPanel from "./ContextPanel"; import DbInfo from "./DbInfo"; import DocumentFilter from "./DocumentFilter"; import SessionManager from "./SessionManager"; -// Must match AGUI_STATE_KEY from haiku.rag.agents.chat -const AGUI_STATE_KEY = "haiku.rag.chat"; +// Must match state_namespace from haiku.rag.skills.rag +const AGUI_STATE_KEY = "rag"; // AG-UI state is namespaced under AGUI_STATE_KEY interface AgentState { - [AGUI_STATE_KEY]?: ChatSessionState; + [AGUI_STATE_KEY]?: RAGState; } // biome-ignore lint/suspicious/noExplicitAny: CopilotKit message objects vary at runtime @@ -152,6 +152,8 @@ function ToolCallIndicator({ return ; case "get_document": return ; + case "execute_skill": + return ; default: return ; } @@ -165,6 +167,12 @@ function ToolCallIndicator({ return "Ask"; case "get_document": return "Document"; + case "execute_skill": + return "Skill"; + case "analyze": + return "Analyze"; + case "research": + return "Research"; default: return toolName; } @@ -172,38 +180,30 @@ function ToolCallIndicator({ const getDescription = () => { switch (toolName) { + case "execute_skill": { + const skill = args.skill_name as string | undefined; + const request = args.request as string | undefined; + return ( + + {skill ? `${skill}: ` : ""} + {request ?? "Processing..."} + + ); + } case "search": { const query = args.query as string; - const docName = args.document_name as string | undefined; - return ( - <> - {query} - {docName && ( - - {" "} - in {docName} - - )} - - ); + return {query}; } case "ask": { const question = args.question as string; - const docName = args.document_name as string | undefined; - return ( - <> - {question} - {docName && ( - - {" "} - from {docName} - - )} - - ); + return {question}; } case "get_document": return {args.query as string}; + case "analyze": + return {args.question as string}; + case "research": + return {args.question as string}; default: return Processing...; } @@ -231,7 +231,7 @@ function ToolCallIndicator({ } // Context for sharing chat state with the message view -const ChatStateContext = createContext(null); +const ChatStateContext = createContext(null); // Wildcard tool call renderer for all server-side tools const toolCallRenderers = [ @@ -251,14 +251,15 @@ const toolCallRenderers = [ // Uses CopilotChatMessageView's children render prop to post-process the // rendered message elements and inject citations at the right positions. function MessageViewWithCitations({ - messages, - isRunning, + messages = [], + isRunning = false, }: { // biome-ignore lint/suspicious/noExplicitAny: AG-UI Message type is a broad union - messages: any[]; - isRunning: boolean; + messages?: any[]; + isRunning?: boolean; }) { - const chatState = useContext(ChatStateContext); + const ragState = useContext(ChatStateContext); + const citationsHistory = ragState ? deriveCitationsHistory(ragState) : []; const cursor = isRunning ? (
@@ -271,7 +272,7 @@ function MessageViewWithCitations({ return ( {({ messageElements }) => { - if (!chatState?.citations_history?.length) { + if (!citationsHistory.length) { return ( <> {messageElements} @@ -284,9 +285,9 @@ function MessageViewWithCitations({ // message (tool messages produce nothing). We correlate elements with // messages to inject CitationBlocks after the right assistant responses. // - // Both search and ask tools append to citations_history in order, + // Both search and ask tools append to citations via qa_history, // so after each assistant text response that followed tool calls, - // we inject the next citations_history entry. + // we inject the next citations entry. const result: React.ReactNode[] = []; let citIdx = 0; let seenToolCalls = false; @@ -317,10 +318,10 @@ function MessageViewWithCitations({ } // After an assistant text response that followed tool calls, - // inject the next citations_history entry (one per turn) + // inject the next citations entry (one per turn) if (msg.role === "assistant" && msg.content && seenToolCalls) { - if (citIdx < chatState.citations_history.length) { - const citations = chatState.citations_history[citIdx]; + if (citIdx < citationsHistory.length) { + const citations = citationsHistory[citIdx]; if (citations?.length) { result.push( ); } +MessageViewWithCitations.Cursor = CopilotChatMessageView.Cursor; function ChatContentInner({ sessionId, @@ -358,8 +360,9 @@ function ChatContentInner({ sessionId: string; onSessionChange: (id: string) => void; }) { - const [contextOpen, setContextOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false); + // Track selected document names locally (frontend-only) + const [selectedDocuments, setSelectedDocuments] = useState([]); const { agent } = useAgent({ agentId: "chat_agent", @@ -376,23 +379,10 @@ function ChatContentInner({ agent.threadId = sessionId; }, [agent, sessionId]); - const chatState = normalizeChatState( + const ragState = normalizeRAGState( (agent.state as AgentState)?.[AGUI_STATE_KEY], ); - const mergeChatState = (partial: Partial) => { - const current = normalizeChatState( - (agent.state as AgentState)?.[AGUI_STATE_KEY], - ); - agent.setState({ - ...agent.state, - [AGUI_STATE_KEY]: { - ...current, - ...partial, - }, - }); - }; - // Restore session from localStorage when agent reference changes. // useAgent returns a provisional agent initially, then the real agent // after runtime connects — re-run restore each time so messages stick. @@ -400,9 +390,9 @@ function ChatContentInner({ if (agent.messages.length > 0) return; const session = getSession(sessionId); if (!session) return; - if (session.chatState) { + if (session.ragState) { agent.setState({ - [AGUI_STATE_KEY]: normalizeChatState(session.chatState), + [AGUI_STATE_KEY]: normalizeRAGState(session.ragState), }); } if (session.messages.length > 0) { @@ -412,21 +402,21 @@ function ChatContentInner({ }, [agent, sessionId]); // Persist messages and state to localStorage. - // Read chatState from agent.state at effect time (not render time) so that + // Read ragState from agent.state at effect time (not render time) so that // restore and persist effects in the same commit see consistent state. // biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify tracks content changes useEffect(() => { if (sessionId && agent.messages.length > 0) { - const currentChatState = normalizeChatState( + const currentRagState = normalizeRAGState( (agent.state as AgentState)?.[AGUI_STATE_KEY], ); updateSessionMessages( sessionId, serializeMessages(agent.messages), - currentChatState, + currentRagState, ); } - }, [JSON.stringify(agent.messages), chatState, sessionId]); + }, [JSON.stringify(agent.messages), ragState, sessionId]); // biome-ignore lint/correctness/useExhaustiveDependencies: stable identity via agent ref const messages = useMemo( @@ -458,24 +448,29 @@ function ChatContentInner({ } }, [agent, ck]); - const sessionContext = chatState.session_context; - const documentFilter = chatState.document_filter; - const initialContext = chatState.initial_context ?? ""; - - // Context is locked after first message (qa_history has entries) - const isContextLocked = (chatState.qa_history?.length ?? 0) > 0; - const handleFilterApply = (selected: string[]) => { - mergeChatState({ document_filter: selected }); - }; - - const handleInitialContextChange = (value: string) => { - if (isContextLocked) return; - mergeChatState({ initial_context: value || null }); + setSelectedDocuments(selected); + // Convert selected document names to SQL filter for the backend + const filter = + selected.length > 0 + ? selected + .map( + (name) => + `(title LIKE '%${name.replace(/'/g, "''")}%' OR uri LIKE '%${name.replace(/'/g, "''")}%')`, + ) + .join(" OR ") + : null; + agent.setState({ + ...agent.state, + [AGUI_STATE_KEY]: { + ...ragState, + document_filter: filter, + }, + }); }; return ( - +
@@ -485,36 +480,19 @@ function ChatContentInner({ /> -
- setContextOpen(false)} - sessionContext={sessionContext} - initialContext={initialContext} - onInitialContextChange={handleInitialContextChange} - isLocked={isContextLocked} - /> setFilterOpen(false)} - selected={documentFilter} + selected={selectedDocuments} onApply={handleFilterApply} /> diff --git a/app/frontend/components/ContextPanel.tsx b/app/frontend/components/ContextPanel.tsx deleted file mode 100644 index 69a8f7c7..00000000 --- a/app/frontend/components/ContextPanel.tsx +++ /dev/null @@ -1,131 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useId, useState } from "react"; -import { formatRelativeTime } from "../lib/format"; -import { BrainIcon } from "../lib/icons"; -import type { SessionContext } from "../lib/sessionStorage"; - -interface ContextPanelProps { - isOpen: boolean; - onClose: () => void; - sessionContext: SessionContext | null; - initialContext?: string; - onInitialContextChange?: (value: string) => void; - isLocked?: boolean; -} - -export default function ContextPanel({ - isOpen, - onClose, - sessionContext, - initialContext = "", - onInitialContextChange, - isLocked = false, -}: ContextPanelProps) { - const titleId = useId(); - const [localValue, setLocalValue] = useState(initialContext); - - useEffect(() => { - if (isOpen) { - setLocalValue(initialContext); - } - }, [isOpen, initialContext]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }, - [onClose], - ); - - const handleSave = useCallback(() => { - onInitialContextChange?.(localValue); - onClose(); - }, [localValue, onInitialContextChange, onClose]); - - if (!isOpen) { - return null; - } - - const hasSessionContext = sessionContext?.summary?.trim(); - // Show edit mode when: not locked AND no session context yet - const isEditMode = !isLocked && !hasSessionContext; - - return ( -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: modal content wrapper */} -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > -
-
- -
-

- {isEditMode ? "Initial Context" : "Session Context"} -

-
-

- {isEditMode - ? "Set background context to guide the conversation. This will be locked after you send your first message." - : "This is what the assistant has learned from your conversation so far. It uses this context to provide more relevant answers."} -

- {isEditMode ? ( -