CHANGELOG-PROPOSAL.txt
2 days ago
QA-LOOP.md
2 days ago
STUDY-CONSOLIDATION.md
2 days ago
STUDY-MCP.md
2 days ago
STUDY-WORKSPACE.md
2 days ago
STUDY-WPAI-LEARNINGS.md
2 days ago
STUDY-WPAI-LEARNINGS.md
312 lines
| 1 | # Open architecture study: lessons from WP AI Client |
| 2 | |
| 3 | **Status:** thinking, not implementing. |
| 4 | **Owner:** Jordy. |
| 5 | **Started:** 2026-04-22. |
| 6 | **Trigger:** building the WP AI Client gateway in `/labs/` (commits |
| 7 | `4e5f9398`, `e14ee516`) exposed real shape mismatches between AI Engine's |
| 8 | internal data model and a modern, typed AI SDK. |
| 9 | |
| 10 | This document is a working proposal, not a roadmap. It captures what we |
| 11 | learned from building the gateway, sketches a typed message/result layer |
| 12 | that AI Engine could grow into, and lists the smaller patterns worth |
| 13 | borrowing. Read before any large refactor of the query / reply layer. |
| 14 | |
| 15 | --- |
| 16 | |
| 17 | ## 1. What the gateway exposed |
| 18 | |
| 19 | When mapping AiClient → AI Engine, three frictions kept showing up: |
| 20 | |
| 21 | ### a. Lossy message shape |
| 22 | |
| 23 | AI Engine messages are flat: |
| 24 | |
| 25 | ```php |
| 26 | $query->messages = [ |
| 27 | [ 'role' => 'user', 'content' => 'Hi.' ], |
| 28 | [ 'role' => 'assistant', 'content' => 'Hi back.' ], |
| 29 | ]; |
| 30 | $query->add_file( DroppedFile::from_url( $url, 'analysis' ) ); |
| 31 | ``` |
| 32 | |
| 33 | Files attach to the **whole query**, not to specific turns. This means a |
| 34 | multi-turn conversation that interleaves images ("here's image A, your |
| 35 | thoughts? — okay, here's image B, compare them") loses position. Engines |
| 36 | have to guess where the file belongs. The gateway worked around this |
| 37 | with a `[attachment]` placeholder string, which is the kind of hack you |
| 38 | write because the data model can't express the truth. |
| 39 | |
| 40 | WP AI Client's `Message`/`MessagePart` solves this naturally: |
| 41 | |
| 42 | ```php |
| 43 | new UserMessage([ |
| 44 | new MessagePart( 'Compare these:' ), |
| 45 | new MessagePart( $imageA ), |
| 46 | new MessagePart( $imageB ), |
| 47 | ]); |
| 48 | ``` |
| 49 | |
| 50 | ### b. Reply shape is a property bag |
| 51 | |
| 52 | `Meow_MWAI_Reply` exposes: |
| 53 | |
| 54 | - `$reply->result` (string, but might be markdown image-tag soup for image queries) |
| 55 | - `$reply->results` (array — embeddings? URLs? text alternatives?) |
| 56 | - `$reply->usage` (associative array) |
| 57 | - `$reply->needFeedbacks`, `$reply->needClientActions` (queues) |
| 58 | |
| 59 | It works, but every consumer reaches into different fields depending on |
| 60 | the query type. The gateway has to normalise this twice: |
| 61 | |
| 62 | - text → take `$reply->result`, wrap in a `MessagePart` text |
| 63 | - image → take `$reply->results`, wrap each as a `MessagePart` file |
| 64 | |
| 65 | A typed `Meow_MWAI_Data_Reply` with explicit `candidates`, `usage`, and |
| 66 | `finish_reason` would let consumers (gateway, REST endpoints, Insights) |
| 67 | read the same fields regardless of operation. |
| 68 | |
| 69 | ### c. Capability is implicit |
| 70 | |
| 71 | A model's tags array (`['core', 'chat', 'vision', 'functions', 'json']`) |
| 72 | declares broad capability, but engines do per-tag checks at dispatch |
| 73 | time. The gateway directory recently added the same tag-based |
| 74 | capability/option mapping the engines do — duplicated logic. |
| 75 | |
| 76 | If the model object itself carried typed capability metadata, both the |
| 77 | engine and the gateway could read it from one place. |
| 78 | |
| 79 | --- |
| 80 | |
| 81 | ## 2. The typed data layer (the main proposal) |
| 82 | |
| 83 | A new namespace under `/classes/data/`. Additive — nothing existing |
| 84 | breaks. Existing arrays continue to work; new code can opt in to the |
| 85 | typed surface. |
| 86 | |
| 87 | ### 2.1 Message DTOs |
| 88 | |
| 89 | ```php |
| 90 | // classes/data/message.php |
| 91 | class Meow_MWAI_Data_Message { |
| 92 | public string $role; // 'user' | 'assistant' | 'system' |
| 93 | public array $parts = []; // list<Meow_MWAI_Data_MessagePart> |
| 94 | |
| 95 | public static function user( array $parts ): self { ... } |
| 96 | public static function assistant( array $parts ): self { ... } |
| 97 | public static function system( string $text ): self { ... } |
| 98 | public function add_part( Meow_MWAI_Data_MessagePart $p ): self { ... } |
| 99 | public function to_array(): array { /* legacy [role, content] form */ } |
| 100 | } |
| 101 | |
| 102 | // classes/data/message-part.php |
| 103 | class Meow_MWAI_Data_MessagePart { |
| 104 | public string $type; // 'text' | 'file' | 'function_call' | 'function_result' |
| 105 | public ?string $text = null; |
| 106 | public ?Meow_MWAI_Query_DroppedFile $file = null; |
| 107 | public ?Meow_MWAI_Data_FunctionCall $function_call = null; |
| 108 | public ?Meow_MWAI_Data_FunctionResult $function_result = null; |
| 109 | |
| 110 | public static function text( string $t ): self { ... } |
| 111 | public static function file( Meow_MWAI_Query_DroppedFile $f ): self { ... } |
| 112 | public static function function_call( Meow_MWAI_Data_FunctionCall $c ): self { ... } |
| 113 | public static function function_result( Meow_MWAI_Data_FunctionResult $r ): self { ... } |
| 114 | } |
| 115 | ``` |
| 116 | |
| 117 | `Meow_MWAI_Data_FunctionCall` and `Meow_MWAI_Data_FunctionResult` already |
| 118 | exist (`classes/data/function-call.php`, `classes/data/function-result.php`). |
| 119 | They slot in cleanly. |
| 120 | |
| 121 | ### 2.2 Query side |
| 122 | |
| 123 | `Meow_MWAI_Query_Text` gains an additive setter: |
| 124 | |
| 125 | ```php |
| 126 | public function set_typed_messages( array $messages ): void { |
| 127 | // accepts list<Meow_MWAI_Data_Message> |
| 128 | // internally, also writes the legacy ->messages array via to_array() so |
| 129 | // every existing engine keeps working unchanged |
| 130 | $this->typed_messages = $messages; |
| 131 | $this->messages = array_map( fn($m) => $m->to_array(), $messages ); |
| 132 | } |
| 133 | ``` |
| 134 | |
| 135 | Engines that want richer data (per-message file attachments, etc.) read |
| 136 | `$query->typed_messages` when present. Engines that don't read `$query->messages` |
| 137 | exactly as today. |
| 138 | |
| 139 | ### 2.3 Reply side |
| 140 | |
| 141 | ```php |
| 142 | // classes/data/reply-candidate.php |
| 143 | class Meow_MWAI_Data_ReplyCandidate { |
| 144 | public Meow_MWAI_Data_Message $message; |
| 145 | public string $finish_reason; // 'stop' | 'length' | 'content_filter' | 'tool_calls' | 'error' |
| 146 | } |
| 147 | |
| 148 | // classes/data/token-usage.php |
| 149 | class Meow_MWAI_Data_TokenUsage { |
| 150 | public int $prompt_tokens; |
| 151 | public int $completion_tokens; |
| 152 | public int $total_tokens; |
| 153 | public ?int $thought_tokens; // for reasoning models |
| 154 | } |
| 155 | ``` |
| 156 | |
| 157 | `Meow_MWAI_Reply` gains optional typed accessors: |
| 158 | |
| 159 | ```php |
| 160 | public function get_candidates(): array { /* list<ReplyCandidate> */ } |
| 161 | public function get_token_usage(): Meow_MWAI_Data_TokenUsage { ... } |
| 162 | public function get_finish_reason(): string { ... } |
| 163 | ``` |
| 164 | |
| 165 | These derive from existing fields when set the old way. Engines can |
| 166 | populate them directly when constructing a fresh Reply. |
| 167 | |
| 168 | ### 2.4 What this buys us |
| 169 | |
| 170 | - **Gateway thinness.** `wpai-gateway-model.php::extract_parts` and |
| 171 | `build_result` collapse to one-liners. |
| 172 | - **Vision/audio/document support** without per-engine guesswork. |
| 173 | - **Function calling** flows through typed parts instead of side-channel |
| 174 | arrays (`needFeedbacks`). |
| 175 | - **Streaming events** can carry `MessagePart` deltas instead of raw |
| 176 | strings. Less ambiguity about what part of the response just changed. |
| 177 | - **Insights** can record per-modality stats cleanly. |
| 178 | |
| 179 | ### 2.5 Migration strategy |
| 180 | |
| 181 | This is the important part. AI Engine has thousands of installs. |
| 182 | Breaking the API is not on the table. |
| 183 | |
| 184 | 1. **Phase A — parallel layer.** Add the DTOs. No engine reads them yet. |
| 185 | Add accessors on Reply that derive from existing fields. Tests + docs. |
| 186 | 2. **Phase B — gateway adopts.** Refactor `wpai-gateway-model.php` to |
| 187 | build typed messages and read typed candidates. This is a low-risk |
| 188 | pilot since the gateway is in `/labs/` and opt-in. |
| 189 | 3. **Phase C — one engine adopts.** Pick the smallest engine |
| 190 | (`anthropic.php` is well-bounded) and have it consume typed messages |
| 191 | when present. Keep the legacy code path. Compare behavior. |
| 192 | 4. **Phase D — REST endpoints adopt.** New endpoints (or new params on |
| 193 | existing ones) accept typed payloads. Old payloads keep working. |
| 194 | 5. **Phase E — engines opt in one by one** as time allows. Years, not |
| 195 | weeks. The legacy path stays compatible forever. |
| 196 | |
| 197 | No deprecations until phase E completes everywhere. |
| 198 | |
| 199 | --- |
| 200 | |
| 201 | ## 3. Other patterns worth borrowing (smaller) |
| 202 | |
| 203 | These are independent and can be picked up à la carte. |
| 204 | |
| 205 | ### a. Capability matching before dispatch |
| 206 | |
| 207 | WP AI Client builds `ModelRequirements::fromPromptData(...)` and matches |
| 208 | it against `ModelMetadata::getSupportedCapabilities()` *before* sending |
| 209 | anything. We do this implicitly via tag checks scattered across engines. |
| 210 | |
| 211 | A central `Meow_MWAI_Services_CapabilityMatcher` could: |
| 212 | |
| 213 | - Take a query. |
| 214 | - Find what features it needs (vision input, JSON output, function tools). |
| 215 | - Cross-check against the resolved model's tag set. |
| 216 | - Throw a clean "model X doesn't support Y" exception up front. |
| 217 | |
| 218 | Saves users from cryptic provider errors. |
| 219 | |
| 220 | ### b. Fluent prompt builder (additive) |
| 221 | |
| 222 | A new `Meow_MWAI_Prompt::create('My text')->with_system('...')->with_max_tokens(500)->run()` |
| 223 | surface, layered on top of `simpleTextQuery`. Old API stays. New API |
| 224 | reads better. |
| 225 | |
| 226 | ### c. Enums for finish reasons, modalities, options |
| 227 | |
| 228 | Replace string literals like `'stop'` / `'length'` / `'tool_calls'` with |
| 229 | class constants. Helps autocomplete in IDEs and prevents typos. Low |
| 230 | priority. |
| 231 | |
| 232 | ### d. Provider availability separated from configuration |
| 233 | |
| 234 | WP AI Client's `ProviderAvailabilityInterface::isConfigured()` is a |
| 235 | runtime check, not a static one. AI Engine could expose |
| 236 | `Meow_MWAI_Services_EnvAvailability::is_ready( $env_id )` that checks |
| 237 | key + reachability, useful for fallback chains. |
| 238 | |
| 239 | --- |
| 240 | |
| 241 | ## 4. Things NOT to copy |
| 242 | |
| 243 | These are areas where AI Engine is already ahead — don't regress. |
| 244 | |
| 245 | - **Streaming.** WP AI Client has no streaming hook. AI Engine's |
| 246 | `$core->run_query( $q, $cb )` is years more mature. |
| 247 | - **Provider plugin per provider.** WP requires a separate plugin per |
| 248 | provider. AI Engine bundles them. Splitting would create more support |
| 249 | burden, not less. |
| 250 | - **Single key per provider.** WP allows one config per provider; AI |
| 251 | Engine has multi-environment, which real teams need (staging vs prod, |
| 252 | client A vs client B). |
| 253 | - **Tool calling via separate side channel.** WP AI Client treats tools |
| 254 | as a special option. AI Engine's `mwai_functions_list` / |
| 255 | `mwai_ai_feedback` filter pair is more WordPress-native and lets |
| 256 | plugins extend without subclassing. |
| 257 | - **No Insights, no rate limiting, no MCP.** These are AI Engine's |
| 258 | reason to exist. Don't dilute. |
| 259 | |
| 260 | --- |
| 261 | |
| 262 | ## 5. Reading list before any work starts |
| 263 | |
| 264 | Before opening this back up, re-read: |
| 265 | |
| 266 | - `/labs/wpai-gateway-model.php` — see `extract_parts()` for the kind of |
| 267 | conversion the typed layer would eliminate. |
| 268 | - `/labs/wpai-gateway-directory.php` — `make_metadata()` mirrors what |
| 269 | WP AI Client expects. |
| 270 | - `/Users/meow/sites/seven/app/public/wp-includes/php-ai-client/src/Messages/DTO/Message.php` |
| 271 | — reference shape for message DTOs. |
| 272 | - `/Users/meow/sites/seven/app/public/wp-includes/php-ai-client/src/Results/DTO/GenerativeAiResult.php` |
| 273 | — reference shape for typed results. |
| 274 | - `/Users/meow/sites/seven/app/public/wp-content/plugins/ai-provider-for-anthropic/src/Models/AnthropicTextGenerationModel.php` |
| 275 | — full reference of how a typed model is built end-to-end. |
| 276 | - AI Engine: `classes/data/function-call.php` and |
| 277 | `classes/data/function-result.php` — existing examples of the DTO |
| 278 | pattern AI Engine already started. |
| 279 | |
| 280 | --- |
| 281 | |
| 282 | ## 6. Open questions |
| 283 | |
| 284 | - Does `Meow_MWAI_Data_Message::to_array()` cleanly degrade when a turn |
| 285 | has only a file part? The gateway uses an `[attachment]` placeholder |
| 286 | today; the typed layer should be honest about what's there. |
| 287 | - How do we signal *which* candidate a tool call came from when an engine |
| 288 | returns multiple candidates with different tool calls? (Anthropic does; |
| 289 | Google often does for parallel calls.) |
| 290 | - Do we want to expose typed messages in the public PHP API |
| 291 | (`Meow_MWAI_API`) immediately, or keep it internal first? |
| 292 | - Is `finish_reason: 'tool_calls'` enough, or do we want richer state |
| 293 | for partially-completed tool loops? |
| 294 | |
| 295 | These don't block the proposal; they're worth thinking about before |
| 296 | phase D. |
| 297 | |
| 298 | --- |
| 299 | |
| 300 | ## 7. When to revisit |
| 301 | |
| 302 | Look at this doc when: |
| 303 | |
| 304 | - Touching `classes/query/text.php`, `classes/reply.php`, or any engine. |
| 305 | - Adding a new modality (vision, audio, video, document) at the API layer. |
| 306 | - Building a new gateway/integration similar to WP AI Client. |
| 307 | - Designing a new query type. |
| 308 | - Considering a major refactor of the function-calling pipeline. |
| 309 | |
| 310 | Otherwise, leave it alone. The current data model works for the cases it |
| 311 | already serves. |
| 312 |