Skip to content

Commit 8d96d17

Browse files
authored
Merge pull request #208 from PredicateSystems/self_improving2
agent: extensible profiles, data-driven pruning, and task learning
2 parents 4e8da42 + 577127f commit 8d96d17

20 files changed

Lines changed: 2240 additions & 4 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@predicatesystems/runtime",
3-
"version": "1.3.4",
3+
"version": "1.4.0",
44
"description": "TypeScript SDK for Sentience AI Agent Browser Automation",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/agents/browser-agent.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,48 @@ class TokenAccountingProvider extends LLMProvider {
207207
}
208208
}
209209

210+
// Re-export planner-executor profile/learning types for extension consumption
211+
export type {
212+
DataDrivenPruningPolicy,
213+
BrowserAgentProfile,
214+
ResolvedAgentProfile,
215+
LearnedTargetFingerprint,
216+
DomainProfile,
217+
} from './planner-executor/profile-types';
218+
export { EMPTY_RESOLVED_PROFILE } from './planner-executor/profile-types';
219+
export {
220+
DataDrivenPruningPolicySchema,
221+
BrowserAgentProfileSchema,
222+
BrowserAgentProfileArraySchema,
223+
LearnedTargetFingerprintSchema,
224+
DomainProfileSchema,
225+
} from './planner-executor/profile-schema';
226+
export { ProfileRegistry } from './planner-executor/profile-registry';
227+
export { pruneWithPolicy } from './planner-executor/data-driven-pruner';
228+
export {
229+
computeTaskHash,
230+
extractDomain,
231+
createFingerprint,
232+
mergeFingerprint,
233+
recordFingerprintFailure,
234+
} from './planner-executor/fingerprint-normalizer';
235+
export type { LearningStore } from './planner-executor/learning-store';
236+
export { InMemoryLearningStore } from './planner-executor/learning-store';
237+
export {
238+
extractFingerprintFromOutcome,
239+
applyFingerprintFailure,
240+
applyFingerprintSuccess,
241+
isFingerprintStale,
242+
isFingerprintExpired,
243+
fingerprintToHint,
244+
computeTaskHash as computeAsyncTaskHash,
245+
isSensitiveUrl as isLearningSensitiveUrl,
246+
} from './planner-executor/learning-extractor';
247+
export type {
248+
LearningExtractionOptions,
249+
LearningExtractionResult,
250+
} from './planner-executor/learning-extractor';
251+
210252
export type StepOutcome = { stepGoal: string; ok: boolean };
211253

212254
export class PredicateBrowserAgent {

src/agents/planner-executor/category-pruner.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type PrunedSnapshotContext,
77
} from './pruning-types';
88
import { TaskCategory } from './task-category';
9+
import { pruneWithPolicy } from './data-driven-pruner';
910

1011
function textOf(element: SnapshotElement): string {
1112
return String(element.text || element.name || '').toLowerCase();
@@ -270,6 +271,39 @@ export function pruneSnapshotForTask(
270271
options: PruneSnapshotOptions
271272
): PrunedSnapshotContext {
272273
const relaxationLevel = Math.max(0, options.relaxationLevel || 0);
274+
275+
// Data-driven path: use profile policy if provided
276+
if (options.profilePolicy) {
277+
const { elements, maxNodes } = pruneWithPolicy(
278+
snapshot,
279+
options.profilePolicy,
280+
options.goal,
281+
relaxationLevel,
282+
options.category,
283+
options.learnedFingerprints
284+
);
285+
const actionableElementCount = selectContextElements(elements, elements.length || 1).length;
286+
287+
return {
288+
category: options.category,
289+
snapshot,
290+
elements,
291+
promptBlock: formatPrunedContext({
292+
category: options.category,
293+
elements,
294+
relaxationLevel,
295+
rawElementCount: snapshot.elements.length,
296+
prunedElementCount: elements.length,
297+
actionableElementCount,
298+
}),
299+
relaxationLevel,
300+
rawElementCount: snapshot.elements.length,
301+
prunedElementCount: elements.length,
302+
actionableElementCount,
303+
};
304+
}
305+
306+
// Built-in category path
273307
const policy = getPolicy(options.category, relaxationLevel);
274308
const filtered = (snapshot.elements || []).filter(element => {
275309
if (policy.block(element)) {

src/agents/planner-executor/common-hints.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { HeuristicHint } from './heuristic-hint';
2+
import type { HeuristicHintInput } from './heuristic-hint';
23

34
export const COMMON_HINTS = {
45
add_to_cart: new HeuristicHint({
@@ -56,8 +57,20 @@ export const COMMON_HINTS = {
5657
}),
5758
} as const;
5859

59-
export function getCommonHint(intent: string): HeuristicHint | null {
60+
/**
61+
* Look up a heuristic hint by intent string.
62+
*
63+
* @param intent - The intent to look up (e.g., "add_to_cart", "book_flight")
64+
* @param profileHints - Optional profile-provided hints to check after built-in hints
65+
* @returns Matching HeuristicHint or null
66+
*/
67+
export function getCommonHint(
68+
intent: string,
69+
profileHints?: HeuristicHintInput[]
70+
): HeuristicHint | null {
6071
const normalized = intent.toLowerCase().replace(/[\s-]+/g, '_');
72+
73+
// Check built-in hints first
6174
const exactMatch = COMMON_HINTS[normalized as keyof typeof COMMON_HINTS];
6275
if (exactMatch) {
6376
return exactMatch;
@@ -69,5 +82,22 @@ export function getCommonHint(intent: string): HeuristicHint | null {
6982
}
7083
}
7184

85+
// Check profile-provided hints
86+
if (profileHints && profileHints.length > 0) {
87+
for (const ph of profileHints) {
88+
const pattern = ph.intentPattern ?? ph.intent_pattern ?? '';
89+
const phNormalized = pattern.toLowerCase().replace(/[\s-]+/g, '_');
90+
if (normalized.includes(phNormalized) || phNormalized.includes(normalized)) {
91+
return new HeuristicHint({
92+
intentPattern: pattern,
93+
textPatterns: ph.textPatterns ?? ph.text_patterns,
94+
roleFilter: ph.roleFilter ?? ph.role_filter,
95+
attributePatterns: ph.attributePatterns ?? ph.attribute_patterns,
96+
priority: ph.priority,
97+
});
98+
}
99+
}
100+
}
101+
72102
return null;
73103
}

src/agents/planner-executor/composable-heuristics.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,33 @@ import type { IntentHeuristics } from './planner-executor-agent';
33
import { COMMON_HINTS } from './common-hints';
44
import { HeuristicHint, type HeuristicHintInput } from './heuristic-hint';
55
import { TaskCategory } from './task-category';
6+
import type { LearnedTargetFingerprint } from './profile-types';
67

78
export interface ComposableHeuristicsOptions {
89
staticHeuristics?: IntentHeuristics;
910
taskCategory?: TaskCategory | null;
1011
useCommonHints?: boolean;
12+
/** Learned fingerprints from previous successful runs */
13+
learnedFingerprints?: LearnedTargetFingerprint[];
1114
}
1215

16+
/** Minimum confidence for a learned fingerprint to be used as a hint */
17+
const MIN_FINGERPRINT_CONFIDENCE = 0.3;
18+
1319
export class ComposableHeuristics implements IntentHeuristics {
1420
private readonly staticHeuristics: IntentHeuristics | null;
1521
private readonly taskCategory: TaskCategory | null;
1622
private readonly useCommonHints: boolean;
1723
private currentHints: HeuristicHint[] = [];
24+
private readonly learnedFingerprints: LearnedTargetFingerprint[];
1825

1926
constructor(options: ComposableHeuristicsOptions = {}) {
2027
this.staticHeuristics = options.staticHeuristics ?? null;
2128
this.taskCategory = options.taskCategory ?? null;
2229
this.useCommonHints = options.useCommonHints ?? true;
30+
this.learnedFingerprints = (options.learnedFingerprints ?? []).filter(
31+
fp => fp.confidence >= MIN_FINGERPRINT_CONFIDENCE
32+
);
2333
}
2434

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

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

71+
// 2. Check learned fingerprints (dynamic hints from successful past runs)
72+
const learnedMatch = this.matchLearnedFingerprint(intent, elements);
73+
if (learnedMatch !== null) {
74+
return learnedMatch;
75+
}
76+
77+
// 3. Check common hints (built-in)
6078
if (this.useCommonHints) {
6179
const commonHint = this.getCommonHintForIntent(intent);
6280
if (commonHint) {
@@ -67,6 +85,7 @@ export class ComposableHeuristics implements IntentHeuristics {
6785
}
6886
}
6987

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

100+
// 5. Fall back to task category defaults
81101
return this.matchTaskCategoryDefaults(elements);
82102
}
83103

84104
priorityOrder(): string[] {
85105
const patterns = [
86106
...this.currentHints.map(hint => hint.intentPattern),
107+
...this.learnedFingerprints.map(fp => fp.intent),
87108
...(this.useCommonHints ? Object.keys(COMMON_HINTS) : []),
88109
];
89110

@@ -108,6 +129,105 @@ export class ComposableHeuristics implements IntentHeuristics {
108129
return null;
109130
}
110131

132+
/**
133+
* Match learned fingerprints against current snapshot elements.
134+
* Fingerprints are sorted by confidence (descending) so the most
135+
* reliable past success is tried first.
136+
*/
137+
private matchLearnedFingerprint(intent: string, elements: SnapshotElement[]): number | null {
138+
if (this.learnedFingerprints.length === 0) {
139+
return null;
140+
}
141+
142+
const normalizedIntent = intent.toLowerCase().replace(/[\s-]+/g, '_');
143+
const sorted = [...this.learnedFingerprints].sort((a, b) => b.confidence - a.confidence);
144+
145+
for (const fp of sorted) {
146+
// Match intent: exact or substring match
147+
const fpIntent = fp.intent.toLowerCase().replace(/[\s-]+/g, '_');
148+
if (
149+
fpIntent !== normalizedIntent &&
150+
!normalizedIntent.includes(fpIntent) &&
151+
!fpIntent.includes(normalizedIntent)
152+
) {
153+
continue;
154+
}
155+
156+
for (const element of elements) {
157+
if (this.fingerprintMatchesElement(fp, element)) {
158+
return element.id;
159+
}
160+
}
161+
}
162+
163+
return null;
164+
}
165+
166+
/**
167+
* Check if a learned fingerprint matches a snapshot element.
168+
* Uses token overlap scoring with a minimum threshold.
169+
*/
170+
private fingerprintMatchesElement(
171+
fp: LearnedTargetFingerprint,
172+
element: SnapshotElement
173+
): boolean {
174+
let score = 0;
175+
let maxScore = 0;
176+
177+
// Role match (weight: 2)
178+
if (fp.role) {
179+
maxScore += 2;
180+
if ((element.role ?? '').toLowerCase() === fp.role) {
181+
score += 2;
182+
}
183+
}
184+
185+
// Text token overlap (weight: up to 3)
186+
if (fp.textTokens && fp.textTokens.length > 0) {
187+
maxScore += 3;
188+
const elementText = [element.text, element.ariaLabel, element.name]
189+
.filter((v): v is string => typeof v === 'string')
190+
.join(' ')
191+
.toLowerCase();
192+
const elementTokens = elementText.split(/\s+/).filter(t => t.length > 0);
193+
const matchingTokens = fp.textTokens.filter(ft =>
194+
elementTokens.some(et => et === ft || et.includes(ft))
195+
);
196+
if (matchingTokens.length > 0) {
197+
score += Math.min(3, Math.ceil((matchingTokens.length / fp.textTokens.length) * 3));
198+
}
199+
}
200+
201+
// ARIA token overlap (weight: up to 2)
202+
if (fp.ariaTokens && fp.ariaTokens.length > 0) {
203+
maxScore += 2;
204+
const ariaText = [element.ariaLabel, element.name]
205+
.filter((v): v is string => typeof v === 'string')
206+
.join(' ')
207+
.toLowerCase();
208+
const ariaTokens = ariaText.split(/\s+/).filter(t => t.length > 0);
209+
const matchingTokens = fp.ariaTokens.filter(at =>
210+
ariaTokens.some(et => et === at || et.includes(at))
211+
);
212+
if (matchingTokens.length > 0) {
213+
score += Math.min(2, Math.ceil((matchingTokens.length / fp.ariaTokens.length) * 2));
214+
}
215+
}
216+
217+
// href path pattern match (weight: 2)
218+
if (fp.hrefPathPattern) {
219+
maxScore += 2;
220+
const href = element.href || '';
221+
if (href.toLowerCase().includes(fp.hrefPathPattern)) {
222+
score += 2;
223+
}
224+
}
225+
226+
// Require at least 50% of available score to consider it a match
227+
if (maxScore === 0) return false;
228+
return score / maxScore >= 0.5;
229+
}
230+
111231
private getCommonHintForIntent(intent: string): HeuristicHint | null {
112232
const normalized = intent.toLowerCase().replace(/[\s-]+/g, '_');
113233
if (normalized in COMMON_HINTS) {

0 commit comments

Comments
 (0)