Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@predicatesystems/runtime",
"version": "1.3.4",
"version": "1.4.0",
"description": "TypeScript SDK for Sentience AI Agent Browser Automation",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
42 changes: 42 additions & 0 deletions src/agents/browser-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,48 @@ class TokenAccountingProvider extends LLMProvider {
}
}

// Re-export planner-executor profile/learning types for extension consumption
export type {
DataDrivenPruningPolicy,
BrowserAgentProfile,
ResolvedAgentProfile,
LearnedTargetFingerprint,
DomainProfile,
} from './planner-executor/profile-types';
export { EMPTY_RESOLVED_PROFILE } from './planner-executor/profile-types';
export {
DataDrivenPruningPolicySchema,
BrowserAgentProfileSchema,
BrowserAgentProfileArraySchema,
LearnedTargetFingerprintSchema,
DomainProfileSchema,
} from './planner-executor/profile-schema';
export { ProfileRegistry } from './planner-executor/profile-registry';
export { pruneWithPolicy } from './planner-executor/data-driven-pruner';
export {
computeTaskHash,
extractDomain,
createFingerprint,
mergeFingerprint,
recordFingerprintFailure,
} from './planner-executor/fingerprint-normalizer';
export type { LearningStore } from './planner-executor/learning-store';
export { InMemoryLearningStore } from './planner-executor/learning-store';
export {
extractFingerprintFromOutcome,
applyFingerprintFailure,
applyFingerprintSuccess,
isFingerprintStale,
isFingerprintExpired,
fingerprintToHint,
computeTaskHash as computeAsyncTaskHash,
isSensitiveUrl as isLearningSensitiveUrl,
} from './planner-executor/learning-extractor';
export type {
LearningExtractionOptions,
LearningExtractionResult,
} from './planner-executor/learning-extractor';

export type StepOutcome = { stepGoal: string; ok: boolean };

export class PredicateBrowserAgent {
Expand Down
34 changes: 34 additions & 0 deletions src/agents/planner-executor/category-pruner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
type PrunedSnapshotContext,
} from './pruning-types';
import { TaskCategory } from './task-category';
import { pruneWithPolicy } from './data-driven-pruner';

function textOf(element: SnapshotElement): string {
return String(element.text || element.name || '').toLowerCase();
Expand Down Expand Up @@ -270,6 +271,39 @@
options: PruneSnapshotOptions
): PrunedSnapshotContext {
const relaxationLevel = Math.max(0, options.relaxationLevel || 0);

// Data-driven path: use profile policy if provided
if (options.profilePolicy) {
const { elements, maxNodes } = pruneWithPolicy(

Check warning on line 277 in src/agents/planner-executor/category-pruner.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 20)

'maxNodes' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 277 in src/agents/planner-executor/category-pruner.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 20)

'maxNodes' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 277 in src/agents/planner-executor/category-pruner.ts

View workflow job for this annotation

GitHub Actions / test (macos-latest, 20)

'maxNodes' is assigned a value but never used. Allowed unused vars must match /^_/u
snapshot,
options.profilePolicy,
options.goal,
relaxationLevel,
options.category,
options.learnedFingerprints
);
const actionableElementCount = selectContextElements(elements, elements.length || 1).length;

return {
category: options.category,
snapshot,
elements,
promptBlock: formatPrunedContext({
category: options.category,
elements,
relaxationLevel,
rawElementCount: snapshot.elements.length,
prunedElementCount: elements.length,
actionableElementCount,
}),
relaxationLevel,
rawElementCount: snapshot.elements.length,
prunedElementCount: elements.length,
actionableElementCount,
};
}

// Built-in category path
const policy = getPolicy(options.category, relaxationLevel);
const filtered = (snapshot.elements || []).filter(element => {
if (policy.block(element)) {
Expand Down
32 changes: 31 additions & 1 deletion src/agents/planner-executor/common-hints.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HeuristicHint } from './heuristic-hint';
import type { HeuristicHintInput } from './heuristic-hint';

export const COMMON_HINTS = {
add_to_cart: new HeuristicHint({
Expand Down Expand Up @@ -56,8 +57,20 @@ export const COMMON_HINTS = {
}),
} as const;

export function getCommonHint(intent: string): HeuristicHint | null {
/**
* Look up a heuristic hint by intent string.
*
* @param intent - The intent to look up (e.g., "add_to_cart", "book_flight")
* @param profileHints - Optional profile-provided hints to check after built-in hints
* @returns Matching HeuristicHint or null
*/
export function getCommonHint(
intent: string,
profileHints?: HeuristicHintInput[]
): HeuristicHint | null {
const normalized = intent.toLowerCase().replace(/[\s-]+/g, '_');

// Check built-in hints first
const exactMatch = COMMON_HINTS[normalized as keyof typeof COMMON_HINTS];
if (exactMatch) {
return exactMatch;
Expand All @@ -69,5 +82,22 @@ export function getCommonHint(intent: string): HeuristicHint | null {
}
}

// Check profile-provided hints
if (profileHints && profileHints.length > 0) {
for (const ph of profileHints) {
const pattern = ph.intentPattern ?? ph.intent_pattern ?? '';
const phNormalized = pattern.toLowerCase().replace(/[\s-]+/g, '_');
if (normalized.includes(phNormalized) || phNormalized.includes(normalized)) {
return new HeuristicHint({
intentPattern: pattern,
textPatterns: ph.textPatterns ?? ph.text_patterns,
roleFilter: ph.roleFilter ?? ph.role_filter,
attributePatterns: ph.attributePatterns ?? ph.attribute_patterns,
priority: ph.priority,
});
}
}
}

return null;
}
120 changes: 120 additions & 0 deletions src/agents/planner-executor/composable-heuristics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,33 @@ import type { IntentHeuristics } from './planner-executor-agent';
import { COMMON_HINTS } from './common-hints';
import { HeuristicHint, type HeuristicHintInput } from './heuristic-hint';
import { TaskCategory } from './task-category';
import type { LearnedTargetFingerprint } from './profile-types';

export interface ComposableHeuristicsOptions {
staticHeuristics?: IntentHeuristics;
taskCategory?: TaskCategory | null;
useCommonHints?: boolean;
/** Learned fingerprints from previous successful runs */
learnedFingerprints?: LearnedTargetFingerprint[];
}

/** Minimum confidence for a learned fingerprint to be used as a hint */
const MIN_FINGERPRINT_CONFIDENCE = 0.3;

export class ComposableHeuristics implements IntentHeuristics {
private readonly staticHeuristics: IntentHeuristics | null;
private readonly taskCategory: TaskCategory | null;
private readonly useCommonHints: boolean;
private currentHints: HeuristicHint[] = [];
private readonly learnedFingerprints: LearnedTargetFingerprint[];

constructor(options: ComposableHeuristicsOptions = {}) {
this.staticHeuristics = options.staticHeuristics ?? null;
this.taskCategory = options.taskCategory ?? null;
this.useCommonHints = options.useCommonHints ?? true;
this.learnedFingerprints = (options.learnedFingerprints ?? []).filter(
fp => fp.confidence >= MIN_FINGERPRINT_CONFIDENCE
);
}

setStepHints(hints?: Array<HeuristicHint | HeuristicHintInput> | null): void {
Expand Down Expand Up @@ -48,6 +58,7 @@ export class ComposableHeuristics implements IntentHeuristics {
return null;
}

// 1. Check current step hints (highest priority)
for (const hint of this.currentHints) {
if (hint.matchesIntent(intent)) {
const elementId = this.matchHint(hint, elements);
Expand All @@ -57,6 +68,13 @@ export class ComposableHeuristics implements IntentHeuristics {
}
}

// 2. Check learned fingerprints (dynamic hints from successful past runs)
const learnedMatch = this.matchLearnedFingerprint(intent, elements);
if (learnedMatch !== null) {
return learnedMatch;
}

// 3. Check common hints (built-in)
if (this.useCommonHints) {
const commonHint = this.getCommonHintForIntent(intent);
if (commonHint) {
Expand All @@ -67,6 +85,7 @@ export class ComposableHeuristics implements IntentHeuristics {
}
}

// 4. Check static heuristics
if (this.staticHeuristics) {
try {
const elementId = this.staticHeuristics.findElementForIntent(intent, elements, url, goal);
Expand All @@ -78,12 +97,14 @@ export class ComposableHeuristics implements IntentHeuristics {
}
}

// 5. Fall back to task category defaults
return this.matchTaskCategoryDefaults(elements);
}

priorityOrder(): string[] {
const patterns = [
...this.currentHints.map(hint => hint.intentPattern),
...this.learnedFingerprints.map(fp => fp.intent),
...(this.useCommonHints ? Object.keys(COMMON_HINTS) : []),
];

Expand All @@ -108,6 +129,105 @@ export class ComposableHeuristics implements IntentHeuristics {
return null;
}

/**
* Match learned fingerprints against current snapshot elements.
* Fingerprints are sorted by confidence (descending) so the most
* reliable past success is tried first.
*/
private matchLearnedFingerprint(intent: string, elements: SnapshotElement[]): number | null {
if (this.learnedFingerprints.length === 0) {
return null;
}

const normalizedIntent = intent.toLowerCase().replace(/[\s-]+/g, '_');
const sorted = [...this.learnedFingerprints].sort((a, b) => b.confidence - a.confidence);

for (const fp of sorted) {
// Match intent: exact or substring match
const fpIntent = fp.intent.toLowerCase().replace(/[\s-]+/g, '_');
if (
fpIntent !== normalizedIntent &&
!normalizedIntent.includes(fpIntent) &&
!fpIntent.includes(normalizedIntent)
) {
continue;
}

for (const element of elements) {
if (this.fingerprintMatchesElement(fp, element)) {
return element.id;
}
}
}

return null;
}

/**
* Check if a learned fingerprint matches a snapshot element.
* Uses token overlap scoring with a minimum threshold.
*/
private fingerprintMatchesElement(
fp: LearnedTargetFingerprint,
element: SnapshotElement
): boolean {
let score = 0;
let maxScore = 0;

// Role match (weight: 2)
if (fp.role) {
maxScore += 2;
if ((element.role ?? '').toLowerCase() === fp.role) {
score += 2;
}
}

// Text token overlap (weight: up to 3)
if (fp.textTokens && fp.textTokens.length > 0) {
maxScore += 3;
const elementText = [element.text, element.ariaLabel, element.name]
.filter((v): v is string => typeof v === 'string')
.join(' ')
.toLowerCase();
const elementTokens = elementText.split(/\s+/).filter(t => t.length > 0);
const matchingTokens = fp.textTokens.filter(ft =>
elementTokens.some(et => et === ft || et.includes(ft))
);
if (matchingTokens.length > 0) {
score += Math.min(3, Math.ceil((matchingTokens.length / fp.textTokens.length) * 3));
}
}

// ARIA token overlap (weight: up to 2)
if (fp.ariaTokens && fp.ariaTokens.length > 0) {
maxScore += 2;
const ariaText = [element.ariaLabel, element.name]
.filter((v): v is string => typeof v === 'string')
.join(' ')
.toLowerCase();
const ariaTokens = ariaText.split(/\s+/).filter(t => t.length > 0);
const matchingTokens = fp.ariaTokens.filter(at =>
ariaTokens.some(et => et === at || et.includes(at))
);
if (matchingTokens.length > 0) {
score += Math.min(2, Math.ceil((matchingTokens.length / fp.ariaTokens.length) * 2));
}
}

// href path pattern match (weight: 2)
if (fp.hrefPathPattern) {
maxScore += 2;
const href = element.href || '';
if (href.toLowerCase().includes(fp.hrefPathPattern)) {
score += 2;
}
}

// Require at least 50% of available score to consider it a match
if (maxScore === 0) return false;
return score / maxScore >= 0.5;
}

private getCommonHintForIntent(intent: string): HeuristicHint | null {
const normalized = intent.toLowerCase().replace(/[\s-]+/g, '_');
if (normalized in COMMON_HINTS) {
Expand Down
Loading
Loading