PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.0
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.0, at includes/ai-copilot/client.php

241 lines 8.2 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 * Runs one generation turn through the AI Client.
124 *
125 * Rebuilds the prompt from the full ordered message list each turn (the
126 * builder's `with_history()` prepends, so it can't append turns in a loop),
127 * advertises the tools as function declarations, and constrains the final
128 * answer to `$answer_schema` when given. Returns the assistant turn normalized
129 * to the shape the loop consumes; `message` has thought-channel parts stripped
130 * ({@see openstation_ai_strip_thought_parts()}) so it is safe to replay.
131 *
132 * @param int $user_id Requesting user id. Currently unused — the
133 * provider comes from Connectors and no
134 * per-user preference is applied; retained for
135 * signature stability and future attribution.
136 * @param array $messages Ordered conversation as SDK Message objects.
137 * @param array $tool_defs Tool definitions to advertise.
138 * @param array|null $answer_schema JSON Schema for the final answer, or null.
139 * @param string $instructions System instruction.
140 * @return array{ text: ?string, function_calls: array, message: mixed, usage: ?array, model: ?array }|WP_Error
141 */
142 function openstation_ai_client_generate( $user_id, array $messages, array $tool_defs, $answer_schema, $instructions ) {
143 $builder = wp_ai_client_prompt( $messages );
144
145 if ( is_string( $instructions ) && '' !== $instructions ) {
146 $builder = $builder->using_system_instruction( $instructions );
147 }
148
149 // Provider + model selection is delegated entirely to the Core AI Client
150 // (Connector-backed); OpenStation pins neither.
151
152 $declarations = openstation_ai_build_function_declarations( $tool_defs );
153 if ( ! empty( $declarations ) ) {
154 $builder = $builder->using_function_declarations( ...$declarations );
155 }
156
157 if ( is_array( $answer_schema ) ) {
158 // Strict structured output: providers reject an object subschema that
159 // doesn't set `additionalProperties: false`, and one such node 400s the
160 // whole turn. Normalize here so no schema author has to know that.
161 $builder = $builder->as_json_response( openstation_ai_normalize_response_schema( $answer_schema ) );
162 }
163
164 $result = $builder->generate_result();
165 if ( is_wp_error( $result ) ) {
166 return $result;
167 }
168
169 $message = $result->toMessage();
170 $function_calls = array();
171 foreach ( $message->getParts() as $part ) {
172 if ( ! $part->getType()->isFunctionCall() ) {
173 continue;
174 }
175 $call = $part->getFunctionCall();
176 if ( ! $call instanceof FunctionCall ) {
177 continue;
178 }
179 $args = $call->getArgs();
180 $function_calls[] = array(
181 'name' => (string) $call->getName(),
182 'call_id' => (string) $call->getId(),
183 'arguments' => wp_json_encode( is_array( $args ) ? $args : array() ),
184 );
185 }
186
187 $text = null;
188 if ( empty( $function_calls ) ) {
189 try {
190 $text = $result->toText();
191 } catch ( \Throwable $e ) {
192 $text = null;
193 }
194 }
195
196 return array(
197 'text' => $text,
198 'function_calls' => $function_calls,
199 'message' => openstation_ai_strip_thought_parts( $message ),
200 'usage' => openstation_ai_result_token_usage( $result ),
201 'model' => openstation_ai_result_model_metadata( $result ),
202 );
203 }
204
205 /**
206 * Extracts normalized token usage from a generation result.
207 *
208 * @param mixed $result GenerativeAiResult.
209 * @return array{ prompt: int, completion: int, total: int }|null
210 */
211 function openstation_ai_result_token_usage( $result ) {
212 try {
213 $usage = $result->getTokenUsage();
214 return array(
215 'prompt' => (int) $usage->getPromptTokens(),
216 'completion' => (int) $usage->getCompletionTokens(),
217 'total' => (int) $usage->getTotalTokens(),
218 );
219 } catch ( \Throwable $e ) {
220 return null;
221 }
222 }
223
224 /**
225 * Extracts the resolved model's id + name from a generation result.
226 *
227 * @param mixed $result GenerativeAiResult.
228 * @return array{ id: string, name: string }|null
229 */
230 function openstation_ai_result_model_metadata( $result ) {
231 try {
232 $model = $result->getModelMetadata();
233 return array(
234 'id' => (string) $model->getId(),
235 'name' => (string) $model->getName(),
236 );
237 } catch ( \Throwable $e ) {
238 return null;
239 }
240 }
241