PluginProbe
Extendify / trunk
Extendify vtrunk
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
← All changes | src/Agent/api.js +165 -55 3.0.4 → trunk View file →
@@ -1,11 +1,64 @@
1 +import { abilityDescriptors, getAbilities } from '@agent/abilities/abilities';
2 +import { findBlockEl, scopeOf } from '@agent/lib/block-el';
3 +import { buildBlockSchema } from '@agent/lib/block-schema';
4 +import {
5 + classifyBlockEdit,
6 + IGNORED_BLOCKS,
7 +} from '@agent/lib/classify-block-edit';
8 +import { getClientTools } from '@agent/lib/client-tools';
9 +import { isExtendableHeader } from '@agent/lib/extendable-header';
10 +import { INSERTABLE_BLOCK_TYPES } from '@agent/lib/insertable-blocks';
11 +import { ensureCoreBlocksRegistered } from '@agent/lib/register-blocks';
12 +import {
13 + buildSubtreeManifest,
14 + buildSubtreeTree,
15 +} from '@agent/lib/subtree-manifest';
16 +import { activeCanvasStep } from '@agent/state/canvas';
1 17 import { useChatStore } from '@agent/state/chat';
2 18 import { useGlobalStore } from '@agent/state/global';
3 -import { useWorkflowStore } from '@agent/state/workflows';
4 19 import { tools } from '@agent/workflows/workflows';
5 20 import { AI_HOST } from '@constants';
21 +import { useQuickEditStore } from '@quick-edit/state/store';
22 +import { digest } from '@shared/api/digest';
6 23 import { reqDataBasics } from '@shared/lib/data';
24 +import { getBlockType } from '@wordpress/blocks';
7 25
26 +// page-templates:v1 declares that find-blocks and stage-block reach into parts.
27 +export const API_FEATURES = ['qa-tool', 'page-templates:v1'];
28 +
29 +const rootFor = (block) =>
30 + block?.id && block?.target
31 + ? findBlockEl(block.id, document, scopeOf(block))
32 + : null;
33 +
34 +// If greater than 5 blocks the agent will narrow the scope
35 +const EAGER_LOAD_MAX = 5;
36 +
37 +// The staged selection's attribute schemas, with the install's palette baked in.
38 +export const blockSchemasFor = async (block) => {
39 + const root = rootFor(block);
40 + const { bucket } = classifyBlockEdit({ block, root });
41 + const types =
42 + bucket === 'single'
43 + ? [block.blockType]
44 + : IGNORED_BLOCKS.has(block?.blockType)
45 + ? []
46 + : [...new Set(buildSubtreeManifest(root).map(({ type }) => type))];
47 + if (!types.length || types.length > EAGER_LOAD_MAX) return [];
48 + await ensureCoreBlocksRegistered();
49 + return types
50 + .map((type) => ({ type, schema: buildBlockSchema(getBlockType(type)) }))
51 + .filter(({ schema }) => schema);
52 +};
53 +
54 +// Only consumed for a multi-block edit; inert for single/combo.
55 +export const subtreeManifestFor = (block) =>
56 + buildSubtreeManifest(rootFor(block));
57 +
58 +// Selection hierarchy for the backend's positional edits.
59 +export const subtreeTreeFor = (block) => buildSubtreeTree(rootFor(block));
60 +
8 61 const extra = () => {
9 62 const { x, y, width, height } = useGlobalStore.getState();
10 63 return {
11 64 userAgent: window?.navigator?.userAgent,
@@ -29,11 +82,13 @@
29 82 const { failedWorkflows, context } = window.extAgentData;
30 83 const failed = failedWorkflows ?? new Set();
31 84 const filteredWorkflows = workflows.filter((wf) => !failed.has(wf.id));
32 85
33 - const { workflowHistory: pastWorkflows, block } = useWorkflowStore.getState();
86 + const block = useQuickEditStore.getState().agentBlock;
34 87
35 - const messages = useChatStore.getState().getMessagesForAI();
88 + const messages = useChatStore
89 + .getState()
90 + .getCurrentMessages({ includeTools: false });
36 91 const lastAssistantMessage = useChatStore
37 92 .getState()
38 93 .getLastAssistantMessage();
39 94
@@ -43,19 +98,22 @@
43 98 signal: options?.signal,
44 99 body: JSON.stringify({
45 100 ...reqDataBasics,
46 101 workflows: filteredWorkflows,
47 - previousAgentName: pastWorkflows.at(0)?.agentName,
48 102 previousWorkflow: {
103 + workflowId: lastAssistantMessage?.details?.workflowId,
104 + language: lastAssistantMessage?.details?.language,
49 105 lastMessage: lastAssistantMessage?.details?.content,
50 106 sessionId: lastAssistantMessage?.details?.sessionId,
51 - ...pastWorkflows?.at(0),
52 107 },
53 108 context,
54 109 agentContext: window.extAgentData.agentContext,
110 + wpAbilities: window.extAgentData.wpAbilities ?? [],
111 + clientTools: getClientTools(),
55 112 messages: messages.slice(-5),
56 113 hasBlock: Boolean(block), // todo: remove this
57 114 blockDetails: block,
115 + features: API_FEATURES,
58 116 ...options,
59 117 extra: extra(),
60 118 }),
61 119 });
@@ -61,13 +119,13 @@
61 119 });
62 120
63 121 if (!response.ok) {
64 122 digest({
65 - caller: 'pick-workflow',
66 123 error: {
67 124 name: response.statusText,
68 125 messages: response.statusMessage,
69 126 },
127 + details: { source: 'agent', caller: 'pick-workflow' },
70 128 });
71 129 const error = new Error('Bad response from server');
72 130 error.response = response;
73 131 throw error;
@@ -75,9 +133,25 @@
75 133 return await response.json();
76 134 };
77 135
78 136 export const handleWorkflow = async ({ workflow, workflowData, options }) => {
79 - const messages = useChatStore.getState().getMessagesForAI();
137 + const { getCurrentMessages, getMessagesFor } = useChatStore.getState();
138 + const block = useQuickEditStore.getState().agentBlock;
139 + const isBlockPatching =
140 + workflow?.id === 'block-patching' ||
141 + (workflow?.id === 'select-block' && Boolean(block));
142 + // Spent once the manifest flows: patching never reads this workflow's inputs.
143 + const { blockSearch: _spent, ...carried } = workflowData ?? {};
144 + // Schemas: pinned up front when the selection is small enough; otherwise
145 + // empty until get-block-schemas fetches them into the same field.
146 + const data = isBlockPatching
147 + ? {
148 + ...carried,
149 + blockSchemas: workflowData?.blockSchemas?.length
150 + ? workflowData.blockSchemas
151 + : await blockSchemasFor(block),
152 + }
153 + : workflowData;
80 154 const response = await fetch(`${AI_HOST}/api/agent/handle-workflow`, {
81 155 method: 'POST',
82 156 headers: { 'Content-Type': 'application/json' },
83 157 signal: options?.signal,
@@ -83,13 +157,27 @@
83 157 signal: options?.signal,
84 158 body: JSON.stringify({
85 159 ...reqDataBasics,
86 160 workflow,
87 - workflowData,
88 - messages: messages,
161 + workflowData: data,
162 + messages: getCurrentMessages(),
163 + previousMessages: getMessagesFor(workflow?.id),
89 164 context: window.extAgentData.context,
90 165 agentContext: window.extAgentData.agentContext,
166 + wpAbilities: window.extAgentData.wpAbilities ?? [],
167 + clientTools: getClientTools(),
168 + // The manifest is the backend's block-patching signal — never send it
169 + // for other workflows or they'd be routed into the patching handler.
170 + blockManifest: isBlockPatching ? subtreeManifestFor(block) : [],
171 + blockTree: isBlockPatching ? subtreeTreeFor(block) : [],
172 + // The plugin owns the add catalog so old fielded builds are never
173 + // offered a type they can't build.
174 + insertableBlockTypes: isBlockPatching ? INSERTABLE_BLOCK_TYPES : [],
175 + // The patcher writes dead CSS for these switches and claims success.
176 + isExtendableHeader:
177 + isBlockPatching && block?.template === 'header' && isExtendableHeader(),
91 178 retry: options?.retry || false,
179 + features: API_FEATURES,
92 180 extra: extra(),
93 181 }),
94 182 });
95 183
@@ -96,8 +184,37 @@
96 184 if (!response.ok) throw new Error('Bad response from server');
97 185 return await response.json();
98 186 };
99 187
188 +export const handleCanvas = async ({
189 + toolId,
190 + sessionId,
191 + abilities,
192 + options,
193 +}) => {
194 + const { getCurrentMessages, getMessagesFor } = useChatStore.getState();
195 + const { values } = activeCanvasStep();
196 + const response = await fetch(`${AI_HOST}/api/agent/handle-canvas`, {
197 + method: 'POST',
198 + headers: { 'Content-Type': 'application/json' },
199 + signal: options?.signal,
200 + body: JSON.stringify({
201 + ...reqDataBasics,
202 + toolId,
203 + values,
204 + abilities: abilityDescriptors(getAbilities(abilities)),
205 + messages: getCurrentMessages(),
206 + previousMessages: getMessagesFor(toolId),
207 + context: window.extAgentData.context,
208 + sessionId,
209 + extra: extra(),
210 + }),
211 + });
212 +
213 + if (!response.ok) throw new Error('Bad response from server');
214 + return await response.json();
215 +};
216 +
100 217 export const rateAnswer = ({ answerId, rating }) =>
101 218 fetch(`${AI_HOST}/api/agent/rate-workflow`, {
102 219 method: 'POST',
103 220 headers: { 'Content-Type': 'application/json' },
@@ -103,60 +220,53 @@
103 220 headers: { 'Content-Type': 'application/json' },
104 221 body: JSON.stringify({ answerId, rating }),
105 222 }).catch((error) =>
106 223 digest({
107 - caller: 'rateAnswer',
108 - error,
109 - extra: { answerId, rating },
224 + error: error,
225 + details: { source: 'agent', caller: 'rateAnswer', answerId, rating },
110 226 }),
111 227 );
112 228
113 -export const callTool = async ({ tool, inputs }) => {
114 - if (!tools[tool]) throw new Error(`Tool ${tool} not found`);
115 - return await tools[tool](inputs);
229 +// An error with no message serializes to {}, which the model reads as an empty success.
230 +const reasonFor = async (error) => {
231 + // An unparsed apiFetch rejects with the Response, whose body holds the reason.
232 + if (typeof error?.json === 'function') {
233 + const body = await error.json().catch(() => null);
234 + return body?.message ?? `The site answered ${error.status}.`;
235 + }
236 +
237 + return (
238 + error?.error ?? error?.message ?? 'The ability failed without saying why.'
239 + );
116 240 };
117 241
118 -export const digest = ({ error, sessionId, caller, additional = {} }) => {
119 - if (Boolean(reqDataBasics?.devbuild) === true) return;
242 +// A throw would reach the model outside the tool's slot, reading as no result.
243 +const runAbility = async (ability, inputs) => {
244 + try {
245 + return await ability.execute(inputs);
246 + } catch (error) {
247 + return { error: await reasonFor(error) };
248 + }
249 +};
120 250
121 - const errorMessage = () => {
122 - if (error.response?.statusText) {
123 - return (
124 - error.response?.statusText || error.response.message || 'Unknown error'
125 - );
126 - }
127 - return typeof error === 'string'
128 - ? error
129 - : error?.message || 'Unknown error';
130 - };
131 -
132 - const errorData = {
133 - message: errorMessage(),
134 - name: error?.name,
135 - };
136 -
137 - return fetch(`${AI_HOST}/api/agent/digest`, {
138 - method: 'POST',
139 - keepalive: true,
140 - headers: { 'Content-Type': 'application/json' },
141 - body: JSON.stringify({
142 - ...reqDataBasics,
143 - phpVersion: window.extSharedData?.phpVersion,
144 - sessionId,
145 - error: errorData,
146 - browser: {
147 - userAgent: window.navigator?.userAgent,
148 - vendor: window.navigator?.vendor,
149 - platform: window.navigator?.platform,
150 - width: window.innerWidth,
151 - height: window.innerHeight,
152 - touchSupport: 'ontouchstart' in window || navigator.maxTouchPoints > 0,
153 - },
154 - caller,
155 - ...additional,
156 - extra: extra(),
157 - }),
158 - }).catch(() => {});
251 +export const callTool = async ({ tool, inputs, abilities = [] }) => {
252 + if (tools[tool]) return await tools[tool](inputs);
253 + // Ours wins a name collision, so a duplicate WP ability can't change what runs.
254 + const ours =
255 + getAbilities(abilities).find(({ name }) => name === tool) ??
256 + getAbilities([tool])[0];
257 + if (ours) return { [tool]: await runAbility(ours, inputs) };
258 + // Ability tools are named after the ability and have no file; the generic
259 + // runner executes them. Key the result to its slot so the loop sees it filled.
260 + const isAbility = (window.extAgentData?.wpAbilities ?? []).some((category) =>
261 + category.abilities?.some((ability) => ability.name === tool),
262 + );
263 + if (isAbility) {
264 + return {
265 + [tool]: await tools['execute-ability']({ ability: tool, input: inputs }),
266 + };
267 + }
268 + throw new Error(`Tool ${tool} not found`);
159 269 };
160 270
161 271 export const recordAgentActivity = ({ action, sessionId, value = {} }) => {
162 272 return fetch(`${AI_HOST}/api/agent/activities`, {