PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / ai-copilot / client.php

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

361 lines 12.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 * Builds the error for a final turn that produced no answer text.
126 *
127 * Observed live with the Anthropic provider under agent runs: a hard task
128 * spends the entire `max_tokens` budget inside a thinking block
129 * (`stop_reason: "max_tokens"`, a single text-less thought part), so the
130 * turn carries neither function calls nor extractable text. Callers that
131 * can meaningfully degrade instead (the command follow-up turn) match on
132 * this code and keep their own fallback.
133 *
134 * @param string $detail Underlying extraction failure, preserved for logs.
135 * @return WP_Error
136 */
137 function openstation_ai_empty_answer_error( $detail ) {
138 return new WP_Error(
139 'openstation_ai_empty_answer',
140 __( 'The AI provider returned no answer text.', 'desktop-mode' ),
141 array(
142 'status' => 502,
143 'detail' => (string) $detail,
144 )
145 );
146 }
147
148 /**
149 * Applies the site's model config to a prompt builder.
150 *
151 * @param mixed $builder WP_AI_Client_Prompt_Builder.
152 * @param array $context Partial filter context; missing keys are defaulted.
153 * @return mixed
154 */
155 function openstation_ai_apply_model_config( $builder, array $context ) {
156 $context = array_merge(
157 array(
158 'user_id' => 0,
159 'request_id' => '',
160 'source' => '',
161 'has_tools' => false,
162 'has_schema' => false,
163 ),
164 $context
165 );
166
167 /**
168 * Filters the model config for one AI turn.
169 *
170 * Defaults to empty. Recipe: `docs/examples/ai-model-config.md`.
171 *
172 * @param array $config { model?: string|ModelInterface, max_tokens?: int, temperature?: float, custom_options?: array<string, mixed> }.
173 * @param array $context { user_id, request_id, source, has_tools, has_schema }.
174 */
175 $config = apply_filters( 'openstation_ai_model_config', array(), $context );
176 if ( ! is_array( $config ) ) {
177 return $builder;
178 }
179
180 $model_config = new ModelConfig();
181
182 if ( isset( $config['max_tokens'] ) && is_numeric( $config['max_tokens'] ) && (int) $config['max_tokens'] > 0 ) {
183 $model_config->setMaxTokens( (int) $config['max_tokens'] );
184 }
185
186 // Unlike max_tokens, 0.0 is a legitimate temperature (deterministic). The
187 // 2.0 ceiling is the range the SDK's own schema declares.
188 if ( isset( $config['temperature'] ) && is_numeric( $config['temperature'] )
189 && (float) $config['temperature'] >= 0.0 && (float) $config['temperature'] <= 2.0 ) {
190 $model_config->setTemperature( (float) $config['temperature'] );
191 }
192
193 $custom_options = array();
194 if ( isset( $config['custom_options'] ) && is_array( $config['custom_options'] ) ) {
195 foreach ( $config['custom_options'] as $key => $value ) {
196 // A list would reach the provider as parameters named `0`, `1`, ….
197 if ( is_string( $key ) && '' !== $key ) {
198 $custom_options[ $key ] = $value;
199 }
200 }
201 }
202
203 if ( ! empty( $custom_options ) ) {
204 $model_config->setCustomOptions( $custom_options );
205 }
206
207 $builder = $builder->using_model_config( $model_config );
208
209 // After the config: `using_model()` merges the model's own defaults under
210 // whatever the builder already carries, so ours has to land first.
211 $model = isset( $config['model'] ) ? $config['model'] : null;
212 if ( $model instanceof ModelInterface ) {
213 $builder = $builder->using_model( $model );
214 } elseif ( is_string( $model ) && '' !== trim( $model ) ) {
215 // `using_model()` needs a ModelInterface, so a bare model id goes
216 // through `using_model_preference()`, which throws on anything that
217 // isn't a non-empty string.
218 $builder = $builder->using_model_preference( trim( $model ) );
219 }
220
221 return $builder;
222 }
223
224 /**
225 * Runs one generation turn through the AI Client.
226 *
227 * Rebuilds the prompt from the full ordered message list each turn (the
228 * builder's `with_history()` prepends, so it can't append turns in a loop),
229 * advertises the tools as function declarations, and constrains the final
230 * answer to `$answer_schema` when given. Returns the assistant turn normalized
231 * to the shape the loop consumes; `message` has thought-channel parts stripped
232 * ({@see openstation_ai_strip_thought_parts()}) so it is safe to replay.
233 *
234 * @param int $user_id Requesting user id.
235 * @param array $messages Ordered conversation as SDK Message objects.
236 * @param array $tool_defs Tool definitions to advertise.
237 * @param array|null $answer_schema JSON Schema for the final answer, or null.
238 * @param string $instructions System instruction.
239 * @param array $context Optional. `{ source?: string, request_id?: string }`
240 * for the model-config filter.
241 * @return array{ text: ?string, function_calls: array, message: mixed, usage: ?array, model: ?array }|WP_Error
242 */
243 function openstation_ai_client_generate( $user_id, array $messages, array $tool_defs, $answer_schema, $instructions, array $context = array() ) {
244 $builder = wp_ai_client_prompt( $messages );
245
246 if ( is_string( $instructions ) && '' !== $instructions ) {
247 $builder = $builder->using_system_instruction( $instructions );
248 }
249
250 // Provider + model selection is delegated to the Core AI Client
251 // (Connector-backed) unless the model-config filter says otherwise.
252
253 $declarations = openstation_ai_build_function_declarations( $tool_defs );
254 if ( ! empty( $declarations ) ) {
255 $builder = $builder->using_function_declarations( ...$declarations );
256 }
257
258 if ( is_array( $answer_schema ) ) {
259 // Strict structured output: providers reject an object subschema that
260 // doesn't set `additionalProperties: false`, and one such node 400s the
261 // whole turn. Normalize here so no schema author has to know that.
262 $builder = $builder->as_json_response( openstation_ai_normalize_response_schema( $answer_schema ) );
263 }
264
265 $builder = openstation_ai_apply_model_config(
266 $builder,
267 array_merge(
268 $context,
269 array(
270 'user_id' => (int) $user_id,
271 'has_tools' => ! empty( $declarations ),
272 'has_schema' => is_array( $answer_schema ),
273 )
274 )
275 );
276
277 $result = $builder->generate_result();
278 if ( is_wp_error( $result ) ) {
279 return $result;
280 }
281
282 $message = $result->toMessage();
283 $function_calls = array();
284 foreach ( $message->getParts() as $part ) {
285 if ( ! $part->getType()->isFunctionCall() ) {
286 continue;
287 }
288 $call = $part->getFunctionCall();
289 if ( ! $call instanceof FunctionCall ) {
290 continue;
291 }
292 $args = $call->getArgs();
293 $function_calls[] = array(
294 'name' => (string) $call->getName(),
295 'call_id' => (string) $call->getId(),
296 'arguments' => wp_json_encode( is_array( $args ) ? $args : array() ),
297 );
298 }
299
300 $text = null;
301 if ( empty( $function_calls ) ) {
302 // A turn with no function calls IS the final answer, so failing to
303 // extract its text is a failed generation, not a valid empty one.
304 // Swallowing it here used to surface as a "successful" run with an
305 // empty answer, invisible to the retry and error paths alike.
306 try {
307 $text = $result->toText();
308 } catch ( \Throwable $e ) {
309 return openstation_ai_empty_answer_error( $e->getMessage() );
310 }
311 if ( ! is_string( $text ) || '' === trim( $text ) ) {
312 return openstation_ai_empty_answer_error( 'The provider response contains no text part.' );
313 }
314 }
315
316 return array(
317 'text' => $text,
318 'function_calls' => $function_calls,
319 'message' => openstation_ai_strip_thought_parts( $message ),
320 'usage' => openstation_ai_result_token_usage( $result ),
321 'model' => openstation_ai_result_model_metadata( $result ),
322 );
323 }
324
325 /**
326 * Extracts normalized token usage from a generation result.
327 *
328 * @param mixed $result GenerativeAiResult.
329 * @return array{ prompt: int, completion: int, total: int }|null
330 */
331 function openstation_ai_result_token_usage( $result ) {
332 try {
333 $usage = $result->getTokenUsage();
334 return array(
335 'prompt' => (int) $usage->getPromptTokens(),
336 'completion' => (int) $usage->getCompletionTokens(),
337 'total' => (int) $usage->getTotalTokens(),
338 );
339 } catch ( \Throwable $e ) {
340 return null;
341 }
342 }
343
344 /**
345 * Extracts the resolved model's id + name from a generation result.
346 *
347 * @param mixed $result GenerativeAiResult.
348 * @return array{ id: string, name: string }|null
349 */
350 function openstation_ai_result_model_metadata( $result ) {
351 try {
352 $model = $result->getModelMetadata();
353 return array(
354 'id' => (string) $model->getId(),
355 'name' => (string) $model->getName(),
356 );
357 } catch ( \Throwable $e ) {
358 return null;
359 }
360 }
361