settings = $settings; $this->current_page = $current_page; $this->remembered_tool_names = $remembered_tool_names; $this->voice_mode = $voice_mode; parent::__construct( $persistence, $resume_token ); // Record tool executions so the controller can persist what ran this turn. $this->activity_observer = new Tool_Activity_Observer(); $this->observe( $this->activity_observer ); } /** * Get current page context * * @return string */ public function get_current_page(): string { return $this->current_page; } /** * Keep only the tools that are safe for a voice call. * * A tool stays only if it implements Read_Only_Tool_Contract. Unmarked * tools are dropped. The 'aisentic_tool_is_read_only' filter can only * remove a tool, never add one. Why: another plugin returning true * could give a call a tool that writes. * A tool that needs approval is always dropped, because a call has no * approval card. This uses the same check as approvable_tools(). * * @param ToolInterface[] $tools Tools to filter. * @return array */ protected function read_only_only( array $tools ): array { return array_values( array_filter( $tools, static function ( $tool ) { if ( apply_filters( 'aisentic_tool_needs_approval', $tool instanceof Approvable_Tool_Contract, $tool->getName() ) ) { return false; } if ( ! $tool instanceof Read_Only_Tool_Contract ) { return false; } /** * Let an integration keep one of its read-only tools out * of a call. Returning true does not add a tool. * * @param bool $safe True when the tool only reads. * @param string $name Tool name. */ return (bool) apply_filters( 'aisentic_tool_is_read_only', true, $tool->getName() ); } ) ); } /** * Build the live runtime context block (date, time, timezone, site, user). * * This is prepended to every system prompt — even a fully custom one set in * settings — so the model is always grounded in the real current date/time * and never assumes a stale year. All values come from the WordPress site * configuration, which is the source of truth. * * @return string */ protected function runtime_context(): string { $now = aisentic_current_datetime(); $timezone = aisentic_timezone_string(); $lines = []; $lines[] = '## Current Context (live, always trust this over your own assumptions)'; $lines[] = 'Current date: ' . $now->format( 'l, F j, Y' ) . ' (' . $now->format( 'Y-m-d' ) . ').'; $lines[] = 'Current time: ' . $now->format( 'g:i A' ) . ' (24h ' . $now->format( 'H:i' ) . '), timezone ' . $timezone . '.'; $lines[] = 'Resolve "today", "tomorrow", "now", "this week" and any relative date/time from the values above. Never assume the year. It is ' . $now->format( 'Y' ) . '.'; $lines[] = 'Site: ' . get_bloginfo( 'name' ) . ' (' . get_site_url() . ').'; // Only the admin assistant addresses "the logged-in administrator". The // public sales agent must not — a WP admin browsing the storefront while // logged in is a shopper there, not an operator. $user = wp_get_current_user(); if ( ! $this instanceof Public_Chat_Agent && $user && $user->exists() ) { $lines[] = 'You are assisting the logged-in administrator: ' . $user->display_name . '.'; } return implode( "\n", $lines ) . "\n\n"; } /** * Tools available for dynamic discovery via tool_search. * * These are kept out of the LLM context upfront and surfaced only when the * agent calls tool_search. Subclasses override this to declare integration * tools. The pool is also scanned for approvable (write) tools so that * ToolApproval gates them correctly when they are dynamically discovered. * * @return array */ protected function tool_pool(): array { return []; } /** * Pool tools the agent already used in earlier turns of this conversation. * * Returns the matching tool instances from tool_pool() so subclasses can * pre-load them into tools() and the model can call them directly instead * of re-running tool_search (or failing with "non-existing tool"). * * @return array */ protected function remembered_pool_tools(): array { if ( empty( $this->remembered_tool_names ) ) { return []; } $remembered = array_flip( $this->remembered_tool_names ); $loaded = []; foreach ( $this->tool_pool() as $tool ) { if ( isset( $remembered[ $tool->getName() ] ) ) { $loaded[] = $tool; } } return $loaded; } /** * Resolve the agent's tools that require human approval. * * Scans both always-loaded tools() and the lazy tool_pool() so that write * tools discovered mid-turn are still covered by the ToolApproval gate. * The 'aisentic_tool_needs_approval' filter lets third-party code opt tools * in or out. Returns both the de-duplicated class-string list (used to * configure the ToolApproval middleware) and the tool-name list (used to tag * executed write tools after a resume). * * @return array{classes: string[], names: string[]} */ protected function approvable_tools(): array { $classes = []; $names = []; foreach ( array_merge( $this->tools(), $this->tool_pool() ) as $tool ) { $needs_approval = apply_filters( 'aisentic_tool_needs_approval', $tool instanceof Approvable_Tool_Contract, $tool->getName() ); if ( $needs_approval ) { // Keyed to de-duplicate; values become the approval lists. $classes[ get_class( $tool ) ] = get_class( $tool ); $names[ $tool->getName() ] = $tool->getName(); } } return [ 'classes' => array_values( $classes ), 'names' => array_values( $names ), ]; } /** * Tool names that require human approval for this agent. * * Exposed so the controller can identify which executed tools were the * approved (write) actions and tag them for duplicate-action prevention. * * @return string[] */ public function get_approvable_tool_names(): array { return $this->approvable_tools()['names']; } /** * Register node middleware. * * Attaches Validated_Tool_Approval (NeuronAI's ToolApproval, plus an * up-front required-argument check) so that tools requiring human approval * pause the workflow (raising a WorkflowInterrupt) before they execute, and * a call the model got wrong never reaches an approval card at all. * The gate is registered for BOTH the sequential ToolNode and the * ParallelToolNode: getMiddlewareForNode() matches the running node by exact * class, and Agent::compose() swaps in ParallelToolNode when parallel tool * calls are enabled — registering only ToolNode would silently disable the * approval gate in that mode. * * @return array */ protected function middleware(): array { // Registered even when nothing needs approval: the middleware also // screens every tool call for missing required arguments, and the // public chat — which has no approvable tools — needs that screen just // as much as the admin assistant. Validated_Tool_Approval reads an // empty list as "gate nothing", unlike its parent. // One stateless middleware instance is safe to share: only the node that // actually runs for this agent will invoke it. $approval = new Validated_Tool_Approval( $this->approvable_tools()['classes'] ); return [ ToolNode::class => [ $approval ], ParallelToolNode::class => [ $approval ], // The public chat's tool step (see Public_Chat_Agent::compose()). Quick_Reply_Tool_Node::class => [ $approval ], ]; } /** * Register global middleware (runs on every workflow node). * * When the subclass provides a tool_pool(), injects * Companion_Tool_Search_Middleware so the agent can discover integration * tools on demand without loading all tool schemas upfront, and so a tool * named in a discovered tool's description is loaded with it rather than * aborting the stream when the model follows that instruction. The * middleware self-guards on AIInferenceEvent so it is a no-op on * non-inference nodes. * * @return array */ protected function globalMiddleware(): array { $pool = $this->tool_pool(); if ( empty( $pool ) ) { return []; } $custom_prompt = "\n\n---\n\n## \`tool_search\`\n\n"; $custom_prompt .= "You have access to the \`tool_search\` tool to discover additional tools that may help you complete your task.\n"; $custom_prompt .= "When you need a capability that your current tools do not provide, use \`tool_search\` to find relevant tools from the available pool.\n\n"; $custom_prompt .= "After searching, matching tools become available for you to use in subsequent steps.\n"; $custom_prompt .= "Always search before concluding that a task cannot be completed — the right tool may exist but not be loaded yet.\n"; $custom_prompt .= 'If the first search returns no match, retry with a simpler or synonymous query (e.g. try "reservation" if "booking" returned nothing, or "event" if "conference" returned nothing).'; // Named argument skips $topN, which is typed `int` (non-nullable) in // NeuronAI's ToolSearchMiddleware — passing null to mean "use the default" // raises a TypeError during agent construction, before the SSE headers are // sent, surfacing as a generic "something went wrong" with no usable // diagnostics. Skipping it defers to the vendor default rather than // restating a value that could drift. return [ new Companion_Tool_Search_Middleware( $pool, systemPrompt: $custom_prompt ) ]; } /** * Hand the provider the discoverable pool so a tool the model names without * searching for it still resolves. The pool never reaches the schema the * model sees, so this costs no tokens. */ public function resolveProvider(): AIProviderInterface { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- Overrides a vendor method. $provider = parent::resolveProvider(); if ( $provider instanceof Resolves_Pool_Tools_Contract ) { $provider->set_pool_tools( $this->tool_pool() ); } return $provider; } /** * Turn a failing tool into a tool result instead of a dead turn. * * ToolNode re-throws when this returns null, which ends the SSE stream on a * generic error and throws away everything the agent had already gathered. * NeuronAI documents this override as the way to set a default handler. * * Returns an invokable object, never a closure: the handler travels on the * ToolNode into the WorkflowInterrupt that tool approval has to serialize, * and PHP cannot serialize a Closure. * * @return callable fn( \Throwable $e, ToolInterface $tool ): string */ protected function resolveToolErrorHandler(): ?callable { return new Tool_Error_Handler(); } /** * Tool calls executed during this turn (name, inputs, result). * * The controller persists this onto the assistant message so later turns * retain a record of what the agent already fetched or did. Activity is * captured by Tool_Activity_Observer via the 'tool-called' workflow event. * * @return array */ public function get_tool_activity(): array { return $this->activity_observer->get_activity(); } }