Skip to content

feat: agent tools and local chat for the PageIndex SDK (v0.2.10) - #396

Merged
rejojer merged 56 commits into
mainfrom
feat/local-chat
Aug 12, 2026
Merged

feat: agent tools and local chat for the PageIndex SDK (v0.2.10)#396
rejojer merged 56 commits into
mainfrom
feat/local-chat

Conversation

@rejojer

@rejojer rejojer commented Aug 11, 2026

Copy link
Copy Markdown
Member

Adds an agent integration layer to the client — the PageIndex cloud MCP tool contract, runnable in-process against either mode, consumable from every major agent surface — and, built on top of that layer, managed local chat over three industry-standard wire protocols. This is the full SDK 0.2.10 increment over main. (Developed as a stack: the tools layer was #393, kept open as that layer's isolated diff and review record; this PR was retargeted to main to carry the complete change.)

What

The tools layer — one call per framework, identical across local and cloud; the mode is decided by the client constructor alone:

# — inside your async app; agent frameworks are async-native —
from pageindex import PageIndexLocalClient

client = PageIndexLocalClient()                 # or PageIndexCloudClient(api_key="...")
doc = client.submit_document("report.pdf", wait=True)

# ── OpenAI Agents SDK ──
# pip install "pageindex[openai]"
from agents import Agent, Runner

agent = Agent(**client.openai_agent_config())   # instructions + tools (+ the local model default)
result = await Runner.run(agent, "What was total revenue this quarter, vs last year?")
print(result.final_output)

# ── Anthropic SDK tool runner ──
# pip install "pageindex[anthropic]"
import anthropic

runner = anthropic.AsyncAnthropic().beta.messages.tool_runner(
    **client.anthropic_runner_config(model="claude-sonnet-4-6", asynchronous=True),
    messages=[{"role": "user", "content": "What was total revenue this quarter?"}],
)
final = await runner.until_done()
print(final.content[-1].text)

# ── Claude Agent SDK ──
# pip install "pageindex[claude]"
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query

options = ClaudeAgentOptions(**client.claude_agent_config())
async for message in query(prompt="What was total revenue this quarter?", options=options):
    if isinstance(message, ResultMessage):
        print(message.result)

# ── Any other framework — no extras needed ──
tools = client.agent_tools()   # plain functions returning JSON envelopes

Each *_config() is pure sugar over the explicit slots — agent_instructions() plus as_openai_tools() / as_anthropic_tools() / as_claude_mcp() — with one include_management / doc_id / naming parameter applied consistently everywhere; drop to the explicit form to customize, and nothing changes underneath.

The chat layer, on the same tools (local mode) — each method = its wire format = its backend protocol, 1:1, no translation layer. Every method takes its protocol's native message form or a bare query string (one user message):

# client and doc from the setup above

# ── Universal — any OpenAI-compatible backend (ollama, vllm, gateways, compat endpoints) ──
answer = client.chat_completions("What was Q3 revenue?", doc_id=doc["doc_id"],
                                 model="gpt-5.4")   # model optional — defaults to the client's retrieve_model
print(answer["choices"][0]["message"]["content"])

# ── Agentic — backends that speak the Responses API ──
result = client.responses("Which section covers risk factors?",
                          doc_id=doc["doc_id"], model="gpt-5.4")
print(result["output"][-1]["content"][0]["text"])

# ── Claude-native — Anthropic Messages protocol ──
# pip install "pageindex[anthropic]"
reply = client.messages("What was Q3 revenue?",
                        doc_id=doc["doc_id"], model="claude-sonnet-4-6")
print(reply["content"][-1]["text"])

Streaming (stream=True) ships on all three chat surfaces: chat_completions keeps the cloud signature's text-pieces/chunk-dicts modes, responses forwards native stream events — one logical response per call: lifecycle events collapsed, sequence numbers monotonic, output_index re-based onto the single logical output, and the terminal event reflects the backend's real terminal state — messages forwards the native Anthropic event stream verbatim. Multi-turn is verbatim append: extend the next call's input with responses()' output items or messages()' new-turn messages (same doc_id each call) and the provider prompt-cache prefix carries over — a tested contract.

Coverage

Bring your own agent — the tools, on every major surface:

Surface Local Cloud
agent_tools() — plain functions, any framework ✅ in-process tools ✅ live cloud tool set over MCP
as_openai_tools() / openai_agent_config() — OpenAI Agents SDK ✅ (hosted=True: execution on OpenAI's side, read-only endpoint by default)
as_anthropic_tools() / anthropic_runner_config() — Anthropic SDK tool runner ✅ sync & async ✅ sync & async
as_claude_mcp() / claude_agent_config() — Claude Agent SDK / Claude Code ✅ in-process MCP server ✅ remote MCP config — read-only endpoint by default
Standard MCP, no SDK involved ⬜ stdio entry point (follow-up) api.pageindex.ai/mcp (read-only: …/mcp?tools=read) — any MCP host, the Anthropic MCP connector, OpenAI hosted MCP

Managed chat — the SDK runs the loop:

Method Wire format Engine Local Cloud
chat_completions() OpenAI chatcmpl openai-agents ✅ hosted endpoint
responses() OpenAI Responses openai-agents ⬜ raises — cloud converges toward this later
messages() Anthropic Messages anthropic tool_runner ⬜ raises

All three chat methods share the same base: bare-string or native-message input, doc_id targeting, model override, streaming, max_turns, sampling passthrough. Local doc_id is enforcement, not just prompting — the registered tools resolve names inside the target allowlist, so out-of-scope documents stay unreachable even under prompt injection. Tool sets: local = browse_documents · get_document · get_document_structure · get_page_content (plus remove_document behind include_management=True); cloud additionally serves search_documents, folders, and get_document_image — discovered live from the server, never frozen into the SDK.

Document QA, end to end

An actual run (local mode, OpenAI Agents SDK, over examples/documents/q1-fy25-earnings.pdf):

Q: What was Disney's total revenue in Q1 FY2025, and how did it compare to the prior-year quarter? Cite the page you found it on.

[tool] get_document_structure({"doc_name": "q1-fy25-earnings.pdf", "part": 1})
[tool] get_page_content({"doc_name": "q1-fy25-earnings.pdf", "pages": "1,3"})

A: Disney's total revenue in Q1 FY2025 was $24.7 billion, up 5% from $23.5 billion in the prior-year quarter.
Citations — Page 1: "Revenues increased 5% for Q1 to $24.7 billion from $23.5 billion in Q1 fiscal 2024"; Page 3: table shows $24,690M vs $23,549M, +5%.

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 — the tools layer

  • The tool surface is the cloud MCP contract: 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's tools/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, so items/enum/pattern/bounds survive on every surface. tests/data/cloud_mcp_contract.json freezes the contract; a parity test guards drift.
  • Local is an honest subset: tools that don't exist locally (folders, 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_document is off by default, behind include_management=True.
  • The management gate is structural wherever possible. The config-handoff surfaces — 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 marks readOnlyHint; local withholds remove_document at registration. include_management=True is 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.
  • Tools never raise, and failures carry the protocol's own error marking — every failure returns the same {"error", "errorCode", "next_steps"} envelope the cloud emits, flagged through each channel that has one (MCP isError propagated, Anthropic tool runner is_error: true via ToolError), 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 the initialize handshake 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.
  • Zero new hard dependencies: openai-agents / claude-agent-sdk / anthropic are imported at call time with actionable errors; the [openai] / [claude] / [anthropic] extras carry floor-only pins. import pageindex and every existing feature work with none installed (covered by tests).
  • submit_document(wait=True) polls with growing intervals; returns on completed, raises on failed or after 30 minutes — the manual polling loop cloud callers write today spins forever on a failed document.

Design — chat on the tools

  • Basis is industry standards, not the cloud chat endpoint. The cloud /chat/completions quirks (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.
  • Passthrough doctrine. Content is never rewritten — the caller's messages, the model's answers, tool outputs, native finish/stop reasons. The SDK owns exactly four things: gatekeeping (structural validation only — no message caps, sampling params pass through), table-setting (thin chat header + the local AGENT_INSTRUCTIONS; caller system content appended, not rejected; the doc_id targeting block leads the conversation and the tool layer enforces it), tool execution (read-only local set, scoped to doc_id), and billing (usage aggregation, envelope ids). Per-run tracing is disabled; prompt-cache routing keys are per-conversation, never pooled across users.
  • Engines are the vendors' own loops, never hand-rolled: openai-agents for the two OpenAI protocols (litellm/-prefixed and provider/model names route through the SDK's LiteLLM model, openai/ strips to the OpenAI SDK), the Anthropic SDK's tool_runner for messages() (floor 0.108.0 — the first release whose runner stops at a refusal carrying a tool_use block instead of executing the tool; verified by probing mock transports against 0.84.0 through 0.108.0). Rule of the layer: engines = each vendor's official thin loop; agent hosts (Claude Code et al.) only ever get tools.
  • Envelopes report what actually happened. responses() carries the backend's real terminal status/incomplete_details (recorded at the transport layer — the engine discards them), a partial page read names every omitted page, and framework exceptions surface as PageIndexAPIError, never as raw engine types.
  • Prompt-cache continuity is a tested contract. Round-tripped history reaches the backend as an item-for-item extension of the previous call's final model input — asserted on both engines against captured payloads. Anthropic's explicit cache_control breakpoints sit on the managed system blocks only.
  • enable_citations raises as cloud-only (citations need block-level OCR data local mode does not store).
  • messages() resolves its max_tokens default per model (8192, or 4096 for the claude-3 generation), so the simple call needs only a question on any model.

Verification

  • 265 tests green (plus 3 skipped without the claude extra and 3 key-gated live tests): tool behavior against a seeded store with no LLM calls; the real chat engines against scripted backends (a Model fake 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 all three surfaces; the round-trip prefix-extension assertions on both engines; doc_id scoping, error-marking, and envelope-honesty regressions.
  • Live against the real cloud MCP server: agent_tools() and as_anthropic_tools() discovered this key's gated tool set (7 read-only tools; include_management=True adds remove_document); frozen-contract parity letter-for-letter; envelope field parity on the analogous calls; the server serves non-empty initialize.instructions.
  • Live against real model backends: OpenAI — chat_completions answered with the structure-first loop; responses round-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_control hit live (cache_read_input_tokens: 1826), native streaming; both the sync and AsyncAnthropic tool runners drove the live cloud tools end-to-end; the Messages API MCP connector reached api.pageindex.ai/mcp server-side (mcp_tool_use/mcp_tool_result in a single call).
  • Review record: multiple independent multi-agent review rounds ran over each layer (adversarial runtime probes, claims-vs-code, refactor-equivalence audits, best-practice review against the frameworks' source); every finding was reproduced before being fixed, and deliberate non-changes are documented alongside. The tools layer's rounds are recorded in feat: agent tools — OpenAI Agents SDK, Claude Agent SDK, and any framework, local & cloud #393; the chat and tools-export rounds in this PR's commit messages. A maximum-effort whole-PR review round then ran over this diff — 26 verified findings, 20 fixed in the first pass (d87fa89, b135711, eb1a230), the remainder triaged with rationale. Nine further review rounds followed (commit messages 31c9150 through eebed64), covering argument coercion, protocol terminal states, envelope honesty, provider error containment, CodeQL findings, and SDK dependency floors — each finding reproduced before the fix landed.

Release gate: the default cloud configs point at the read-only MCP endpoint — VectifyAI/pageindex-chat#448 must be deployed before 0.2.10 ships (an older server ignores the tools=read parameter and would silently serve the full set behind a URL that promises read-only).

Follow-ups (not in this PR): an AsyncPageIndexClient twin 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 as async for), and cloud transport gains an httpx track; a stdio pageindex-mcp entry point for non-Python MCP hosts; a public doc_id scope on the BYO tool exports (the chat surfaces already enforce it); the docs-site agent-integration page; cloud /responses·/messages convergence toward these surfaces.

rejojer added 21 commits August 11, 2026 22:00
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.
@rejojer rejojer changed the title feat: local chat — chat_completions / responses / messages over the agent tools (v0.2.10) feat: agent tools and local chat for the PageIndex SDK (v0.2.10) Aug 11, 2026
@rejojer
rejojer changed the base branch from feat/agent-tools to main August 11, 2026 19:41
…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.
Comment thread tests/test_agent_tools.py
"name": "_invoke", "description": "d",
"inputSchema": {"type": "object", "properties": {"x": {"type": "string"}},
"required": ["x"]}})
assert invoke_named("v") == "ok"
Comment thread tests/test_agent_tools.py
"name": "t", "description": "d",
"inputSchema": {"type": "object", "properties": {"dict": {"type": "string"}},
"required": ["dict"]}})
assert dict_param("v") == "ok"
Comment thread pageindex/agent_tools.py Fixed
Comment thread pageindex/agent_tools.py Fixed
Comment thread pageindex/agent_tools.py Fixed
Comment thread pageindex/agent_tools.py Fixed
Comment thread pageindex/agent_tools.py Fixed
Comment thread pageindex/agent_tools.py Fixed
Comment thread pageindex/agent_tools.py Fixed
Comment thread tests/test_local_chat.py Fixed
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.
Comment thread tests/test_agent_tools.py Fixed
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.
Comment thread tests/test_local_chat.py Fixed
- _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
Comment thread tests/test_local_chat.py Fixed
- 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
- 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.
Comment thread tests/test_local_chat.py Fixed
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.
Comment thread tests/test_local_chat.py Fixed
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.
Comment thread tests/test_agent_tools.py Fixed
…lopes

- 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
Comment thread pageindex/agent_tools.py Fixed
Comment thread tests/test_local_chat.py Fixed
- 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.
Comment thread tests/test_local_chat.py Fixed
…ol 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
Comment thread tests/test_local_chat.py
# machine with only one extra installed still covers the other surface.

try:
import agents # noqa: F401
@rejojer

rejojer commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Code review

No 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 👎.

Comment thread tests/test_agent_tools.py

import pytest

import pageindex.agent_tools as agent_tools_module
Comment thread tests/test_agent_tools.py
def test_as_openai_tools_cloud_default_uses_bridge(monkeypatch):
pytest.importorskip("agents")
from agents import FunctionTool
import pageindex.mcp_bridge as mcp_bridge
Comment thread tests/test_agent_tools.py
"""An object-typed server parameter used to abort the whole build with
agents.exceptions.UserError; array items used to degrade to {}."""
pytest.importorskip("agents")
import pageindex.mcp_bridge as mcp_bridge
Comment thread tests/test_agent_tools.py

@pytest.fixture
def cloud_with_fake_bridge(monkeypatch):
import pageindex.mcp_bridge as mcp_bridge
Comment thread tests/test_agent_tools.py


def test_cloud_agent_tools_list_failure_raises(monkeypatch):
import pageindex.mcp_bridge as mcp_bridge
Comment thread tests/test_agent_tools.py Fixed
Comment thread tests/test_agent_tools.py Fixed
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).
Comment thread tests/test_local_chat.py

import pytest

import pageindex.local_chat as local_chat
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).
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_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.
- _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
Comment thread pageindex/agent_tools.py
Comment on lines +458 to +459
"Index the document again with "
"PageIndexClient.submit_document()",
@rejojer
rejojer merged commit 4e41acd into main Aug 12, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant