review: the complete v0.2.10 line β agent tools, local chat, Flash default - #400
review: the complete v0.2.10 line β agent tools, local chat, Flash default#400rejojer wants to merge 137 commits into
Conversation
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.
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.
β¦.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.
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.
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.
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.
β¦onversation 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.
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.
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.
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.
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.
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.
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.
β¦lation, 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.
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.
Correction to the review aboveThe finding's mechanism is confirmed, but its stated consequence is wrong β a full A/B run reverses it. Confirmed: pdfium split Reversed: the predicted heuristic breakage did not materialize. Extracting all 9 example PDFs (1574 pages) under both versions:
What remains, now fixed in 228426d: the actual defect was the dual-semantics window β Lines 34 to 36 in 228426d PageIndex/tests/test_flash_extraction.py Lines 1 to 23 in 228426d The π€ Generated with Claude Code |
The blanket litellm.validate_environment gate (all three indexing entry points) only sees environment variables, so it hard-failed providers whose credentials resolve at call time β Ollama (litellm's own localhost:11434 default), Bedrock IAM chains on bare EC2, Vertex ADC β before any request was sent, on the default flash path. The chat lane already draws the line at OpenAI-shaped names (bare or openai/) and lets every other provider resolve natively; the indexing lane now uses one shared helper (_openai_missing_keys) with the same rule at _litellm_model, the tree_optimize CLI, and the run_pageindex flash pre-check. Unknown-provider grammar validation is unchanged.
β¦-free Since optimize defaults to "full", the bare call makes LLM calls, so "without an LLM" was false for the default run. The tree extraction itself still uses none; the docstring now says exactly that and names the fully LLM-free form (summary=False, optimize=False), matching the flash README.
Reverts the ride-along edits: comments explaining pre-existing behavior (the underscore guard, the classic fallthrough), two re-wrapped statements, and two _LAZY entries mapping to the value _LAZY.get() already defaults to β provably inert. What stays is what 0.2.10 required: the litellm model-map setdefault and the new module names in _SUBMODULES.
β¦model grammar, prompt caching summarize_tree's fail-fast only saw root-node failures: the recursive child gather ran with return_exceptions=True and discarded the results, so a per-leaf 401 stored a blank subtree as "completed". The child results are now checked like the root's, and the flat generate_summaries_for_structure sibling re-raises unrecoverable failures the same way instead of blanking the node. The missing-key pre-check trusted litellm.validate_environment, which reports a blank or whitespace OPENAI_API_KEY as present β the fail-fast never fired and the failure resurfaced as a 10-retry stall per node with a misleading message. The gate is a truthiness check now, matching the chat lane's, and covers the CLI and tree_optimize copies through the shared helper. The exec-synthesized plain functions were the one tool surface that raised instead of answering: a cloud-only parameter pruned from the local signature TypeErrored out of the call and aborted the framework loop. A wrapper turns binding rejections into the same INVALID_INPUT envelope call_tool returns, with the schema-bearing signature preserved via __signature__. A non-string doc_name likewise crashed get_close_matches, converting a clean NOT_FOUND (with similar names) into an INTERNAL_ERROR whose next_steps invited retrying the identical bad call. _openai_agent's nested pop evaluated its default eagerly, so a per-call api_base was popped and discarded whenever the client carried base_url β and the chat lane resolved a different host than the SDK constructors for byte-identical config. The backend now normalizes through _sdk_backend at entry, where insertion order makes the per-call spelling win. chat_model stored the litellm/ routing prefix, so the documented model=client.chat_model hand-off sent a non-existent id to the Anthropic SDK. The attribute keeps the caller's spelling; the only consumer that needs the prefix (openai_agent_config) applies it at the config door β which now also runs the py3.10 litellm type repair, since the BYO runner resolves that model through LiteLLM outside our completion helpers. Prompt caching gets its second breakpoint on every Claude lane: the chat-lane injection points add the newest-message mark (the pair LiteLLM itself seeds for Anthropic/Bedrock, extended to Vertex), messages() and anthropic_runner_config set the top-level cache_control so each loop turn re-reads the growing conversation (live-verified: delta-writes work, and a fifth breakpoint 400s β messages() counts the caller's marks and stands down at four). The indexing lane is the inverse case β single-shot unique prompts β so its calls carry a no-match injection point that stops litellm 1.97 from seeding cache writes nothing ever reads back.
d375c00's tree-search retrieval consumed it; when retrieval became agent-driven the consumer went away and the constructor parameter stayed β required, stored, never read. The client-level retrieve_model alias (property, setter, ConfigLoader chain) is unrelated and stays.
The default flash call reached the missing-key failure only after the full PDF layout pass, surfacing as a raw litellm.AuthenticationError naming a model the caller never chose. The check now runs at entry β before any PDF work β and raises PageIndexAPIError naming both exits: configure a key, or take the LLM-free tree via optimize='merge'/False. Skipped when the indexing-lane backend contextvar carries connection overrides, and drawn on the same env-inspectable line as the shared pre-check (provider-prefixed models keep resolving credentials at call time). Ruled over the degrade-to-merge alternative: the tree stays identical across environments β loud when impossible, never quietly less.
β¦trimmed
- doc_targeting_block collects missing IDs in its own fetch loop; the
_doc_block pre-check that fetched every document a second time is
deleted. Chat-surface error text unchanged (test-pinned);
agent_instructions now reports the same batched message instead of
get_document's raw error.
- final_ids reads block.get("id"), matching the history_ids comprehension
one line below.
- _usage_sums skips a None usage. Both agents model classes guarantee a
Usage instance today (verified in 0.20.0 and the 0.18.1 floor wheel);
the guard is insurance against a future version dropping that.
- Module and _openai_model docstrings cut to one line; the routing
rationale lives in the history (28eaab8, d0007c2 era) and here:
chat = LiteLLM (bare names get openai/ shorthand), responses =
OpenAI-SDK native so provider-prefixed names are refused.
300s was below the industry standard (OpenAI/Anthropic SDKs both default to 600s) and could clip complex multi-document agent runs the cloud endpoint processes synchronously.
β¦ cut, breakpoint docs get_page_content sized its response budget in raw characters while the envelope is emitted as JSON, where every quote, backslash, newline and tab costs an escape character β quote-dense tables measured 108,777 and code-dense pages 116,506 against the 100,000 limit while the accounting said 94,400. The same sizing-units-vs-emitted-units defect was raised on the structure path in #393 and fixed there via _serialized_size; the sibling never got the fix. The budget now measures the serialized page entry plus json's ", " item separator, closing the two residual gaps an adversarial sweep still breached (per-entry shell: 300 short pages; separators: 4000 tiny pages, 102,274 emitted). All shapes now land under the limit except the deliberate first-page exception. The budget test's pages are now escape-dense β raw length under the budget, serialized length over β so it fails on the old accounting, and it asserts the real property: the emitted envelope stays within TOOL_RESPONSE_CHAR_LIMIT. The demo's .doc_id sidecar cache is deleted: the name lookup kept right below it resolves every re-run (submit's uniquing suffixes later duplicates only, the first copy keeps its plain name), so the cache bought one list_documents call on a local store ahead of a multi-minute index step, at the cost of a read/validate/unlink block, two write-backs, a disk artifact, and a .gitignore entry. Step 1 returns to its 0.2.9 shape, wait=True kept. Verified end to end against the seeded store. messages() docstring promised the top-level cache_control is skipped "when your own blocks already use all four breakpoints" β the managed prefix always holds one, so a caller placing four puts five marks on the wire and the API rejects the request. Now states the caller budget of three, matching the _cache_marks < 4 gate. Non-changes: _split_structure has the same separator undercount but its node counts are too small to breach (no measured overshoot); the first-page overshoot exception stays (one page must always return); the _split_oversized_node budget floor stays (trigger is ~16x beyond real summary sizes).
β¦DME flag scoping agent_tools()'s lead sentence promised "the full cloud tool set" while the default exposes only tools the server marks read-only β the Args block and both sibling exports already say "the full live read tool set"; the lead now matches. The README's optional-arguments note scoped the whole block to --mode standard; --index-model works in the default flash mode, so the note now names the flags it covers.
β¦listing early-exit - The page-mode unicode walk consumes char_extract's raw_chars (surrogate pairs already merged) instead of re-reading the textpage, which split astral chars back into two lone-surrogate slots, desynced the walk against their one-char cmap targets, and silently dropped the whole page's patch. One census for both modes makes the desync structurally impossible; negative-codepoint chars now follow that census too (absent, conservative rollback) instead of walking as "\x00". A/B over the nine example PDFs: structures byte-identical. - _coerce_bool_args is schema-driven and runs on the cloud bridge invoker as well; the coercion previously landed only on call_tool, so a model's "false" reached the wire verbatim β schema-validating servers rejected the call, lenient ones read it truthy (wait_for_completion blocking up to 3 minutes). Server-supplied schemas cover cloud-only tools the local contract never named. - _all_documents stops paging once every wanted id has been seen (both modes list newest-first): doc-scoped chat turns, scoped resolution, browse and remove no longer sweep the whole library to use a handful of entries; an id absent from the listing still costs the full sweep. _index_flash also drops its add_node_text call β the text was built, excluded from the description prompt, and stripped before save.
- llm_completion/llm_acompletion pass max_retries=0 via a dict-merge a
backend override still wins: the 10-try loop is the retry policy, and
litellm's client default (2) silently multiplied it 3x after the SDK
migration dropped the original openai.OpenAI(max_retries=0). 429/5xx
keep flowing to the loop (_UNRECOVERABLE_STATUS is 401/403/404 only);
anthropic's payload builder never sees the param (source-verified).
- exec-synthesized tools set __name__/__qualname__ to the tool name β
binding TypeErrors quote __qualname__, so the model saw
"_synthesized() got an unexpected keyword argument" with no tool name
to correct against. Pinned by test.
- McpBridge stubs EmbeddedResource blobs (resource.blob one level down);
only top-level data payloads were stubbed, so a nested base64 image
was json.dumps'd into the model's context. Pinned by test.
- run_messages closes its anthropic client on both paths: try/finally
after the non-stream iteration (the params read-back is offline), and
a finally in events() that runs on exhaustion and abandonment alike.
Each call leaked an httpx pool until GC.
- run_pageindex: the md branch filters None user options before
ConfigLoader.load β merged {**defaults, **user} let unset CLI flags
clobber yaml defaults, silently dropping node ids and summaries from
md trees; --optimize rejects every value outside flash+pdf (is not
None, matching the sibling guards) instead of letting 'off' slip;
dead _openai_missing_keys import dropped.
- tests.yml uninstalls openai-agents on the without-frameworks leg:
requirements.txt now carries it, so the leg had become a duplicate of
the with-leg and the 41 importorskip guards never skipped.
- test_optimize_full_fails_fast_without_a_key imports litellm before
delenv (its import may load a .env on a daemon thread after the
client constructor returns; the sibling tests already guard this).
- test_agent_tools imports pageindex.utils before reading
PAGEINDEX_API_KEY: the lazy package import never triggered utils'
load_dotenv, so the module-scope getenv always ran before the .env
load and the three live drift tests had never executed even with a
key present. First live run: all green, including frozen-contract
parity against the real tools/list.
CI pulled mcp 2.0.0 (via openai-agents), which removed Server.request_handlers. Use _tool_specs invoke callables instead.
β¦scope, one created_at - doc_targeting_block batches a doc_id into "Documents not found or access denied" only on a definite 403/404 (or a local raise, which carries no status); a cloud 429/5xx in the fetch loop now propagates with its original text instead of masquerading as a missing document. PageIndexAPIError grows an optional status_code, set at the cloud get_document raise β main let these errors through raw; the batching added in 7abf88a had widened to every failure. - _conversation_cache_key seeds doc_id alongside the first item: the same opening question against different documents is different conversations with different prefixes, yet pooled under one prompt_cache_key before this. A str doc_id and its one-item list form hash identically (same targeting, same prefix). - run_responses mints created_at once beside response_id: the streamed response.created event and the terminal envelope now carry the same timestamp for the same response id (the created event previously kept the backend's per-turn timestamp with only the id patched).
β¦docstring - 50d86bf's mcp-2.0 migration rewrote test_claude_agent_config_doc_scope_enforced_in_tools to build its own callables via _tool_specs, so nothing drove the handlers build_claude_mcp actually registers. Dropping doc_ids at any hop of claude_agent_config -> as_claude_mcp -> build_claude_mcp left the whole suite green, and the test's own docstring ("wire scope all the way into the tool invoke callables") was no longer true. The assertion now spies create_sdk_mcp_server and calls the registered SdkMcpTool.handler -- public API, so it survives mcp 2.0 without reaching for the Server.request_handlers the migration had to drop. A browse assertion catches over-tight scope the way the openai/anthropic siblings do. All four mutations (three hops plus over-scoping) now fail. - The module docstring still said arguments outside a pruned local signature "fail at the Python call boundary". 03ffab3 made proxy() catch that binding TypeError and return the same guided envelope call_tool returns, as _make_tool_function's docstring and test_plain_functions_answer_bad_arguments_with_the_envelope already state; the header was the last copy of the old behavior.
Flash's parallel parser spawns workers for PDFs >= 64 pages; Python's spawn bootstrap re-imports the caller's __main__, so a script without the if __name__ == '__main__' guard re-ran wholesale in every worker. The parser's broad sequential fallback then swallowed the RuntimeError Python raises for exactly this case, so each worker completed a full duplicate index: N documents in the store and N times the LLM spend, with zero warnings. Default-on since flash became the default mode (v0.2.10.dev1). Three independent layers, in order of engagement: - _anonymous_main hides __main__'s __file__/__spec__ while workers spawn. Workers import everything by module name and never need the caller's script, so spawn simply skips the re-import: unguarded scripts now get full parallel speed (measured at parity with guarded ones; the old behavior was 3x slower wall-clock on top of the 8x duplication). - The fallback re-raises instead of running sequentially when the process is a spawn child mid-bootstrap (_inheriting), so if the hiding ever stops working the duplicate run dies loudly with Python's canonical guard message instead of silently completing. - submit_document refuses outright during a foreign spawn bootstrap, which also covers a user's own unguarded pool re-running top-level submits of small documents that never reach the parser. _inheriting is private CPython API; it is read via getattr with a False default and only powers the insurance layers, so its removal degrades to the old fallback rather than breaking anything.
β¦nking-safe defaults Ten approved findings from the Aug 19 max review of PR #400: - agent_tools() gains doc_id β the one BYO tool surface without it: passed through to _tool_specs' enforced allowlist, refused loudly on cloud like its siblings. - Chat streams request usage (ModelSettings.include_usage=True): the stream_metadata terminal chunk carried all zeros. Live-verified real counts on both OpenAI and Anthropic backends; agents forwards the flag as stream_options only on streaming calls, so non-stream runs are untouched, and litellm consumes it itself for providers without native stream_options support. - chat_completions() reports the backend's native finish_reason on the non-stream envelope and the terminal chunk: _record_chat_finish captures it from the raw LiteLLM response at the model's fetch seam (openai-agents' ModelResponse discards it; this door has no transport client to hook, cf. _record_response_status). Live-verified: truncation reports "length", clean turns "stop". Degrades to the old "stop" literal if the private seam moves. - get_page_content()'s page-spec rejections surface as the documented PageIndexAPIError instead of the tool layer's private ValueError subclass; validation semantics unchanged. - The markdown CLI passes if_add_* verbatim again: filtering None out of user_opt let config.yaml's PDF-lane summary default switch on a per-node LLM pass the md CLI never ran β unkeyed runs died with a raw traceback, keyed runs billed silently. - messages() resolves the default max_tokens to budget_tokens + 8192 when thinking is enabled: the wire requires max_tokens above the budget, so the flat 8192 default made every thinking call with a budget >= 8192 a hard 400. Explicit values pass through untouched. - The litellm/ routing prefix skips the env-only OPENAI_API_KEY pre-check (litellm resolves litellm.api_key and keyless OPENAI_BASE_URL servers itself), and both lanes' provider allowlists consult litellm.custom_provider_map, whose providers join provider_list only at completion time. The pre-checks stay: litellm wraps a typo'd provider as a 400 the retry loop treats as recoverable, so deleting them would burn ten retries per call. - agent_instructions(doc_id=...) passes scoped like the config sugar, so a newer same-name document elsewhere in the library no longer shadows an id the scoped tools reach by allowlist; duplicates within the targeted set still raise. - The LITELLM_LOCAL_MODEL_COST_MAP stamp moves from package import to _preload_litellm and the CLI head: importing pageindex no longer switches the host process's own litellm to the frozen bundled cost map. Library-direct callers without a client pay litellm's one-time fetch fallback instead β accepted. - _await_completion polls through a transient refetch failure to the deadline instead of returning early, which the caller reported as the full three-minute timeout after five seconds. One superseded test removed (the agent_instructions shadow raise, replaced by the scoped-contract test), three stale pins updated to the documented error type. Suite: 335 passed.
β¦ports survive, gated cloud endpoints, optimize precedence Correctness (silent behavior): - agent_instructions(doc_id=) returns to the strict shadow check: built alone it cannot know whether the caller's tools carry the same doc_id scope, so a newer same-name document blocks loudly again. The *_agent_config bundles keep the relaxed in-set check β they build both sides. Undoes 914dc43's blanket scoped=True. - messages()/responses() no longer close a caller-owned http_client passed through chat_backend/backend ("passed verbatim"): messages() gates both closes on the merged dict, the responses lane marks the SDK client at build time and _aclose_backend leaves it open. - get_tree()/get_document_structure() keep key_items: the merge optimization (the local flash default) folds collapsed subsection titles into it, and the formatter silently dropped them while the agent tools still showed them. - _openai_agent classifies OpenAI-protocol destinations through litellm's own routing (get_llm_provider + openai_compatible_providers + azure/openrouter) instead of a name-prefix test: azure/openrouter/ deepseek/groq/xai destinations get prompt_cache_key again and their extra_body stays in the request body. - page_index_flash: an explicit optimize= now wins over the deprecated optimize_expand modifier (precedence was inverted); the modifier applies only to the legacy spellings (optimize absent or True), warns DeprecationWarning, and is back in the docstring. optimize=None is the unset sentinel and resolves to "full". Error contract: - messages() pre-checks Anthropic credentials (env, or any non-empty backend dict β the same wide rule as the OpenAI lanes) instead of leaking the SDK's request-time bare TypeError on the most common misconfiguration. - anthropic_runner_config(thinking=) resolves the max_tokens default through the same thinking-aware helper messages() uses (budget+8192) and includes thinking in the returned kwargs, keeping the docstring's "the default messages() uses" claim true. - _bridge_invoker re-raises 401/403 instead of wrapping permanent auth failures in a "temporary, retry" envelope that burns the agent loop; bridge HTTP failures now carry status_code per the errors.py contract. - Cloud discovery and instructions ride the endpoint matching the tool gate (?tools=read unless include_management), threaded through the config bundles and agent_instructions. Live-verified: the gated endpoint serves the 7 read-only tools with annotations; instructions are byte-identical on both endpoints today, so this is protocol fidelity plus future-proofing, not a behavior change. - OpenAI Agents adapter: documented that is_error has no per-result non-aborting channel on hand-built FunctionTools at the 0.18.1 floor (raising aborts the run via UserError), so the envelope text is the whole signal. Comment only. Reuse: - _openai_model's chat branch delegates to utils._litellm_model β the two hand-copies had already diverged on the keyless-backend rule; the wide rule (any non-empty backend dict stands aside) now covers both lanes, so one keyless OpenAI-compatible server works end to end. Tests: 12 new β 11 fail on the pre-fix tree (stash A/B verified), plus a seam pin asserting LitellmModel._fetch_response exists so an upstream rename cannot silently report finish_reason "stop" for truncated turns. conftest now sets ANTHROPIC_API_KEY too: the suite no longer depends on the repo .env (verified green with .env removed, 343 passed + 3 live skipped). test_page_index_md's __main__ block moved to EOF so direct execution reaches both classes. Ruled not-fixed: one-shot doc_id iterables (off-contract input), a 5th caller cache_control breakpoint (loud 400, caller workaround), and malformed JSON-RPC defense (our own server). Attempted and withdrawn: stop_ids on the unscoped shadow sweep (trades an order-robust guard for a server-sort assumption), a bigger local listing page (LocalAPI mirrors cloud's 1-100 limit by design), and final_blocks reuse (the "duplicate" pass is load-bearing copy semantics: the appendable branch embeds those dicts into envelope["messages"] on every plain text answer, and callers currently get independent copies).
Dev builds need an explicit ==pin to install, so their GitHub Releases carry no install value and double the feed next to the same-day stable (v0.2.10 + v0.2.10.dev6 showed as two near-identical cards). The dev6 Release was deleted by hand; this keeps future dev tags PyPI-only.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. π€ Generated with Claude Code |
β¦bbed, honest poll errors
Silent breakage:
- submit_document restores the index-time page-bounds gate the PR lost
with add_node_text: a tree whose span escapes 1..page_count (pdfium
and PyPDF2 disagreeing about the page tree) now fails the submit
instead of storing a completed document whose every read IndexErrors.
- Lone UTF-16 surrogates can no longer reach stored text, where they
survive in memory but kill every utf-8 JSON save after the LLM spend.
All three producers are guarded: PDFium textpage output (unpaired
halves the pair reassembly cannot compose), font-map walk targets
(uniD83D glyph names, surrogate-band CIDs via the chr fallbacks β
scrubbed at targets_for, the map's single consumer, so walk patches
and glyph synthesis cannot write one back), and PyPDF2 page texts
(its _cmap decodes broken ToUnicode with surrogatepass). Each becomes
U+FFFD in place, so census slots and the unicode walk stay synced.
- _anonymous_main is depth-counted under a lock: overlapping windows
from concurrent submits restore the true __main__ identity instead of
a mid-window snapshot that left the host's __spec__ None forever.
- page_index_flash skips the LLM expand pass when the extraction
carries no page texts (bookmark-only documents): expand burned its
retries on IndexErrors per node and expanded nothing; merge still runs.
Error contracts:
- messages() pre-checks credentials whenever the merged backend lacks
api_key/auth_token/default_headers, not only when it is empty β a
backend={"timeout": 30} or client-level chat_backend no longer lets
the SDK's request-time bare TypeError escape the PageIndexAPIError
contract.
- _wait_until_ready re-raises definite poll answers (401/403/404)
immediately instead of retrying and advising to keep polling a
document that will never turn up.
_no_cache_seeding_kwargs rested on a false premise β litellm 1.97 seeds no cache marks unprompted (its hook fires only on explicit injection points) β and with a system message present it caused the paid cache write it claimed to prevent. Deleted; backend keys still merge through, and the rewritten test pins the honest shape: no cache params sent, backend keys win the merge. Docs that contradicted shipped behavior: the three "tools never raise" sites now admit the deliberate 401/403 re-raise (49a24e1 updated only the private ones); chat_completions' finish_reason line caught up with 914dc43's native surfacing; errors.py stops implying status_code=None means local; char_extract's IsGenerated comment no longer points at a deleted second read site; the parallel parser's parity contract admits the spawn-child re-raise.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. π€ Generated with Claude Code - If this code review was useful, please react with π. Otherwise, react with π. |
The docstring claimed LiteLLM seeds cache_control for Anthropic- and Bedrock-hosted Claude on its own, with Vertex the lone manual exception. litellm 1.97 seeds nothing unprompted β its hook fires only on explicit cache_control_injection_points, and the auto-seed path is gated behind LITELLM_ENABLE_ANTHROPIC_PROMPT_CACHING, default off β so every LiteLLM-routed Claude agent built from the bundle ran uncached, paying full prompt price per turn, while chat_completions() marked the very same model through _cache_extra_args. 3504664 trued up the sibling doc sites and deleted the false-premise kwarg but missed this paragraph. Attach the same helper's marks as model_settings when non-None: bare/gpt names and cloud-without-model are unchanged (key absent), unknown providers and litellm failures degrade to None. Verified on all three channels through the real Agent(**config) -> Runner -> LitellmModel path: anthropic direct real-key E2E (cache_creation 2131 -> cache_read 2131, 3 uncached tokens), bedrock converse cachePoint and vertex rawPredict cache_control both captured on the outbound body. Per-run RunConfig overrides keep the marks β ModelSettings.resolve() dict-merges extra_args β as the rewritten docstring now states. Claude-Session: https://claude.ai/code/session_01YXqF4bsMfWwt1G3nTXJX6b
model_settings merges on top of the bundled cache marks via the SDK's own ModelSettings.resolve() β caller fields win, extra_args dict-merge β so customizing one knob no longer silently drops the marks the way wholesale replacement of the returned key does; with no marks in play the caller's object rides through verbatim (cloud included). The alongside-the-splat spelling Agent(**cfg, model_settings=...) cannot be rescued at the class level β Python raises the duplicate-keyword TypeError while assembling the call, before any __init__ runs β so the merge lives where the two settings meet as data, mirroring the SDK's own RunConfig merge point. name joins as a pure-forward parameter by explicit ruling: it is cosmetic on this bundle (unlike claude_agent_config's server_name, which must stay in sync with the mcp__<name> allowed_tools pre-approval string), but in composition it seeds the SDK-derived handoff and as_tool names β live-checked: handoff(agent).tool_name follows it. The demo's commented model_settings template moves inside the config call, so uncommenting it is safe under any model. Claude-Session: https://claude.ai/code/session_01YXqF4bsMfWwt1G3nTXJX6b
PageIndex SDK 0.2.10 adds two things:
client.chat()for the answer, or three standard chat APIs for the full envelopes.Both work in local mode (runs on your machine, with your model keys) and cloud mode (runs on api.pageindex.ai, with a PageIndex API key).
(Review note: this PR holds the complete 0.2.10 diff for review. The code is already on
mainvia #396, #402, #404, #405, #406, #409, and #410. This PR is not meant to be merged; the tools layer's own review record is #393.)Install
0.2.10 is a pre-release β plain
pip install pageindexstill resolves 0.2.8, so pin the version. An extra only adds a vendor's own SDK surface; everything else ships with the base install.Quick start
One rule for keys: the key belongs to whatever model does the thinking β at indexing time the summary model, at chat time the agent's model. The navigation tools themselves make no LLM calls. A missing key fails fast, naming the variable to set; cloud mode needs only
api_keyfrom dash.pageindex.ai, no model keys. (PageIndexLocalClient/PageIndexCloudClientpin the mode explicitly instead of inferring it.)Two model knobs:
index_model(indexing β structure and summaries, defaultgpt-5.6-luna) andchat_model(every chat door, defaultgpt-5.6-sol), settable as constructor kwargs or in config.yaml.modelsets both at once, and the released role names (summary_model,retrieve_model) stay accepted β new names win over old, specific over general. Chat-lane model names are LiteLLM's grammar, verbatim: bare names are OpenAI-compatible shorthand (OPENAI_BASE_URLpicks the server),provider/modelreaches that provider, and no prefix triggers a side lane.Local indexing defaults to PageIndex Flash β the tree comes from the PDF's layout in seconds, the LLM only writes summaries.
mode="standard"for the fully LLM-built tree. CLI:python run_pageindex.py --pdf_path doc.pdf. Documents persist under./.pageindexβ submit once, then reuse thedoc_id(client.list_documents()shows what is stored).Chat with your documents
chat()β question in, answer outchat()returns just the answer; the agent loop β tree navigation, page reads β runs inside. It is stateless: you keep the history.chat()'s knobs stay business-level on purpose β who answers (model), about what (doc_id), how hard it thinks (reasoning_effort). Sampling and wire-level controls live on the protocol surfaces.Three standard chat APIs
chat()is sugar overchat_completions(). When you need the envelope β usage accounting, streaming metadata, the tool-use process β call a protocol surface. Each speaks one standard wire format, so request and response look exactly like the API you already know; pass a plain string or full messages in that protocol's format.All three stream with
stream=Trueand do multi-turn the protocol's own way: append the previous reply to your next call's messages. Provider prompt caching keeps working across turns β Claude models (Anthropic direct, Bedrock, Vertex) get the managed prefix cache-marked automatically.doc_idtargeting is enforced by the tools, not just suggested to the model.Tuning rides each protocol's own vocabulary, forwarded verbatim with no defaults of ours: reasoning via
chat_completions(reasoning_effort=)/responses(reasoning={})/messages(thinking={}); sampling and output caps viatemperature,top_p, andmax_tokens(max_output_tokensonresponses()) β the caps bound each backend call in the agent loop, the waymax_turnsbounds the loop. Fields a method doesn't name go throughextra_body, merged into the request last so caller keys win.Connection config follows the same doctrine:
index_backend/chat_backendon the constructor (plus per-callbackendon the protocol doors, per-call keys winning) carry each lane's own connection vocabulary verbatim β LiteLLM params on the indexing lane andchat_completions(), openai SDK client params onresponses(), anthropic SDK client params onmessages()β so two clients can point at two providers without environment juggling. Credentials belong there, never inextra_body.extra_headerson all three doors sends raw HTTP headers (beta flags and the like); the one wire-probed exception is documented: LiteLLM's anthropic adapter ownsanthropic-beta, so Anthropic beta flags belong onmessages().Bring your own agent framework
One call returns everything the framework needs β instructions plus tools. Local and cloud clients work identically. Agent frameworks are async-native, so the snippets assume an async context, with
clientanddocfrom the quick start.OpenAI Agents SDK β ships with the SDK
More configuration options
Anthropic SDK tool runner β
pip install "pageindex[anthropic]"More configuration options
Claude Agent SDK β
pip install "pageindex[claude]"More configuration options
Any other framework β no extras needed
Drop to the explicit calls to customize. All of these accept
doc_id=...to point the agent at specific documents, andinclude_management=Trueto also expose document deletion (off by default).What works where
Bring your own agent β the tools, on every major surface:
agent_tools()β plain functions, any frameworkas_openai_tools()/openai_agent_config()β OpenAI Agents SDKhosted=True: execution on OpenAI's side, read-only endpoint by default)as_anthropic_tools()/anthropic_runner_config()β Anthropic SDK tool runneras_claude_mcp()/claude_agent_config()β Claude Agent SDK / Claude Codeapi.pageindex.ai/mcp(read-only:β¦/mcp?tools=read) β any MCP host, the Anthropic MCP connector, OpenAI hosted MCPManaged chat β the SDK runs the loop:
chat()chat_completions()chat_completions()responses()messages()tool_runnerLocal serves four read-only tools:
browse_documents,get_document,get_document_structure,get_page_content(remove_documentonly withinclude_management=True). Cloud addssearch_documents, folders, andget_document_imageβ discovered live from the server, never frozen into the SDK.What a run looks like
An actual run (local mode, OpenAI Agents SDK, over
examples/documents/q1-fy25-earnings.pdf):This is the intended loop: the agent reads the tree structure first, picks tight page ranges, and answers from tool output with page citations. No vector index, no chunking. The retrieval intelligence is your agent's own model β the navigation tools make no LLM calls.
Everything below is design rationale and the test record, written for reviewers. You don't need it to use the SDK.
Design β the tools layer
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. Adapters hand the contract/server schema to the framework verbatim (FunctionTool(params_json_schema=β¦),beta_tool(input_schema=β¦)) β no regeneration from Python signatures, soitems/enum/pattern/bounds survive on every surface.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. 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 off by default, behindinclude_management=True.as_claude_mcp(),as_openai_tools(hosted=True), the raw connector URL β point at the server's read-only endpoint (/mcp?tools=read) by default, so the URL itself is the gate and works identically in every MCP client. The in-process surfaces (agent_tools(),as_openai_tools(),as_anthropic_tools()) expose only tools the server marksreadOnlyHint; local withholdsremove_documentat registration.include_management=Trueis the one switch that opens the complete list in either mode, on every surface. All four tool exports stay polymorphic: on a cloud client the live tool set β including new server-side tools β arrives without an SDK release.{"error", "errorCode", "next_steps"}envelope the cloud emits, flagged through each channel that has one (MCPisErrorpropagated, Anthropic tool runneris_error: trueviaToolError), so the model can always tell a failed call from data. Destructive calls validate every argument before acting β a rejection envelope means nothing was deleted.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 β server-side guidance updates arrive without an SDK release, and an empty server response raises instead of silently substituting. Local: the built-in playbook for the in-process tools, a trimmed subset with a consistency test that every tool it names exists locally.doc_id(str or list) appends the target documents β in the run above it is what let the agent skip discovery.openai-agents, the chat engine (chat() is the front door; ~15 MB on a base tree already carrying litellm + openai; the>=0.18.1floor is the live-probed minimum that works with current openai, and the latest release passes the full suite).claude-agent-sdk/anthropicstay call-time imports behind vendor extras with actionable errors; the[openai]extra remains declared but empty so existing install commands keep resolving. Thelitellmfloor rises to1.97.0β the release whose bridge routes sol-class chatcmpl+tools calls onto/v1/responsesautomatically (A/B-verified against 1.96.2), so the default chat model works out of the box. That release also ships a Python 3.10 defect βMessage/Deltaannotations whose nested forward refs don't resolve, killing everycompletion()(upstream [Bug]: pydantic.errors.PydanticUserError:Messageis not fully definedΒ BerriAI/litellm#36384) β repaired by a version-gated, best-effort model rebuild at our completion gateways that no-ops on 3.11+ and on fixed releases.submit_document(wait=True)polls with growing intervals; returns oncompleted, raises onfailedor after 30 minutes β the manual polling loop cloud callers write today spins forever on a failed document.summary_model).page_index_flash()takesoptimize="full"(default) /"merge"/False;Trueis accepted as"full"for backward compatibility, unknown values raise instead of silently degrading. The CLI's--mode {flash,standard}replaces--flash(kept as a hidden compatibility alias); standard-only tuning flags now error in flash mode instead of being silently ignored; the missing-key pre-check (litellm.validate_environment, all providers) runs only when an LLM will actually be called. Both modes emit identical output schemas end to end.Design β chat on the tools
/chat/completionsquirks (history flattening, bespoke prompt, stateless re-reading, arbitrary caps) are not mirrored; local targets the standard formats and becomes the reference the cloud can later converge toward.responses()/messages()raise on cloud clients until then.AGENT_INSTRUCTIONS; callersystemcontent appended, not rejected; thedoc_idtargeting block leads the conversation and the tool layer enforces it), tool execution (read-only local set, scoped todoc_id), and billing (usage aggregation, envelope ids). Per-run tracing is disabled; prompt-cache routing keys are per-conversation, never pooled across users.tool_runnerformessages()(floor0.108.0β the first release whose runner stops at a refusal carrying atool_useblock instead of executing the tool; verified by probing mock transports against 0.84.0 through 0.108.0). The chat lane routes every model through LiteLLM β bare names are OpenAI-compatible shorthand (env config unchanged), and no prefix triggers a direct lane, so a routing decision can never hide in a model-name spelling;responses()stays on the OpenAI SDK natively (LiteLLM cannot speak the Responses wire format). Rule of the layer: engines = each vendor's official thin loop; agent hosts (Claude Code et al.) only ever get tools.responses()carries the backend's real terminalstatus/incomplete_details(recorded at the transport layer β the engine discards them), a partial page read names every omitted page, and framework exceptions surface asPageIndexAPIError, never as raw engine types.cache_controlbreakpoints sit on the managed system blocks only. The same decision reaches the LiteLLM lane: Claude models routed through LiteLLM β Anthropic direct, Bedrock, and Vertex, resolved by LiteLLM's ownget_llm_providerβ pass LiteLLM'scache_control_injection_points(via the Agents SDK'sextra_args, both documented parameters), so the managed prefix (tools + instructions + doc block) caches there too. Each channel live-verified with a writeβread cycle (anthropic per-turn reads through the full stack; Bedrock 7264 and Vertex 4842 tokens read on the second call).chat()is output sugar, not a fourth protocol. It returns the answer string and hides the envelope; the wire underneath ischat_completions()unchanged, so it works on every backend in both modes. Its contract is the one surface not pinned to a wire format β a futureengine=selector is a non-breaking add β while a merged multi-protocol method stays rejected: round-trip formats are protocol-specific, so a switch parameter abstracts nothing.ConfigLoader.load()fills index/summary/chat from whichever names were given β new names win over old, specific over general,modelsets every role, and the built-in defaults close each chain. The packaged config.yaml ships no model keys (comments only), so key presence is what distinguishes a user's choice from a default; every name that ever shipped in a release stays accepted, without deprecation warnings.reasoning_effort/reasoning/thinking;max_tokens/max_output_tokens), values forwarded untranslated with no defaults of ours β capability rules are the backend's (LiteLLM refuses unsupported combinations loudly, wrapped asPageIndexAPIError). The named sampling knobs ride ModelSettings fields β the one channel clean on every lane β whileextra_bodycovers fields the methods don't name: OpenAI destinations get a true body merge, LiteLLM providers take the keys as LiteLLM's own params,messages()hands them to the Anthropic SDK's nativeextra_body. Merged last, so caller keys win.index_backend/chat_backend(and per-callbackend) hand each stack its own connection params untranslated: the indexing gateways take the dict as LiteLLM call kwargs scoped by a contextvar (the env-key pre-check yields to it; the openai-SDK fast path normalizesapi_base), the chat lane liftsapi_key/base_urlintoLitellmModel's two pinned constructor slots and rides the rest as call kwargs,responses()/messages()construct their SDK clients from the dict (unknown keys raise, wrapped uniformly). Per-call wins over per-client;chat()takes no per-call backend but honors the client's; config bundles deliberately carry no credentials β they run in your environment.enable_citationsraises as cloud-only (citations need block-level OCR data local mode does not store).messages()resolves itsmax_tokensdefault per model (8192, or 4096 for the claude-3 generation), so the simple call needs only a question on any model.Verification
Modelfake under openai-agents, a mock HTTP transport under the real anthropic SDK); contract parity vs the frozen snapshot; framework-missing/-installed behavior both ways; streaming on every chat surface; thechat()front door (answer extraction, streamed chunks, multi-turn history passthrough, cloud envelope unwrap); the round-trip prefix-extension assertions on both engines; doc_id scoping, error-marking, and envelope-honesty regressions; a model-resolution matrix with one row per released knob generation; per-door passthrough delivery (reasoning channels, sampling knobs on ModelSettings,extra_bodyasserted on the captured anthropic wire body); backend delivery per lane (contextvar scoping on both gateway paths, the env-precheck yield, constructor lift and kwargs remainder on the LiteLLM lane, SDK-client construction on responses/messages, merge precedence) andextra_headerson every door (anthropic-betaasserted on the anthropic wire).agent_tools()andas_anthropic_tools()discovered this key's gated tool set (7 read-only tools;include_management=Trueaddsremove_document); frozen-contract parity letter-for-letter; envelope field parity on the analogous calls; the server serves non-emptyinitialize.instructions.chat_completionsanswered with the structure-first loop;responsesround-trip answered the follow-up with zero new tool calls. Anthropic βmessages()history was accepted verbatim by the real API, follow-up answered with zero new tool turns,cache_controlhit live (cache_read_input_tokens: 1826), native streaming; both the sync andAsyncAnthropictool runners drove the live cloud tools end-to-end; the Messages API MCP connector reachedapi.pageindex.ai/mcpserver-side (mcp_tool_use/mcp_tool_resultin a single call).d87fa89,b135711,eb1a230), the remainder triaged with rationale. Nine further review rounds followed (commit messages31c9150througheebed64), covering argument coercion, protocol terminal states, envelope honesty, provider error containment, CodeQL findings, and SDK dependency floors β each finding reproduced before the fix landed. The feat: model knobs, LiteLLM-verbatim chat lane, per-door passthrough paramsΒ #409 wave (model knobs, routing flip, passthrough params) went through three more audits with wire-level probes: LiteLLM'smax_tokenstranslation verified on both of its paths (chatcmplmax_completion_tokens, bridgemax_output_tokens),extra_bodydelivery captured per lane, and the sol-bridge litellm floor bisected to the exact release by source and behavioral A/B. The backend wave (feat: backend connection overrides; extra_headers on every local doorΒ #411) ran three further audits backed by ~20 mock-server wire probes that mapped LiteLLM's channel semantics per adapter (kwargs vsextra_bodyvs headers), re-proved custom-header forwarding after catching a case-sensitivity bug in the probe itself, and isolated theanthropic-betadrop to LiteLLM'scompletion()plumbing β its transformation layer merges user betas when called directly. The post-dev5 hardening (Aug 18β19,03ffab3through49a24e1) ran five more maximum-effort rounds across the tool layer, chat lanes, and flash: the standaloneagent_instructionsshadow guard re-armed strict (the*_agent_configbundles keep the relaxed in-set check they can prove), caller-owned transports surviving the per-call backend closes, cloud discovery and instructions riding the gated?tools=readendpoint (live-verified: 7 annotated read-only tools; instructions byte-identical on both endpoints today), thinking-safe runner defaults, explicitoptimize=precedence over the deprecated modifier, mcp 2.0 compatibility, and an.env-independent suite β every finding reproduced before its fix, withdrawals and non-changes triaged with rationale in the commit bodies.responses()envelope β officialoutput(model items only) +items(full transcript for round-trip) + usage aggregated across turns, verified against the real OpenAI API; python floor declared>=3.10; staleanthropic>=0.84.0hints updated to the real 0.108.0 floor; the bridge's binary-stub behavior disclosed on the two image-advertising tool surfaces;_run_syncmoved off theexcept RuntimeErrorprobe so user exceptions stop carrying a phantom "no running event loop" context.Release gate β satisfied: the default cloud configs point at the read-only MCP endpoint, so VectifyAI/pageindex-chat#448 had to be deployed before 0.2.10 ships (an older server ignores the
tools=readparameter and would silently serve the full set behind a URL that promises read-only). Verified live before publishing0.2.10.dev1:β¦/mcp?tools=readserves 7 tools withoutremove_document,β¦/mcpserves 8 with it.Follow-ups (not in this PR): an
AsyncPageIndexClienttwin per the industry dual-client pattern β every layer around the SDK is already async-native (FastAPI server, agent engines, agent frameworks); the async chat path is the engines' native form (drops the sync bridge, streams pass through asasync for), and cloud transport gains an httpx track; a stdiopageindex-mcpentry point for non-Python MCP hosts; a publicdoc_idscope on the BYO tool exports (the chat surfaces already enforce it); the docs-site agent-integration page; cloud/responsesΒ·/messagesconvergence toward these surfaces.