Skip to content

Commit 563fd36

Browse files
authored
Merge pull request #202 from PredicateSystems/planner_executor_agent_gaps
complete planner-executor parity with tracing coverage
2 parents 72056bf + 499eef2 commit 563fd36

42 files changed

Lines changed: 7263 additions & 385 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.husky/pre-commit

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
#!/usr/bin/env sh
2-
. "$(dirname -- "$0")/_/husky.sh"
3-
41
npx lint-staged
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { z } from 'zod';
2+
import { TaskCategory, normalizeTaskCategory } from './task-category';
3+
4+
export interface AutomationTask {
5+
task: string;
6+
startUrl?: string;
7+
goal?: Record<string, unknown>;
8+
category?: TaskCategory | null;
9+
domainHints: string[];
10+
}
11+
12+
const TaskCategorySchema = z.preprocess(value => {
13+
if (typeof value === 'string') {
14+
return normalizeTaskCategory(value);
15+
}
16+
return value;
17+
}, z.nativeEnum(TaskCategory).nullable().optional());
18+
19+
export const AutomationTaskSchema = z.object({
20+
task: z.string().min(1),
21+
startUrl: z.string().url().optional(),
22+
goal: z.record(z.string(), z.unknown()).optional(),
23+
category: TaskCategorySchema,
24+
domainHints: z.array(z.string()).default([]),
25+
});

src/agents/planner-executor/boundary-detection.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
* successfully navigated as far as possible without credentials.
1010
*/
1111

12+
import type { SnapshotElement, PredicateSpec } from './plan-models';
13+
1214
/**
1315
* Configuration for authentication boundary detection.
1416
*/
@@ -226,3 +228,119 @@ export function isCheckoutElement(
226228

227229
return false;
228230
}
231+
232+
function normalizeIntentText(value: string | undefined | null): string {
233+
return (value || '').toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
234+
}
235+
236+
function extractUrlSignals(url: string): string[] {
237+
if (!url) {
238+
return [];
239+
}
240+
241+
try {
242+
const parsed = new URL(url);
243+
const signals = [
244+
parsed.pathname,
245+
parsed.search,
246+
parsed.searchParams.get('q') || '',
247+
parsed.searchParams.get('query') || '',
248+
parsed.searchParams.get('search') || '',
249+
parsed.searchParams.get('keyword') || '',
250+
parsed.searchParams.get('keywords') || '',
251+
parsed.searchParams.get('term') || '',
252+
parsed.searchParams.get('s') || '',
253+
];
254+
return signals.map(signal => normalizeIntentText(signal)).filter(Boolean);
255+
} catch {
256+
return [normalizeIntentText(url)];
257+
}
258+
}
259+
260+
function queryTerms(text: string | undefined | null): string[] {
261+
return normalizeIntentText(text)
262+
.split(/\s+/)
263+
.filter(term => term.length >= 3);
264+
}
265+
266+
function urlPredicateSignals(verify: PredicateSpec[] | undefined): string[] {
267+
if (!Array.isArray(verify)) {
268+
return [];
269+
}
270+
271+
const signals: string[] = [];
272+
for (const predicate of verify) {
273+
if (
274+
predicate &&
275+
typeof predicate.predicate === 'string' &&
276+
(predicate.predicate === 'url_contains' || predicate.predicate === 'url_matches')
277+
) {
278+
const firstArg = predicate.args?.[0];
279+
if (typeof firstArg === 'string' && firstArg.trim()) {
280+
signals.push(normalizeIntentText(firstArg));
281+
}
282+
}
283+
}
284+
285+
return signals;
286+
}
287+
288+
export function isSearchLikeTypeAndSubmit(
289+
step: { action?: string; intent?: string; input?: string; verify?: PredicateSpec[] },
290+
element?: Pick<SnapshotElement, 'role' | 'text' | 'name' | 'ariaLabel'> | null
291+
): boolean {
292+
if ((step.action || '').toUpperCase() !== 'TYPE_AND_SUBMIT') {
293+
return false;
294+
}
295+
296+
const cues = [
297+
normalizeIntentText(step.intent),
298+
normalizeIntentText(step.input),
299+
normalizeIntentText(element?.role),
300+
normalizeIntentText(element?.text),
301+
normalizeIntentText(element?.name),
302+
normalizeIntentText(element?.ariaLabel),
303+
...urlPredicateSignals(step.verify),
304+
].filter(Boolean);
305+
306+
return cues.some(cue =>
307+
/\b(search|searchbox|find|lookup|look up|query|keywords?|results?)\b/.test(cue)
308+
);
309+
}
310+
311+
export function isUrlChangeRelevantToIntent(
312+
previousUrl: string,
313+
nextUrl: string,
314+
step: { action?: string; intent?: string; input?: string; verify?: PredicateSpec[] },
315+
element?: Pick<SnapshotElement, 'role' | 'text' | 'name' | 'ariaLabel'> | null
316+
): boolean {
317+
const normalizedPrevious = normalizeIntentText(previousUrl).replace(/\/+$/, '');
318+
const normalizedNext = normalizeIntentText(nextUrl).replace(/\/+$/, '');
319+
if (!normalizedNext || normalizedNext === normalizedPrevious) {
320+
return false;
321+
}
322+
323+
const predicateSignals = urlPredicateSignals(step.verify);
324+
const nextSignals = extractUrlSignals(nextUrl);
325+
if (
326+
predicateSignals.length > 0 &&
327+
predicateSignals.every(signal => nextSignals.some(nextSignal => nextSignal.includes(signal)))
328+
) {
329+
return true;
330+
}
331+
332+
if (!isSearchLikeTypeAndSubmit(step, element)) {
333+
return true;
334+
}
335+
336+
const searchTerms = [
337+
...queryTerms(step.input),
338+
...queryTerms(step.intent),
339+
'search',
340+
'query',
341+
'results',
342+
'find',
343+
];
344+
345+
return searchTerms.some(term => nextSignals.some(signal => signal.includes(term)));
346+
}

0 commit comments

Comments
 (0)