feat: agent tools β OpenAI Agents SDK, Claude Agent SDK, and any framework, local & cloud - #393
Open
rejojer wants to merge 18 commits into
Open
feat: agent tools β OpenAI Agents SDK, Claude Agent SDK, and any framework, local & cloud#393rejojer wants to merge 18 commits into
rejojer wants to merge 18 commits into
Conversation
rejojer
force-pushed
the
feat/agent-tools
branch
11 times, most recently
from
August 10, 2026 08:09
5feb847 to
4e82306
Compare
Member
Author
Code reviewFound 5 issues:
PageIndex/pageindex/agent_tools.py Lines 580 to 583 in 7d37c3f
PageIndex/pageindex/agent_tools.py Lines 657 to 671 in 7d37c3f
PageIndex/pageindex/agent_tools.py Lines 968 to 976 in 7d37c3f
PageIndex/pageindex/agent_tools.py Lines 594 to 611 in 7d37c3f
PageIndex/pageindex/integrations/claude_agent_sdk.py Lines 15 to 23 in 7d37c3f π€ Generated with Claude Code - If this code review was useful, please react with π. Otherwise, react with π. |
* feat: add local mode to the PageIndex SDK client
One PageIndexClient, two backends. With api_key: the 0.2.x cloud SDK,
request for request (with the reviewed fixes: bounded timeouts on JSON
endpoints, none on uploads, URL-encoded ids, 401 key hint, empty DELETE
body tolerated). Without api_key: the same methods run locally β
page_index builds the tree in submit_document (mode="flash" uses
PageIndex Flash), documents are stored as plain JSON per doc under
storage_path, submit_query is LLM tree search with retrieve_model, and
chat_completions answers over the retrieved nodes with OpenAI-style
responses and streaming.
Local responses mirror the cloud wire shapes verified against the server
source: tree nodes rename start_index to page_index and drop end_index,
a non-leaf summary becomes prefix_summary, and the metadata/list/delete/
retrieval envelopes match key for key. Cloud-only features (folders,
beta_headers, enable_citations) raise instead of pretending.
Replaces the demo-only workspace client (index/get_document_structure/
get_page_content had no real users) and its retrieve.py helpers.
page_index_main gains an optional logger param so the SDK can keep
./logs out of the caller's working directory; pymupdf import is now lazy
(only the optional PyMuPDF parser path needs it).
* chore: package pageindex 0.3.0.dev4 for PyPI
Poetry packaging for the combined SDK + local pipeline: every production
import is a declared dependency (openai and requests join requirements.txt
for the same reason), config.yaml and the flash data tables ship in the
wheel, the benchmark PNG does not. pymupdf drops to an optional note now
that its import is lazy. dev4 follows the already-published 0.3.0.dev1-3;
pip still resolves plain 'pip install pageindex' to 0.2.8 until a final
0.3.0 β install with --pre.
* docs: add SDK section to README; move the agentic demo onto the SDK
The demo keeps its flow and the post-cutoff demo paper, swapping the
removed workspace client for PageIndexClient local mode (list_documents
for the doc-id cache, get_tree/get_ocr behind the agent tools). The old
examples/workspace JSONs demoed the removed format and go with it.
* refactor: rebuild cloud_api on the 0.2.8 client text
Ray's rule for the cloud half: his 0.2.8 code is the base; a Kylin-lineage
change survives only when strictly better β invisible on healthy traffic
while fixing a real failure mode. Kept under that bar: request timeouts
(dead connections hung forever; uploads still pass none, exactly like
0.2.8), the upload handle closed via with (leaked on request errors),
URL-encoded path ids (a crafted id could reroute the URL), the
empty-DELETE-body guard, and stream hardening (choices guard,
response.close in finally). Reverted as not strictly better: the
lowercase summary param (the server accepts both spellings), the 401
message hint (visible text change; AUTH_HINT dropped from errors.py), and
the _request/requests.request reorganization β every method body,
docstring, and section comment is 0.2.8's text again.
diff -w against ../pageindex_sdk/pageindex/client.py now reads as that
surgical patch plus plumbing: CloudAPI reads BASE_URL/api_key through the
owning client, PageIndexAPIError comes from errors.py, and
is_retrieval_ready lives verbatim on PageIndexClient shared by both
modes. A mocked-requests harness driving 0.2.8 and this file through 19
identical calls shows the only remaining request-level difference is
timeout.
* refactor: drop the local retrieval endpoints β cloud-only, deprecated
Ray: the cloud already marks POST /retrieval/ and GET /retrieval/{id}/
deprecated in favor of chat completions, so local mode should not grow a
fresh implementation of a retiring surface. submit_query/get_retrieval
now raise in local mode with a pointer to chat_completions; cloud mode is
untouched (the endpoint still works there and 0.2.8 code keeps running).
The tree search that backed them stays as chat_completions' retrieval
engine; the retrievals/ storage goes away. This also closes the one real
cross-mode parity gap β the retrieved_nodes inner shape β by removing
its local half.
* feat: manifest.json β one-file document listings for the local store
Ray wanted a metafile that shows every document in one place instead of
per-directory reads. It is a cache, never a second source of truth:
writers update it best-effort after save/delete (atomic replace, no
locks), and list_metas trusts it only while its id set matches the docs/
directory names β documents are immutable, so matching names imply valid
content. Any mismatch (lost concurrent update, crash, corrupt or deleted
manifest) rebuilds it from the doc.json files, reading only the missing
entries. Incomplete dirs (no doc.json) stay invisible and are never
recorded, so a save that completes later is still picked up.
1000-doc listing: 37ms of per-dir reads -> 2.3ms warm (scandir names +
one manifest read); one-time rebuild 160ms.
* fix: align local doc_id prefix and createdAt format with the cloud
Local doc ids now carry the cloud's pi- namespace prefix (random token
stays uuid4 hex β nothing parses cuid internals, the prefix is the
contract; chat ids already mirrored chatcmpl-). createdAt now matches
the server byte for byte: the cloud emits the DB datetime's bare
isoformat β naive UTC, second precision β while we emitted microseconds
plus +00:00.
* feat: optional metadata tags on submit_document (both modes)
Ray's call after the alignment review: the metadata key in tree/OCR
envelopes and list entries should carry real data, and the only honest
way is exposing the field the server already accepts. Cloud mode
forwards it as the existing metadata form field; local mode validates it
early (a JSON-serializable dict, checked before any LLM spend), stores
it in doc.json, and returns it from the same three places. get_document
still omits it, mirroring the server, whose metadata-endpoint SQL never
selects that column. Scope stays deliberately narrow: set at submit and
read back β no metadata_filter, no update API.
* docs: state that createdAt is UTC and show how to localize it
The value is naive UTC in both modes (the cloud column is timestamp
DEFAULT CURRENT_TIMESTAMP on a UTC server, emitted via bare isoformat).
Wall-clock display is the consumer's layer: emitting local time under
the same format would silently change meaning per machine, and adding an
offset marker would break both the byte-format parity and string-order
sorting.
* fix: createdAt carries milliseconds, matching the cloud's datetime(3)
The earlier second-precision alignment was reasoned from the postgres
schema file, but production is MySQL (DATABASE_BACKEND defaults to
mysql) and its FilePageIndex.createdAt is datetime(3) DEFAULT
CURRENT_TIMESTAMP(3) β so the server isoformat()s a millisecond-
precision naive-UTC datetime, emitting .XXX000 fractions (bare seconds
only when the millisecond happens to be zero). Local now generates
through the same mechanism. The docs' 2024-01-15T10:30:00.000Z sample is
a JS-style string Python isoformat cannot produce β not evidence.
* fix: createdAt at millisecond precision, matching the timestamp(3) column
The cloud column is timestamp(3); its datetimes render through bare
isoformat() as six fractional digits ending in 000 (or no fraction when
the millisecond is exactly zero). Truncate to the millisecond and render
the same way, replacing the second-precision guess from cd44ef0. Worth
one confirmation against a live cloud response when a key is around.
* chore: trim non-essential comments
Alignment narration (mirrors the cloud, matches 0.2.8, trailing-period
notes) moves out of the code β that rationale lives in the commit
history. Kept only constraints the code can't show: the storage commit
protocol and manifest trust rule, the id-escape guard, the silent
logger's reason to exist, the client-reference indirection, and the
tree-node reshape spec.
* fix: close the local_store crash and corruption holes found in review
Adversarial review reproduced three edge holes in the no-lock design:
a torn delete (doc.json unlinked, rmtree unfinished) left a ghost the
manifest kept listing forever; one truncated doc.json crashed every
listing with a raw JSONDecodeError; and two concurrent deletes of the
same id could crash the loser's rmtree.
doc.json is now the existence marker in both directions β written last
on save, unlinked first on delete β and list_metas serves an entry only
after confirming it still exists, instead of trusting the manifest/dir
name-set proxy. Atomic writes fsync before replace, closing the
power-loss truncation window. An unreadable JSON file is logged and
treated as absent; a document meta additionally falls back to the
manifest copy (immutable docs make the cache a valid replica). rmtree
runs with ignore_errors: the commit point has passed and cleanup is
best-effort, which also absorbs the double-delete race.
Also restores the reversed-page-range error in the demo's page parser
(silently empty since the retrieve.py removal). Warm 1000-doc listing
goes 2.3ms -> 8.3ms for the per-doc existence check; full parse remains
37ms.
* docs: correct two docstring claims and the pymupdf note
is_retrieval_ready reports only API errors as False β transport errors
propagate; delete_document may return {} on an empty cloud body; pymupdf
is also used by tree_optimize's page loading, not just get_page_tokens.
* chore: target 0.2.9 for the local-mode release
Ray's call: 0.2.x stays the no-collections line, so local mode ships as
0.2.9 and 0.3.0 stays reserved; plain pip installs never see the
0.3.0.devN pre-releases, and the cookbooks' existing 'pip install
--upgrade pageindex' will deliver the new SDK without any --pre
instructions.
* ci: publish to PyPI on version tags
Tag-driven releases: the pushed v-tag is the single source of truth for
the version β validated as PEP 440, injected into pyproject, built, and
published via OIDC trusted publishing with no stored credentials; a
GitHub Release with the artifacts is created alongside.
* chore: tighten the store docstring to essentials
* fix: contain invalid-UTF-8 corruption; fail loud on unreadable data files
The corruption guard caught JSONDecodeError but not the
UnicodeDecodeError a torn multi-byte write produces β the exact scenario
the guard targets whenever names or descriptions carry non-ASCII text β
so that flavor crashed listings and gets raw, and regressed the old
manifest read's broader ValueError guard. _read_json now catches
ValueError, which covers both.
Unreadable tree.json/pages.json under an intact doc.json previously
served an empty tree with retrieval_ready true β a silent lie; those
paths now raise 'stored document data is unreadable' and
is_retrieval_ready honestly reports False. delete_document survives a
doc.json tampered into a directory (cleans it, reports not-found)
while real unlink failures such as permissions stay loud.
* feat: LocalClient and CloudClient for explicit mode selection
PageIndexClient(api_key=os.getenv(...)) with an unset variable gets None
and silently falls into local mode β methods keep working against local
storage on the caller's own LLM bill, the silent mode flip the
empty-string guard can't see. The explicit classes close it at the type
level: CloudClient raises on a missing key, LocalClient has no api_key
parameter at all. Names per Ray.
* feat: explicit-mode clients PageIndexCloudClient and PageIndexLocalClient
PageIndexClient(api_key=os.getenv(...)) with an unset variable yields
None and silently lands in local mode β with both modes fully working,
that's a silent mode flip onto the user's own LLM bill. The explicit
classes pin the mode at construction: the cloud one refuses a missing or
empty key, the local one has no key parameter at all. Names follow the
package's PageIndex- prefix convention.
* fix local mode edge cases
* fix: keep the litellm/ prefix normalization the demo depends on
a108c02 added _normalize_retrieve_model because the agentic demo hands
client.retrieve_model straight to the OpenAI Agents SDK, which routes a
non-OpenAI provider only when the name carries a litellm/ prefix. The
normalization sat in client.py, so the demo line never had to change --
and this rewrite dropped the helper while leaving that lone consumer
untouched. A retrieve_model like anthropic/claude-sonnet-4-6, the form
config.yaml documents, then died at Agent() with "Unknown prefix".
Local mode is indifferent to which form it gets: llm_completion and
_chat_llm both removeprefix("litellm/"), count_tokens returns the same
count either way, and _is_openai_model classifies both as LiteLLM, so
_require_llm_key still asks for no OPENAI_API_KEY. Models without a
provider path (the packaged gpt-5.4 default) pass through untouched.
* fix: restore the published 0.2.8 helper signatures the cookbooks call
pyproject.toml makes this repo the source of the PyPI pageindex package,
so this utils.py replaces the published 0.2.8 one -- whose helpers are
the documented surface of the cookbook notebooks. Three had drifted:
remove_fields lost max_len, create_node_mapping lost
include_page_ranges/max_page, print_tree lost exclude_fields. Both
README-linked notebooks open with `pip install --upgrade pageindex` and
pass exactly those kwargs, so tagging v0.2.9 as-is would TypeError
every Colab run of vision_RAG_pageindex.ipynb.
remove_fields and create_node_mapping readopt the 0.2.8 bodies, strict
supersets of the current ones (no in-repo caller passes the new params).
print_tree keeps the outline view as its default and routes an explicit
exclude_fields= to the 0.2.8 pprint view -- the two versions disagree
on what the second positional means (indent vs exclude_fields), and the
notebooks pass it by keyword. call_llm, the fifth published name, stays
out: nothing imports it -- the one notebook using a call_llm defines
its own, with a different signature.
* perf: resolve the indexing stack lazily from pageindex/__init__
`import pageindex` eagerly pulled page_index, flash, and tree_optimize
-- 0.73s warm, numpy and pypdfium2 in-process -- while the published
0.2.8 package imported in 0.08s on requests+openai alone. A cloud-only
SDK user upgrading to 0.2.9 would pay for an indexing stack they never
call, on every interpreter start.
The eager surface shrinks to client and errors (2ms warm); everything
else resolves on first attribute access via PEP 562 and is cached in
the module namespace. `pageindex.page_index` stays the function, still
shadowing its submodule as the old star-import had it. __all__ now
names the public surface, so `from pageindex import *` binds the same
working set as before instead of 124 names including stdlib modules.
A TYPE_CHECKING block keeps real signatures visible to IDEs.
The test suite's sys.modules lookup assumed the eager import chain;
it now imports pageindex.page_index explicitly.
* fix: wrap PDF read failures in PageIndexAPIError on local submit
_extract_page_texts sat one line outside the try that wraps everything
else in submit_document, so a corrupt PDF surfaced as a raw
PyPDF2.errors.PdfReadError and a password-protected one as
FileNotDecryptedError -- while a blank PDF, checked on the very next
line, got a clean PageIndexAPIError. Callers handling the SDK error
type crashed on exactly the malformed downloads and encrypted files
an ingest loop sees most.
The extraction gets its own wrap rather than joining the indexer try
below, whose except would re-prefix the blank-PDF error into "Failed
to submit document: Failed to submit document: ...". FileNotFoundError
stays native, asserted by test_submit_rejections as cloud parity.
* fix: address code review findings across local mode and publish workflow
- _parse_json_reply: switch from extract_json to _reply_json (dead
try/except, global Noneβnull substitution, wrong error messages)
- client.py: config override filter uses `is not None` instead of
truthiness, so empty-string model args are no longer silently dropped
- llm_completion/llm_acompletion: raise RuntimeError after retries
exhausted instead of returning empty string
- _require_llm_key: extend to anthropic/gemini/mistral providers
- _chat_llm: reuse _openai_sync_client singleton from utils
- _tree_search: drop redundant deepcopy before non-mutating remove_fields
- _stream_chunks: move final chunk inside try so usage data is reachable;
close stays in finally
- _validate_chat_messages: accept system messages, merge them into the
internal system prompt for cloud/local parity
- _build_chat_context: accept pre-read metas and pass structure through
to _tree_search, eliminating double get_meta and double tree.json reads
- _index_standard: reuse ConfigLoader from construction; reject empty
structure (parity with flash mode)
- extract_json: bare except: β except Exception: (no longer swallows
KeyboardInterrupt)
- Replace all str.removeprefix() with _strip_prefix() helper to restore
Python 3.7 compatibility; pyproject.toml back to python >= 3.7
- publish.yml: add test job (py3.10 + py3.13) gating the publish job
- Remove unused bare `import pageindex` from tests
* fix: tighten CI permissions, timestamp format, import style, and docstring
- publish.yml: add permissions: {contents: read} to the test job
- local_api: emit 3-digit ms timestamps via isoformat(timespec="milliseconds")
- local_api: use relative import for pageindex.utils in _chat_llm
- local_api: note the node_summary gate in _format_tree_node docstring
- test_client: update createdAt regex to match the new 3-digit format
* fix: accept GOOGLE_API_KEY for Gemini; mark pre-releases in GitHub
- _require_llm_key: Gemini provider now accepts either GEMINI_API_KEY
or GOOGLE_API_KEY (LiteLLM supports both)
- publish.yml: set prerelease flag on rc/dev/alpha/beta tags so they
are not shown as regular releases on GitHub
* fix: preserve print_tree backward compat with 0.2.8 positional call
Detect list passed as second positional arg (old 0.2.8 signature) and
treat it as exclude_fields instead of indent.
* refactor: print_tree param order β exclude_fields second for 0.2.8 compat
Move exclude_fields back to the second position (matching 0.2.8) instead
of detecting list-as-indent. Recursive call uses indent= keyword arg.
* refactor: remove _require_llm_key pre-check entirely
Let OpenAI SDK and LiteLLM report their own missing-key errors instead
of maintaining a parallel provider-to-env-var map. The except Exception
wrapper in chat_completions already converts these to PageIndexAPIError.
* fix: let LLM provider errors propagate instead of wrapping them
OpenAI/litellm auth, rate-limit, and other provider errors now reach the
caller as their original type (e.g. openai.AuthenticationError) instead
of being wrapped in PageIndexAPIError. PageIndexAPIError stays reserved
for PageIndex's own errors (bad doc_id, invalid params, indexing failures).
* refactor: catch only RuntimeError instead of isinstance check on openai
Only our own _tree_search logic raises RuntimeError (bad JSON, missing
node_list). Provider errors and unexpected bugs propagate naturally.
* fix: restore createdAt to 6-digit .177000 format matching cloud DATETIME(3)
Revert the timespec="milliseconds" that a linter introduced β it output
.177 (3 digits) while the cloud server's isoformat() outputs .177000
(6 digits). Verified against pageindex-compute server/lib/db/planet.py:
DATETIME(3) column + bare isoformat() = .177000.
* fix: resolve 15 review findings from PR #389
- Rename page_index.py β page_index_classic.py to fix __getattr__
shadowing (function permanently replaced by submodule after import)
- Add return_exceptions=True to verify_toc, generate_summaries, and
summarize_tree gathers so one LLM failure doesn't abort the batch
- Gracefully degrade generate_doc_description to "" on failure instead
of discarding the entire completed index
- Normalize file_path with str()/expanduser/abspath to support
pathlib.Path and tilde paths
- Strip text from tree.json at save time; reconstruct from pages.json
on read via _load_tree_with_text
- Filter empty-string model overrides in PageIndexClient constructor
- Guard empty choices list before indexing response.choices[0]
- Wrap streaming iteration errors as PageIndexAPIError inside the
generator
- Catch PermissionError in _read_json alongside FileNotFoundError
- Set max_retries=3 for OpenAI and num_retries=3 for litellm in
_chat_llm to match the retry behavior of the indexing path
- Replace _SilentLogger with logging.getLogger(__name__)
- Add .github/workflows/tests.yml for PR and push-to-main test runs
* fix: close publish workflow injection and detect flash silent summary failure
1. publish.yml: pass VERSION via os.environ instead of shell interpolation
into python -c, eliminating the command injection vector.
2. summarize_tree: raise RuntimeError when every node's summary generation
fails (e.g. missing LLM credentials), instead of silently saving a
document with all-empty summaries marked as completed.
* refactor: make chat_completions cloud-only until agent-based local chat lands
The local implementation was a tree-search RAG engine (retrieve prompt +
context stuffing) that diverged from the cloud /chat/completions design,
where an agent navigates documents through MCP tools. Rather than ship
the divergent engine in 0.2.9, remove it; local chat returns as an agent
loop built on the agent-tools layer in the 0.2.10 line.
Also from the PR #389 review:
- get_tree fails loud on unreadable pages.json instead of silently
serving textless nodes
- drop the wasted deepcopy in get_tree (_format_tree_node builds new dicts)
- generate_doc_description catches only RuntimeError so provider errors
(bad key, unknown model) propagate instead of storing ""
- _read_json treats IsADirectoryError as unreadable
- list_metas skips directory names that fail _is_safe_id
- constructing with api_key plus local-only args raises PageIndexAPIError
(was ValueError) to match the empty-api_key path
* fix: verification follow-ups for the chat removal commit
- retrieve_model docstring no longer promises the deleted tree-search
machinery; the param is reserved for the coming agent-based local chat
- empty pages.json ([]) fails loud like unreadable pages: a stored
document can never legitimately have zero pages, and get_tree's
"nodes always carry text" promise held only for the None case
- README: chat example moved to its own cloud-only block so the local
quickstart no longer ends in a raise
- tests: pin IsADirectoryError loud-fail, list_metas unsafe-name skip,
empty-pages loud-fail, and generate_doc_description's swallow-vs-
propagate boundary
* fix: detect classic-path silent summary failure like flash already does
generate_summaries_for_structure absorbed every per-node error to
summary: "" with no systemic check, so a bad key or model name produced
an all-empty-summary tree with zero errors on the default CLI config
(if_add_node_summary: yes, if_add_doc_description: no). Mirror flash's
summarize_tree guard: partial failures still absorb, all-failed raises.
* fix: accurate wording for retrieve_model docstring and empty-pages error
- retrieve_model is not "unused": the agent demo drives its model from
client.retrieve_model today; say so instead
- an empty pages.json is invalid, not unreadable β dedicated
_require_pages raises "stored document has no page content" so the
operator reindexes instead of hunting for file corruption
* chore: trim non-essential comments, fix three docstring issues
Review fixes:
- client.py: remove unverified "pending" from get_document status enum
- cloud_api.py: update stale "kept line-for-line" module docstring
- cloud_api.py: add node_summary to get_tree Args
Comment trimming across __init__.py, client.py, cloud_api.py, errors.py,
local_api.py, local_store.py β module docstrings shortened, explanatory
inline comments removed, multi-line class docstrings collapsed to one line.
* feat: add get_page_content and get_tree include_text parameter
- client.get_page_content(doc_id, pages): convenience method wrapping
get_ocr + page filtering; _parse_pages moved from the demo into client.py
- client.get_tree(..., include_text=False): skips node text for
structure-only views; local reads the stored tree directly (no text
to begin with), cloud strips client-side via remove_fields
- demo simplified: tools now call the new client methods directly
* simplify: drop redundant try/except in demo get_page_content tool
* feat: add get_document_structure convenience method
* test: cover get_page_content, get_document_structure, include_text=False
* fix: guard against four edge-case crashes found in PR #389 review
- Demo: use getattr for retrieve_model so cloud clients don't AttributeError
- utils: return empty string when start/end page index is None instead of TypeError
- local_store: clean up temp file on _write_json_atomic failure
- local_api: re-raise PageIndexAPIError before the catch-all Exception block
* chore: trim verbose optional-dep comments in requirements.txt
* revert: restore README.md to main β SDK section deferred to next version
* fix: eliminate double PDF parse in local standard indexing
_extract_page_texts already reads the PDF via PyPDF2; pass the
pre-extracted texts as page_list to page_index_main so it skips
its own get_page_tokens call. Token counts are computed once via
litellm.token_counter in _index_standard.
* test: assert page_list is passed and correctly shaped
* fix: wire include_text to cloud API and guard get_page_content on processing docs
cloud_api.py now sends include_text as a query param so the server can
omit node text from tree responses, saving bandwidth. Backward-compatible:
old servers ignore the param, client-side remove_fields still strips.
get_page_content raises PageIndexAPIError instead of TypeError when the
document is still processing (get_ocr returns result: null).
Four new client methods make PageIndex documents available to agent frameworks, in both modes, with the mode decided solely by the client constructor: - agent_tools(): plain functions (browse_documents, get_document, get_document_structure, get_page_content) matching the PageIndex cloud MCP server's tools/list β same names, schemas, descriptions, and JSON response envelopes β so agent prompts port unchanged between the cloud MCP connection and these in-process tools. Tools never raise; errors come back in the same envelope. remove_document ships behind include_management=False. - as_openai_tools(): the same tools wrapped for the OpenAI Agents SDK. - as_claude_mcp(): one mcp_servers entry for the Claude Agent SDK β cloud clients get the remote MCP config (the framework connects to api.pageindex.ai/mcp and discovers the full cloud tool set), local clients get an in-process SDK MCP server. - agent_instructions(doc_id=None): orchestration guidance for the agent's system prompt; doc_id (same shape as chat_completions) appends the target documents. submit_document() gains wait=True: poll get_document status until completed, raise on failed or after 30 minutes β the manual polling loop every cloud caller writes today spins forever on a failed document. Neither framework becomes a dependency: imports happen at call time with actionable errors, and pageindex[openai] / pageindex[claude] extras are floor-only pins. tests/data/cloud_mcp_contract.json freezes the tool contract; a parity test guards against drift. 36 new tests (95 total), plus a live OpenAI Agents SDK run over a seeded local store verifying the structure-first navigation flow end to end.
β¦mantics - Large-doc next_steps now says structure-first, consistent with tool descriptions and agent instructions - _remove_document fetches document list once instead of per-name - call_tool returns error envelope for unknown names instead of raising - _not_ready_error timed_out flag reflects actual wait outcome - openai_agents.py docstring corrected to match default (FunctionTools) - Removed unused ModelSettings import from demo
β¦data merge - McpBridge reads session/protocol headers under the lock (now RLock: _ensure_initialized posts while holding it). openai-agents runs sync tools on threads and executes parallel tool calls concurrently, so bridge functions genuinely race; a torn read sent a new session id with a stale protocol header. Measured: one session expiry under 8 threads cost 4 initializations before, minimal 2 after. - Session-expiry retry also resets the negotiated protocol version, so the re-handshake carries no stale MCP-Protocol-Version header. - browse_documents time sort pages list_documents natively instead of fetching the whole library to slice one window (relevance still needs the full list for scoring). - _await_completion: a status refetch that nulls out metadata no longer clobbers the listing's copy (setdefault was a no-op on existing None). - Structure tool reads the raw stored tree via a named LocalAPI raw_tree() seam instead of reaching into _api._store internals; drop the redundant deepcopy before _format_structure (store re-reads from disk, formatting builds fresh containers). - Shared pageindex/_version.py replaces _sdk_version duplicated in mcp_bridge and the Claude integration. Left as-is after source verification against the cloud MCP: first-page budget bypass, pageNum falsy-zero, and the page-gap fallback text are letter-for-letter cloud behavior β parity wins over local repair.
β¦lience, contract drift - _parse_page_spec bounds the requested span arithmetically (10k pages) before materializing it; pages="1-1000000000" previously expanded to a billion integers inside the caller's process. - Local submit_document uniquifies document names the way the cloud upload does (taken name -> _1.._99, then reject with the cloud's own message). Same-name duplicates broke name-addressed tools: resolution always picks the newest, so older duplicates were unreachable. - agent_instructions(doc_id=...) now fails loud when the pinned doc's name is shadowed by a newer same-name document (legacy stores predate the rename) β it previews resolution with the same _resolve_document the tools use, so the check cannot drift from actual behavior. - submit_document(wait=True) tolerates transient network errors, not just API errors; a dropped connection at minute 25 of a 30-minute wait no longer kills it. Third strike wraps into PageIndexAPIError per the documented contract. - The live contract-parity test compares full per-param schemas, not just names and descriptions. It immediately caught real drift the shallow check had been passing: the server now emits nullables as anyOf unions and stamps MAX_SAFE_INTEGER maxima on offset/part. Contract and snapshot updated to the served wire form; _annotation_for learned anyOf so bridge signatures stay Optional[str] instead of degrading to Any. Adjudicated, not changed: the allowed_tools wildcard example stays (docstring advice covers scoping; Ray's call), and raw-length response accounting stays (letter-for-letter cloud behavior, parity wins).
Compute PR #558 makes /doc/ return {"doc_id", "name"} carrying the
post-dedup-rename name. Mirror it end to end: local submit returns the
stored name, the client warns when it differs from the uploaded file
name (read via .get so older cloud servers stay compatible), the local
name-exhaustion check runs before indexing instead of after the LLM
spend, and the demo caches doc_id in a file instead of name-matching β
a renamed document made the name lookup re-index on every run.
rejojer
force-pushed
the
feat/agent-tools
branch
from
August 11, 2026 14:09
78ab1ad to
dece6e6
Compare
The cloud MCP server publishes its agent instructions in the initialize result, adapted to each key's tool set. agent_instructions() previously returned the SDK's local-subset text in both modes β a silently forked copy that lacks the guidance for cloud-only tools (search_documents escalation, folders, images) and drifts as the server's prompt evolves. Cloud clients now serve the server's live instructions, captured from the initialize handshake on a per-client bridge shared with agent_tools() (one session, no extra request). An empty server response raises instead of silently substituting the subset text β same posture as the annotation-regression guard. The local constant stays as the honest subset for the in-process tools, with its provenance noted and a consistency test that every tool it names exists in the local registry.
sort="relevance" is cloud-side semantic ranking; the local substring imitation could satisfy the letter of the interface while silently missing semantically relevant documents. Per the honest-subset rule (same treatment as folders), local now returns the "not available here" envelope for sort="relevance" or a stray query, and the local instructions steer discovery through name/description matching plus full-library paging instead of prescribing a capability that does not exist here. The tool schema keeps the cloud contract verbatim, like folder_id: honesty lives in the runtime answer, not a forked contract.
"Not available here" read as a broken feature; the honest framing is that folders and semantic ranking exist on PageIndex cloud and are not in local mode yet. Both envelopes now say so and name the cloud client in next_steps, so agents relay an accurate story to the user.
The cloud-verbatim browse_documents description invites sort="relevance" and folder drilling, so a local agent's first semantic search attempt was a guaranteed dead end discovered only from the runtime error envelope. Local registration now appends a LOCAL MODE note to the description β the agent learns what is cloud-only before calling; the runtime envelope stays as the backstop for prompts that ignore descriptions. The cloud-facing contract stays byte-verbatim.
Appending a retraction to the cloud-verbatim description left the model parsing an instruction and its negation β and kept the cloud text recommending search_documents and get_folder_structure, tools that are not registered locally (get_page_content likewise pointed at get_document_image). Guidance now adapts to the local surface the way AGENT_INSTRUCTIONS already does: schema structure stays byte-identical to the contract (mechanically asserted by a strip-descriptions test), while local description strings teach only what works here and point to PageIndex cloud for the rest. A dead-reference test forbids local guidance from naming tools outside the local registry, so a contract refresh that reintroduces a cloud-only reference fails loudly.
folder_id, sort, query, and recursive were exposed locally with localized "cloud-only" descriptions, leaving the dead-end calls expressible and discovered at runtime. Schema constraints beat guidance: the local surface now serves the contract minus these parameters, so strict-schema frameworks make the calls inexpressible and a prompt that insists on sort="relevance" degrades to the bare call (the correct local behavior) instead of an error round-trip. The implementations still accept the hidden parameters and answer with the guided "works on PageIndex cloud" envelope β the backstop for direct call_tool callers and hosts without schema enforcement. wait_for_completion stays: seeded or torn stores can hold documents that are genuinely not completed. The structural guard now asserts the local schema equals the contract minus the documented hidden set, descriptions aside.
Three independent review passes over the agent-instructions increment surfaced six fixes: - The per-client bridge moved off the instance into a weak-keyed, lock-guarded module cache: cloud clients stay picklable (threading.RLock no longer rides on the client) and concurrent first calls can no longer construct duplicate bridges/sessions. - Blank or non-string initialize.instructions now hit the same honest error as a missing one β a whitespace-only or structured value could previously become the system prompt (or crash the doc_id append with a raw TypeError). - The invalid-sort envelope no longer prescribes sort="relevance" β the one error text that still taught the cloud-only value it would then reject. - "Page through the rest of the library" is emitted only when has_more is true; a fully-listed library no longer instructs a pointless call. - The mandatory full-library paging step now says limit: 50 β 6 calls instead of 30 on a 300-document library. - Docstrings and comments rescoped to what is actually true: the never-raise contract covers invocations the signatures accept (unknown params fail at the Python boundary; call_tool answers them with the guided envelope), recursive is accepted as the identity rather than errored, lenient framework arg models drop hidden params pre-call, and the module header no longer claims full schema parity. The capability-phrase guard now covers every local docstring, not just browse_documents.
The frozen contract guards tools/list, but the response envelopes the local tools emit were hand-built to mirror the cloud's and had no drift detector. A key-gated live test now asserts every field local emits exists in the live cloud response for the analogous call (top-level keys, next_steps, document entries, structure nodes, content entries). Guidance wording is deliberately localized and not compared. Verified green against the live server: local and cloud field structures currently match exactly.
rejojer
added a commit
that referenced
this pull request
Aug 12, 2026
* feat: agent tools β the cloud MCP tool contract on the client
Four new client methods make PageIndex documents available to agent
frameworks, in both modes, with the mode decided solely by the client
constructor:
- agent_tools(): plain functions (browse_documents, get_document,
get_document_structure, get_page_content) matching the PageIndex cloud
MCP server's tools/list β same names, schemas, descriptions, and JSON
response envelopes β so agent prompts port unchanged between the cloud
MCP connection and these in-process tools. Tools never raise; errors
come back in the same envelope. remove_document ships behind
include_management=False.
- as_openai_tools(): the same tools wrapped for the OpenAI Agents SDK.
- as_claude_mcp(): one mcp_servers entry for the Claude Agent SDK β
cloud clients get the remote MCP config (the framework connects to
api.pageindex.ai/mcp and discovers the full cloud tool set), local
clients get an in-process SDK MCP server.
- agent_instructions(doc_id=None): orchestration guidance for the
agent's system prompt; doc_id (same shape as chat_completions) appends
the target documents.
submit_document() gains wait=True: poll get_document status until
completed, raise on failed or after 30 minutes β the manual polling loop
every cloud caller writes today spins forever on a failed document.
Neither framework becomes a dependency: imports happen at call time with
actionable errors, and pageindex[openai] / pageindex[claude] extras are
floor-only pins. tests/data/cloud_mcp_contract.json freezes the tool
contract; a parity test guards against drift. 36 new tests (95 total),
plus a live OpenAI Agents SDK run over a seeded local store verifying
the structure-first navigation flow end to end.
* fix: agent tools review β next_steps order, resolve caching, error semantics
- Large-doc next_steps now says structure-first, consistent with tool
descriptions and agent instructions
- _remove_document fetches document list once instead of per-name
- call_tool returns error envelope for unknown names instead of raising
- _not_ready_error timed_out flag reflects actual wait outcome
- openai_agents.py docstring corrected to match default (FunctionTools)
- Removed unused ModelSettings import from demo
* fix: agent tools review 2 β bridge thread safety, browse paging, metadata merge
- McpBridge reads session/protocol headers under the lock (now RLock:
_ensure_initialized posts while holding it). openai-agents runs sync
tools on threads and executes parallel tool calls concurrently, so
bridge functions genuinely race; a torn read sent a new session id
with a stale protocol header. Measured: one session expiry under 8
threads cost 4 initializations before, minimal 2 after.
- Session-expiry retry also resets the negotiated protocol version, so
the re-handshake carries no stale MCP-Protocol-Version header.
- browse_documents time sort pages list_documents natively instead of
fetching the whole library to slice one window (relevance still needs
the full list for scoring).
- _await_completion: a status refetch that nulls out metadata no longer
clobbers the listing's copy (setdefault was a no-op on existing None).
- Structure tool reads the raw stored tree via a named LocalAPI
raw_tree() seam instead of reaching into _api._store internals; drop
the redundant deepcopy before _format_structure (store re-reads from
disk, formatting builds fresh containers).
- Shared pageindex/_version.py replaces _sdk_version duplicated in
mcp_bridge and the Claude integration.
Left as-is after source verification against the cloud MCP: first-page
budget bypass, pageNum falsy-zero, and the page-gap fallback text are
letter-for-letter cloud behavior β parity wins over local repair.
* fix: agent tools review 3 β page-span cap, duplicate names, wait resilience, contract drift
- _parse_page_spec bounds the requested span arithmetically (10k pages)
before materializing it; pages="1-1000000000" previously expanded to a
billion integers inside the caller's process.
- Local submit_document uniquifies document names the way the cloud
upload does (taken name -> _1.._99, then reject with the cloud's own
message). Same-name duplicates broke name-addressed tools: resolution
always picks the newest, so older duplicates were unreachable.
- agent_instructions(doc_id=...) now fails loud when the pinned doc's
name is shadowed by a newer same-name document (legacy stores predate
the rename) β it previews resolution with the same _resolve_document
the tools use, so the check cannot drift from actual behavior.
- submit_document(wait=True) tolerates transient network errors, not
just API errors; a dropped connection at minute 25 of a 30-minute
wait no longer kills it. Third strike wraps into PageIndexAPIError
per the documented contract.
- The live contract-parity test compares full per-param schemas, not
just names and descriptions. It immediately caught real drift the
shallow check had been passing: the server now emits nullables as
anyOf unions and stamps MAX_SAFE_INTEGER maxima on offset/part.
Contract and snapshot updated to the served wire form; _annotation_for
learned anyOf so bridge signatures stay Optional[str] instead of
degrading to Any.
Adjudicated, not changed: the allowed_tools wildcard example stays
(docstring advice covers scoping; Ray's call), and raw-length response
accounting stays (letter-for-letter cloud behavior, parity wins).
* feat: surface the stored document name from submit_document
Compute PR #558 makes /doc/ return {"doc_id", "name"} carrying the
post-dedup-rename name. Mirror it end to end: local submit returns the
stored name, the client warns when it differs from the uploaded file
name (read via .get so older cloud servers stay compatible), the local
name-exhaustion check runs before indexing instead of after the LLM
spend, and the demo caches doc_id in a file instead of name-matching β
a renamed document made the name lookup re-index on every run.
* fix: add missing page_list kwarg in duplicate-name test mock
* revert: keep README.md unchanged from main β SDK section deferred
* feat: serve cloud agent instructions live from the MCP server
The cloud MCP server publishes its agent instructions in the initialize
result, adapted to each key's tool set. agent_instructions() previously
returned the SDK's local-subset text in both modes β a silently forked
copy that lacks the guidance for cloud-only tools (search_documents
escalation, folders, images) and drifts as the server's prompt evolves.
Cloud clients now serve the server's live instructions, captured from
the initialize handshake on a per-client bridge shared with
agent_tools() (one session, no extra request). An empty server response
raises instead of silently substituting the subset text β same posture
as the annotation-regression guard. The local constant stays as the
honest subset for the in-process tools, with its provenance noted and a
consistency test that every tool it names exists in the local registry.
* fix: local relevance sort answers honestly instead of imitating
sort="relevance" is cloud-side semantic ranking; the local substring
imitation could satisfy the letter of the interface while silently
missing semantically relevant documents. Per the honest-subset rule
(same treatment as folders), local now returns the "not available
here" envelope for sort="relevance" or a stray query, and the local
instructions steer discovery through name/description matching plus
full-library paging instead of prescribing a capability that does not
exist here. The tool schema keeps the cloud contract verbatim, like
folder_id: honesty lives in the runtime answer, not a forked contract.
* docs: note the cloud+Claude instructions duplication trade-off in as_claude_mcp
* fix: unsupported-capability envelopes say local-mode-yet, point to cloud
"Not available here" read as a broken feature; the honest framing is
that folders and semantic ranking exist on PageIndex cloud and are not
in local mode yet. Both envelopes now say so and name the cloud client
in next_steps, so agents relay an accurate story to the user.
* fix: local tool descriptions pre-announce cloud-only capabilities
The cloud-verbatim browse_documents description invites
sort="relevance" and folder drilling, so a local agent's first semantic
search attempt was a guaranteed dead end discovered only from the
runtime error envelope. Local registration now appends a LOCAL MODE
note to the description β the agent learns what is cloud-only before
calling; the runtime envelope stays as the backstop for prompts that
ignore descriptions. The cloud-facing contract stays byte-verbatim.
* refactor: localized tool guidance replaces the appended LOCAL MODE note
Appending a retraction to the cloud-verbatim description left the model
parsing an instruction and its negation β and kept the cloud text
recommending search_documents and get_folder_structure, tools that are
not registered locally (get_page_content likewise pointed at
get_document_image). Guidance now adapts to the local surface the way
AGENT_INSTRUCTIONS already does: schema structure stays byte-identical
to the contract (mechanically asserted by a strip-descriptions test),
while local description strings teach only what works here and point to
PageIndex cloud for the rest. A dead-reference test forbids local
guidance from naming tools outside the local registry, so a contract
refresh that reintroduces a cloud-only reference fails loudly.
* feat: hide cloud-only parameters from the local tool surface
folder_id, sort, query, and recursive were exposed locally with
localized "cloud-only" descriptions, leaving the dead-end calls
expressible and discovered at runtime. Schema constraints beat
guidance: the local surface now serves the contract minus these
parameters, so strict-schema frameworks make the calls inexpressible
and a prompt that insists on sort="relevance" degrades to the bare
call (the correct local behavior) instead of an error round-trip.
The implementations still accept the hidden parameters and answer with
the guided "works on PageIndex cloud" envelope β the backstop for
direct call_tool callers and hosts without schema enforcement.
wait_for_completion stays: seeded or torn stores can hold documents
that are genuinely not completed. The structural guard now asserts the
local schema equals the contract minus the documented hidden set,
descriptions aside.
* fix: incremental-review findings β bridge cache, guards, envelope drift
Three independent review passes over the agent-instructions increment
surfaced six fixes:
- The per-client bridge moved off the instance into a weak-keyed,
lock-guarded module cache: cloud clients stay picklable
(threading.RLock no longer rides on the client) and concurrent first
calls can no longer construct duplicate bridges/sessions.
- Blank or non-string initialize.instructions now hit the same honest
error as a missing one β a whitespace-only or structured value could
previously become the system prompt (or crash the doc_id append with
a raw TypeError).
- The invalid-sort envelope no longer prescribes sort="relevance" β the
one error text that still taught the cloud-only value it would then
reject.
- "Page through the rest of the library" is emitted only when has_more
is true; a fully-listed library no longer instructs a pointless call.
- The mandatory full-library paging step now says limit: 50 β 6 calls
instead of 30 on a 300-document library.
- Docstrings and comments rescoped to what is actually true: the
never-raise contract covers invocations the signatures accept
(unknown params fail at the Python boundary; call_tool answers them
with the guided envelope), recursive is accepted as the identity
rather than errored, lenient framework arg models drop hidden params
pre-call, and the module header no longer claims full schema parity.
The capability-phrase guard now covers every local docstring, not
just browse_documents.
* chore: keep the demo's doc_id cache file out of the repo
* test: live envelope field-parity guard against cloud response drift
The frozen contract guards tools/list, but the response envelopes the
local tools emit were hand-built to mirror the cloud's and had no drift
detector. A key-gated live test now asserts every field local emits
exists in the live cloud response for the analogous call (top-level
keys, next_steps, document entries, structure nodes, content entries).
Guidance wording is deliberately localized and not compared. Verified
green against the live server: local and cloud field structures
currently match exactly.
* feat: local chat β three protocol surfaces over the agent tools (v0.2.10)
Local mode gains managed document QA: an agent over the #393 local tool
set, reachable through three wire protocols, each 1:1 with the backend
and with no translation layer.
- chat_completions(): standard chat.completions semantics on any
OpenAI-compatible backend (openai-agents engine). Final answer only,
cross-turn aggregated usage, streaming as text pieces or chunk dicts
(the existing cloud signature, now implemented locally; model and
max_turns are local-only additions).
- responses(): the agentic surface β OpenAI Responses format, the tool
process is standard output items, streaming forwards native events
(tool outputs emitted as response.output_item.done, the way the
platform streams its own server-side tools). Round-tripping output
into the next input keeps provider prompt-cache prefix continuity and
the agent's memory β live-verified: the follow-up call answered from
round-tripped tool output with zero new tool calls.
- messages(): Anthropic-native via the SDK's own tool runner (new
pageindex[anthropic] extra, floor 0.68.0 verified for
tool_runner/beta_tool(input_schema)). tool_use/tool_result round-trip
is the format's native behavior; the envelope is the final message
with aggregated usage plus the full new-turn sequence; the managed
system blocks carry cache_control breakpoints.
Shared skeleton: thin chat header + the local AGENT_INSTRUCTIONS
(caller system content is appended, not rejected), the doc_id targeting
block as a leading context item (factored out of
build_agent_instructions), read-only toolset, structural-only
validation (no arbitrary caps β backend limits govern), sampling params
passed through, per-run tracing disabled, enable_citations rejected as
cloud-only. Design basis is industry-standard formats rather than the
cloud chat endpoint; responses()/messages() raise on cloud clients
until the cloud converges.
Tests run the real engines against scripted backends (a Model fake for
openai-agents, a mock HTTP transport under the real anthropic SDK) with
real tool execution against a seeded store, including the round-trip
prefix-extension assertions on both engines.
* fix: local-chat review findings β truncation, serialization, streams
Three independent review passes (bug scan, claims-vs-code, adversarial
runtime probes) over the local-chat increment; every fix below was
reproduced before being fixed.
messages():
- A max_turns cut no longer duplicates the final assistant turn: the
runner has already appended it when iterations exhaust, so the
round-trip history carried a duplicate tool_use id and ended on an
unanswered tool_use β a guaranteed 400 on continuation. The append
now keys on stop_reason, and truncation reads natively as
stop_reason: "tool_use" with a continuable history.
- The envelope is JSON-serializable end to end: runner-stored turns
carry pydantic content blocks; everything is dumped to plain dicts,
excluding SDK-internal __api_exclude__ fields (parsed_output) that
the API rejects on round-trip.
- Bounded by default (max_iterations 10, like the OpenAI surfaces);
usage aggregation now preserves the final turn's native fields and
sums the token counters None-safely; empty caller system strings are
skipped; non-dict message entries and bad doc_id types raise
PageIndexAPIError; anthropic < 0.68 gets an actionable version error;
the doc block no longer spends a cache_control breakpoint.
chat_completions()/responses():
- MaxTurnsExceeded wraps into PageIndexAPIError on all four run paths.
- responses(stream=True) is one logical response: per-turn backend
lifecycle events are collapsed (a canonical consumer previously
stopped at turn 1's response.completed and never saw the answer),
sequence numbers are reassigned monotonically, and the synthesized
tool-output event carries output_index/sequence_number.
- The responses envelope carries the real request surface
(instructions, the actual function tool definitions, tool_choice,
parallel_tool_calls, error/incomplete_details).
- RunConfig(group_id) pins a stable prompt_cache_key: openai-agents
otherwise stamps each run with a fresh key, tagging round-tripped
prefixes as different cache groups and defeating the feature the
round-trip exists for.
- Abandoning a stream now cancels the run: a watchdog task lets the
cancellation land even while the pump awaits the backend, and the
per-call AsyncOpenAI client is closed before its loop ends (fixes
"Task exception was never retrieved" noise). The opening role chunk
is emitted even for empty outputs; empty responses() input and
enable_citations-before-extra ordering fixed.
Docs rescoped to what is true: finish_reason/status reflect loop
completion on the OpenAI surfaces (the engine does not surface per-turn
backend reasons); chat streaming yields visible narration including
pre-tool text; messages(stream=True) forwards the Anthropic SDK's
native event objects (not wire-verbatim); the doc block is a leading
conversation item on OpenAI surfaces and a system block on messages().
Tests: 25 in the file (11 new), with per-extra skip sections so a
machine with only one framework still covers the other surface;
without-frameworks matrix re-verified; live smoke re-run green with a
clean exit.
* feat: as_anthropic_tools β Anthropic tool-runner export, both modes
Fills the last cell of the agent-connection matrix: users driving their
own anthropic tool_runner loop get runnable tools directly. Cloud wraps
the live MCP tool set with input schemas passing through verbatim (MCP
inputSchema is the Messages API schema shape); local exposes the same
set messages() runs internally. The beta_tool wrapping moves from
local_chat into integrations/anthropic_sdk.py, parallel to
openai_agents.py, and messages() now consumes the shared builder.
agent_tools grows _bridge_invoker/_read_only_tools so the plain-function
and beta_tool cloud paths share invocation containment and the
read-only gate.
* fix: as_anthropic_tools review findings β async flavor, schema isolation
Adversarial + best-practice review of 4590dd8 (three independent passes)
surfaced two holes. The export was sync-only: AsyncAnthropic's runner
accepts only BetaAsyncFunctionTool and splices anything else into the
request body unserialized, so the first call died with an opaque
TypeError β asynchronous=True now builds beta_async_tool runnables
(present since the 0.68.0 floor) that run the blocking bridge/store call
in a worker thread, keeping I/O off the caller's event loop. And
beta_tool stores input_schema by reference, so cloud tools aliased the
bridge's cached metas while the local path deep-copied β the builder now
copies, and the passthrough test asserts equal-but-not-aliased so it can
no longer compare an object with itself. Docstring fixes from the same
round: the MCP-connector pointer now carries the full live-verified
shape (authorization_token was missing β following it literally gave a
401), and the manual messages.create loop's to_dict() serialization is
documented. Tests pin the runnable flavor both ways (isinstance), which
existing tests could not distinguish.
* docs: doc_id is per-call table-setting β keep it identical across a conversation
The targeting block doc_id adds is re-set on every call and sits in the
cached prompt prefix, so a round-trip that drops (or changes) doc_id
silently diverges the prefix and loses the cache continuation. State the
rule on all three chat surfaces' doc_id docs, and pin it with a prefix
test that passes the same doc_id on both calls.
* feat: every chat surface takes a bare query string
query + doc_id is the minimal PageIndex contract, so it now works
uniformly: chat_completions and messages accept a plain string (one
user message), as responses always did per its wire format. The wrap
is input sugar at the SDK surface, not a translation layer β the
outgoing wire is unchanged, and managed agent surfaces taking strings
is the ecosystem convention (Runner.run, claude_agent_sdk.query).
Cloud chat_completions gains the same acceptance; blank strings raise
on every path.
* feat: messages() defaults max_tokens to 4096
The Messages API requires a per-turn output budget on the wire, but
that is table-setting, not a PageIndex-layer user obligation β the
simple call is now a question + model + doc_id. The knob stays
overridable (passthrough intact); model stays required because no
cross-vendor default is honest to guess.
* fix: raise messages() max_tokens default to 8192
max_tokens is a cap, not consumption, so the default should be the
highest universally safe value: 4096 could truncate long-form answers
(whole-document summaries), while 8192 is the output ceiling every
non-EOL Claude model accepts and stays under the SDK's non-streaming
long-request threshold.
* fix: restore per-extra skip markers the string-input tests displaced
Inserting tests above decorated ones absorbed their @needs_agents
markers, so two tests ran (and failed) in the without-frameworks CI
job. Both simulated-bare and full runs are green again.
* fix: close 17 findings from the v0.2.10 max review
Tool layer:
- anthropic adapter: failed tool calls raise ToolError so the runner
emits tool_result is_error:true; McpBridge.call_tool returns
(text, is_error) and surfaces the server's MCP isError marking
- as_openai_tools builds FunctionTool with the contract/server schema
verbatim (strict off) β function_tool() regenerated schemas from
signatures, dropping items/enum/pattern/bounds and aborting the whole
list on object-typed params; shared _tool_specs() feeds both adapters
- remove_document validates every name before deleting anything; call_tool
classifies only bind-time TypeErrors as INVALID_INPUT
- unknown-tool envelope formatted with _dumps like every other envelope
Local chat:
- doc_id is enforced at the tool layer (allowlist threaded through
call_tool and the adapters), not just prompted; the shadow check runs
inside the scope
- _openai_model routes litellm/ and provider/ paths via LitellmModel and
strips openai/ β the normalized retrieve_model 404'd as a raw wire name
- responses() reports the backend's real terminal status (recorded at the
transport client; the framework discards Response.status) and wraps
framework exceptions in PageIndexAPIError
- chat_completions streaming yields its opening chunk inside try, so an
abandoned iterator still cancels the run and closes the backend
- prompt-cache group_id is per-conversation (model+instructions+first
item) instead of one global constant pooling every user
- messages() max_tokens default resolves per model (claude-3 caps at 4096)
Packaging / surface:
- __init__ registers the 0.2.10 modules in _SUBMODULES; unknown names
raise AttributeError instead of eagerly importing page_index_classic
- anthropic floor 0.84.0: first release with ToolError whose runner also
executes the final turn's tools on a max_iterations cut
- client docstrings caught up with local chat landing
Claude Agent SDK gate:
- claude_allowed_tools(mcp_servers) derives mcp__<key>__<tool> entries
from the caller's own registration map (live server annotations on
cloud, the contract locally) β no name is ever spelled twice
- claude_agent_config() bundles the three slots as one-call sugar over
the explicit form
Examples:
- demo runs against cloud again (getattr for local-only attrs) and finds
an existing indexed copy by name before re-indexing
Tests: monkeypatches replace the consuming module's binding instead of
mutating the shared time/requests modules; 185 -> 211.
* feat: one-call config bundles for every bring-your-own-framework surface
claude_agent_config() gets two symmetric siblings, so each framework's
front door is a single splat over the same explicit primitives:
- openai_agent_config(): Agent(**...) kwargs β instructions, tools, and
the local retrieve_model (cloud omits model for the framework default)
- anthropic_runner_config(): tool_runner(**...) kwargs β system, tools,
and the messages() defaults (per-model max_tokens, 10-iteration bound);
only the user's messages remain
Bundles stay pure sugar: doc_id rides agent_instructions, no extra
semantics over the explicit form, docstrings point both ways. The demo
agent shrinks to Agent(**client.openai_agent_config(doc_id=...)).
Construction is pinned against the real frameworks in tests (Agent and
tool_runner both built offline), so an upstream kwargs rename fails
loudly; 211 -> 215 tests.
* fix: three more review findings β partial-read reporting, reply correlation, output_index axis
- get_page_content: the summary is additive, not either/or β a call that
both truncates for size and has out-of-range pages reported only the
latter, telling the agent every in-range page was returned (#2)
- McpBridge._extract_result: strict request-id correlation only; the
eager fallback could hand back a stale or mis-correlated JSON-RPC
message as this call's reply (#16)
- responses() streaming: output_index now addresses the logical
response.output β backend per-turn indexes are re-based past prior
turns' items and the SDK-injected tool outputs take the next slot on
that axis, instead of reusing the event-sequence counter (#15)
215 -> 217 tests.
* feat: gate the config-handoff surfaces by the read-only MCP endpoint
pageindex-chat#448 adds /mcp?tools=read β the server registers only
readOnlyHint-annotated tools β so the URL itself becomes the gate for
every surface that hands a config to a third party:
- as_claude_mcp: include_management now picks the endpoint on cloud;
the parameter is real in both modes
- as_openai_tools(hosted=True): OpenAI connects to the read-only
endpoint by default and require_approval simplifies to "never" β the
approval-flow middle ground becomes hard absence, matching every
other surface's default
- claude_allowed_tools() retired before ever shipping: with the server
gated, allowed_tools degenerates to whole-server pre-approval, which
claude_agent_config emits as the constant ["mcp__<name>"] β no
setup-time bridge round-trip remains
- in-process surfaces (agent_tools, as_openai_tools, as_anthropic_tools
over the bridge) keep bare /mcp + client-side annotation filtering:
they materialize tools locally and hand no URL to anyone
Release ordering: 0.2.10 must ship after pageindex-chat#448 deploys β
an older server ignores unknown query params and would silently serve
the full set behind a URL that promises read-only.
* fix: chat_completions wraps framework exceptions like responses()
The AgentsException -> PageIndexAPIError wrap from the responses() fix
covered only that surface; a backend stream dying without a terminal
event (or any engine failure) still escaped chat_completions as a raw
openai-agents exception type on both its paths.
* fix: same-name documents in different folders no longer refuse doc_id targeting
The shadow check in doc_targeting_block compared names across the whole
library, so agent_instructions(doc_id=...) hard-raised for a legal cloud
layout β one file name in two folders β with advice (rename/remove) that
contradicts the contract, whose folder_id parameter exists precisely to
disambiguate this case.
Shadowing is now judged per folder: only a newer same-name document in
the SAME folder makes the name unreachable and raises. A same-name
document in another folder serves the call, and the targeting block adds
a directive to pass folder_id on every tool call β dropping the raise
alone would have traded a loud refusal for the agent silently reading
the newer document.
Local mode (folderId always None) and the scoped chat path (allowlist
resolution, fixed with the doc_id enforcement) are behaviorally
unchanged.
* Revert "fix: same-name documents in different folders no longer refuse doc_id targeting"
This reverts commit 9d16dbf6, whose premise collapsed on verification
against the cloud upload paths. Review finding #10 inferred from the
folder_id tool description that one file name in two folders is a legal
cloud layout; both upload paths actually dedup names per USER SPACE with
no folder dimension β chat's getSignedUploadUrl queries fileName +
sourceName + mode + owner (file-access.service.ts), and compute's
get_upload_url probes the S3 key (user, source, file_name) β so own
documents cannot share a name across any folders. The only legitimate
same-name source is shared mounts (shared-with-me/following), which the
api-proxy surface this SDK talks to never carries.
Cloud and local therefore share one invariant β names unique per space,
server-enforced β and the original global shadow check was the right
shape: a duplicate is an anomaly worth refusing loudly, not a layout to
accommodate with per-folder adjudication and conditional prompt notes.
The invariant is now stated in doc_targeting_block's docstring so the
finding does not get re-raised.
* docs: state the name-uniqueness invariant in library terms
* docs: trim doc_targeting_block docstring to the contract
* fix: compress out-of-range page lists in get_page_content
Two message strings enumerated every out-of-range page number one by one
while the payload fields beside them already used _format_page_spec.
Against a 2-page document, pages="3-10000" produced a 59,310-character
error whose own requested_pages field expressed the identical set as
"3-10000"; the mixed case pages="1-10000" produced 59,479. Both now
render through the helper: 431 and 600 characters.
This was inherited behaviour, not a local slip β the cloud MCP server
enumerated at the same two sites, so local reproduced it verbatim. The
cloud fixed it first (pageindex-chat #449), and this follows to keep the
strings byte-identical; the error message now matches the served one
character for character. A differential run of the two compressors over
94 inputs (empty, single, unsorted, duplicated, 10k spans, 80 random)
agrees on every one, separator included.
The new test pins all three shapes, including the non-contiguous case
("5,9" must not collapse into a range) that the compressor had no direct
coverage for.
* docs: messages() marks only the managed prefix with cache_control
The method docstring claimed the doc targeting block carries a
cache_control breakpoint too, and the doc_id note called that block part
of the cached prompt prefix. _anthropic_system deliberately marks only
the stable managed prefix β the API allows four breakpoints and the
varying doc block must not consume one β and the block is appended after
the sole breakpoint, so it is never cached.
a45b554 added the block with cache_control, making the claim true when
written; daac9d2 removed it without touching the docstring, and adb2f1f
then added the "cached prompt prefix" sentence after the fact. The same
phrase at the chat_completions and responses docstrings is correct β
there the block is a leading conversation item inside the auto-cached
prefix β so only the Messages surface is reworded.
The doc_id advice itself stands: the block is per-call table-setting and
should stay identical across a conversation. Only the caching rationale
was wrong.
* docs: _stream_sync cancels on close, not on abandonment
The docstring promised that closing "or abandoning" the iterator cancels
the run. Abandoning only works when refcounting collects the generator:
a caller that breaks out of the loop while keeping the reference never
runs the finally that sets the cancel event, so the pump thread stays
parked on the full queue and the backend client is never released.
Closing is correct and is what the dedicated test exercises. Narrowing
the promise to the behaviour the code actually provides is the honest
fix; a watchdog or finalizer would be machinery bought for a shape the
sync surface is not meant to serve, and the async client planned for
0.2.11 gets native task cancellation instead.
* fix: raise the openai-agents floor to 0.14.0
_conversation_group_id feeds RunConfig.group_id into OpenAI's
prompt_cache_key so a round-tripped prefix stays in one cache group.
That wiring first appears in openai-agents 0.14.0: 0.8.0 through 0.13.x
have no prompt_cache_key at all, and group_id there is a tracing group
id only β inert, since tracing is disabled on the line above. An install
resolving to the declared floor lost the cache continuity that the
responses() docstring sells, silently and with no test able to catch it.
The old floor's rationale (0.8.0 offloads sync tools to a thread) is
subsumed by the new one. Every symbol the package imports predates
0.14.0, so nothing else constrains the bound.
* fix: enforce doc_id at the tool layer in the framework config helpers
openai_agent_config / anthropic_runner_config / claude_agent_config
accepted doc_id but built unscoped tools, so the parameter that is a
structural allowlist on chat_completions() was prompt-only advice here β
the agent could read every document in the store regardless.
- as_openai_tools / as_anthropic_tools / as_claude_mcp take a doc_id
tail parameter and thread it to the existing _allowed_ids channel;
the config helpers pass it through in local mode
- cloud config helpers keep prompt-level targeting (tool scoping is
server-side there, documented); explicit as_*(doc_id=...) raises on
cloud instead of silently dropping the allowlist β including the
hosted branch, which returned before _tool_specs' existing guard
- _require_local_scope consolidates the cloud rejection that was
inlined in _tool_specs
- doc_id=[] is an empty allowlist, not "unscoped": dropped the
`or None` at the three local chat surfaces
* fix: two chat findings β final-turn append and cache-key seeding
run_messages keyed its re-append guard on stop_reason, but the anthropic
runner executes tools whenever the turn's content carries tool_use
blocks (refusal excepted) β a max_tokens turn with complete tool_use
blocks was already appended by the runner, so the guard re-appended it,
duplicating tool_use ids and 400ing the documented verbatim
continuation. The guard now checks whether final's tool_use ids already
sit in the appended history; unexecuted tool_use blocks (refusal turns)
are stripped from the appendable history, as the SDK itself does when
rebuilding params around an unresulted turn.
_conversation_group_id seeded on items[0], which is the doc-targeting
block whenever doc_id is set β byte-identical across every conversation
about a document, so all of them pooled under one prompt_cache_key and
evicted each other's prefixes. Seed on the conversation's own first
item instead: continuations keep their key, unrelated conversations
never share one.
Also drop the dead pytestmark_openai assignment (pytest's magic name is
pytestmark; the section gate it implied never existed).
* fix: six review findings β pagination, compat, and containment
- _all_documents advances by what actually arrived and treats `total`
as an optimization: absent/null totals and short pages silently
truncated the library behind every name resolution
- _make_bridge_function survives description: null (the parallel
_tool_specs path already did)
- as_openai_tools answers a malformed argument string with the guided
error envelope instead of raising through the caller's whole run
- the pre-0.2.10 package attributes (ConfigLoader, count_tokens, ...)
resolve again: main's underscore-guarded fallthrough is restored β
dunder probes stay lazy, a non-underscore typo pays one classic
import before its AttributeError
- _split_structure chunks are always lists: the structure field no
longer changes JSON type between parts of one paginated response
- the bridge replays only session-carrying 404s (the spec's expiry
status); 400 raises instead of re-running side effects, and the
reset double-checks under the lock so concurrent retries cannot
clobber a freshly re-initialized session
* fix: five secondary review findings β containment and guards
- the bridge maps content blocks individually: base64 payloads
(image/audio) become metadata stubs instead of handing the model the
raw blob, text blocks pass verbatim, anything else keeps the JSON
dump (revisit if tool results become real multimodal input)
- cloud proxy annotations keep array item types (list[str], not bare
list) so strict function calling accepts the round-trip; a type-array
in items degrades to bare list instead of crashing the build
- run_messages raises when set_messages_params stops delivering params
instead of silently dropping every tool turn from the envelope
- call_tool drops None-valued arguments (None β‘ omitted, the
contract's semantics) β adapters that forward the model's nulls
verbatim no longer trip parameter validation
- client._parse_pages bounds the span arithmetically before
materializing it, like the tool layer: "1-999999999" raises instead
of allocating a billion integers
* fix: three review findings β protocol honesty, model echo, containment
- responses() promised the Responses protocol ("no translation layer")
but _openai_model ignored protocol on the LiteLLM branch: provider-
prefixed models silently ran chat.completions under a responses-shaped
envelope, and with no transport hook to record status (LitellmModel
has no _client.responses) a turn truncated at the output cap reported
status "completed". The branch now raises for protocol == "responses"
β at agent-build time, before any backend call β naming the routes
out: chat_completions(), messages() for Anthropic models, or
OPENAI_BASE_URL + a bare/openai/-prefixed name for backends that
genuinely speak /responses. Refusal, not emulation: most providers
have no /responses endpoint to drive.
- chat_completions envelopes echoed retrieve_model verbatim, which
carries the SDK's litellm/ routing marker after normalization β a
name no provider catalog contains, and a different string than the
same model passed per-call. The envelope and every streaming chunk
now report the name the provider actually serves; routing and the
prompt-cache group key keep the prefixed form. responses() needs no
change (post-refusal the prefix cannot reach its envelope), and the
user-typed openai/ prefix stays echoed as typed.
- _remove_document caught only PageIndexAPIError around the per-doc
delete, so a bare OSError (local_store re-raises them) or a transport
error (cloud delete_document wraps nothing) escaped mid-batch,
discarded the entries for documents already irreversibly deleted, and
surfaced as a generic INTERNAL_ERROR envelope inviting a retry β which
then reports the destroyed document as not_found. The loop now catches
Exception, keeping the per-document results the contract promises.
* fix: config bundles use the scoped shadow check their tools earned
9f67fdd made the three config helpers enforce doc_id at the tool layer
but left their instructions on doc_targeting_block's unscoped default,
so a bundle refused any doc_id whose name a newer library-wide
duplicate shadows β a raise whose message ("the tools address documents
by name and would read the newer one") had just become false: the
bundle's own tools resolve names inside the allowlist and read the
targeted document correctly. chat_completions() accepted the same
doc_id via _doc_block's scoped=True.
Each helper now computes scope = _local_doc_scope(doc_id) once and
derives both slots from it β scoped=scope is not None for the
instructions, doc_id=scope for the tools β so the check mode and the
tool allowlist come from one fact and cannot drift apart again.
build_agent_instructions grows a scoped passthrough; cloud stays on the
whole-library check (scope is None there and the tools are genuinely
unscoped), and the public agent_instructions() keeps its unscoped
default for the same reason. An in-set duplicate still raises β and in
that case the message is true on every surface that emits it.
* docs: as_openai_tools' remote-MCP note moves to the Cloud paragraph
The MCPServerStreamableHttp alternative sat in the Local: paragraph
pointing at bare {BASE_URL}/mcp β a cloud-only route (BASE_URL is the
hosted API; local has no HTTP MCP server) that as written would connect
unauthenticated to the full tool set. Now stated where it applies, in
the as_anthropic_tools connector-note form: Cloud paragraph, Bearer
auth spelled out, ?tools=read default with the drop-it escape.
* fix: nine review findings β argument coercion, scope, and honest envelopes
- call_tool coerces string booleans per the TOOL_CONTRACT schema
("false"/"no"/"0" read as False, not a truthy 3-minute wait) and
survives arguments: null (json.loads("null") reaches the seam as None)
- _local_doc_scope raises on an explicitly empty doc_id on cloud: with
no tool-layer allowlist there, dropping it silently widened an empty
scope to the whole library
- both page-spec caps count distinct pages instead of summing parts, so
overlapping ranges (a parent section plus its children) within the
10k union pass again as they did in 0.2.9; the per-part arithmetic
bound still rejects billion-page specs before materializing anything
- _remove_document deduplicates doc_names: a repeated name is one
deletion, not a second "failed" row with an internal error string
- doc_targeting_block merges the user's metadata tags from the listing
(local get_document keeps the 7-key cloud detail wire shape, which
carries none) so the block delivers the metadata it promises
- _wait_until_ready folds its two raise branches into one that carries
the doc_id: a poll that dies no longer discards the handle to an
uploaded, billed document
- _reported_model strips both routing prefixes (litellm/ and openai/)
and responses() now reports it too, instead of echoing a model id the
provider never served
- _openai_model wraps AsyncOpenAI() construction so a missing backend
credential surfaces as PageIndexAPIError like every other gate on the
chat surfaces (and builds the client once for both protocols)
- _browse_documents advances its cursor by the rows that actually
arrived and guards a null/absent total β the same hazards
_all_documents already guards β and an empty window ends pagination
instead of freezing the cursor
* fix: two chat findings β protocol terminal states, provider error types
- responses(stream=True) raised PageIndexAPIError when the backend
ended the response with response.failed / response.incomplete:
openai-agents yields the terminal lifecycle event, then re-raises it
as ModelBehaviorError, so the generic AgentsException wrap
short-circuited the emit the agen's tail was built for β its
failed/incomplete terminal mapping was dead code against the real
engine, and the caller lost both the partial output and the real
status. The wrap now steps aside when the recorded terminal state is
failed/incomplete, and the stream ends with the honest terminal
event (committed output, real status, error/incomplete_details) β
the backend's terminal state is a protocol event, not an engine
failure. Non-stream was already honest for incomplete via the
transport recorder; a failed response arrives there as an HTTP
error, covered below. Known ceiling: the truncated final turn's
partial text was already streamed as deltas but is not reconstructed
into the terminal event's output (the engine commits items only on
turn completion).
- Provider exceptions (network, auth, rate limit) leaked as raw
openai/anthropic types through every chat surface, against the
layer's own "never raw engine types" contract. Every engine boundary
now wraps its vendor's base exception into PageIndexAPIError
(chained): the four OpenAI-engine sites catch openai.OpenAIError β
LiteLLM's exception types subclass openai's, so one handler covers
both routing paths β and messages() catches anthropic.AnthropicError
around the batch drive and the stream generator.
* fix: guided failure for unknown LiteLLM providers, non-object call_tool args
- _openai_model pre-checks the first path segment against
litellm.provider_list (fail-open if the attribute ever disappears):
a HuggingFace repo id like Qwen/Qwen2.5-7B-Instruct on an
OpenAI-compatible server now fails at build time with the escape
spelled out β 'openai/<id>' plus OPENAI_BASE_URL β instead of at
request time inside LiteLLM with "LLM Provider NOT provided". The
slash-means-provider routing convention itself is unchanged; the
retrieve_model and chat_completions docstrings now document it where
they promise "any OpenAI-compatible server works"
- call_tool answers a non-dict arguments value (a JSON array or scalar
from a misbehaving caller) with the guided INVALID_INPUT envelope
instead of raising AttributeError through the agent loop, matching
the openai adapter's own non-object guard
* fix: wrap litellm import in PageIndexAPIError when not installed
* fix: silence CodeQL findings β merge implicit string concat, drop unused vars
* fix: two external review findings β init-notification race, SDK floor
notifications/initialized moves inside the bridge lock: a concurrent
first use could send tools/list between the handshake and the
notification, which strict MCP servers reject with a 400 the bridge
never replays. Regression test races two threads through a stalled
notification window.
claude-agent-sdk floor rises to 0.1.53 β below it, string prompts with
SDK MCP servers (the documented local-mode flow) hit invisible
registration (#597) and a deadlock (#780).
* refactor: drop the unused exc parameter from _wrap_max_turns
The parameter was dead from the moment it was introduced (daac9d2):
the body reads only max_turns, and every call site already carries the
cause via `raise ... from exc`. The signature implied the helper
inspected the engine exception, which it never did.
No behavior change β message text and __cause__ chaining verified
identical across all four call sites (chat_completions and responses,
stream and non-stream).
* fix: raise the anthropic and openai-agents floors past broken releases
Both declared floors named a version that cannot work, and CI never
caught either because it installs the latest.
anthropic >=0.84.0 -> >=0.108.0. Probed against a mock transport: on a
turn with stop_reason="refusal" carrying a tool_use block, 0.84.0,
0.92.0 and 0.100.0 all execute the tool and post the tool_result back;
0.108.0 and later stop at the refusal. test_messages_refusal_with_
tool_use_stays_appendable asserts the latter, so that test was false at
the floor. messages() is unaffected in practice (it never passes
include_management, so remove_document is not registered), but
as_anthropic_tools(include_management=True) hands it to a caller's own
runner.
openai-agents >=0.14.0 -> >=0.18.1. 0.14.0 and 0.16.0 raise pydantic
ValidationError on InputTokensDetails.cache_write_tokens before any
request reaches the transport when paired with openai 2.54.0 β and they
declare openai <3,>=2.26.0, so pip resolves exactly that pair. 0.18.1 is
clean. The 0.14.0 rationale (RunConfig.group_id -> prompt_cache_key)
still holds above the new floor.
The three extras' floor comments are cut to the binding constraint; the
reasoning lives here.
* test: cover max_turns wrapping on every chat surface
test_chat_completions_max_turns_wrapped only drove chat_completions, so
the two responses() call sites had no coverage, and no test asserted
that the engine exception survives as __cause__. Parametrized over both
surfaces and both stream modes; the non-positive max_turns rejection
splits out, since it is input validation rather than wrapping.
* fix: four review findings β envelope size honesty, contained tool errors
- _dumps drops indent=2: emission now matches _serialized_size's compact
accounting, so the pagination budget bounds what is actually sent
(indented parts measured under 95k but emitted ~1.8x the 100k cap)
- call_tool builds the _allowed_ids frozenset inside the guarded block:
a non-iterable doc_id returns the INVALID_INPUT envelope instead of
raising into the agent loop; same move for _bridge_invoker's
arguments normalization
- next_steps strings qualify submit_document() as
PageIndexClient.submit_document() (three sites), matching the one
already-qualified site β it is a client method, not a registered tool
- tests: import httpx at module scope (guaranteed via the hard openai
dependency) so agents-gated tests survive an install without the
anthropic extra; formatting assertion follows the compact envelope
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #389 (the SDK with local mode). Adds an agent integration layer to the client: the PageIndex cloud MCP tool contract, runnable in-process against either mode.
What
One line per framework, identical across local and cloud β the mode is decided by the client constructor alone:
Document QA, end to end
An actual run (local mode, OpenAI Agents SDK, over
examples/documents/q1-fy25-earnings.pdf):This is the reasoning-based retrieval loop working as designed: the agent reads the tree structure first, picks tight page ranges, and answers strictly from tool output with page citations β no vector index, no chunking, and the retrieval "intelligence" is the host agent's own model (the navigation tools themselves make no LLM calls).
Design
browse_documents/get_document/get_document_structure/get_page_content, doc_name-addressed, same input schemas, descriptions, and JSON response envelopes as the hosted MCP server'stools/listβ agent prompts port unchanged between the cloud MCP connection and these in-process tools.tests/data/cloud_mcp_contract.jsonfreezes the contract; a parity test guards drift.search_documents,get_document_image) are not registered, mirroring the server's gating semantics. Cloud-only parameters (folder_id,sort/query,recursive) are hidden from the local surface entirely β strict-schema frameworks then cannot express the dead-end calls, and the call_tool/MCP path still answers direct calls with a guided "works on PageIndex cloud" envelope as the backstop (lenient framework argument models drop the hidden params pre-call, degrading to the bare call). Local descriptions and instructions teach only that surface: the exposed schema is the contract minus the documented hidden set (mechanically asserted), and a dead-reference test keeps local guidance from naming cloud-only tools.remove_documentis behindinclude_management=False.as_claude_mcp()hands the framework the remote MCP config,as_openai_tools()serves the full set as plain function tools from your process (any model backend;hosted=Trueopts into a hosted MCP tool with server-side execution on the Responses API, for OpenAI models), andagent_tools()discovers the livetools/listthrough a built-in minimal MCP client and synthesizes one plain function per tool (signatures and docstrings from the server's schemas, calls proxied from your process) β so the plan-gated tool set, including new server-side tools, arrives without an SDK release, and works from any framework or model backend. The default is the compatible path;hosted=Trueis the explicit latency optimization (the framework's ownMCPServerStreamableHttpagainstapi.pageindex.ai/mcpremains the async-native alternative). On a local client, all three serve the in-process contract subset. Because plain functions have no framework permission layer,agent_tools()applies its management gate in both modes: by default only tools the server marks read-only (readOnlyHint) are exposed, andinclude_management=Trueopens the complete list β the same switch that gatesremove_documentlocally.{"error", "errorCode", "next_steps"}envelope the cloud emits, so agent behavior is uniform across frameworks.openai-agents/claude-agent-sdkare imported at call time with actionable errors;pageindex[openai]/pageindex[claude]extras carry floor-only pins.import pageindexand every existing client feature work with neither installed (covered by tests).submit_document(wait=True): pollsget_documentstatus with growing intervals; returns oncompleted, raises onfailedor after 30 minutes. The manual polling loop cloud callers write today spins forever on a failed document; default stayswait=Falseso batch submission is unaffected.agent_instructions(doc_id=None)supplies the retrieval playbook for the agent's system prompt. Cloud: the live instructions the MCP server serves for the key's tool set, captured from theinitializehandshake over the same bridge session asagent_tools()β server-side guidance updates arrive without an SDK release, and an empty server response raises instead of silently substituting the subset text. Local: the built-in playbook for the in-process tools (structure-first over 20 pages, tight page ranges, persistence protocol) β a trimmed subset of the server's instructions, with a consistency test that every tool it names exists locally.doc_id(str or list, same shape aschat_completions) appends the target documents β in the run above it is what let the agent skip discovery and go straight to the named document.Verification
Optionalso strict schemas don't force values (browse.querywas unusable for time-sort on the cloud+OpenAI path); a server annotation regression can no longer silently zero out the toolset (loud error instead); exec synthesis hardened (fixed internal def name + dict-literal args β tool/param names can no longer recurse or shadow builtins); SSE parsing handles CRLF multi-message bodies; transport failures wrap intoPageIndexAPIErrorper the documented contract;hosted=Truenow routes non-read-only tools through the Responses API approval flow ({"never": {"read_only": true}}) instead of auto-approving everything; localget_document_structureserves the raw stored tree so nodes carrystart_index/end_indexlike the cloud (live-verified shape);openai-agentsfloor raised to>=0.8.0(older versions run sync tools inline on the event loop); tool annotations added to the frozen contract and passed to the Claude in-process server;wait=tolerates transient poll failures; failed documents get a real "processing failed" message instead of "still processing".tools/list, assert the server serves non-emptyinitialize.instructions, and check envelope field parity (every field the local tools emit exists in the live cloud response for the analogous call): contract parity vs the frozen snapshot, tool behavior against a seeded local store (no LLM calls), framework-missing/-installed behavior both ways,wait=completed/failed/timeout semantics.agent_tools()discovered this key's gated tool set (7 read-only tools;include_management=Trueaddsremove_document; upload tools correctly absent per plan), a realbrowse_documentscall returned the expected envelope, and a live parity check matched the frozen contract letter-for-letter on all shared tools β after catching and fixing one real bug (SSE responses decoded as latin-1 by requests' charset guess; SSE is UTF-8 by spec).examples/agentic_vectorless_rag_demo.pyrewritten to the new API (its inline tool definitions collapse intoclient.as_openai_tools()).Follow-ups (not in this PR): a stdio
pageindex-mcpentry point for non-Python MCP hosts (pageindex[mcp]), and the docs-site agent-integration page.