PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 All 36 releases
desktop-mode / includes / ai-copilot / client.php

client.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.12, at includes/ai-copilot/client.php

458 lines 16.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — AI Copilot: WordPress AI Client adapter.
4 *
5 * Thin wrappers around `wp_ai_client_prompt()` that the agentic search loop
6 * and the comment-scoring job use to generate. Credentials are injected by
7 * Core from the configured Connector — nothing here ever handles an API key.
8 *
9 * The search loop advertises its tools — built-in WordPress Abilities (see
10 * abilities.php) plus client command tools — as function declarations, and
11 * dispatches ability calls through `wp_get_ability()->execute()`.
12 *
13 * All SDK classes referenced here ship with WordPress 7.0+. The `use`
14 * statements are compile-time aliases only; every call site is reached solely
15 * through {@see openstation_ai_is_available()}, so this file is inert (and
16 * never resolves the classes) on older WordPress.
17 *
18 * @package OpenStation
19 */
20
21 use WordPress\AiClient\Messages\DTO\Message;
22 use WordPress\AiClient\Messages\DTO\MessagePart;
23 use WordPress\AiClient\Messages\DTO\UserMessage;
24 use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
25 use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
26 use WordPress\AiClient\Tools\DTO\FunctionCall;
27 use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
28 use WordPress\AiClient\Tools\DTO\FunctionResponse;
29
30 defined( 'ABSPATH' ) || exit;
31
32 /**
33 * Builds SDK function declarations from the loop's tool definitions.
34 *
35 * Each definition is the neutral tool shape the registry already produces:
36 * `{ type: 'function', name, description, parameters (JSON Schema) }`.
37 *
38 * @param array $tool_defs List of tool definitions.
39 * @return FunctionDeclaration[]
40 */
41 function openstation_ai_build_function_declarations( array $tool_defs ) {
42 $declarations = array();
43 foreach ( $tool_defs as $def ) {
44 if ( ! is_array( $def ) ) {
45 continue;
46 }
47 $name = isset( $def['name'] ) ? (string) $def['name'] : '';
48 if ( '' === $name ) {
49 continue;
50 }
51 $description = isset( $def['description'] ) ? (string) $def['description'] : '';
52 $parameters = isset( $def['parameters'] ) && is_array( $def['parameters'] ) ? $def['parameters'] : null;
53
54 $declarations[] = new FunctionDeclaration( $name, $description, $parameters );
55 }
56 return $declarations;
57 }
58
59 /**
60 * Wraps a user query as a text message for the conversation history.
61 *
62 * @param string $text
63 * @return UserMessage
64 */
65 function openstation_ai_user_text_message( $text ) {
66 return new UserMessage( array( new MessagePart( (string) $text ) ) );
67 }
68
69 /**
70 * Wraps tool results as a user message of function-response parts.
71 *
72 * @param array $tool_outputs List of `{ call_id, name, response }` entries.
73 * @return UserMessage
74 */
75 function openstation_ai_tool_result_message( array $tool_outputs ) {
76 $parts = array();
77 foreach ( $tool_outputs as $output ) {
78 $parts[] = new MessagePart(
79 new FunctionResponse(
80 isset( $output['call_id'] ) && '' !== $output['call_id'] ? (string) $output['call_id'] : null,
81 isset( $output['name'] ) && '' !== $output['name'] ? (string) $output['name'] : null,
82 isset( $output['response'] ) ? $output['response'] : null
83 )
84 );
85 }
86 return new UserMessage( $parts );
87 }
88
89 /**
90 * Strips thought-channel parts from a message before it re-enters history.
91 *
92 * Providers cannot reliably round-trip reasoning blocks: the Anthropic
93 * provider drops the cryptographic `signature` when parsing a `thinking`
94 * block, and the API rejects any replayed thinking block without one
95 * (`thinking.signature: Field required`). Thought parts carry no information
96 * the next turn needs — the model re-reasons from the visible conversation —
97 * so the agentic loop replays assistant turns without them.
98 *
99 * If every part is a thought (no text, no function call), the message is
100 * returned unchanged rather than emptied; the loop never replays such a
101 * turn anyway.
102 *
103 * @param Message $message Assistant message as returned by the AI Client.
104 * @return Message Message safe to append to the conversation history.
105 */
106 function openstation_ai_strip_thought_parts( Message $message ) {
107 $kept = array();
108 $stripped = false;
109 foreach ( $message->getParts() as $part ) {
110 if ( $part->getChannel()->isThought() ) {
111 $stripped = true;
112 continue;
113 }
114 $kept[] = $part;
115 }
116
117 if ( ! $stripped || empty( $kept ) ) {
118 return $message;
119 }
120
121 return new Message( $message->getRole(), $kept );
122 }
123
124 /**
125 * Output-token ceiling for every generation turn the site's
126 * `openstation_ai_model_config` filter leaves uncapped.
127 *
128 * The three default providers disagree about what "no ceiling" means.
129 * The OpenAI and Google providers send none, so the model's own maximum
130 * applies; the Anthropic provider must send one and falls back to a
131 * hard-coded 4096. That is enough for a chat answer and nowhere near
132 * enough for a tool call carrying a whole post: the model runs out of
133 * room inside the call's JSON, the API returns only the argument pairs
134 * that were complete before the cut, and the ability rejects the call
135 * for its missing `content`. The Localizer failed seven translations of
136 * one post in a row exactly that way, each attempt cut at the same place.
137 *
138 * 16384 is the largest value every current-generation model of the three
139 * providers accepts (OpenAI's gpt-4o family caps output at exactly that).
140 * A site that needs more, or pins an older model with a smaller limit,
141 * sets `max_tokens` in the filter: the filter's value always wins.
142 */
143 const OPENSTATION_AI_DEFAULT_MAX_TOKENS = 16384;
144
145 /**
146 * Whether the provider stopped because the reply hit the output-token
147 * ceiling.
148 *
149 * Anthropic's `max_tokens` and Google's `MAX_TOKENS` stop reasons both
150 * map to the SDK's LENGTH finish reason. The OpenAI provider throws
151 * instead and never builds a result; {@see openstation_ai_client_generate()}
152 * maps that path from the WP_Error Core turns the exception into.
153 *
154 * @param mixed $result GenerativeAiResult.
155 * @return bool
156 */
157 function openstation_ai_result_is_truncated( $result ) {
158 try {
159 foreach ( $result->getCandidates() as $candidate ) {
160 if ( $candidate->getFinishReason()->isLength() ) {
161 return true;
162 }
163 }
164 } catch ( \Throwable $e ) {
165 return false;
166 }
167 return false;
168 }
169
170 /**
171 * Builds the error for a turn the output-token ceiling cut short.
172 *
173 * A truncated reply is never usable. A JSON answer no longer parses,
174 * and a function call arrives with only the argument pairs that were
175 * complete before the cut, so the ability rejects it for a missing
176 * required field, and the model, reading its own truncated call back
177 * from history, sends the same call again to the same end. Failing the
178 * turn here turns a run of silent retries into one error that names
179 * the cause.
180 *
181 * @param string $detail Underlying provider detail, preserved for logs.
182 * @param array|null $usage Token usage of the truncated turn, if known.
183 * @return WP_Error
184 */
185 function openstation_ai_output_truncated_error( $detail, $usage = null ) {
186 return new WP_Error(
187 'openstation_ai_output_truncated',
188 __( 'The AI provider cut the reply short at the output-token limit.', 'desktop-mode' ),
189 array(
190 'status' => 502,
191 'detail' => (string) $detail,
192 'completion_tokens' => is_array( $usage ) && isset( $usage['completion'] ) ? (int) $usage['completion'] : null,
193 )
194 );
195 }
196
197 /**
198 * Builds the error for a final turn that produced no answer text.
199 *
200 * Observed live with the Anthropic provider under agent runs: a hard task
201 * spends the entire `max_tokens` budget inside a thinking block
202 * (`stop_reason: "max_tokens"`, a single text-less thought part), so the
203 * turn carries neither function calls nor extractable text. Callers that
204 * can meaningfully degrade instead (the command follow-up turn) match on
205 * this code and keep their own fallback.
206 *
207 * @param string $detail Underlying extraction failure, preserved for logs.
208 * @return WP_Error
209 */
210 function openstation_ai_empty_answer_error( $detail ) {
211 return new WP_Error(
212 'openstation_ai_empty_answer',
213 __( 'The AI provider returned no answer text.', 'desktop-mode' ),
214 array(
215 'status' => 502,
216 'detail' => (string) $detail,
217 )
218 );
219 }
220
221 /**
222 * Applies the site's model config to a prompt builder.
223 *
224 * @param mixed $builder WP_AI_Client_Prompt_Builder.
225 * @param array $context Partial filter context; missing keys are defaulted.
226 * @return mixed
227 */
228 function openstation_ai_apply_model_config( $builder, array $context ) {
229 $context = array_merge(
230 array(
231 'user_id' => 0,
232 'request_id' => '',
233 'source' => '',
234 'has_tools' => false,
235 'has_schema' => false,
236 ),
237 $context
238 );
239
240 /**
241 * Filters the model config for one AI turn.
242 *
243 * Defaults to empty; the only value OpenStation fills in afterwards is
244 * `max_tokens` ({@see OPENSTATION_AI_DEFAULT_MAX_TOKENS}), and only
245 * when the filter left it unset. Recipe: `docs/examples/ai-model-config.md`.
246 *
247 * @param array $config { model?: string|ModelInterface, max_tokens?: int, temperature?: float, custom_options?: array<string, mixed> }.
248 * @param array $context { user_id, request_id, source, has_tools, has_schema }.
249 */
250 $config = apply_filters( 'openstation_ai_model_config', array(), $context );
251 if ( ! is_array( $config ) ) {
252 $config = array();
253 }
254
255 $model_config = new ModelConfig();
256
257 $max_tokens = OPENSTATION_AI_DEFAULT_MAX_TOKENS;
258 if ( isset( $config['max_tokens'] ) && is_numeric( $config['max_tokens'] ) && (int) $config['max_tokens'] > 0 ) {
259 $max_tokens = (int) $config['max_tokens'];
260 }
261 $model_config->setMaxTokens( $max_tokens );
262
263 // Unlike max_tokens, 0.0 is a legitimate temperature (deterministic). The
264 // 2.0 ceiling is the range the SDK's own schema declares.
265 if ( isset( $config['temperature'] ) && is_numeric( $config['temperature'] )
266 && (float) $config['temperature'] >= 0.0 && (float) $config['temperature'] <= 2.0 ) {
267 $model_config->setTemperature( (float) $config['temperature'] );
268 }
269
270 $custom_options = array();
271 if ( isset( $config['custom_options'] ) && is_array( $config['custom_options'] ) ) {
272 foreach ( $config['custom_options'] as $key => $value ) {
273 // A list would reach the provider as parameters named `0`, `1`, ….
274 if ( is_string( $key ) && '' !== $key ) {
275 $custom_options[ $key ] = $value;
276 }
277 }
278 }
279
280 if ( ! empty( $custom_options ) ) {
281 $model_config->setCustomOptions( $custom_options );
282 }
283
284 $builder = $builder->using_model_config( $model_config );
285
286 // After the config: `using_model()` merges the model's own defaults under
287 // whatever the builder already carries, so ours has to land first.
288 $model = isset( $config['model'] ) ? $config['model'] : null;
289 if ( $model instanceof ModelInterface ) {
290 $builder = $builder->using_model( $model );
291 } elseif ( is_string( $model ) && '' !== trim( $model ) ) {
292 // `using_model()` needs a ModelInterface, so a bare model id goes
293 // through `using_model_preference()`, which throws on anything that
294 // isn't a non-empty string.
295 $builder = $builder->using_model_preference( trim( $model ) );
296 }
297
298 return $builder;
299 }
300
301 /**
302 * Runs one generation turn through the AI Client.
303 *
304 * Rebuilds the prompt from the full ordered message list each turn (the
305 * builder's `with_history()` prepends, so it can't append turns in a loop),
306 * advertises the tools as function declarations, and constrains the final
307 * answer to `$answer_schema` when given. Returns the assistant turn normalized
308 * to the shape the loop consumes; `message` has thought-channel parts stripped
309 * ({@see openstation_ai_strip_thought_parts()}) so it is safe to replay.
310 *
311 * @param int $user_id Requesting user id.
312 * @param array $messages Ordered conversation as SDK Message objects.
313 * @param array $tool_defs Tool definitions to advertise.
314 * @param array|null $answer_schema JSON Schema for the final answer, or null.
315 * @param string $instructions System instruction.
316 * @param array $context Optional. `{ source?: string, request_id?: string }`
317 * for the model-config filter.
318 * @return array{ text: ?string, function_calls: array, message: mixed, usage: ?array, model: ?array }|WP_Error
319 */
320 function openstation_ai_client_generate( $user_id, array $messages, array $tool_defs, $answer_schema, $instructions, array $context = array() ) {
321 $builder = wp_ai_client_prompt( $messages );
322
323 if ( is_string( $instructions ) && '' !== $instructions ) {
324 $builder = $builder->using_system_instruction( $instructions );
325 }
326
327 // Provider + model selection is delegated to the Core AI Client
328 // (Connector-backed) unless the model-config filter says otherwise.
329
330 $declarations = openstation_ai_build_function_declarations( $tool_defs );
331 if ( ! empty( $declarations ) ) {
332 $builder = $builder->using_function_declarations( ...$declarations );
333 }
334
335 if ( is_array( $answer_schema ) ) {
336 // Strict structured output: providers reject an object subschema that
337 // doesn't set `additionalProperties: false`, and one such node 400s the
338 // whole turn. Normalize here so no schema author has to know that.
339 $builder = $builder->as_json_response( openstation_ai_normalize_response_schema( $answer_schema ) );
340 }
341
342 $builder = openstation_ai_apply_model_config(
343 $builder,
344 array_merge(
345 $context,
346 array(
347 'user_id' => (int) $user_id,
348 'has_tools' => ! empty( $declarations ),
349 'has_schema' => is_array( $answer_schema ),
350 )
351 )
352 );
353
354 $result = $builder->generate_result();
355 if ( is_wp_error( $result ) ) {
356 // The OpenAI provider reports the output ceiling as an exception
357 // rather than a finish reason; Core maps it to this code.
358 if ( 'prompt_token_limit_reached' === $result->get_error_code() ) {
359 return openstation_ai_output_truncated_error( $result->get_error_message() );
360 }
361 return $result;
362 }
363
364 $message = $result->toMessage();
365 $function_calls = array();
366 $has_text = false;
367 foreach ( $message->getParts() as $part ) {
368 if ( $part->getType()->isText() && ! $part->getChannel()->isThought() ) {
369 $has_text = true;
370 }
371 if ( ! $part->getType()->isFunctionCall() ) {
372 continue;
373 }
374 $call = $part->getFunctionCall();
375 if ( ! $call instanceof FunctionCall ) {
376 continue;
377 }
378 $args = $call->getArgs();
379 $function_calls[] = array(
380 'name' => (string) $call->getName(),
381 'call_id' => (string) $call->getId(),
382 'arguments' => wp_json_encode( is_array( $args ) ? $args : array() ),
383 );
384 }
385
386 // Whatever the ceiling cut off is partial, and partial is unusable:
387 // a function call missing its longest argument, or a JSON answer
388 // missing its closing half. A budget spent entirely on reasoning
389 // leaves nothing written at all; that case keeps its own error below.
390 if ( ( ! empty( $function_calls ) || $has_text ) && openstation_ai_result_is_truncated( $result ) ) {
391 return openstation_ai_output_truncated_error(
392 'The provider stopped at the output-token ceiling (finish reason: length).',
393 openstation_ai_result_token_usage( $result )
394 );
395 }
396
397 $text = null;
398 if ( empty( $function_calls ) ) {
399 // A turn with no function calls IS the final answer, so failing to
400 // extract its text is a failed generation, not a valid empty one.
401 // Swallowing it here used to surface as a "successful" run with an
402 // empty answer, invisible to the retry and error paths alike.
403 try {
404 $text = $result->toText();
405 } catch ( \Throwable $e ) {
406 return openstation_ai_empty_answer_error( $e->getMessage() );
407 }
408 if ( ! is_string( $text ) || '' === trim( $text ) ) {
409 return openstation_ai_empty_answer_error( 'The provider response contains no text part.' );
410 }
411 }
412
413 return array(
414 'text' => $text,
415 'function_calls' => $function_calls,
416 'message' => openstation_ai_strip_thought_parts( $message ),
417 'usage' => openstation_ai_result_token_usage( $result ),
418 'model' => openstation_ai_result_model_metadata( $result ),
419 );
420 }
421
422 /**
423 * Extracts normalized token usage from a generation result.
424 *
425 * @param mixed $result GenerativeAiResult.
426 * @return array{ prompt: int, completion: int, total: int }|null
427 */
428 function openstation_ai_result_token_usage( $result ) {
429 try {
430 $usage = $result->getTokenUsage();
431 return array(
432 'prompt' => (int) $usage->getPromptTokens(),
433 'completion' => (int) $usage->getCompletionTokens(),
434 'total' => (int) $usage->getTotalTokens(),
435 );
436 } catch ( \Throwable $e ) {
437 return null;
438 }
439 }
440
441 /**
442 * Extracts the resolved model's id + name from a generation result.
443 *
444 * @param mixed $result GenerativeAiResult.
445 * @return array{ id: string, name: string }|null
446 */
447 function openstation_ai_result_model_metadata( $result ) {
448 try {
449 $model = $result->getModelMetadata();
450 return array(
451 'id' => (string) $model->getId(),
452 'name' => (string) $model->getName(),
453 );
454 } catch ( \Throwable $e ) {
455 return null;
456 }
457 }
458