Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Tests

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.13"]
# "without" proves the package works with no agent framework
# installed; "with" covers the framework integration paths.
agent-frameworks: [without, with]
name: py${{ matrix.python-version }} (${{ matrix.agent-frameworks }} frameworks)
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
# requirements.txt works on every branch; the packaging metadata
# (pyproject) does not exist on all of them.
- run: pip install -r requirements.txt pytest
- if: matrix.agent-frameworks == 'with'
run: pip install openai-agents claude-agent-sdk
# python -m pytest puts the repo root on sys.path, so the in-repo
# `pageindex` package is imported without an install step.
- run: python -m pytest -q
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,42 @@ answer = client.chat_completions(

Local documents are stored as plain JSON under `./.pageindex` (configurable via `storage_path`). Local mode supports PDFs; folders, `beta_headers`, `enable_citations`, and the deprecated retrieval API (`submit_query`/`get_retrieval`) remain cloud-only — each method's docstring spells out the differences. To pin the mode at construction instead of inferring it from `api_key`, use `PageIndexCloudClient` (fails without a real key) or `PageIndexLocalClient` (has no key parameter).

### 🤖 Agent integration

The client exposes its documents as **agent tools**, following one rule: **cloud clients always serve the live tool set of the [PageIndex MCP server](https://docs.pageindex.ai/mcp)** (search, folders, images — as enabled for your key, discovered dynamically; management tools like delete/upload sit behind `include_management=True` or the framework's approval layer), while local clients serve the same contract's built-in navigation subset (`browse_documents`, `get_document`, `get_document_structure`, `get_page_content`). Tool names and schemas are shared, so agent prompts port unchanged, and switching local ↔ cloud is just the client constructor line:

```python
client = PageIndexLocalClient() # or PageIndexCloudClient(api_key=...)
client.submit_document("doc.pdf", wait=True) # wait=True: return once the doc is ready (both modes)

# OpenAI Agents SDK (pip install "pageindex[openai]")
agent = Agent(
name="PageIndex",
instructions=client.agent_instructions(), # retrieval playbook for the agent's system prompt
tools=client.as_openai_tools(), # local: in-process tools; cloud: the full cloud MCP tool set (any model backend)
) # cloud + OpenAI models: hosted=True runs tool calls server-side (fastest)

# Claude Agent SDK (pip install "pageindex[claude]")
options = ClaudeAgentOptions(
system_prompt=client.agent_instructions(),
mcp_servers={"pageindex": client.as_claude_mcp()}, # local: in-process server; cloud: connects to api.pageindex.ai/mcp
allowed_tools=["mcp__pageindex__*"],
)

# Any other framework: plain functions, wrap with your framework's one-liner
tools = client.agent_tools() # local: built-in tools; cloud: full live tool set over MCP
# e.g. [StructuredTool.from_function(f) for f in tools]
```

Neither framework is a required dependency — each is imported only when its method is called. Claude Code / Cursor and other MCP hosts connect to cloud documents via the hosted MCP server directly (no SDK needed); see the [MCP docs](https://docs.pageindex.ai/mcp).

## 🚀 Agentic Vectorless RAG: An Example

For a simple, end-to-end **agentic vectorless RAG** example using **self-hosted PageIndex** (with OpenAI Agents SDK), see [`examples/agentic_vectorless_rag_demo.py`](examples/agentic_vectorless_rag_demo.py).

```bash
# Install optional dependency
pip3 install openai-agents
# Install with the OpenAI Agents SDK extra
pip3 install "pageindex[openai]"

# Run the demo
python3 examples/agentic_vectorless_rag_demo.py
Expand Down
97 changes: 25 additions & 72 deletions examples/agentic_vectorless_rag_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,106 +6,55 @@
chunking, PageIndex builds a hierarchical tree index and uses agentic LLM
reasoning for human-like, context-aware retrieval.

Agent tools:
- get_document() — document metadata (status, page count, etc.)
- get_document_structure() — tree structure index of a document
- get_page_content() — retrieve text content of specific pages
The agent tools come straight from the SDK — ``client.as_openai_tools()``
exposes the PageIndex tool contract (browse_documents, get_document,
get_document_structure, get_page_content) and ``client.agent_instructions()``
provides the retrieval playbook, so the whole agent is a few lines. Swap
``PageIndexLocalClient()`` for ``PageIndexCloudClient(api_key=...)`` and the
same code runs against the cloud.

Steps:
1 — Index a PDF locally and view its tree structure index
2 — View document metadata
3 — Ask a question (agent reasons over the index and auto-calls tools)

Requirements: pip install openai-agents; OPENAI_API_KEY in the environment.
Requirements: pip install "pageindex[openai]"; OPENAI_API_KEY in the environment.
"""
import sys
import json
import asyncio
import concurrent.futures
from pathlib import Path
import requests

sys.path.insert(0, str(Path(__file__).parent.parent))

from agents import Agent, Runner, function_tool, set_tracing_disabled
from agents.model_settings import ModelSettings
from agents import Agent, Runner, set_tracing_disabled
from agents.stream_events import RawResponsesStreamEvent, RunItemStreamEvent
from openai.types.responses import ResponseTextDeltaEvent, ResponseReasoningSummaryTextDeltaEvent

from pageindex import PageIndexClient
from pageindex import PageIndexAPIError, PageIndexLocalClient
import pageindex.utils as utils

PDF_URL = "https://arxiv.org/pdf/2603.15031"

_EXAMPLES_DIR = Path(__file__).parent
PDF_PATH = _EXAMPLES_DIR / "documents" / "attention-residuals.pdf"
DOC_ID_PATH = _EXAMPLES_DIR / "documents" / "attention-residuals.doc_id"
STORAGE_PATH = _EXAMPLES_DIR / ".pageindex"

AGENT_SYSTEM_PROMPT = """
You are PageIndex, a document QA assistant.
TOOL USE:
- Call get_document() first to confirm status and page count.
- Call get_document_structure() to identify relevant page ranges.
- Call get_page_content(pages="5-7") with tight ranges; never fetch the whole document.
- Before each tool call, output one short sentence explaining the reason.
Answer based only on tool output. Be concise.
"""


def _parse_pages(pages: str) -> list[int]:
"""Parse a pages string like '5-7', '3,8', or '12' into a list of ints."""
result = []
for part in pages.split(","):
part = part.strip()
if "-" in part:
start, end = (int(x) for x in part.split("-", 1))
if start > end:
raise ValueError(f"Invalid range '{part}': start must be <= end")
result.extend(range(start, end + 1))
else:
result.append(int(part))
return sorted(set(result))


def query_agent(client: PageIndexClient, doc_id: str, prompt: str, verbose: bool = False) -> str:
def query_agent(client: PageIndexLocalClient, doc_id: str, prompt: str, verbose: bool = False) -> str:
"""Run a document QA agent using the OpenAI Agents SDK.

Streams text output token-by-token and returns the full answer string.
Tool calls are always printed; verbose=True also prints arguments and output previews.
"""

@function_tool
def get_document() -> str:
"""Get document metadata: status, page count, name, and description."""
return json.dumps(client.get_document(doc_id))

@function_tool
def get_document_structure() -> str:
"""Get the document's full tree structure (without text) to find relevant sections."""
tree = client.get_tree(doc_id, node_summary=True)["result"]
return json.dumps(utils.remove_fields(tree, fields=["text"]), ensure_ascii=False)

@function_tool
def get_page_content(pages: str) -> str:
"""
Get the text content of specific pages.
Use tight ranges: e.g. '5-7' for pages 5 to 7, '3,8' for pages 3 and 8, '12' for page 12.
"""
try:
wanted = set(_parse_pages(pages))
except ValueError:
return json.dumps({"error": f"Invalid pages format: {pages!r}. Use '5-7', '3,8', or '12'."})
all_pages = client.get_ocr(doc_id, format="page")["result"]
return json.dumps(
[p for p in all_pages if p["page_index"] in wanted], ensure_ascii=False
)

agent = Agent(
name="PageIndex",
instructions=AGENT_SYSTEM_PROMPT,
tools=[get_document, get_document_structure, get_page_content],
instructions=client.agent_instructions(doc_id=doc_id),
tools=client.as_openai_tools(),
model=client.retrieve_model,
# model_settings=ModelSettings(reasoning={"effort": "low", "summary": "auto"}), # Uncomment to enable reasoning
# model_settings=ModelSettings(reasoning={"effort": "low", "summary": "auto"}), # from agents.model_settings import ModelSettings
)

async def _run():
Expand Down Expand Up @@ -175,21 +124,25 @@ async def _run():
print("Download complete.\n")

# Setup: local mode — no PageIndex API key needed, your LLM key does the work
client = PageIndexClient(storage_path=str(STORAGE_PATH))
client = PageIndexLocalClient(storage_path=str(STORAGE_PATH))

# Step 1: Index PDF and view tree structure
print("=" * 60)
print("Step 1: Index PDF and view tree structure")
print("=" * 60)
doc_id = next(
(doc["id"] for doc in client.list_documents(limit=100)["documents"]
if doc["name"] == PDF_PATH.name),
None,
)
doc_id = None
if DOC_ID_PATH.exists():
cached = DOC_ID_PATH.read_text().strip()
try:
client.get_document(cached)
doc_id = cached
except PageIndexAPIError:
DOC_ID_PATH.unlink()
if doc_id:
print(f"\nLoaded cached doc_id: {doc_id}")
else:
doc_id = client.submit_document(str(PDF_PATH))["doc_id"]
doc_id = client.submit_document(str(PDF_PATH), wait=True)["doc_id"]
DOC_ID_PATH.write_text(doc_id)
print(f"\nIndexed. doc_id: {doc_id}")
print("\nTree Structure (top-level sections):")
structure = client.get_tree(doc_id, node_summary=True)["result"]
Expand Down
10 changes: 10 additions & 0 deletions pageindex/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Installed-package version, shared by every surface that reports it upstream."""
from __future__ import annotations


def sdk_version() -> str:
try:
from importlib.metadata import version
return version("pageindex")
except Exception:
return "0.0.0"
Loading
Loading