PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.1
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.1
3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 2.8.17 2.8.18 All 259 releases
wpfunnels / includes / core / AI / AgentLoop.php

AgentLoop.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.1, at includes/core/AI/AgentLoop.php

356 lines 11.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AgentLoop — one frontend-driven iteration of the WPFunnels AI agent.
4 *
5 * Each step() call performs exactly ONE model request plus its tool batch,
6 * then returns; the client immediately requests the next step while status is 'running'.
7 * This bounds every HTTP request to a single model round-trip — avoiding PHP timeouts.
8 *
9 * States returned by step():
10 * running — tools executed; client should call step() again
11 * pending_confirmation — a destructive tool awaits user approval (confirm())
12 * done — final assistant text produced
13 * rate_limited — provider throttled this request; client should wait
14 * `retry_after` seconds and call step() again (same
15 * turn — nothing was persisted for this attempt)
16 * error — provider or loop failure (message included)
17 *
18 * @package WPFunnels\AI
19 * @since 3.13.0
20 */
21
22 namespace WPFunnels\AI;
23
24 defined( 'ABSPATH' ) || exit;
25
26 use WPFunnels\AI\Settings\AISettings;
27
28 /**
29 * Class AgentLoop
30 */
31 class AgentLoop {
32
33 /**
34 * Maximum autonomous model iterations per turn.
35 */
36 public const MAX_STEPS_PER_TURN = 25;
37
38 /**
39 * Run one loop iteration for a conversation.
40 *
41 * @param int $conversation_id Conversation ID.
42 * @param int $user_id Current user ID. Defaults to get_current_user_id().
43 * @return array
44 */
45 public static function step( $conversation_id, $user_id = 0 ) {
46 $user_id = $user_id ? (int) $user_id : get_current_user_id();
47 $conversation = ConversationStore::getOwnedConversation( $conversation_id, $user_id );
48
49 if ( ! $conversation ) {
50 return self::errorState( 'Conversation not found or access denied.' );
51 }
52
53 if ( 'awaiting_confirmation' === $conversation['status'] ) {
54 return [
55 'status' => 'pending_confirmation',
56 'pending' => self::pendingForClient( $conversation['pending'] ),
57 ];
58 }
59
60 $messages = ConversationStore::getMessages( $conversation_id );
61 if ( empty( $messages ) ) {
62 return self::errorState( 'Conversation has no messages yet.' );
63 }
64
65 $steps_this_turn = ConversationStore::assistantStepsThisTurn( $messages );
66
67 // Turn budget check.
68 if ( $steps_this_turn >= self::MAX_STEPS_PER_TURN ) {
69 $note = 'I hit the step limit for this request. Here is where things stand — tell me to continue if you want me to keep going.';
70 ConversationStore::appendMessage( $conversation_id, 'assistant', [ 'text' => $note, 'tool_calls' => [] ] );
71 ConversationStore::updateConversation( $conversation_id, [ 'status' => 'idle' ] );
72 return [
73 'status' => 'done',
74 'message' => $note,
75 ];
76 }
77
78 $provider = AIInit::activeProvider();
79 if ( is_wp_error( $provider ) ) {
80 return self::errorState( $provider->get_error_message(), $provider->get_error_code() );
81 }
82
83 $system = SystemPrompt::build( (string) $conversation['context_type'], (int) $conversation['context_id'] );
84 $tools = ToolGateway::toolDefinitions();
85
86 // First model call of the turn must call a tool — otherwise the model can
87 // answer with pure narration ("I'll do X now...", no tool_calls), which
88 // this loop would then treat as a finished turn (see below) and nothing
89 // ever gets built. Once real progress has been made this turn, later
90 // steps go back to 'auto' so the model can produce its final text reply.
91 $force_tool_use = 0 === $steps_this_turn && ! empty( $tools );
92
93 $response = $provider->chat( $system, $messages, $tools, $force_tool_use );
94 if ( is_wp_error( $response ) ) {
95 if ( 'ai_rate_limited' === $response->get_error_code() ) {
96 // Transient — nothing was persisted for this attempt (no message
97 // appended, turn budget untouched), so the client can just wait
98 // out the provider's own cooldown and call step() again for the
99 // SAME turn, instead of surfacing a dead-end error banner over
100 // what is normally a few-second throttle.
101 $data = $response->get_error_data();
102 $retry_after = ! empty( $data['retry_after'] ) ? (float) $data['retry_after'] : 5.0;
103 return [
104 'status' => 'rate_limited',
105 'message' => $response->get_error_message(),
106 'retry_after' => $retry_after,
107 ];
108 }
109 return self::errorState( $response->get_error_message(), $response->get_error_code() );
110 }
111
112 // Persist the assistant response message.
113 ConversationStore::appendMessage(
114 $conversation_id,
115 'assistant',
116 [
117 'text' => $response['text'],
118 'tool_calls' => $response['tool_calls'],
119 ],
120 [
121 'provider' => $provider->slug(),
122 'raw' => isset( $response['raw'] ) ? $response['raw'] : null,
123 'usage' => isset( $response['usage'] ) ? $response['usage'] : [],
124 ]
125 );
126
127 if ( empty( $response['tool_calls'] ) ) {
128 ConversationStore::updateConversation( $conversation_id, [ 'status' => 'idle' ] );
129 return [
130 'status' => 'done',
131 'message' => $response['text'],
132 ];
133 }
134
135 $executed = [];
136 $pending = [];
137 $progress = null;
138
139 foreach ( $response['tool_calls'] as $call ) {
140 $call_name = isset( $call['name'] ) ? (string) $call['name'] : '';
141 $call_args = isset( $call['arguments'] ) && is_array( $call['arguments'] ) ? $call['arguments'] : [];
142 $call_id = isset( $call['id'] ) ? (string) $call['id'] : uniqid( 'call_' );
143
144 if ( ToolGateway::requiresConfirmation( $call_name ) ) {
145 $pending[] = [
146 'id' => $call_id,
147 'name' => $call_name,
148 'arguments' => $call_args,
149 'summary' => ToolGateway::describeCall( $call_name, $call_args ),
150 'details' => ToolGateway::describeCallDetails( $call_name, $call_args ),
151 ];
152 continue;
153 }
154
155 $result = ToolGateway::executeTool( $call_name, $call_args );
156
157 ConversationStore::appendMessage(
158 $conversation_id,
159 'tool',
160 [
161 'tool_call_id' => $call_id,
162 'name' => $call_name,
163 'content' => $result['content'],
164 'is_error' => $result['is_error'],
165 'preview' => ToolGateway::previewFor( $call_name, $result ),
166 ]
167 );
168
169 $executed[] = [
170 'tool' => $call_name,
171 'is_error' => $result['is_error'],
172 ];
173
174 $tool_progress = ToolGateway::progressFor( $call_name );
175 if ( $tool_progress ) {
176 $progress = $tool_progress;
177 }
178
179 self::bindFunnelContext( $conversation_id, $call_name, $result );
180 }
181
182 if ( ! empty( $pending ) ) {
183 ConversationStore::updateConversation(
184 $conversation_id,
185 [
186 'status' => 'awaiting_confirmation',
187 'pending' => $pending,
188 ]
189 );
190 return [
191 'status' => 'pending_confirmation',
192 'assistant_text' => $response['text'],
193 'executed' => $executed,
194 'pending' => self::pendingForClient( $pending ),
195 'progress' => $progress,
196 ];
197 }
198
199 ConversationStore::updateConversation( $conversation_id, [ 'status' => 'idle' ] );
200
201 return [
202 'status' => 'running',
203 'assistant_text' => $response['text'],
204 'executed' => $executed,
205 'progress' => $progress,
206 ];
207 }
208
209 /**
210 * Resolve a pending confirmation: execute or deny queued tool calls.
211 *
212 * @param int $conversation_id Conversation ID.
213 * @param int $user_id User ID.
214 * @param bool $approve Whether approved.
215 * @param string $deny_reason Optional reason for denial.
216 * @return array
217 */
218 public static function confirm( $conversation_id, $user_id = 0, $approve = true, $deny_reason = '' ) {
219 $user_id = $user_id ? (int) $user_id : get_current_user_id();
220 $conversation = ConversationStore::getOwnedConversation( $conversation_id, $user_id );
221
222 if ( ! $conversation ) {
223 return self::errorState( 'Conversation not found or access denied.' );
224 }
225
226 if ( 'awaiting_confirmation' !== $conversation['status'] || empty( $conversation['pending'] ) ) {
227 return self::errorState( 'Nothing is awaiting confirmation.' );
228 }
229
230 $executed = [];
231 foreach ( (array) $conversation['pending'] as $call ) {
232 $call_name = isset( $call['name'] ) ? (string) $call['name'] : '';
233 $call_args = isset( $call['arguments'] ) && is_array( $call['arguments'] ) ? $call['arguments'] : [];
234 $call_id = isset( $call['id'] ) ? (string) $call['id'] : uniqid( 'call_' );
235
236 if ( $approve ) {
237 $result = ToolGateway::executeTool(
238 $call_name,
239 array_merge( $call_args, [ 'confirm' => true ] )
240 );
241 self::bindFunnelContext( $conversation_id, $call_name, $result );
242 } else {
243 $result = [
244 'content' => wp_json_encode(
245 [
246 'error' => 'denied_by_user',
247 'message' => 'The user declined this action.' . ( $deny_reason ? ' Reason: ' . $deny_reason : '' ),
248 ]
249 ),
250 'is_error' => true,
251 ];
252 }
253
254 ConversationStore::appendMessage(
255 $conversation_id,
256 'tool',
257 [
258 'tool_call_id' => $call_id,
259 'name' => $call_name,
260 'content' => $result['content'],
261 'is_error' => $result['is_error'],
262 'preview' => $approve ? ToolGateway::previewFor( $call_name, $result ) : null,
263 ]
264 );
265
266 $executed[] = [
267 'tool' => $call_name,
268 'is_error' => $result['is_error'],
269 'approved' => (bool) $approve,
270 ];
271 }
272
273 ConversationStore::updateConversation(
274 $conversation_id,
275 [
276 'status' => 'idle',
277 'pending' => null,
278 ]
279 );
280
281 return [
282 'status' => 'running',
283 'executed' => $executed,
284 ];
285 }
286
287 /**
288 * When create-funnel or step operations succeed, bind conversation to funnel.
289 *
290 * @param int $conversation_id Conversation ID.
291 * @param string $tool_name Tool name.
292 * @param array $result Execution result.
293 * @return void
294 */
295 private static function bindFunnelContext( $conversation_id, $tool_name, $result ) {
296 if ( ! empty( $result['is_error'] ) ) {
297 return;
298 }
299
300 if ( ! in_array( $tool_name, [ 'wpfunnels/create-funnel', 'wpfunnels/create-step', 'wpfunnels/reorder-steps', 'wpfunnels/import-funnel-template' ], true ) ) {
301 return;
302 }
303
304 $decoded = json_decode( (string) ( isset( $result['content'] ) ? $result['content'] : '' ), true );
305 if ( ! empty( $decoded['funnel_id'] ) ) {
306 ConversationStore::updateConversation(
307 $conversation_id,
308 [
309 'context_type' => 'funnel',
310 'context_id' => (int) $decoded['funnel_id'],
311 ]
312 );
313 }
314 }
315
316 /**
317 * Normalize pending calls for client responses.
318 *
319 * @param mixed $pending Pending array.
320 * @return array
321 */
322 public static function pendingForClient( $pending ) {
323 return array_map(
324 static function ( $call ) {
325 $name = isset( $call['name'] ) ? (string) $call['name'] : '';
326 $args = isset( $call['arguments'] ) && is_array( $call['arguments'] ) ? $call['arguments'] : [];
327
328 return [
329 'tool' => $name,
330 'summary' => isset( $call['summary'] ) ? (string) $call['summary'] : ToolGateway::describeCall( $name, $args ),
331 'arguments' => $args,
332 'details' => isset( $call['details'] ) && is_array( $call['details'] )
333 ? $call['details']
334 : ToolGateway::describeCallDetails( $name, $args ),
335 ];
336 },
337 is_array( $pending ) ? $pending : []
338 );
339 }
340
341 /**
342 * Build error state payload.
343 *
344 * @param string $message Error message.
345 * @param string $code Error code.
346 * @return array
347 */
348 private static function errorState( $message, $code = 'ai_error' ) {
349 return [
350 'status' => 'error',
351 'code' => $code,
352 'message' => $message,
353 ];
354 }
355 }
356