PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
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 / agents / draft.php

draft.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.9, at includes/agents/draft.php

271 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Agents — drafting an agent from a brief.
4 *
5 * "Draft it for me" in the create flow used to ride the Copilot's
6 * search route. That route forces its own answer schema (answer type,
7 * message, entity, admin links) and its own search-shaped system
8 * prompt, so a request for a bare JSON draft was fighting the loop it
9 * ran in: the draft came back wrapped inside `message` when it came
10 * back at all. Drafting is one generate call with no tools and a
11 * strict answer schema of its own, which is what this file is.
12 *
13 * The site's catalogues are the authority twice: they are written
14 * into the schema as enums so the model can only pick from them, and
15 * the answer is filtered against them again on the way out, because a
16 * pre-filter or a provider that ignores enums must not be able to
17 * hand the wizard a role or an ability the site does not have.
18 *
19 * @package OpenStation
20 */
21
22 defined( 'ABSPATH' ) || exit;
23
24 /** Longest brief the route accepts, in characters. */
25 const OPENSTATION_AGENT_DRAFT_BRIEF_MAX = 2000;
26
27 /** Caps on what a draft may fill in, matching the store's own. */
28 const OPENSTATION_AGENT_DRAFT_NAME_MAX = 80;
29 const OPENSTATION_AGENT_DRAFT_VIBES_MAX = 120;
30
31 /**
32 * Draft an agent definition from a plain-language brief.
33 *
34 * @param string $brief What the agent should do, in the user's words.
35 * @param int $user_id Requesting user, for the AI client's context.
36 * @return array|WP_Error `{ name, description, vibes, instructions, role, abilities }`
37 * with every value already filtered against the
38 * site's catalogues; `role` is '' when the model's
39 * pick was not one the site allows.
40 */
41 function openstation_agent_draft( $brief, $user_id ) {
42 $brief = trim( (string) $brief );
43 if ( '' === $brief ) {
44 return new WP_Error(
45 'openstation_agent_draft_empty',
46 __( 'Describe the agent first. A sentence is enough.', 'desktop-mode' ),
47 array( 'status' => 400 )
48 );
49 }
50
51 $roles = array_values( openstation_agent_allowed_roles() );
52 $catalogue = openstation_agents_abilities_catalogue();
53
54 /**
55 * Pre-filter the draft. Return a non-null array shaped like the
56 * route's response (or a WP_Error) to short-circuit the AI Client;
57 * the seam PHPUnit and alternative runtimes plug into. Whatever
58 * comes back is still filtered against the catalogues.
59 *
60 * @param array|WP_Error|null $draft Null to proceed with the AI Client.
61 * @param string $brief The brief.
62 * @param string[] $roles Role slugs the site allows for agents.
63 * @param array $catalogue The abilities catalogue rows.
64 * @param int $user_id Requesting user id.
65 */
66 $draft = apply_filters( 'openstation_agent_draft', null, $brief, $roles, $catalogue, $user_id );
67
68 if ( null === $draft ) {
69 if ( ! function_exists( 'openstation_ai_client_generate' ) || ! openstation_ai_is_available() ) {
70 return new WP_Error(
71 'openstation_agent_ai_unavailable',
72 __( 'The WordPress AI Client is not available on this site.', 'desktop-mode' ),
73 array( 'status' => 503 )
74 );
75 }
76 $draft = openstation_agent_draft_generate( $brief, $roles, $catalogue, (int) $user_id );
77 }
78
79 if ( is_wp_error( $draft ) ) {
80 return $draft;
81 }
82
83 return openstation_agent_draft_sanitize( is_array( $draft ) ? $draft : array(), $roles, $catalogue );
84 }
85
86 /**
87 * One generate call: the brief as the user message, the drafting
88 * instructions as the system instruction, the catalogues as enums.
89 *
90 * @param string $brief The brief.
91 * @param array $roles Allowed role slugs.
92 * @param array $catalogue Abilities catalogue rows.
93 * @param int $user_id Requesting user id.
94 * @return array|WP_Error Decoded draft, or an error carrying a REST status.
95 */
96 function openstation_agent_draft_generate( $brief, array $roles, array $catalogue, $user_id ) {
97 $messages = array( openstation_ai_user_text_message( $brief ) );
98 $schema = openstation_agent_draft_answer_schema( $roles, wp_list_pluck( $catalogue, 'slug' ) );
99 $instructions = openstation_agent_draft_instructions( $roles, $catalogue );
100
101 $generated = openstation_agent_with_http_timeout(
102 static function () use ( $user_id, $messages, $schema, $instructions ) {
103 return openstation_ai_client_generate(
104 $user_id,
105 $messages,
106 array(),
107 $schema,
108 $instructions,
109 array( 'source' => 'agents/draft' )
110 );
111 }
112 );
113
114 if ( is_wp_error( $generated ) ) {
115 $error = openstation_agent_humanize_generate_error( $generated );
116 $data = $error->get_error_data();
117 if ( ! is_array( $data ) || ! isset( $data['status'] ) ) {
118 // A provider failure is the upstream's, not the caller's.
119 $error->add_data( array_merge( is_array( $data ) ? $data : array(), array( 'status' => 502 ) ) );
120 }
121 return $error;
122 }
123
124 $text = isset( $generated['text'] ) && is_string( $generated['text'] ) ? $generated['text'] : '';
125 $parsed = json_decode( $text, true );
126 if ( ! is_array( $parsed ) ) {
127 return new WP_Error(
128 'openstation_agent_draft_parse',
129 __( 'The draft came back in a shape that could not be read. Try again, or fill the fields in yourself.', 'desktop-mode' ),
130 array(
131 'status' => 502,
132 'detail' => mb_substr( $text, 0, 200 ),
133 )
134 );
135 }
136 return $parsed;
137 }
138
139 /**
140 * The strict answer schema for a draft.
141 *
142 * Enums are only declared when there is something to enumerate: an
143 * empty `enum` is a schema no provider accepts.
144 *
145 * @param string[] $roles Allowed role slugs.
146 * @param string[] $ability_slugs Ability slugs the site registers.
147 * @return array JSON Schema.
148 */
149 function openstation_agent_draft_answer_schema( array $roles, array $ability_slugs ) {
150 $role = array(
151 'type' => 'string',
152 'description' => 'The least-privileged role that still lets the agent do its job.',
153 );
154 if ( ! empty( $roles ) ) {
155 $role['enum'] = array_values( array_map( 'strval', $roles ) );
156 }
157 $ability = array( 'type' => 'string' );
158 if ( ! empty( $ability_slugs ) ) {
159 $ability['enum'] = array_values( array_map( 'strval', $ability_slugs ) );
160 }
161 return array(
162 'type' => 'object',
163 'additionalProperties' => false,
164 'required' => array( 'name', 'description', 'vibes', 'instructions', 'role', 'abilities' ),
165 'properties' => array(
166 'name' => array(
167 'type' => 'string',
168 'description' => 'A short working name for the agent, four words at most.',
169 ),
170 'description' => array(
171 'type' => 'string',
172 'description' => 'One sentence saying when to reach for this agent.',
173 ),
174 'vibes' => array(
175 'type' => 'string',
176 'description' => 'The agent\'s voice in a few words, lowercase, no full stop. Examples: "blunt, precise, no sugarcoating" or "warm, reads the room".',
177 ),
178 'instructions' => array(
179 'type' => 'string',
180 'description' => 'The agent system prompt. Concrete, scoped to the brief, written to the agent in the second person.',
181 ),
182 'role' => $role,
183 'abilities' => array(
184 'type' => 'array',
185 'description' => 'Only the abilities the brief genuinely needs. Empty when it needs none.',
186 'items' => $ability,
187 ),
188 ),
189 );
190 }
191
192 /**
193 * The system instruction for a draft.
194 *
195 * Not translated: it is a model instruction, and the catalogue it
196 * quotes is what the schema's enums already constrain the answer to.
197 *
198 * @param string[] $roles Allowed role slugs.
199 * @param array $catalogue Abilities catalogue rows.
200 * @return string
201 */
202 function openstation_agent_draft_instructions( array $roles, array $catalogue ) {
203 $lines = array(
204 'The user is a WordPress administrator defining a new site agent: a durable AI worker that lives on the site as a user, acts through registered abilities under its own role, and answers in a chat window.',
205 'Treat the user\'s message as the agent brief and fill in the agent definition.',
206 'name: a short working name for the agent, four words at most.',
207 'description: one sentence saying when to reach for this agent.',
208 'vibes: the agent\'s voice in a few words, lowercase, no full stop.',
209 'instructions: the agent system prompt. Concrete, scoped to the brief, written to the agent. Say what it should watch, what it should write, where it may act, and what it must never do.',
210 'role: the least-privileged fit among: ' . implode( ', ', array_map( 'strval', $roles ) ) . '.',
211 'abilities: only the slugs the brief genuinely needs, from this catalogue:',
212 );
213 if ( empty( $catalogue ) ) {
214 $lines[] = '(no abilities are registered on this site; return an empty list)';
215 }
216 foreach ( $catalogue as $row ) {
217 $slug = isset( $row['slug'] ) ? (string) $row['slug'] : '';
218 $label = isset( $row['label'] ) ? (string) $row['label'] : '';
219 if ( '' === $slug ) {
220 continue;
221 }
222 $lines[] = sprintf(
223 '- %s: %s%s',
224 $slug,
225 $label,
226 empty( $row['readonly'] ) ? ' (can modify the site)' : ''
227 );
228 }
229 return implode( "\n", $lines );
230 }
231
232 /**
233 * Filter a draft against the site's catalogues and the store's caps.
234 *
235 * @param array $draft Whatever came back, pre-filter or provider.
236 * @param array $roles Allowed role slugs.
237 * @param array $catalogue Abilities catalogue rows.
238 * @return array The route's response shape.
239 */
240 function openstation_agent_draft_sanitize( array $draft, array $roles, array $catalogue ) {
241 $str = static function ( $value, $max ) {
242 return is_string( $value ) ? mb_substr( trim( sanitize_text_field( $value ) ), 0, $max ) : '';
243 };
244
245 $role = isset( $draft['role'] ) && is_string( $draft['role'] ) ? sanitize_key( $draft['role'] ) : '';
246 if ( ! in_array( $role, array_map( 'strval', $roles ), true ) ) {
247 $role = '';
248 }
249
250 $known = array_map( 'strval', wp_list_pluck( $catalogue, 'slug' ) );
251 $abilities = array();
252 if ( isset( $draft['abilities'] ) && is_array( $draft['abilities'] ) ) {
253 foreach ( $draft['abilities'] as $slug ) {
254 if ( is_string( $slug ) && in_array( $slug, $known, true ) && ! in_array( $slug, $abilities, true ) ) {
255 $abilities[] = $slug;
256 }
257 }
258 }
259
260 return array(
261 'name' => $str( $draft['name'] ?? '', OPENSTATION_AGENT_DRAFT_NAME_MAX ),
262 'description' => $str( $draft['description'] ?? '', 500 ),
263 'vibes' => $str( $draft['vibes'] ?? '', OPENSTATION_AGENT_DRAFT_VIBES_MAX ),
264 'instructions' => isset( $draft['instructions'] ) && is_string( $draft['instructions'] )
265 ? trim( sanitize_textarea_field( $draft['instructions'] ) )
266 : '',
267 'role' => $role,
268 'abilities' => $abilities,
269 );
270 }
271