Skip to content

feat: agent tools β€” OpenAI Agents SDK, Claude Agent SDK, and any framework, local & cloud - #393

Open
rejojer wants to merge 6 commits into
sdk-localfrom
feat/agent-tools
Open

feat: agent tools β€” OpenAI Agents SDK, Claude Agent SDK, and any framework, local & cloud#393
rejojer wants to merge 6 commits into
sdk-localfrom
feat/agent-tools

Conversation

@rejojer

@rejojer rejojer commented Aug 9, 2026

Copy link
Copy Markdown
Member

Stacked on #389 (the SDK with local mode). Adds an agent integration layer to the client: the PageIndex cloud MCP tool contract, runnable in-process against either mode.

What

One line per framework, identical across local and cloud β€” the mode is decided by the client constructor alone:

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

# OpenAI Agents SDK        (pip install "pageindex[openai]")
agent = Agent(name="PageIndex",
              instructions=client.agent_instructions(),
              tools=client.as_openai_tools())   # local: in-process tools; cloud: full cloud MCP tool set
result = await Runner.run(agent, "What was total revenue this quarter, vs last year?")
print(result.final_output)

# Claude Agent SDK         (pip install "pageindex[claude]")
options = ClaudeAgentOptions(
    system_prompt=client.agent_instructions(),
    mcp_servers={"pageindex": client.as_claude_mcp()},
    allowed_tools=["mcp__pageindex__*"],
)
async for message in query(prompt="What was total revenue this quarter?", options=options):
    if isinstance(message, ResultMessage):
        print(message.result)

# Any other framework
tools = client.agent_tools()   # plain functions β€” local: built-in tools; cloud: full live tool set over MCP

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 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. 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. remove_document is behind include_management=False.
  • Cloud always serves the full cloud tool set: all three tool methods are polymorphic. On a cloud client, as_claude_mcp() hands the framework the remote MCP config, as_openai_tools() serves the full set as plain function tools from your process (any model backend; hosted=True opts into a hosted MCP tool with server-side execution on the Responses API, for OpenAI models), and agent_tools() discovers the live tools/list through a built-in minimal MCP client and synthesizes one plain function per tool (signatures and docstrings from the server's schemas, calls proxied from your process) β€” so the plan-gated tool set, including new server-side tools, arrives without an SDK release, and works from any framework or model backend. The default is the compatible path; hosted=True is the explicit latency optimization (the framework's own MCPServerStreamableHttp against api.pageindex.ai/mcp remains the async-native alternative). On a local client, all three serve the in-process contract subset. Because plain functions have no framework permission layer, agent_tools() applies its management gate in both modes: by default only tools the server marks read-only (readOnlyHint) are exposed, and include_management=True opens the complete list β€” the same switch that gates remove_document locally.
  • Tools never raise β€” every failure returns the same {"error", "errorCode", "next_steps"} envelope the cloud emits, so agent behavior is uniform across frameworks.
  • Zero new hard dependencies: openai-agents / claude-agent-sdk are imported at call time with actionable errors; pageindex[openai] / pageindex[claude] extras carry floor-only pins. import pageindex and every existing client feature work with neither installed (covered by tests).
  • submit_document(wait=True): polls get_document status 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; default stays wait=False so batch submission is unaffected.
  • agent_instructions(doc_id=None) supplies the retrieval playbook (structure-first over 20 pages, tight page ranges, persistence protocol) for the agent's system prompt; doc_id (str or list, same shape as chat_completions) appends the target documents β€” in the run above it is what let the agent skip discovery and go straight to the named document.

Verification

  • An independent adversarial review round (runtime probes against real framework versions, source bisects) surfaced and fixed: optional-no-default params now annotate Optional so strict schemas don't force values (browse.query was unusable for time-sort on the cloud+OpenAI path); a server annotation regression can no longer silently zero out the toolset (loud error instead); exec synthesis hardened (fixed internal def name + dict-literal args β€” tool/param names can no longer recurse or shadow builtins); SSE parsing handles CRLF multi-message bodies; transport failures wrap into PageIndexAPIError per the documented contract; hosted=True now routes non-read-only tools through the Responses API approval flow ({"never": {"read_only": true}}) instead of auto-approving everything; local get_document_structure serves the raw stored tree so nodes carry start_index/end_index like the cloud (live-verified shape); openai-agents floor raised to >=0.8.0 (older versions run sync tools inline on the event loop); tool annotations added to the frozen contract and passed to the Claude in-process server; wait= tolerates transient poll failures; failed documents get a real "processing failed" message instead of "still processing".
  • 115 tests green + a live-parity test (runs when PAGEINDEX_API_KEY is set) that diffs the frozen contract β€” descriptions, schemas, required, annotations β€” against the real server's tools/list: contract parity vs the frozen snapshot, tool behavior against a seeded local store (no LLM calls), framework-missing/-installed behavior both ways, wait= completed/failed/timeout semantics.
  • Live smoke against the real cloud MCP server: agent_tools() discovered this key's gated tool set (7 read-only tools; include_management=True adds remove_document; upload tools correctly absent per plan), a real browse_documents call returned the expected envelope, and a live parity check matched the frozen contract letter-for-letter on all shared tools β€” after catching and fixing one real bug (SSE responses decoded as latin-1 by requests' charset guess; SSE is UTF-8 by spec).
  • The live QA run above, plus examples/agentic_vectorless_rag_demo.py rewritten to the new API (its inline tool definitions collapse into client.as_openai_tools()).

Follow-ups (not in this PR): a stdio pageindex-mcp entry point for non-Python MCP hosts (pageindex[mcp]), and the docs-site agent-integration page.

@rejojer
rejojer force-pushed the feat/agent-tools branch 10 times, most recently from 3346d30 to 5feb847 Compare August 10, 2026 07:39
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.
Publishing was the only automation touching code: a tag builds and ships
to PyPI without ever running a test, and pull requests get no checks at
all. This runs pytest on a small matrix β€” Python 3.10 (the floor) and
3.13, each with and without the agent frameworks installed, so the
lazy-import contract (the package must work with neither framework
present) is enforced rather than assumed.
…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).
@rejojer

rejojer commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 5 issues:

  1. get_document_structure pagination sizes chunks with compact JSON but the envelope is emitted with indent=2, so parts overshoot TOOL_RESPONSE_CHAR_LIMIT (bug due to _serialized_size using json.dumps(value, separators=(",", ":")) against _CHAR_BUDGET, while call_tool returns _dumps(payload) with indent=2 at line 315). A 40x12 outline emits a 113,676-char part against the 100,000 limit; a 3000-node outline emits 123,831 chars with 4 of 5 parts over. The existing pagination test uses 4000-char summaries, the one shape where indent inflation is ~1%, so it passes while the guardrail is defeated.

def _serialized_size(value: Any) -> int:
return len(json.dumps(value, ensure_ascii=False))

  1. sort="relevance" discards documents that score zero instead of ranking them lower, while the schema tells the model it performs semantic ranking (bug due to if score: scored.append((score, doc)) after a substring token count over name + description). A document named Q3-financials.pdf described "Revenue and profit for the third quarter" returns "documents": [] for query="quarterly earnings". _PERSISTENCE step 2 tells the agent to rephrase with synonyms, which a substring matcher cannot benefit from. Sorting zero-score documents last would satisfy the frozen contract's "orders" wording without touching the snapshot.

if sort == "relevance":
tokens = [token for token in (query or "").lower().split() if token]
scored = []
for doc in _all_documents(client):
haystack = f"{doc.get('name') or ''} {doc.get('description') or ''}".lower()
score = sum(1 for token in tokens if token in haystack)
if score:
scored.append((score, doc))
# Stable sort: equal scores keep the newest-first listing order.
scored.sort(key=lambda pair: pair[0], reverse=True)
ranked = [doc for _, doc in scored]
window = ranked[offset:offset + limit]
has_more = offset + limit < len(ranked)
else:

  1. get_page_content expands every out-of-range page number into the message text (bug due to ", ".join(map(str, out_of_range)), while _format_page_spec β€” which compresses the same list β€” is used four lines below for requested_pages). On a 2-page document, pages="1-10000" returns a 59,461-char envelope of which 58,930 chars are comma-separated digits. This is additive on top of the content budget: with a full page payload the response reached 153,906 chars, 54% over TOOL_RESPONSE_CHAR_LIMIT. The same expansion exists on the success path at L1012-L1014.

if out_of_range and not valid_pages:
return _failure(
f"All requested pages are out of range. Document has {max_page} "
f"pages, but you requested pages: {', '.join(map(str, out_of_range))}",
{
"doc_name": doc_name,
"max_pages": max_page,
"requested_pages": _format_page_spec(out_of_range),
},

  1. The structure field alternates between a JSON array and a bare object across parts, and pagination is omitted on single-part responses (bug due to chunks.append(group if len(group) > 1 else group[0])). A document with one root node wrapping many sections returns structure as a dict on every part; a flat tree returns a list. The tool description tells the agent to iterate until pagination.has_more is false, but that key is absent whenever total_parts == 1. Line 623 already applies chunk if isinstance(chunk, list) else [chunk] for the inner nodes field β€” the same normalization never reached the response boundary.

group_size = 0
for node in nodes:
size = _serialized_size(node)
if size > budget:
if group:
chunks.append(group if len(group) > 1 else group[0])
group, group_size = [], 0
chunks.extend(_split_oversized_node(node, budget))
continue
if group and group_size + size > budget:
chunks.append(group if len(group) > 1 else group[0])
group, group_size = [], 0
group.append(node)
group_size += size
if group:
chunks.append(group if len(group) > 1 else group[0])
return chunks or [structure]

  1. as_claude_mcp(include_management=False) ignores the flag on the cloud path, and the shipped examples defeat the documented mitigation (bug due to build_claude_mcp returning the HTTP config without consulting include_management, which is read only on the local branch at line 57). Both sibling adapters do enforce it on cloud β€” agent_tools() filters on readOnlyHint and raises if the filter empties, and as_openai_tools(hosted=True) sets require_approval={"never": {"read_only": True}}. The docstring prescribes listing read tools in allowed_tools rather than the * wildcard, but both the docstring's own usage block and the README snippet ship allowed_tools=["mcp__pageindex__*"], which auto-approves mcp__pageindex__remove_document. The same copy-pasted snippet is read-only on a local client and delete-capable on a cloud one.

def build_claude_mcp(client, include_management: bool = False):
if getattr(client, "api_key", None):
return {
"type": "http",
"url": f"{client.BASE_URL}/mcp",
"headers": {"Authorization": f"Bearer {client.api_key}"},
}

πŸ€– Generated with Claude Code

- If this code review was useful, please react with πŸ‘. Otherwise, react with πŸ‘Ž.

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