Skip to content

Commit e58ea10

Browse files
authored
fix: harden adaptive policy block evidence (#2101)
* fix: bound adaptive policy block evidence * fix: tighten adaptive failure correlation * ci: run adaptive browser oracle checks
1 parent ab8f91c commit e58ea10

4 files changed

Lines changed: 213 additions & 21 deletions

File tree

.github/workflows/agent-task-contracts.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ on:
2424
- "tests/production-boundary-enforcement.test.ts"
2525
- "tests/runtime-tool-policy.test.ts"
2626
- "tests/browser-canonical-preview-origin.test.ts"
27+
- "tests/browser-adaptive-exploration.test.ts"
28+
- "packages/runtime-core/src/browser-adaptive-exploration.ts"
2729
- "packages/runtime-playground/src/browser-actions-runner.ts"
30+
- "packages/runtime-playground/src/browser-adaptive-explorer.ts"
2831
- "packages/runtime-playground/src/browser-artifacts.ts"
2932
- "packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts"
3033
- "packages/runtime-playground/src/browser-preview-routing.ts"
@@ -65,7 +68,10 @@ on:
6568
- "tests/production-boundary-enforcement.test.ts"
6669
- "tests/runtime-tool-policy.test.ts"
6770
- "tests/browser-canonical-preview-origin.test.ts"
71+
- "tests/browser-adaptive-exploration.test.ts"
72+
- "packages/runtime-core/src/browser-adaptive-exploration.ts"
6873
- "packages/runtime-playground/src/browser-actions-runner.ts"
74+
- "packages/runtime-playground/src/browser-adaptive-explorer.ts"
6975
- "packages/runtime-playground/src/browser-artifacts.ts"
7076
- "packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts"
7177
- "packages/runtime-playground/src/browser-preview-routing.ts"
@@ -108,6 +114,7 @@ jobs:
108114
- name: Install Playwright Chromium
109115
run: npx playwright install --with-deps chromium
110116
- run: npm run build
117+
- run: npm run test:browser-accessibility-oracles
111118
- run: npm run test:agent-task-contracts
112119
- run: npm run test:browser-canonical-preview-origin
113120
- run: npm run test:bounded-runtime-plan

packages/runtime-core/src/browser-adaptive-exploration.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ export interface BrowserAdaptiveTransition {
8686
loadingAfter: number
8787
oracleFingerprints: string[]
8888
networkFailures?: BrowserAdaptiveNetworkFailure[]
89+
networkFailureSummary?: {
90+
total: number
91+
retained: number
92+
policyBlocks: number
93+
oracleFindings: number
94+
truncated: boolean
95+
}
8996
accessibilityFindingFingerprints?: string[]
9097
}
9198
status: "ok" | "revisited" | "rejected" | "error" | "cancelled"

packages/runtime-playground/src/browser-adaptive-explorer.ts

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ interface FrontierEntry {
4545
path: BrowserAdaptiveAction[]
4646
}
4747

48+
interface AdaptiveNetworkFailureRetention {
49+
remainingCount: number
50+
remainingBytes: number
51+
}
52+
4853
export async function exploreAdaptiveBrowserStateMachine({
4954
page,
5055
baseUrl,
@@ -79,6 +84,10 @@ export async function exploreAdaptiveBrowserStateMachine({
7984
let revisits = 0
8085
let keyboardActions = 0
8186
let exhausted: BrowserAdaptiveExplorationResult["summary"]["budgetExhausted"]
87+
const networkFailureRetention: AdaptiveNetworkFailureRetention = {
88+
remainingCount: Math.min(contract.budgets.maxErrors, contract.descriptorLimits.maxDiagnostics),
89+
remainingBytes: Math.floor(contract.budgets.maxArtifactBytes / 8),
90+
}
8291

8392
const initial = await captureAdaptiveState(page, contract, 0, navigationScope)
8493
appendDiagnostics(diagnostics, initial.diagnostics, contract.descriptorLimits.maxDiagnostics)
@@ -144,7 +153,7 @@ export async function exploreAdaptiveBrowserStateMachine({
144153
const newConsoleRecords = consoleErrorRecords(observations.consoleMessages.slice(beforeConsole))
145154
const newConsoleErrors = newConsoleRecords.map(recordMessage)
146155
const newPageErrors = errorMessages(observations.errors.slice(beforeErrors))
147-
const oracleEvidence = adaptiveOracleEvidence(newConsoleRecords, observations.network.slice(beforeNetwork), newPageErrors, contract, networkPolicy)
156+
const oracleEvidence = adaptiveOracleEvidence(newConsoleRecords, observations.network.slice(beforeNetwork), newPageErrors, contract, networkPolicy, networkFailureRetention)
148157
const fingerprints = [...oracleEvidence.fingerprints]
149158
const existing = states.get(stabilized.state.digest)
150159
const newState = !existing
@@ -189,6 +198,7 @@ export async function exploreAdaptiveBrowserStateMachine({
189198
loadingAfter: stabilized.loading,
190199
oracleFingerprints: fingerprints,
191200
...(oracleEvidence.networkFailures.length > 0 ? { networkFailures: oracleEvidence.networkFailures } : {}),
201+
...(oracleEvidence.networkFailureSummary ? { networkFailureSummary: oracleEvidence.networkFailureSummary } : {}),
192202
...(accessibilityFingerprints.length > 0 ? { accessibilityFindingFingerprints: accessibilityFingerprints } : {}),
193203
},
194204
status: actionError ? "error" : scopeRejection ? "rejected" : newState ? "ok" : "revisited",
@@ -569,34 +579,70 @@ function consoleErrorRecords(records: object[]): Record<string, unknown>[] {
569579
return records.map((record) => record as Record<string, unknown>).filter((record) => record.type === "error" || record.level === "error").slice(0, 100)
570580
}
571581

572-
function adaptiveOracleEvidence(consoleRecords: Record<string, unknown>[], networkRecords: object[], pageErrors: string[], contract: BrowserAdaptiveExplorationContract, networkPolicy?: BrowserPreviewNetworkPolicy): { fingerprints: string[]; networkFailures: BrowserAdaptiveNetworkFailure[]; errorCount: number } {
582+
function adaptiveOracleEvidence(consoleRecords: Record<string, unknown>[], networkRecords: object[], pageErrors: string[], contract: BrowserAdaptiveExplorationContract, networkPolicy?: BrowserPreviewNetworkPolicy, retention?: AdaptiveNetworkFailureRetention): { fingerprints: string[]; networkFailures: BrowserAdaptiveNetworkFailure[]; networkFailureSummary?: BrowserAdaptiveTransition["observations"]["networkFailureSummary"]; errorCount: number } {
573583
const failures = networkRecords.map((record) => record as Record<string, unknown>).filter((record) => record.type === "requestfailed")
574-
const networkFailures = failures.map((record): BrowserAdaptiveNetworkFailure => {
584+
const classifiedFailures = failures.map((record): BrowserAdaptiveNetworkFailure & { expectedBlock: boolean } => {
575585
const url = typeof record.url === "string" ? record.url : ""
576586
const decision = networkPolicy ? browserPreviewNetworkDecision(url, networkPolicy) : { url, urlClassification: "invalid" as const, policyDecision: "unknown" as const, policyReason: "network-policy-unavailable" }
577587
const failure = networkFailureMessage(record)
578588
const expectedBlock = decision.policyDecision === "blocked" && /ERR_BLOCKED_BY_CLIENT|blockedbyclient/i.test(failure)
579-
return { ...decision, ...(failure ? { failure } : {}), oracleFinding: !expectedBlock || contract.oraclePolicy.policyBlocks === "finding" }
589+
return { ...decision, ...(failure ? { failure } : {}), oracleFinding: !expectedBlock || contract.oraclePolicy.policyBlocks === "finding", expectedBlock }
580590
})
581-
const expectedBlockUrls = networkFailures.filter((failure) => !failure.oracleFinding).map((failure) => failure.url)
582-
const findingConsoleRecords = consoleRecords.filter((record) => {
583-
if (!/ERR_BLOCKED_BY_CLIENT/i.test(recordMessage(record))) return true
584-
const location = objectRecord(record.location)
585-
const match = typeof location.url === "string" && location.url
586-
? expectedBlockUrls.indexOf(location.url)
587-
: expectedBlockUrls.length > 0 ? 0 : -1
588-
if (match < 0) return true
589-
expectedBlockUrls.splice(match, 1)
590-
return false
591+
const unmatchedFailures = new Set(classifiedFailures.map((_failure, index) => index))
592+
const consoleMessages = consoleRecords.flatMap((record) => {
593+
const message = recordMessage(record)
594+
const locationUrl = objectRecord(record.location).url
595+
const match = classifiedFailures.findIndex((failure, index) => unmatchedFailures.has(index)
596+
&& (typeof locationUrl === "string" && locationUrl ? failure.url === locationUrl && failureTokenMatches(message, failure.failure) : failureTokenMatches(message, failure.failure)))
597+
if (match < 0) return [message]
598+
unmatchedFailures.delete(match)
599+
const failure = classifiedFailures[match]!
600+
return failure.expectedBlock && contract.oraclePolicy.policyBlocks === "evidence" ? [] : [message]
591601
})
592-
const consoleUrls = new Set(findingConsoleRecords.map((record) => objectRecord(record.location).url).filter((url): url is string => typeof url === "string"))
593-
const networkMessages = networkFailures.filter((failure) => failure.oracleFinding && !consoleUrls.has(failure.url)).map((failure) => stableJson({ type: "requestfailed", url: failure.url, failure: failure.failure, urlClassification: failure.urlClassification, policyDecision: failure.policyDecision, policyReason: failure.policyReason }))
594-
const messages = [...findingConsoleRecords.map(recordMessage), ...pageErrors, ...networkMessages]
602+
const networkMessages = classifiedFailures.filter((failure, index) => unmatchedFailures.has(index) && failure.oracleFinding).map(networkFailureOracleMessage)
603+
const messages = [...consoleMessages, ...pageErrors, ...networkMessages]
604+
const networkFailureSummary = classifiedFailures.length > 0 ? boundedNetworkFailureEvidence(classifiedFailures, contract, retention) : undefined
595605
return {
596606
fingerprints: [...new Set(messages.map((message) => browserAdaptiveDigest("oracle", message)))].sort(),
597-
networkFailures,
598-
errorCount: findingConsoleRecords.length + pageErrors.length + networkMessages.length,
607+
networkFailures: networkFailureSummary?.failures ?? [],
608+
networkFailureSummary: networkFailureSummary?.summary,
609+
errorCount: consoleMessages.length + pageErrors.length + networkMessages.length,
610+
}
611+
}
612+
613+
function boundedNetworkFailureEvidence(failures: Array<BrowserAdaptiveNetworkFailure & { expectedBlock: boolean }>, contract: BrowserAdaptiveExplorationContract, cumulativeRetention?: AdaptiveNetworkFailureRetention): { failures: BrowserAdaptiveNetworkFailure[]; summary: NonNullable<BrowserAdaptiveTransition["observations"]["networkFailureSummary"]> } {
614+
const ordered = failures.map(({ expectedBlock: _expectedBlock, ...failure }) => failure).sort((left, right) => stableJson(left).localeCompare(stableJson(right)))
615+
const retention = cumulativeRetention ?? {
616+
remainingCount: Math.min(contract.budgets.maxErrors, contract.descriptorLimits.maxDiagnostics),
617+
remainingBytes: Math.floor(contract.budgets.maxArtifactBytes / 8),
618+
}
619+
const retained: BrowserAdaptiveNetworkFailure[] = []
620+
for (const failure of ordered.slice(0, retention.remainingCount)) {
621+
const bytes = Buffer.byteLength(stableJson(failure)) + 1
622+
if (bytes > retention.remainingBytes) break
623+
retained.push(failure)
624+
retention.remainingCount -= 1
625+
retention.remainingBytes -= bytes
599626
}
627+
return {
628+
failures: retained,
629+
summary: {
630+
total: failures.length,
631+
retained: retained.length,
632+
policyBlocks: failures.filter((failure) => failure.expectedBlock).length,
633+
oracleFindings: failures.filter((failure) => failure.oracleFinding).length,
634+
truncated: retained.length < failures.length,
635+
},
636+
}
637+
}
638+
639+
function failureTokenMatches(message: string, failure: string | undefined): boolean {
640+
const token = failure?.match(/ERR_[A-Z0-9_]+/i)?.[0]
641+
return Boolean(token && message.toUpperCase().includes(token.toUpperCase()))
642+
}
643+
644+
function networkFailureOracleMessage(failure: BrowserAdaptiveNetworkFailure): string {
645+
return stableJson({ type: "requestfailed", url: failure.url, failure: failure.failure, urlClassification: failure.urlClassification, policyDecision: failure.policyDecision, policyReason: failure.policyReason })
600646
}
601647

602648
function networkFailureMessage(record: Record<string, unknown>): string {

0 commit comments

Comments
 (0)