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

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

272 lines 9.5 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\Tools\DTO\FunctionCall;
25 use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
26 use WordPress\AiClient\Tools\DTO\FunctionResponse;
27
28 defined( 'ABSPATH' ) || exit;
29
30 /**
31 * Builds SDK function declarations from the loop's tool definitions.
32 *
33 * Each definition is the neutral tool shape the registry already produces:
34 * `{ type: 'function', name, description, parameters (JSON Schema) }`.
35 *
36 * @param array $tool_defs List of tool definitions.
37 * @return FunctionDeclaration[]
38 */
39 function openstation_ai_build_function_declarations( array $tool_defs ) {
40 $declarations = array();
41 foreach ( $tool_defs as $def ) {
42 if ( ! is_array( $def ) ) {
43 continue;
44 }
45 $name = isset( $def['name'] ) ? (string) $def['name'] : '';
46 if ( '' === $name ) {
47 continue;
48 }
49 $description = isset( $def['description'] ) ? (string) $def['description'] : '';
50 $parameters = isset( $def['parameters'] ) && is_array( $def['parameters'] ) ? $def['parameters'] : null;
51
52 $declarations[] = new FunctionDeclaration( $name, $description, $parameters );
53 }
54 return $declarations;
55 }
56
57 /**
58 * Wraps a user query as a text message for the conversation history.
59 *
60 * @param string $text
61 * @return UserMessage
62 */
63 function openstation_ai_user_text_message( $text ) {
64 return new UserMessage( array( new MessagePart( (string) $text ) ) );
65 }
66
67 /**
68 * Wraps tool results as a user message of function-response parts.
69 *
70 * @param array $tool_outputs List of `{ call_id, name, response }` entries.
71 * @return UserMessage
72 */
73 function openstation_ai_tool_result_message( array $tool_outputs ) {
74 $parts = array();
75 foreach ( $tool_outputs as $output ) {
76 $parts[] = new MessagePart(
77 new FunctionResponse(
78 isset( $output['call_id'] ) && '' !== $output['call_id'] ? (string) $output['call_id'] : null,
79 isset( $output['name'] ) && '' !== $output['name'] ? (string) $output['name'] : null,
80 isset( $output['response'] ) ? $output['response'] : null
81 )
82 );
83 }
84 return new UserMessage( $parts );
85 }
86
87 /**
88 * Strips thought-channel parts from a message before it re-enters history.
89 *
90 * Providers cannot reliably round-trip reasoning blocks: the Anthropic
91 * provider drops the cryptographic `signature` when parsing a `thinking`
92 * block, and the API rejects any replayed thinking block without one
93 * (`thinking.signature: Field required`). Thought parts carry no information
94 * the next turn needs — the model re-reasons from the visible conversation —
95 * so the agentic loop replays assistant turns without them.
96 *
97 * If every part is a thought (no text, no function call), the message is
98 * returned unchanged rather than emptied; the loop never replays such a
99 * turn anyway.
100 *
101 * @param Message $message Assistant message as returned by the AI Client.
102 * @return Message Message safe to append to the conversation history.
103 */
104 function openstation_ai_strip_thought_parts( Message $message ) {
105 $kept = array();
106 $stripped = false;
107 foreach ( $message->getParts() as $part ) {
108 if ( $part->getChannel()->isThought() ) {
109 $stripped = true;
110 continue;
111 }
112 $kept[] = $part;
113 }
114
115 if ( ! $stripped || empty( $kept ) ) {
116 return $message;
117 }
118
119 return new Message( $message->getRole(), $kept );
120 }
121
122 /**
123 * Builds the error for a final turn that produced no answer text.
124 *
125 * Observed live with the Anthropic provider under agent runs: a hard task
126 * spends the entire `max_tokens` budget inside a thinking block
127 * (`stop_reason: "max_tokens"`, a single text-less thought part), so the
128 * turn carries neither function calls nor extractable text. Callers that
129 * can meaningfully degrade instead (the command follow-up turn) match on
130 * this code and keep their own fallback.
131 *
132 * @param string $detail Underlying extraction failure, preserved for logs.
133 * @return WP_Error
134 */
135 function openstation_ai_empty_answer_error( $detail ) {
136 return new WP_Error(
137 'openstation_ai_empty_answer',
138 __( 'The AI provider returned no answer text.', 'desktop-mode' ),
139 array(
140 'status' => 502,
141 'detail' => (string) $detail,
142 )
143 );
144 }
145
146 /**
147 * Runs one generation turn through the AI Client.
148 *
149 * Rebuilds the prompt from the full ordered message list each turn (the
150 * builder's `with_history()` prepends, so it can't append turns in a loop),
151 * advertises the tools as function declarations, and constrains the final
152 * answer to `$answer_schema` when given. Returns the assistant turn normalized
153 * to the shape the loop consumes; `message` has thought-channel parts stripped
154 * ({@see openstation_ai_strip_thought_parts()}) so it is safe to replay.
155 *
156 * @param int $user_id Requesting user id. Currently unused — the
157 * provider comes from Connectors and no
158 * per-user preference is applied; retained for
159 * signature stability and future attribution.
160 * @param array $messages Ordered conversation as SDK Message objects.
161 * @param array $tool_defs Tool definitions to advertise.
162 * @param array|null $answer_schema JSON Schema for the final answer, or null.
163 * @param string $instructions System instruction.
164 * @return array{ text: ?string, function_calls: array, message: mixed, usage: ?array, model: ?array }|WP_Error
165 */
166 function openstation_ai_client_generate( $user_id, array $messages, array $tool_defs, $answer_schema, $instructions ) {
167 $builder = wp_ai_client_prompt( $messages );
168
169 if ( is_string( $instructions ) && '' !== $instructions ) {
170 $builder = $builder->using_system_instruction( $instructions );
171 }
172
173 // Provider + model selection is delegated entirely to the Core AI Client
174 // (Connector-backed); OpenStation pins neither.
175
176 $declarations = openstation_ai_build_function_declarations( $tool_defs );
177 if ( ! empty( $declarations ) ) {
178 $builder = $builder->using_function_declarations( ...$declarations );
179 }
180
181 if ( is_array( $answer_schema ) ) {
182 // Strict structured output: providers reject an object subschema that
183 // doesn't set `additionalProperties: false`, and one such node 400s the
184 // whole turn. Normalize here so no schema author has to know that.
185 $builder = $builder->as_json_response( openstation_ai_normalize_response_schema( $answer_schema ) );
186 }
187
188 $result = $builder->generate_result();
189 if ( is_wp_error( $result ) ) {
190 return $result;
191 }
192
193 $message = $result->toMessage();
194 $function_calls = array();
195 foreach ( $message->getParts() as $part ) {
196 if ( ! $part->getType()->isFunctionCall() ) {
197 continue;
198 }
199 $call = $part->getFunctionCall();
200 if ( ! $call instanceof FunctionCall ) {
201 continue;
202 }
203 $args = $call->getArgs();
204 $function_calls[] = array(
205 'name' => (string) $call->getName(),
206 'call_id' => (string) $call->getId(),
207 'arguments' => wp_json_encode( is_array( $args ) ? $args : array() ),
208 );
209 }
210
211 $text = null;
212 if ( empty( $function_calls ) ) {
213 // A turn with no function calls IS the final answer, so failing to
214 // extract its text is a failed generation, not a valid empty one.
215 // Swallowing it here used to surface as a "successful" run with an
216 // empty answer, invisible to the retry and error paths alike.
217 try {
218 $text = $result->toText();
219 } catch ( \Throwable $e ) {
220 return openstation_ai_empty_answer_error( $e->getMessage() );
221 }
222 if ( ! is_string( $text ) || '' === trim( $text ) ) {
223 return openstation_ai_empty_answer_error( 'The provider response contains no text part.' );
224 }
225 }
226
227 return array(
228 'text' => $text,
229 'function_calls' => $function_calls,
230 'message' => openstation_ai_strip_thought_parts( $message ),
231 'usage' => openstation_ai_result_token_usage( $result ),
232 'model' => openstation_ai_result_model_metadata( $result ),
233 );
234 }
235
236 /**
237 * Extracts normalized token usage from a generation result.
238 *
239 * @param mixed $result GenerativeAiResult.
240 * @return array{ prompt: int, completion: int, total: int }|null
241 */
242 function openstation_ai_result_token_usage( $result ) {
243 try {
244 $usage = $result->getTokenUsage();
245 return array(
246 'prompt' => (int) $usage->getPromptTokens(),
247 'completion' => (int) $usage->getCompletionTokens(),
248 'total' => (int) $usage->getTotalTokens(),
249 );
250 } catch ( \Throwable $e ) {
251 return null;
252 }
253 }
254
255 /**
256 * Extracts the resolved model's id + name from a generation result.
257 *
258 * @param mixed $result GenerativeAiResult.
259 * @return array{ id: string, name: string }|null
260 */
261 function openstation_ai_result_model_metadata( $result ) {
262 try {
263 $model = $result->getModelMetadata();
264 return array(
265 'id' => (string) $model->getId(),
266 'name' => (string) $model->getName(),
267 );
268 } catch ( \Throwable $e ) {
269 return null;
270 }
271 }
272