diff --git a/README.md b/README.md index 02c46b2..ebefdb1 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ Build autonomous, hook-driven agents for WordPress — automate tasks and add LL - **Providers:** Define and manage LLM providers (e.g., OpenAI, Anthropic) through a unified API for completions and structured outputs. - **Tools:** Register callable functions that agents can dynamically execute during reasoning or task completion. -- **REST API:** Add REST API endpoints to enable external chat and interaction with agents. -- **Input:** Capture and preprocess data from actions and filters to generate structured prompts for LLMs. +- **REST API:** Provides endpoints that allow external applications to interact with agents and send or receive chat messages programmatically. +- **Memory:** Supports persistent conversation history, enabling agents to retain context across sessions and handle long-running or multi-turn interactions. - **Agents:** Create modular agents that interact with WordPress using a consistent architecture. - **Tests:** Basic Pest testing structure in place — full coverage and assertions to be implemented. @@ -70,7 +70,6 @@ https://github.com/santerref/wp-agents-demo ## Roadmap - **Workflows:** Introduce workflows to connect and orchestrate multiple agents for complex, multi-step tasks. -- **Memory:** Implement persistent memory to maintain context across conversations and enable long-running sessions. - **Tests:** Expand and complete Pest test coverage for agents, tools, and provider logic. - **Providers:** Add more LLM providers and improve compatibility with third-party APIs. - **RAG:** Integrate Retrieval-Augmented Generation to allow agents to query custom datasets, documents, or WordPress content for more context-aware responses. diff --git a/lib/agents/class-abstract-llm-agent.php b/lib/agents/class-abstract-llm-agent.php index e84e961..7c93881 100644 --- a/lib/agents/class-abstract-llm-agent.php +++ b/lib/agents/class-abstract-llm-agent.php @@ -2,8 +2,7 @@ namespace Wp_Agents\Agents; -use Wp_Agents\Providers\Provider_Interface; -use Wp_Agents\Services\Provider_Manager; +use Wp_Agents\Memory\Abstract_Memory; use Wp_Agents\System\Agent_Runner; abstract class Abstract_Llm_Agent { @@ -18,6 +17,16 @@ abstract class Abstract_Llm_Agent { protected array $tools = array(); + protected ?string $memory = null; + + protected ?int $memory_limit = null; + + protected string $name = ''; + + public function __construct( string $name ) { + $this->name = $name; + } + abstract public function instructions(): string; public function filters(): array { @@ -31,6 +40,10 @@ public function prompt( mixed $input ): Agent_Runner { ); } + public function name(): string { + return $this->name; + } + public function get_model(): string { return $this->model; } @@ -47,6 +60,13 @@ public function tools(): array { return $this->tools; } - public function handle_response( mixed $answer, array $args = array() ): void { + public function get_memory( string $session_id ): ?Abstract_Memory { + if ( $this->memory ) { + $memory_class = $this->memory; + + return new $memory_class( $this->name(), $session_id ); + } + + return null; } } diff --git a/lib/memory/class-abstract-memory.php b/lib/memory/class-abstract-memory.php new file mode 100644 index 0000000..28c8aa0 --- /dev/null +++ b/lib/memory/class-abstract-memory.php @@ -0,0 +1,24 @@ +agent = $agent; + $this->session_id = $session_id; + } + + abstract public function remember( Message $message ): void; + + abstract public function load( ?int $limit = null ): Message_Stack; + + abstract public function forget_all(): void; +} diff --git a/lib/memory/class-database-memory.php b/lib/memory/class-database-memory.php new file mode 100644 index 0000000..5a03fec --- /dev/null +++ b/lib/memory/class-database-memory.php @@ -0,0 +1,78 @@ +db = $wpdb; + $this->table = $wpdb->prefix . 'agents_memory_messages'; + } + + public function remember( Message $message ): void { + $this->db->insert( + $this->table, + array( + 'agent' => $this->agent, + 'session_id' => $this->session_id, + 'author' => $message->get_author(), + 'message' => $message->get_message(), + 'metadata' => $message->get_metadata() + ? wp_json_encode( $message->get_metadata() ) + : null, + ) + ); + } + + public function load( ?int $limit = null ): Message_Stack { + $sql = "SELECT author, message, metadata + FROM {$this->table} + WHERE agent = %s AND session_id = %s + ORDER BY id ASC"; + + $params = array( $this->agent, $this->session_id ); + + if ( null !== $limit ) { + $sql .= ' LIMIT %d'; + $params[] = $limit; + } + + $results = $this->db->get_results( + $this->db->prepare( $sql, ...$params ), + ARRAY_A + ); + + $messages = array_map( + function ( $row ) { + return new Memorized_Message( + $row['author'], + $row['message'], + json_decode( $row['metadata'] ?? '[]', true ) + ); + }, + $results + ); + + return new Message_Stack( $messages ); + } + + public function forget_all(): void { + $this->db->delete( + $this->table, + array( + 'agent' => $this->agent, + 'session_id' => $this->session_id, + ) + ); + } +} diff --git a/lib/providers/class-open-ai-provider.php b/lib/providers/class-open-ai-provider.php index a0c7e4a..1305497 100644 --- a/lib/providers/class-open-ai-provider.php +++ b/lib/providers/class-open-ai-provider.php @@ -15,20 +15,22 @@ public function __construct( string $api_key ) { $this->api_key = $api_key; } - public function chat( string $input, Abstract_Llm_Agent $agent ): Message_Stack { + public function chat( Message_Stack $message_stack, Abstract_Llm_Agent $agent ): void { $client = \OpenAI::client( $this->api_key ); $tools = $agent->tools(); - $messages = new Message_Stack( - array( - new Message( 'system', $agent->instructions() ), - new Message( 'user', $input ), - ) - ); + $message_stack->unshift( new Message( 'system', $agent->instructions() ) ); + + $flatten = function ( Message $message ) { + return array( + 'role' => $message->get_author(), + 'content' => $message->get_message(), + ) + $message->get_metadata(); + }; $parameters = array( 'model' => $agent->get_model(), - 'messages' => $messages->to_raw_array(), + 'messages' => $message_stack->map( $flatten ), 'response_format' => array( 'type' => $agent->json() ? 'json_object' : 'text' ), ); @@ -37,21 +39,16 @@ public function chat( string $input, Abstract_Llm_Agent $agent ): Message_Stack $parameters['tools'] = $tool_registry->definitions(); } - $response = $client->chat()->create( $parameters ); - - $message = $response->choices[0]->message; - $messages->add( - new Message( - $message->role, - $message->content, - $message->toArray() - ) - ); + $response = $client->chat()->create( $parameters ); + $openai_message = $response->choices[0]->message; // phpcs:ignore WordPress.NamingConventions.ValidVariableName - if ( ! empty( $message->toolCalls ) ) { + if ( ! empty( $openai_message->toolCalls ) ) { + $tool_calls = array(); + $tool_messages = array(); + // phpcs:ignore WordPress.NamingConventions.ValidVariableName - foreach ( $message->toolCalls as $call ) { + foreach ( $openai_message->toolCalls as $call ) { $function = $call->function; $args = json_decode( $function->arguments ?? '{}', true ); @@ -60,35 +57,57 @@ public function chat( string $input, Abstract_Llm_Agent $agent ): Message_Stack } $result = $tool_registry->execute( $function->name, $args ); - $messages->add( - new Message( - 'tool', - wp_json_encode( $result ), - array( - 'tool_call_id' => $call->id, - ) + $tool_calls[] = array( + 'id' => $call->id, + 'type' => $call->type ?? 'function', + 'function' => array( + 'name' => $call->function->name, + 'arguments' => $call->function->arguments, + ), + ); + + $tool_messages[] = new Message( + 'tool', + wp_json_encode( $result ), + array( + 'tool_call_id' => $call->id, ) ); } + $message_stack->add( + new Message( + $openai_message->role, + $openai_message->content, + array( + 'tool_calls' => $tool_calls, + ) + ) + ); + $message_stack->add( $tool_messages ); + $follow = $client->chat()->create( array( 'model' => $agent->get_model(), - 'messages' => $messages->to_raw_array(), + 'messages' => $message_stack->map( $flatten ), 'response_format' => array( 'type' => $agent->json() ? 'json_object' : 'text' ), ) ); - $message = $follow->choices[0]->message; - $messages->add( + $openai_message = $follow->choices[0]->message; + $message_stack->add( new Message( - $message->role, - $message->content, - $message->toArray(), + $openai_message->role, + $openai_message->content + ) + ); + } else { + $message_stack->add( + new Message( + $openai_message->role, + $openai_message->content, ) ); } - - return $messages; } } diff --git a/lib/providers/class-provider-interface.php b/lib/providers/class-provider-interface.php index f942f21..11033d5 100644 --- a/lib/providers/class-provider-interface.php +++ b/lib/providers/class-provider-interface.php @@ -7,5 +7,5 @@ interface Provider_Interface { - public function chat( string $input, Abstract_Llm_Agent $agent ): Message_Stack; + public function chat( Message_Stack $message_stack, Abstract_Llm_Agent $agent ): void; } diff --git a/lib/services/class-agent-manager.php b/lib/services/class-agent-manager.php index 72acf15..179f220 100644 --- a/lib/services/class-agent-manager.php +++ b/lib/services/class-agent-manager.php @@ -10,7 +10,7 @@ class Agent_Manager { protected static array $agents = array(); public static function register( string $name, string $agent_class ): void { - self::$agents[ $name ] = new $agent_class(); + self::$agents[ $name ] = new $agent_class( $name ); } public static function get( string $name ): Abstract_Llm_Agent { diff --git a/lib/system/class-agent-runner.php b/lib/system/class-agent-runner.php index 7bbe6a2..c9c39a2 100644 --- a/lib/system/class-agent-runner.php +++ b/lib/system/class-agent-runner.php @@ -14,14 +14,32 @@ class Agent_Runner { protected Abstract_Llm_Agent $agent; + protected ?string $session_id = null; + public function __construct( string $input, Abstract_Llm_Agent $agent ) { $this->input = $input; $this->agent = $agent; $this->provider = Provider_Manager::get( $agent->get_provider() ); } + public function with_session( ?string $session_id ): static { + $this->session_id = $session_id; + + return $this; + } + public function chat(): Message { - $message_stack = $this->provider->chat( $this->input, $this->agent ); + $memory = $this->session_id ? $this->agent->get_memory( $this->session_id ) : null; + $message_stack = $memory ? $memory->load() : new Message_Stack(); + $message_stack->add( new Message( 'user', $this->input ) ); + + $this->provider->chat( $message_stack, $this->agent ); + + foreach ( $message_stack->all() as $message ) { + if ( ! $message->memorized() && 'system' !== $message->get_author() ) { + $memory?->remember( $message ); + } + } return $message_stack->last(); } diff --git a/lib/system/class-memorized-message.php b/lib/system/class-memorized-message.php new file mode 100644 index 0000000..e3f60a2 --- /dev/null +++ b/lib/system/class-memorized-message.php @@ -0,0 +1,8 @@ +messages = $messages; } @@ -30,16 +30,25 @@ public function last(): ?Message { return $count ? $this->messages[ $count - 1 ] : null; } - public function add( Message $message ) { - $this->messages[] = $message; + public function unshift( Message $message ) { + array_unshift( $this->messages, $message ); } - public function to_raw_array(): array { - return array_map( - function ( Message $message ) { - return $message->get_raw_response(); - }, - $this->messages - ); + public function add( array|Message $messages ) { + $messages = $messages instanceof Message ? array( $messages ) : $messages; + + foreach ( $messages as $message ) { + if ( $message instanceof Message ) { + $this->messages[] = $message; + } + } + } + + public function all(): array { + return $this->messages; + } + + public function map( callable $callback ): array { + return array_map( $callback, $this->all() ); } } diff --git a/lib/system/class-message.php b/lib/system/class-message.php index ba21196..82afcfb 100644 --- a/lib/system/class-message.php +++ b/lib/system/class-message.php @@ -4,46 +4,41 @@ class Message { - protected string $role; + protected string $author; - protected ?string $content; + protected ?string $message; - protected array $raw_response = array(); + protected array $metadata = array(); - public function __construct( string $role, ?string $content, array $raw_response = array() ) { - $this->role = $role; - $this->content = $content; + protected bool $memorized = false; - if ( empty( $raw_response ) ) { - $this->raw_response = array( - 'role' => $role, - 'content' => $content, - ); - } else { - $this->raw_response = $raw_response; - } + public function __construct( string $author, ?string $message, array $metadata = array() ) { + $this->author = $author; + $this->message = $message; + $this->metadata = $metadata; } - public function get_role(): string { - return $this->role; + public function get_author(): string { + return $this->author; } - public function get_content(): string { - return $this->content; + public function get_message(): string { + return $this->message; } - public function get_raw_response(): array { - return array( - 'role' => $this->role, - 'content' => $this->content, - ) + $this->raw_response; + public function memorized(): bool { + return $this->memorized; + } + + public function get_metadata( ?string $key = null, mixed $default_value = null ): array { + return $key ? ( $this->metadata[ $key ] ?? $default_value ) : $this->metadata; } public function to_array(): array { return array( - 'role' => $this->role, - 'content' => $this->content, - 'raw_response' => $this->raw_response, + 'author' => $this->author, + 'message' => $this->message, + 'metadata' => $this->metadata, ); } } diff --git a/lib/system/class-rest.php b/lib/system/class-rest.php index cdfbbd5..7b9b395 100644 --- a/lib/system/class-rest.php +++ b/lib/system/class-rest.php @@ -24,13 +24,14 @@ public static function handle( \WP_REST_Request $request ) { $message = $request->get_param( 'message' ); $response = $agent ->prompt( $message ) + ->with_session( $request->get_param( 'session_id' ) ) ->chat(); return rest_ensure_response( array( 'agent' => $request->get_param( 'agent' ), 'message' => $message, - 'response' => $response->get_raw_response(), + 'response' => $response->to_array(), ) ); } diff --git a/lib/system/class-schema.php b/lib/system/class-schema.php new file mode 100644 index 0000000..661c68f --- /dev/null +++ b/lib/system/class-schema.php @@ -0,0 +1,39 @@ +db = $wpdb; + } + + public function install(): void { + $installed = get_option( 'wp_agents_installed' ); + if ( ! $installed ) { + $table = $this->db->prefix . 'agents_memory_messages'; + $charset = $this->db->get_charset_collate(); + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + dbDelta( + "CREATE TABLE $table ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + agent VARCHAR(150) NOT NULL, + session_id VARCHAR(150) NOT NULL, + author VARCHAR(150) NOT NULL, + message LONGTEXT NOT NULL, + metadata JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX (agent), + INDEX (session_id) + ) $charset;" + ); + + update_option( 'wp_agents_installed', true ); + } + } +} diff --git a/wp-agents.php b/wp-agents.php index c125249..1255361 100644 --- a/wp-agents.php +++ b/wp-agents.php @@ -3,7 +3,7 @@ * Plugin Name: WP Agents * Plugin URI: https://santerref.com/ * Description: Build autonomous, hook-driven agents for WordPress — automate tasks and add LLM intelligence with clean, developer-first architecture. - * Version: 0.0.1 + * Version: 0.3.0 * Requires at least: 6.8 * Requires PHP: 8.4 * Author: Francis Santerre @@ -13,12 +13,23 @@ * Domain Path: /languages */ -add_action( - 'plugins_loaded', - function () { - if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { - require_once __DIR__ . '/vendor/autoload.php'; +if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { + require_once __DIR__ . '/vendor/autoload.php'; + if ( ! function_exists( 'wp_agents_install' ) ) { + + function wp_agents_install() { + $schema = new \Wp_Agents\System\Schema(); + $schema->install(); + } + + register_activation_hook( __FILE__, 'wp_agents_install' ); + + } + + add_action( + 'plugins_loaded', + function () { if ( ! function_exists( 'wp_agents_logger' ) ) { function wp_agents_logger() { @@ -63,5 +74,6 @@ function wp_agents_get( string $name ) { add_action( 'init', array( \Wp_Agents\Services\Agent_Manager::class, 'boot' ) ); add_action( 'rest_api_init', array( \Wp_Agents\System\Rest::class, 'register' ) ); } - } -); + ); + +}