Commit graph

7 commits

Author SHA1 Message Date
mountain
e18ccdeaf8 fix: prompt guidance referenced fields get_document doesn't return
OPEN_SYSTEM_PROMPT and SCOPED_SYSTEM_PROMPT told the agent to call
get_document(doc_id) "to confirm status and page/line count", but neither
backend returns a page/line count and the local backend has no status field
(get_document returns doc_name/doc_type/doc_description). The agent would
hunt for fields that don't exist, degrading QA. Align both prompts with the
demo's wording ("confirm the document's name and type"). Regression test
asserts the prompts no longer reference the non-existent page/line count.
2026-07-10 10:53:43 +08:00
mountain
b9d021916f fix: prompt-injection delimiter escape, legacy config coercion, gather resilience, true cross-thread concurrency bound
Addresses items 4-8 from the max-effort review of PR #272 (VectifyAI/PageIndex#272).

- agent.py: wrap_with_doc_context() strips '<'/'>' from doc_name/doc_description
  (untrusted: unsanitized filename / LLM-generated from document content) before
  inserting them into the <docs>...</docs> block, so embedded content can never
  form a literal </docs> that closes the delimiter early and escapes the
  untrusted-data boundary SCOPED_SYSTEM_PROMPT relies on. Deterministic
  per-field transform, doesn't touch the (cacheable) static system prompt.

- ConfigLoader.load() (legacy 0.2.x compat) now routes merged overrides through
  IndexConfig before returning, so a legacy 'no' string gets pydantic's bool
  coercion instead of surviving as a truthy non-empty string — page_index_main's
  bare `if opt.if_add_node_summary:` checks (changed from `== 'yes'` elsewhere
  in this PR) were silently inverting caller intent and firing unwanted billed
  LLM calls.

- verify_toc, process_large_node_recursively, tree_parser,
  generate_summaries_for_structure, generate_summaries_for_structure_md: added
  return_exceptions=True to their asyncio.gather calls (llm_completion/
  llm_acompletion raise RuntimeError on retry exhaustion, added earlier in this
  PR), each with a degrade path matching the pattern already used by sibling
  hardened gathers in the same files. One transient LLM failure no longer
  aborts the whole document's indexing.

- _llm_semaphore is now a true process-wide ceiling (threading.Semaphore,
  shared across every thread/event loop) instead of one asyncio.Semaphore per
  event loop -- concurrently indexing N documents on N threads no longer
  multiplies the effective cap by N. A max_concurrency_scope() override is
  layered as a second, nested, per-loop restriction that can only tighten the
  effective cap within the ceiling, never widen past it.

- set_llm_params() mutated a bare process-wide dict with no per-call isolation,
  unlike max_concurrency which already had ContextVar scoping. Added
  llm_params_scope() (mirrors max_concurrency_scope) + IndexConfig.llm_params,
  wired into build_index() the same way max_concurrency already was, so
  concurrent indexing jobs with different llm kwargs don't leak into each
  other.

Adds regression tests for all five. Full suite: 221 passed, 2 skipped (one
pre-existing, unrelated flaky cloud-streaming test intermittently fails on
rerun; confirmed independent of this change).

Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:15:58 +08:00
mountain
2d46d68052 fix(index): bound LLM concurrency at the leaf, not per gather call
The previous approach (bounded_gather building a fresh semaphore per call)
did NOT compose: the indexing call graph nests gathers
(tree_parser -> process_large_node_recursively -> recurse, plus each node's
check_title gather), and each level got its own independent cap. Peak
in-flight LLM calls grew ~N^depth, so a deep/wide document still exhausted
file descriptors (Errno 24) — the exact failure the cap was meant to
prevent — while the flat summary phase was over-serialized. Naively sharing
one semaphore across gather levels would instead deadlock (a parent holds a
slot while awaiting children that need slots).

Move the throttle to the single chokepoint every LLM call funnels through,
llm_acompletion: one shared semaphore per event loop, acquired only around
the litellm.acompletion network call. This gives a true global cap that
composes across any nesting and can't deadlock (a parent awaiting children
holds no slot). bounded_gather is gone; the call sites revert to plain
asyncio.gather.

Also:
- Reject bool in max_concurrency validation (bool is an int subclass, so
  set_max_concurrency(True) / IndexConfig(max_concurrency=True) previously
  became Semaphore(1) and silently serialized). Shared _validate_max_concurrency
  + a pydantic field_validator.
- Guard check_title_appearance_in_start_concurrent against an out-of-range
  or 0 physical_index (LLM can emit one): it was dereferenced during task
  construction, outside the gather's return_exceptions protection, aborting
  the whole build; 0 silently wrapped to the last page. Now marked 'no'.
- Propagate contextvars into agent.py's worker-thread run (mirrors
  pipeline._run_async) so ContextVar settings stay consistent.
- Tests rewritten to cover the nested case the old flat tests missed, the
  leaf-level throttle in llm_acompletion, bool rejection, and the
  out-of-range physical_index guard.

Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-08 11:26:36 +08:00
mountain
956147d864 fix: resolve five P1 defects from the SDK review
- AgentRunner.run: offload to a worker-thread event loop when called
  from inside a running loop (Jupyter, FastAPI handlers) — mirrors
  pipeline._run_async; Runner.run_sync raised RuntimeError there.

- SQLiteStorage: create connections with check_same_thread=False so
  close() can actually close connections created by worker threads.
  Each thread still gets its own connection via threading.local; with
  the default True those closes raised ProgrammingError (silently
  swallowed) and leaked every worker connection.

- CloudBackend.query: non-streaming chat completions now use a 300s
  timeout and a single attempt. The default 30s ReadTimeout fired
  before generation finished and the retry loop re-billed the full
  server-side retrieval + generation up to three times. _request gains
  retries/timeout overrides; the exhausted-retry path also no longer
  sleeps before raising.

- MarkdownParser: content before the first heading (abstract/preamble)
  becomes a node instead of being silently dropped and unretrievable;
  a file with no headings at all yields a single document node instead
  of zero nodes (which pushed an empty page list into the pipeline).

- LegacyCloudAPI.is_retrieval_ready: API failures (revoked key, network
  down) now propagate as PageIndexAPIError instead of reading as
  "not ready", which turned polling loops into infinite loops.

Adds regression tests for each fix.

Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 10:12:51 +08:00
mountain
a47c36a3f5 feat(collection): doc_ids accepts str|list, design cleanups
- Collection.query and Backend.query/query_stream accept doc_ids as
  str, list[str] or None. Single str is normalized to [str] inside each
  backend; bare [] is rejected with ValueError at both layers.
- wrap_with_doc_context wraps the scoped doc list in <docs>...</docs>
  and SCOPED_SYSTEM_PROMPT instructs the agent to treat that block as
  data, not instructions (defense against prompt injection via
  auto-generated doc_description).
- _require_cloud_api now distinguishes api_key="" from api_key=None;
  the former gives a targeted error pointing at the empty-string vs
  fall-back-to-local situation when legacy SDK methods are called.
- Legacy PageIndexClient.list_documents docstring spells out the
  return-shape difference vs collection.list_documents() to flag a
  silent migration footgun (paginated dict with id/name keys vs plain
  list[dict] with doc_id/doc_name keys).
- Remove dead CloudBackend.get_agent_tools stub (not on the Backend
  protocol; only ever returned an empty AgentTools()) and the
  SYSTEM_PROMPT alias (OPEN_/SCOPED_SYSTEM_PROMPT are the explicit
  names now).
- README quick start and streaming example now pass doc_ids; new
  multi-document section shows both str and list forms.
- examples/demo_query_modes.py exercises all five query-mode cases
  (single-doc, multi-doc with/without env var, scoped single, scoped
  multi) for manual verification.
2026-05-15 17:03:17 +08:00
mountain
d7b36aaf3f feat(collection): scoped query mode and experimental multi-doc warning
- get_agent_tools branches on doc_ids:
  - scoped (doc_ids=[...]): drops list_documents and hard-enforces a
    whitelist on the remaining tools; system prompt switches to
    SCOPED_SYSTEM_PROMPT (no list_documents instruction); doc list +
    summaries are prepended to the user message via wrap_with_doc_context.
  - open (doc_ids=None): unchanged 4-tool agent loop.
- list_documents now exposes doc_description (sqlite + cloud).
- Collection.query emits UserWarning when doc_ids is None and the
  collection holds >1 documents; PAGEINDEX_EXPERIMENTAL_MULTIDOC=1
  silences it. Single-doc collections skip the warning; empty
  collections raise ValueError.
- Agents SDK tracing upload disabled by default (avoids SSL timeouts);
  PAGEINDEX_AGENTS_TRACING=1 re-enables it.
- README: new SDK Usage section covering local/cloud quick start,
  streaming, multi-doc as experimental, and runnable examples.
2026-05-15 11:14:12 +08:00
Kylin
c7fe93bb56 feat: add PageIndex SDK with local/cloud dual-mode support (#207) 2026-04-08 20:21:58 +08:00