Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
6b343f5
feat: agent tools — the cloud MCP tool contract on the client
rejojer Aug 9, 2026
8737799
fix: agent tools review — next_steps order, resolve caching, error se…
rejojer Aug 10, 2026
2ba9035
fix: agent tools review 2 — bridge thread safety, browse paging, meta…
rejojer Aug 10, 2026
3f13158
fix: agent tools review 3 — page-span cap, duplicate names, wait resi…
rejojer Aug 10, 2026
40b1706
feat: surface the stored document name from submit_document
rejojer Aug 10, 2026
bf9e6da
fix: add missing page_list kwarg in duplicate-name test mock
rejojer Aug 11, 2026
dece6e6
revert: keep README.md unchanged from main — SDK section deferred
rejojer Aug 11, 2026
3c37cdc
feat: serve cloud agent instructions live from the MCP server
rejojer Aug 11, 2026
50fd618
fix: local relevance sort answers honestly instead of imitating
rejojer Aug 11, 2026
f1301e7
docs: note the cloud+Claude instructions duplication trade-off in as_…
rejojer Aug 11, 2026
20ed81f
fix: unsupported-capability envelopes say local-mode-yet, point to cloud
rejojer Aug 11, 2026
8dc929f
fix: local tool descriptions pre-announce cloud-only capabilities
rejojer Aug 11, 2026
2b929ee
refactor: localized tool guidance replaces the appended LOCAL MODE note
rejojer Aug 11, 2026
e790c37
feat: hide cloud-only parameters from the local tool surface
rejojer Aug 11, 2026
1fa3eb7
fix: incremental-review findings — bridge cache, guards, envelope drift
rejojer Aug 11, 2026
63b767f
chore: keep the demo's doc_id cache file out of the repo
rejojer Aug 11, 2026
6c9fe2e
test: live envelope field-parity guard against cloud response drift
rejojer Aug 11, 2026
a45b554
feat: local chat — three protocol surfaces over the agent tools (v0.2…
rejojer Aug 11, 2026
daac9d2
fix: local-chat review findings — truncation, serialization, streams
rejojer Aug 11, 2026
4590dd8
feat: as_anthropic_tools — Anthropic tool-runner export, both modes
rejojer Aug 11, 2026
02022df
fix: as_anthropic_tools review findings — async flavor, schema isolation
rejojer Aug 11, 2026
adb2f1f
docs: doc_id is per-call table-setting — keep it identical across a c…
rejojer Aug 11, 2026
3f1919b
feat: every chat surface takes a bare query string
rejojer Aug 11, 2026
d28a8af
feat: messages() defaults max_tokens to 4096
rejojer Aug 11, 2026
2de3b18
fix: raise messages() max_tokens default to 8192
rejojer Aug 11, 2026
2607a86
fix: restore per-extra skip markers the string-input tests displaced
rejojer Aug 11, 2026
d87fa89
fix: close 17 findings from the v0.2.10 max review
rejojer Aug 12, 2026
dbe5857
feat: one-call config bundles for every bring-your-own-framework surface
rejojer Aug 12, 2026
b135711
fix: three more review findings — partial-read reporting, reply corre…
rejojer Aug 12, 2026
eb1a230
feat: gate the config-handoff surfaces by the read-only MCP endpoint
rejojer Aug 12, 2026
5f35a33
fix: chat_completions wraps framework exceptions like responses()
rejojer Aug 12, 2026
9d16dbf
fix: same-name documents in different folders no longer refuse doc_id…
rejojer Aug 12, 2026
ec58189
Revert "fix: same-name documents in different folders no longer refus…
rejojer Aug 12, 2026
001493c
docs: state the name-uniqueness invariant in library terms
rejojer Aug 12, 2026
ec1fd84
docs: trim doc_targeting_block docstring to the contract
rejojer Aug 12, 2026
b14af33
fix: compress out-of-range page lists in get_page_content
rejojer Aug 12, 2026
f523485
docs: messages() marks only the managed prefix with cache_control
rejojer Aug 12, 2026
166c60c
docs: _stream_sync cancels on close, not on abandonment
rejojer Aug 12, 2026
18171d6
fix: raise the openai-agents floor to 0.14.0
rejojer Aug 12, 2026
9f67fdd
fix: enforce doc_id at the tool layer in the framework config helpers
rejojer Aug 12, 2026
66912c6
fix: two chat findings — final-turn append and cache-key seeding
rejojer Aug 12, 2026
31c9150
fix: six review findings — pagination, compat, and containment
rejojer Aug 12, 2026
32c4940
fix: five secondary review findings — containment and guards
rejojer Aug 12, 2026
ce1deaf
fix: three review findings — protocol honesty, model echo, containment
rejojer Aug 12, 2026
adeb209
fix: config bundles use the scoped shadow check their tools earned
rejojer Aug 12, 2026
2b62194
docs: as_openai_tools' remote-MCP note moves to the Cloud paragraph
rejojer Aug 12, 2026
b997e3b
fix: nine review findings — argument coercion, scope, and honest enve…
rejojer Aug 12, 2026
bcd0bf4
fix: two chat findings — protocol terminal states, provider error types
rejojer Aug 12, 2026
bc25f72
fix: guided failure for unknown LiteLLM providers, non-object call_to…
rejojer Aug 12, 2026
031d411
fix: wrap litellm import in PageIndexAPIError when not installed
rejojer Aug 12, 2026
1c863fa
fix: silence CodeQL findings — merge implicit string concat, drop unu…
rejojer Aug 12, 2026
15eecee
fix: two external review findings — init-notification race, SDK floor
rejojer Aug 12, 2026
ce0dbf0
refactor: drop the unused exc parameter from _wrap_max_turns
rejojer Aug 12, 2026
f58cca1
fix: raise the anthropic and openai-agents floors past broken releases
rejojer Aug 12, 2026
c88f9d0
test: cover max_turns wrapping on every chat surface
rejojer Aug 12, 2026
eebed64
fix: four review findings — envelope size honesty, contained tool errors
rejojer Aug 12, 2026
9001ef9
fix: conformant responses() envelope — official output, transcript in…
rejojer Aug 13, 2026
4888986
fix: stale anthropic>=0.84.0 hints — the supported floor is 0.108.0
rejojer Aug 13, 2026
f521fe7
docs: disclose the bridge's binary-stub behavior on the two image-adv…
rejojer Aug 13, 2026
56c28b7
refactor: trim the envelope-change comments to the essential constraint
rejojer Aug 13, 2026
fee6890
fix: declare the real python floor — >=3.10
rejojer Aug 13, 2026
525caa4
chore: trim rationale comments from this session's commits
rejojer Aug 13, 2026
db78209
chore: trim non-essential comments across the PR
rejojer Aug 13, 2026
1a97218
fix: break the phantom exception chain in _run_sync
rejojer Aug 13, 2026
d3880c6
feat: Flash with full optimization becomes the default local indexing…
rejojer Aug 13, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ jobs:
cache: pip
- run: pip install -r requirements.txt pytest
- if: matrix.agent-frameworks == 'with'
run: pip install openai-agents claude-agent-sdk
run: pip install openai-agents claude-agent-sdk anthropic
- run: python -m pytest -q
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ __pycache__
logs/
.pageindex/
dist/
*.doc_id
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,10 @@ python3 run_pageindex.py --pdf_path /path/to/your/document.pdf
<details>
<summary>Optional parameters</summary>
<br>
You can customize the processing with additional optional arguments:
You can customize the processing with additional optional arguments (the structure-tuning flags below require <code>--mode standard</code>):

```
--mode Processing mode: flash (default) or standard
--model LLM model to use (default: gpt-4o-2024-11-20)
--toc-check-pages Pages to check for table of contents (default: 20)
--max-pages-per-node Max pages per node (default: 10)
Expand All @@ -199,13 +200,13 @@ python3 run_pageindex.py --md_path /path/to/your/document.md
</details>

> ### ⚡ PageIndex Flash *(preview)*
> **PageIndex Flash** ([`pageindex/flash`](pageindex/flash)) generates tree structures from PDFs in seconds. Structure extraction is purely heuristic-based, no LLM needed. LLM is only used to generate node summaries.
> **PageIndex Flash** ([`pageindex/flash`](pageindex/flash)) generates tree structures from PDFs in seconds. Structure extraction is purely heuristic-based, no LLM needed. An LLM is used only for node summaries and the optimization's expansion pass.
>
> ```bash
> python3 run_pageindex.py --flash --pdf_path /path/to/your/document.pdf
> python3 run_pageindex.py --mode flash --pdf_path /path/to/your/document.pdf
> ```
>
> Add `--optimize` to refine the tree structure for more efficient retrieval (with an LLM expansion pass).
> Tree optimization for retrieval (a deterministic merge, then an LLM expansion pass) is on by default; pass `--optimize off` to disable.

## 🚀 Agentic Vectorless RAG: An Example

Expand Down
83 changes: 32 additions & 51 deletions examples/agentic_vectorless_rag_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,83 +6,52 @@
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 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."""
return json.dumps(client.get_document_structure(doc_id), 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.
"""
return json.dumps(client.get_page_content(doc_id, pages), ensure_ascii=False)

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

async def _run():
Expand Down Expand Up @@ -152,21 +121,33 @@ 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 is None:
# The .doc_id cache is gitignored — on a fresh clone with an
# existing store, find the already-indexed copy by name instead of
# re-indexing it.
doc_id = next(
(doc["id"] for doc in client.list_documents(limit=100)["documents"]
if doc["name"] == PDF_PATH.name), None)
if doc_id:
DOC_ID_PATH.write_text(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
22 changes: 16 additions & 6 deletions pageindex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,36 @@
]

_LAZY = {
"page_index": ".page_index_classic",
"page_index_main": ".page_index_classic",
"page_index_flash": ".flash",
"optimize_tree": ".tree_optimize",
"md_to_tree": ".page_index_md",
}
_SUBMODULES = {"client", "cloud_api", "errors", "flash", "local_api",
"local_store", "page_index_classic", "page_index_md", "tree_optimize",
"utils"}

_SUBMODULES = {"agent_tools", "client", "cloud_api", "errors", "flash",
"integrations", "local_api", "local_chat", "local_store",
"mcp_bridge", "page_index_classic", "page_index_md",
"tree_optimize", "utils"}

def __getattr__(name):
if name.startswith("_"):
# Dunder probes (copy, pickle, inspect) are the frequent unknown
# names — they must not trigger the classic import below.
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
if name in _SUBMODULES:
return importlib.import_module(f".{name}", __name__)
module = importlib.import_module(_LAZY.get(name, ".page_index_classic"), __name__)
# Pre-0.2.10 compat: unknown names fall through to the classic module,
# whose public surface (ConfigLoader, count_tokens, ...) resolved as
# package attributes. A non-underscore typo pays one classic import
# before its AttributeError — not worth an allowlist.
module = importlib.import_module(_LAZY.get(name, ".page_index_classic"),
__name__)
try:
value = getattr(module, name)
except AttributeError:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
raise AttributeError(
f"module {__name__!r} has no attribute {name!r}") from None
globals()[name] = value
return value

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