| 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 |
|