Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions pageindex/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1691,16 +1691,18 @@ def _base_instructions(client, include_management: bool = False) -> str:
"""Cloud: the live instructions the MCP server serves for the tool set
actually shipped. Local: the built-in subset instructions."""
if not getattr(client, "api_key", None):
return AGENT_INSTRUCTIONS
instructions = _cloud_bridge(
client, gated=not include_management).instructions()
if not isinstance(instructions, str) or not instructions.strip():
raise PageIndexAPIError(
"The MCP server returned no agent instructions — refusing to "
"substitute the SDK's local-subset guidance, which does not "
"cover the cloud tool set."
)
return instructions
base = AGENT_INSTRUCTIONS
else:
base = _cloud_bridge(
client, gated=not include_management).instructions()
if not isinstance(base, str) or not base.strip():
raise PageIndexAPIError(
"The MCP server returned no agent instructions — refusing "
"to substitute the SDK's local-subset guidance, which does "
"not cover the cloud tool set."
)
own = getattr(client, "instructions", None)
return f"{base}\n\n{own}" if own else base


def doc_targeting_block(client, doc_id) -> Optional[str]:
Expand Down
83 changes: 59 additions & 24 deletions pageindex/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ class PageIndexClient:
``backend`` keys win over it. The dict reaches whichever
door runs, in that door's vocabulary (see each method) —
``api_key`` / ``base_url`` mean the same thing on all three.
instructions (str, optional): Standing guidance for the answering
agent — persona, language, format — appended after the
managed system prompt on every chat surface, the managed
cloud chat included, and in ``agent_instructions()`` and the
``*_agent_config()`` bundles. Not a chat-side spelling: it
combines with any ``chat=`` and never selects own-model chat.
``chat(instructions=...)`` adds to it per call. Indexing
has no prompt to extend.

PageIndexCloudClient / PageIndexLocalClient pin the index side at
construction instead of inferring it from api_key.
Expand Down Expand Up @@ -347,12 +355,20 @@ def __init__(
storage_path: Optional[Union[str, os.PathLike[str]]] = None,
index_backend: Optional[dict[str, Any]] = None,
chat_backend: Optional[dict[str, Any]] = None,
instructions: Optional[str] = None,
):
if api_key == "":
raise PageIndexAPIError(
"api_key is an empty string. Pass a real PageIndex API key for "
"cloud mode, or omit api_key entirely for local mode."
)
if instructions is not None and not isinstance(instructions, str):
raise PageIndexAPIError(
f"instructions must be a str, got {type(instructions).__name__}. "
"Pass the guidance as text; Messages system blocks belong to "
"chat(protocol=\"messages\", model=..., instructions=[...]) on "
"a client with chat_model=... set.")
self.instructions = (instructions or "").strip() or None
# Each side picks one spelling — its slot, or the flat arguments.
# ``model`` sets every role, so it claims both sides.
index_flat: dict[str, Any] = {
Expand Down Expand Up @@ -519,9 +535,8 @@ def _local_chat(self) -> bool:
return model is not None

def _require_own_chat(self, lane: str) -> None:
# The one refusal for the Responses / Messages lanes, the doors
# behind them, and instructions: shared, so the doors cannot drift
# from chat().
# The one refusal for the Responses / Messages lanes and the doors
# behind them: shared, so the doors cannot drift from chat().
if self._local_chat:
return
if not getattr(self, "api_key", None):
Expand Down Expand Up @@ -1040,13 +1055,15 @@ def chat(
— the wire protocol, engine, and input/output shapes of
this call. Own-model chat only, except
``"chat_completions"``, which the managed chat serves too.
instructions: Own-model chat only — persona or extra guidance
instructions: Persona or extra guidance for this call,
appended after the managed system prompt (which stays: it
carries the tool guidance and the document context). A
string on every lane; with ``protocol="messages"`` also
a list of Messages system blocks. On the answer lane and
carries the tool guidance and the document context) and
the client's own ``instructions``. A string on every
lane; with ``protocol="messages"`` also a list of
Messages system blocks. On the answer lane and
``protocol="chat_completions"`` it precedes any ``system``
rows in the history.
rows in the history; the managed cloud chat receives them
all as its one leading system message.
citations: Own-model chat: cite every claim the way PageIndex
chat does — ``<cite doc="…" page="…"/>`` tags, ``block="…"``
added where the cloud document has blocks. The guidance is
Expand Down Expand Up @@ -1192,18 +1209,16 @@ def chat(
system=instructions, max_turns=max_turns, extra_body=body,
extra_headers=extra_headers, backend=backend)
if instructions:
self._require_own_chat("instructions")
if isinstance(messages, str):
if not messages.strip():
raise PageIndexAPIError(
"messages must be a non-empty string or a list of "
"message dicts.")
messages = [{"role": "user", "content": messages}]
if isinstance(messages, list):
# The first system text: managed prompt, then instructions,
# then the history's own system rows.
messages = [{"role": "system", "content": instructions},
*messages]
# The first system text: managed prompt, then instructions,
# then the history's own system rows.
messages = [{"role": "system", "content": instructions},
*messages]
if protocol == "chat_completions":
return self.chat_completions(
messages, stream=stream, stream_metadata=True, doc_id=doc_id,
Expand Down Expand Up @@ -1303,10 +1318,11 @@ def chat_completions(
Args:
messages: Conversation messages with 'role' and 'content' keys,
or a bare query string (it becomes a single user message).
Own-model chat also accepts system/developer messages —
their content is appended to the managed system prompt —
and takes text history only: tool-role turns are rejected
(the managed endpoint forwards them verbatim), and message
System/developer messages, wherever they sit, join the
managed system prompt after the client's ``instructions``
(the managed endpoint receives them as its one leading
system message); the history is text only: tool-role
turns are rejected on both engines, and message
fields beyond role/content are dropped.
Comment on lines +1324 to 1326

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document managed-history forwarding accurately

For a plain cloud client, the managed branch preserves every non-system message verbatim (including tool roles, structured content, and extra fields) and lets the endpoint reject unsupported input. Consequently, this public docstring incorrectly promises that tool turns are rejected by both engines and extra fields are dropped; callers can instead receive endpoint 400/422 errors and have extra fields transmitted. Qualify these guarantees as applying only to the own-model engine and describe the managed pass-through behavior.

Useful? React with 👍 / 👎.

stream: Enable streaming responses.
doc_id: Document ID or list of IDs to scope the conversation.
Expand Down Expand Up @@ -1406,6 +1422,20 @@ def chat_completions(
"agent in your process, or drop them to use the managed "
"chat endpoint, which selects its own model."
)
# The endpoint takes one system message, first: the client's
# instructions and the history's system rows fold into it.
from .local_chat import _system_text
texts = [self.instructions] if self.instructions else []
history = []
for message in messages:
role = message.get("role") if isinstance(message, dict) else None
if role in ("system", "developer"):
texts.append(_system_text(message.get("content")))
else:
history.append(message)
texts = [t for t in texts if t.strip()]
messages = ([{"role": "system", "content": "\n\n".join(texts)}]
if texts else []) + history
from .cloud_api import CloudAPI
return cast(CloudAPI, self._api).chat_completions(
messages=messages, stream=stream, doc_id=doc_id,
Expand Down Expand Up @@ -1943,10 +1973,11 @@ def as_claude_mcp(self, include_management: bool = False, *,
in-process server — match it to the key you register the entry
under (cloud entries carry no name).

Cloud hosts that surface MCP server instructions receive the same
guidance ``agent_instructions()`` returns natively — passing both
duplicates the text (harmless). ``system_prompt`` stays the
recommended channel: it is guaranteed delivery, and the only
Cloud hosts that surface MCP server instructions receive the tool
guidance natively — not the client's ``instructions``, which only
``system_prompt`` carries; passing both duplicates the guidance
(harmless). ``system_prompt`` stays the recommended channel: it is
guaranteed delivery, and the only
channel local mode has.

Usage (or ``claude_agent_config()`` for all three slots in one
Expand Down Expand Up @@ -2009,6 +2040,7 @@ def agent_instructions(self, *, include_management: bool = False) -> str:
``agent_tools()`` — server-side guidance updates arrive without an
SDK release. Raises PageIndexAPIError if the server cannot be
reached. Local: the built-in guidance for the in-process tools.
The client's ``instructions``, if set, follow the guidance.

Static by design: document targeting is conversation content, not
guidance — see ``document_context()``.
Expand Down Expand Up @@ -2138,6 +2170,7 @@ def __init__(
chat_model: Optional[str] = None,
retrieve_model: Optional[str] = None,
chat_backend: Optional[dict[str, Any]] = None,
instructions: Optional[str] = None,
):
if index is None:
if api_key is None:
Expand All @@ -2152,7 +2185,7 @@ def __init__(
)
super().__init__(api_key, index=index, chat=chat,
chat_model=chat_model, retrieve_model=retrieve_model,
chat_backend=chat_backend)
chat_backend=chat_backend, instructions=instructions)


class PageIndexLocalClient(PageIndexClient):
Expand All @@ -2173,9 +2206,11 @@ def __init__(
storage_path: Optional[Union[str, os.PathLike[str]]] = None,
index_backend: Optional[dict[str, Any]] = None,
chat_backend: Optional[dict[str, Any]] = None,
instructions: Optional[str] = None,
):
super().__init__(None, index=index, chat=chat,
index_model=index_model, chat_model=chat_model,
model=model, summary_model=summary_model,
retrieve_model=retrieve_model, storage_path=storage_path,
index_backend=index_backend, chat_backend=chat_backend)
index_backend=index_backend, chat_backend=chat_backend,
instructions=instructions)
10 changes: 6 additions & 4 deletions pageindex/local_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,18 @@ def _managed_instructions(client, extra_system: list[str]) -> str:
# Local: the built-in subset guidance. Own-model chat over cloud
# documents: the live instructions the MCP server serves.
base: str = _base_instructions(client)
return "\n\n".join([CHAT_HEADER, base, *extra_system])
return "\n\n".join([CHAT_HEADER, base,
*[t for t in extra_system if t.strip()]])


def _system_text(content: Any) -> str:
"""Text of a system/developer message: a string, or text parts joined."""
if isinstance(content, str):
return content
if isinstance(content, list):
texts = [part.get("text") for part in content
texts = [part["text"] for part in content
if isinstance(part, dict) and isinstance(part.get("text"), str)]
if texts:
if texts and len(texts) == len(content):
return "\n".join(texts)
raise PageIndexAPIError(
"system message content must be a string or a list of text parts."
Expand All @@ -51,7 +52,8 @@ def _split_chat_messages(messages) -> "tuple[list[str], list[dict]]":
content joins the managed instructions; user/assistant history passes
through. Tool-history round-trips belong to chat(protocol="responses")
or chat(protocol="messages")."""
if not isinstance(messages, list) or not messages:
messages = list(messages)
if not messages:
raise PageIndexAPIError("messages must be a non-empty list.")
system_texts: list[str] = []
history: list[dict] = []
Expand Down
26 changes: 26 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2160,3 +2160,29 @@ def broken(**kwargs):
with pytest.raises(utils.LLMRetriesExhausted):
utils.llm_completion("openai/gpt-x", "hi")
assert sum("Retrying" in r.getMessage() for r in caplog.records) == 9


# ── client-level instructions ──

def test_instructions_stored_on_every_constructor(tmp_path):
from pageindex import PageIndexCloudClient, PageIndexLocalClient
store = str(tmp_path / "store")
assert PageIndexClient(storage_path=store,
instructions=" persona ").instructions == "persona"
assert PageIndexLocalClient(storage_path=store,
instructions="p").instructions == "p"
assert PageIndexCloudClient(api_key="pi-k",
instructions="p").instructions == "p"
both = PageIndexClient(api_key="pi-k", chat="gpt-x", instructions="p")
assert (both.chat_model, both.instructions) == ("gpt-x", "p")
managed = PageIndexClient(api_key="pi-k", instructions="p")
assert managed.chat_model is None and managed.instructions == "p"
assert PageIndexClient(storage_path=store).instructions is None
assert PageIndexClient(storage_path=store,
instructions=" ").instructions is None


def test_instructions_must_be_a_string(tmp_path):
with pytest.raises(PageIndexAPIError, match="instructions must be a str"):
PageIndexClient(storage_path=str(tmp_path / "s"),
instructions=[{"type": "text", "text": "x"}])
Loading
Loading