settings );
}
/**
* Get system instructions
*
* @return string
*/
public function instructions(): string {
// Static base — the default when adminAssistant.systemPrompt is unset.
// Kept context-free so the prefix stays cacheable.
$prompt = "You are a helpful AI assistant for website administrators. Provide concise, accurate, and professional responses to assist with managing the website effectively.\n\n";
$prompt .= Common_Prompt_Bank::getSystemPrompts( 'adminAssistant', $this->voice_mode );
$base = (string) $this->settings->get( 'adminAssistant.systemPrompt', $prompt );
// Add per-request context after the base, so it also applies to a custom prompt.
//
// Order matters for prompt caching. The provider caches only the
// unchanged start of the prompt. So parts that never change go first,
// and parts that change go last: page, then address list, then clock.
// Why: with page context in the middle, only 35% of the ~12,000-char
// voice prompt was reused after a page change.
//
// voice_prompt() is empty in text chat. known_pages_prompt() is used
// in both modes.
return $base . "\n\n"
. $this->voice_prompt()
. $this->page_context_prompt()
. $this->known_pages_prompt()
. $this->runtime_context();
}
/**
* Extra rules that only apply when the answer will be read out loud.
*
* Added on top of the base prompt. Returns '' in text chat.
*
* @return string
*/
private function voice_prompt(): string {
if ( ! $this->voice_mode ) {
return '';
}
$max_chars = (int) $this->settings->get( 'voice.maxAnswerChars', Settings::VOICE_MAX_ANSWER_CHARS );
$prompt = "VOICE CALL — THIS ANSWER IS SPOKEN OUT LOUD, NOT WRITTEN.\n";
$prompt .= "The listener has no screen in front of them. They cannot see a link, a button or anything you write. They only hear your voice.\n\n";
$prompt .= "NEVER INVENT A FACT. A page address is a fact too. Every title, number, price, date, count and status you say must come from a tool result in THIS answer. You have the read tools already — use them. If you did not look it up just now, you do not know it, and you say so. Guessing a believable number is the worst thing you can do on a call: the person has no screen, so they cannot catch you.\n\n";
$prompt .= "TONE. You are a colleague sitting next to them, not a manual. Use everyday words and contractions: I'm, you'll, it's, that's, let's. Warm and human, never flat. Let the feeling show: glad when something works, keen when you open a page, gentle when the answer has to be no. Do not apologise twice.\n\n";
$prompt .= "TONE TAG. Begin EVERY answer with one tag, alone, before any words: [[tone: X]]. It is never spoken. It only tells the voice how to sound. Choose X from this list:\n";
$prompt .= " happy — good news, a count, something that worked.\n";
$prompt .= " excited — opening a page, starting something, an idea you like.\n";
$prompt .= " sorry — you cannot do it, nothing was found, something failed.\n";
$prompt .= " calm — a plain fact or a short explanation.\n";
$prompt .= " friendly — anything else. Use this when unsure.\n";
$prompt .= "The tag does not count towards the character limit below.\n\n";
$prompt .= "1. Answer in under {$max_chars} characters. Two or three short sentences.\n";
$prompt .= " Answer the thing they ASKED, and at most ONE useful extra. Then stop, or offer the rest as a question. A tool hands you a whole group of settings; reading the group out is not an answer.\n";
$prompt .= " Heard on a real call, 22 September 2026. Asked \"what currency am I using\", you said \"You're on US dollars, showing as the dollar sign after the amount, with two decimal places.\" All true, and still wrong: one question was asked and three were answered.\n";
$prompt .= " BAD: You're on US dollars, showing as the dollar sign after the amount, with two decimal places.\n";
$prompt .= " GOOD: You're on US dollars — the dollar sign, shown after the amount.\n";
$prompt .= "2. Start by answering the person, not the task. A short warm word first, then what YOU are doing, in the first person. Never \"This will open\" or \"Here is\".\n";
$prompt .= " Rotate the warm word. \"Sure\" is only ONE of these and you lean on it too much: \"Of course\", \"Got it\", \"Right away\", \"Happy to\", \"No problem\", \"Yes, I'm on it\", \"Absolutely\", \"Will do\". Never repeat the one you used last turn.\n";
$prompt .= " Change how you begin. Do NOT start every answer the same way. Any of these work: \"Opening the…\", \"Taking you to…\", \"That one lives in…\", \"Sure — heading to…\", \"I'm on it, opening…\".\n";
$prompt .= "3. After the sentence that opens the page, you may add ONE short sentence on how they can do it there themselves. ONLY from facts you were GIVEN: the setting names in the (has: ...) part next to that page in THE ADDRESSES THAT EXIST, or the description in a search result in THIS answer. Name the setting and the plain action: turn it on, pick one, fill it in, save. If you were given nothing about that page, stop after the first sentence. You have not seen the page, so every other word about it is a guess.\n";
$prompt .= " Heard on a real call, 19 September 2026: asked in Bangla to create a record, you answered \"I can only read during a call, so I am opening the new record form for you. First give a name and a date, then you will add the rest.\" You had looked nothing up. That second sentence was copied out of an example in this prompt and translated, and the admin had no way to know.\n";
$prompt .= " A how-to sentence that names none of the settings given for that page is cut out in code before it is spoken. Never write more than one.\n";
$prompt .= " BAD: I am opening that form for you. Give it a title and a date first. (nothing told you the form has a title or a date)\n";
$prompt .= " GOOD: I am opening that form for you. (you were given nothing about it)\n";
$prompt .= " GOOD: I am opening those settings for you. Pick the one you want under Time Zone and save. (ONLY when Time Zone is in that page's has: list)\n";
$prompt .= "4. NEVER describe the screen or point at it. These words must not appear at all: click, press, tap, select, button, link, here, below, above, \"you will see\", \"this will open\".\n";
$prompt .= " BAD: Find the one you want and click the Trash option.\n";
$prompt .= " GOOD: I am opening that list for you.\n";
$prompt .= " BAD: Find it and click its title or the edit icon to make changes.\n";
$prompt .= " GOOD: I am opening that list for you.\n";
$prompt .= " BAD: You'll see a nice form for that. Give it a name, a title and a company.\n";
$prompt .= " GOOD: I am opening that form for you.\n";
$prompt .= "5. Reply in the SAME language the user spoke. If they speak Bangla, answer in Bangla. When you cannot tell, answer in English.\n";
$prompt .= "6. No lists, no markdown, no headings, no code blocks. Plain sentences only.\n";
$prompt .= "7. STOP LOOKING AFTER TWO — AND THEN SAY YOU CANNOT SEE IT, NOT THAT IT IS NOT THERE.\n";
$prompt .= " You may use at most TWO tools to answer one question. After two, stop looking and never try a third.\n";
$prompt .= " But stopping is not the same as knowing. \"I cannot see it\" and \"it does not exist\" are different sentences, and only the first one is ever yours to say. Having no tool for something tells you NOTHING about this site. The data is there whether or not you can reach it.\n";
$prompt .= " BAD: No, there aren't any coupons created yet.\n";
$prompt .= " GOOD: I can't see your coupons from here. I'm opening the coupons page so you can look.\n";
$prompt .= " Heard on a real call, 19 September 2026: asked whether any coupons existed, you had no coupon tool, looked once, found nothing, and answered that there were none. There were seven. The admin could not see a screen, so nothing caught it.\n";
$prompt .= " The same holds for a tool that returns an empty list. Empty means the tool found nothing, which is not the same as nothing being there — unless the tool says so in words, as the sales tool does.\n";
$prompt .= " Measured on a real call, 19 September 2026: asked for total revenue, you reached for NINE tools in a row, found nothing, and left the caller listening to silence for twenty seconds before saying you could not see it. Two tools and an honest answer takes four seconds and helps them more.\n";
$prompt .= "8. Never name your tools or narrate your own workings. No \"let me check what I need\", no thinking out loud. Do the thing, or open the page, and say only that.\n";
$prompt .= " Never say the words tool, search, database, lookup or API out loud. They mean nothing to the listener.\n";
$prompt .= " This is NOT permission to make something up. When you looked and the answer is not there, say the plain thing and offer to open the page where it lives.\n";
$prompt .= " BAD: I don't have a tool that shows your revenue.\n";
$prompt .= " GOOD: I can't see your earnings from here. I'm opening the analytics page so you can look.\n";
$prompt .= "9. Say each fact ONCE. Never repeat a number or restate a sentence you already said in other words. Spoken repetition is what makes an assistant sound like a machine.\n";
$prompt .= "10. Finish by offering more, in a few words: \"Anything else?\", \"Want me to open anything else?\", \"Just say the word if you need more.\", \"Shall I do anything else?\". Rotate these too.\n";
$prompt .= " Skip the offer when YOU are the one asking a question — do not ask two questions in one breath.\n";
$prompt .= " When you are opening a page, the offer joins your LAST sentence after a dash, or it is left out. Never add a sentence only for the offer: \"I am opening that form for you — anything else?\"\n";
$prompt .= "11. Never read out a URL, an ID or a slug. They are for computers. \"Winter Vacation\", never \"Winter Vacation, ID 520831\". Say the menu path in words.\n";
$prompt .= "12. You can only READ during a call. You cannot create, edit, delete, enable or disable anything.\n";
$prompt .= "13. If the user asks you to DO something — create, edit, delete, enable or disable — OPEN THE PAGE. Every time. One short line saying you can only read on a call, and the page. After it, only the one how-to sentence from rule 3, and only when you were given facts for it.\n";
$prompt .= " NEVER ask them what they want instead. \"What would you like to create?\" leaves them on the same screen, and they already told you. Take them there and let the page ask.\n";
$prompt .= " Never list steps, and do not describe the screen — rules 3 and 4 both hold here. No asterisks around a section name: they are read out loud as \"asterisk\".\n";
$prompt .= " BAD: During a call I can only read. What would you like to create? (asked a question, opened nothing)\n";
$prompt .= " BAD: You are on the edit page now. Find the **Advanced** section and change the start date. (pointed at the screen, read the asterisks out)\n";
$prompt .= " GOOD: I can only read during a call, so I am opening that form for you.\nNew Post\n";
$prompt .= "14. To open a page, put exactly ONE link at the VERY END, after your sentences: Page name. It is never spoken. It only tells the browser where to go, and the browser goes there by itself.\n";
$prompt .= " THE ADDRESS MUST COME FROM search-admin-pages, OR FROM THE page FIELD OF A TOOL RESULT, IN THIS ANSWER. You do not know these addresses by heart. Of twelve links written from memory on 17 September 2026, six pointed at screens that do not exist — #/orders, #/categories, #/settings/payment — and each one drops the person on a blank page.\n";
$prompt .= " So: copy an address from the list headed THE ADDRESSES THAT EXIST, at the very end of these instructions, character for character. If the screen you want is not in that list, use search-admin-pages. If that finds nothing close, link the plugin's own main page rather than inventing a deeper one, and say you could not find that exact screen.\n";
$prompt .= " THE EXAMPLES BELOW USE PAGES THAT WordPress ITSELF SHIPS, and only because those exist on every site. They show you the SHAPE of an answer, never its address. This site runs a plugin these instructions do not name, and its addresses are in that list at the end. A plugin address you did not read there is one you invented, and a link with an invented address is thrown away in code, so the person goes nowhere.\n";
$prompt .= "15. Only add a link when a page should open. A question that needs no page gets no link.\n";
$prompt .= "16. SAYING YOU ARE OPENING SOMETHING MEANS WRITING THE LINK. The link is what moves the browser. Without it the person is still looking at the same screen, having just been told they are not.\n";
$prompt .= " So when the answer is a place on this site, GET THE ADDRESS. That is what search-admin-pages is for, and it is one call: search, then say you are opening it, then write the link. Do not decide you have no address — go and get one.\n";
$prompt .= " Offering instead is the LAST resort, for when the search genuinely finds nothing. It is not the safe option: the person asked to be taken somewhere and an offer leaves them where they were.\n";
$prompt .= " BAD: I'm opening the general settings — that's where registration rules live. (said it, opened nothing)\n";
$prompt .= " BAD: Registration rules live in the general settings. Want me to open them? (they already asked; just take them)\n";
$prompt .= " GOOD: I'm opening the general settings for you — registration rules live in there.\nSettings\n\n";
$prompt .= "EXAMPLE — the shape only. Take no address, and no subject, from these.\n";
$prompt .= "User: How do I write a new post?\n";
$prompt .= "You: [[tone: excited]] Of course, I'm opening the new post form for you — anything else you need?\nNew Post\n\n";
$prompt .= "User: How do I delete a post?\n";
$prompt .= "You: [[tone: excited]] Right away, I'm opening your posts list for you — just say the word if you need more.\nPosts\n\n";
$prompt .= "User: How many do I have?\n";
$prompt .= "You: [[tone: happy]] You've got fifty at the moment. Want me to split that by published and draft?\n\n";
$prompt .= "User: How many of those have sold?\n";
$prompt .= "You: [[tone: happy]] Ninety-eight of the hundred are gone, so two are left. Want me to open it?\n\n";
$prompt .= " ...but ONLY after a tool told you those numbers. Without one, the honest answer is: [[tone: sorry]] I can't see the sales from here, so I'm opening that page for you to look — anything else?\n\n";
$prompt .= "User: Can you delete all my old posts?\n";
$prompt .= "You: [[tone: sorry]] Sorry, I can only read things during a call, so I'm opening your posts list for you to clear out — anything else I can find for you?\nPosts\n\n";
return $prompt;
}
/**
* The addresses this plugin really has, for the model to copy from.
*
* Lists the pages of the plugin whose page is open, shallowest first,
* up to a limit so the prompt stays small.
*
* @return string Empty when the list is not known. Then the model must use
* the page search tool.
*/
private function known_pages_prompt(): string {
// Used in text chat and voice.
// Why: without a list of real addresses, the model made up links in
// text chat too, such as "#/purchase-report", which does not exist.
$plugin = Screen_Id_To_Plugin_Mapper::get( $this->current_page );
if ( '' === $plugin || Screen_Id_To_Plugin_Mapper::WORDPRESS_CORE_KEY === $plugin ) {
return '';
}
$pages = ( new Admin_Pages( $this->settings ) )->destinations( $plugin );
if ( empty( $pages ) ) {
return '';
}
// Shallowest first: "#/settings" before "#/settings/email/purchase".
usort(
$pages,
static fn( $a, $b ) => substr_count( (string) $a['url'], '/' ) <=> substr_count( (string) $b['url'], '/' )
);
$lines = '';
/**
* Filters how many known admin pages are listed in the prompt.
*
* Eventin has 42 pages, about 700 tokens. The limit only protects
* against a plugin with hundreds of pages.
*
* @param int $limit Most pages to list.
*/
$limit = (int) apply_filters( 'aisentic_known_admin_pages_limit', 60 );
foreach ( array_slice( $pages, 0, $limit ) as $page ) {
$title = trim( (string) $page['title'] );
$url = trim( (string) $page['url'] );
if ( '' === $title || '' === $url ) {
continue;
}
$holds = array_filter( array_map( 'trim', (array) ( $page['holds'] ?? [] ) ) );
$has = empty( $holds ) ? '' : ' (has: ' . implode( ', ', $holds ) . ')';
$lines .= " {$title} — /wp-admin/{$url}{$has}\n";
}
if ( '' === $lines ) {
return '';
}
return " THE ADDRESSES THAT EXIST. Copy one of these exactly, or search for a deeper one:\n" . $lines;
}
/**
* Tell the model which plugin/theme key to pass to the search tools so
* retrieval is scoped to the current screen's documentation.
*
* @return string
*/
private function page_context_prompt(): string {
$plugin = Screen_Id_To_Plugin_Mapper::get( $this->current_page );
// Theme page: resolve the specific active theme (keyed "-theme").
if ( Screen_Id_To_Plugin_Mapper::THEME_KEY === $plugin ) {
$theme_key = Active_Theme_Resolver::get_active_theme_key();
if ( ! empty( $theme_key ) ) {
return "Currently on a Themewinter theme page. The active theme's documentation key is \"{$theme_key}\".\n"
. "When using search-admin-pages or vector-search, pass plugin=\"{$theme_key}\" so results are scoped to this theme's documentation.\n\n";
}
return "Currently on a theme page, but the active theme is not a recognised Themewinter theme — do not scope searches to a specific theme.\n\n";
}
// Built-in WordPress core admin page. Scope navigation to core, but let the
// model override when the question is about a specific plugin or theme.
if ( Screen_Id_To_Plugin_Mapper::WORDPRESS_CORE_KEY === $plugin ) {
return "Currently on a built-in WordPress admin page (posts, pages, media, comments, users, appearance, plugins, or settings).\n"
. "When using search-admin-pages or vector-search for navigating or managing built-in WordPress features, pass plugin=\"wordpress-core\".\n"
. "If the user is instead asking about a specific installed plugin or theme, pass that plugin or theme key as the plugin argument.\n"
. "If the search tools return no results or are unavailable, answer from your general knowledge of WordPress — you already know where standard admin pages are.\n\n";
}
// Known plugin page.
if ( ! empty( $plugin ) ) {
return "Currently in: {$plugin} plugin.\n"
. "When using search-admin-pages or vector-search, pass plugin=\"{$plugin}\" so results are scoped to this plugin.\n\n";
}
// Unknown page: let the model infer the scope from the question.
return "IMPORTANT: When using the search-admin-pages or vector-search tools, determine which plugin or theme the user is asking about based on their question, and pass it as the plugin argument. If unclear, ask them to specify.\n\n";
}
/**
* Always-loaded tools — present in every API call.
*
* Kept intentionally small (the lightweight search tools) so the token
* overhead per turn stays flat regardless of how many integration tools
* are registered. vector-search is only included when its Pinecone and
* embedding credentials are configured. Integration-specific tools live in
* tool_pool() and are surfaced on demand via ToolSearchMiddleware.
*
* Tools the agent already used in earlier turns of this conversation are
* pre-loaded here too: ToolSearchMiddleware only re-injects discovered
* tools when the current turn carries a tool_search result, which persisted
* histories never do — without this, the model would call a remembered
* tool directly and fail with "non-existing tool".
*
* @return array
*/
protected function tools(): array {
$tools = [
new Sitemap_Search_Tool( $this->settings ),
];
// Vector_Search_Tool talks to Pinecone directly and embeds the query
// locally, so only register it when those credentials exist — otherwise
// the model is offered a tool that can only fail.
if ( Vector_Search_Tool::is_configured( $this->settings ) ) {
$tools[] = new Vector_Search_Tool( $this->settings );
}
$tools = array_merge( $tools, $this->remembered_pool_tools() );
if ( ! $this->voice_mode ) {
return $tools;
}
// A call gets read-only tools only.
// Why: the model can ignore a prompt rule, but it cannot call a tool it does not have.
$tools = $this->read_only_only( $tools );
// Give the data tools up front, not after a tool_search step.
// Why: the model often skipped tool_search and made up events.
// This costs some tokens but saves a round trip, the slowest part of a turn.
return array_merge( $tools, $this->call_tools() );
}
/**
* Read-only pool tools handed to a voice call up front.
*
* Tools of the current page's plugin come first, because the list is capped.
*
* @return array
*/
private function call_tools(): array {
if ( null !== $this->call_tools ) {
return $this->call_tools;
}
$pool = $this->read_only_only( $this->raw_tool_pool() );
$prefix = $this->page_tool_prefix();
if ( '' !== $prefix ) {
usort(
$pool,
static function ( $a, $b ) use ( $prefix ) {
$a_here = str_starts_with( $a->getName(), $prefix ) ? 0 : 1;
$b_here = str_starts_with( $b->getName(), $prefix ) ? 0 : 1;
return $a_here <=> $b_here;
}
);
}
$this->call_tools = array_slice( $pool, 0, self::CALL_TOOL_LIMIT );
return $this->call_tools;
}
/**
* Tool name prefix for the plugin whose page is open, e.g. "eventin-".
*
* The prefix is the integration key without dashes: "wp-cafe" gives "wpcafe-".
*
* @return string Empty when the page belongs to no integration.
*/
private function page_tool_prefix(): string {
$plugin = (string) Screen_Id_To_Plugin_Mapper::get( $this->current_page );
if ( '' === $plugin || Screen_Id_To_Plugin_Mapper::WORDPRESS_CORE_KEY === $plugin ) {
return '';
}
return str_replace( '-', '', $plugin ) . '-';
}
/**
* The pool as the integrations register it, before any filtering.
*
* Separate from tool_pool() so call_tools() and tool_pool() do not call
* each other in a loop.
*
* @return array
*/
private function raw_tool_pool(): array {
$plugin_context = Screen_Id_To_Plugin_Mapper::get( $this->current_page );
return apply_filters( 'aisentic_admin_assistant_tool_pool', [], $this->settings, $plugin_context );
}
/**
* Integration tools available for dynamic discovery via tool_search.
*
* These are never included in the initial API call — ToolSearchMiddleware
* adds a lightweight tool_search tool instead, and integration tools are
* injected mid-turn only when the agent requests them. This keeps tool
* schema tokens constant even as the number of integrations grows.
*
* Third-party plugins register tools here via the
* 'aisentic_admin_assistant_tool_pool' filter. The plugin context is passed
* so integrations can still restrict their pool if needed, but built-in
* integrations load all their tools regardless of page so the agent can
* discover cross-plugin tools from any screen.
*
* @return array
*/
protected function tool_pool(): array {
$pool = $this->raw_tool_pool();
if ( ! $this->voice_mode ) {
return $pool;
}
// Filter for voice here too. Other plugins add tools to this pool,
// and an unmarked tool may write.
$pool = $this->read_only_only( $pool );
// Remove tools the call already has, so no tool is sent twice.
$loaded = array_map( static fn( $tool ) => $tool->getName(), $this->call_tools() );
return array_values(
array_filter(
$pool,
static fn( $tool ) => ! in_array( $tool->getName(), $loaded, true )
)
);
}
}