Skip to content

Commit 570209e

Browse files
authored
Merge pull request #227 from PredicateSystems/predicate-contracts
consume predicate contracts
2 parents 9507b00 + d150c42 commit 570209e

File tree

8 files changed

+430
-7
lines changed

8 files changed

+430
-7
lines changed

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,57 @@ def login_example() -> None:
143143
raise RuntimeError("login failed")
144144
```
145145

146+
## Pre-action authority hook (production pattern)
147+
148+
If you want every action proposal to be authorized before execution, pass a
149+
`pre_action_authorizer` into `RuntimeAgent`.
150+
151+
This hook receives a shared `predicate-contracts` `ActionRequest` generated from
152+
runtime state (`snapshot` + assertion evidence) and must return either:
153+
154+
- `True` / `False`, or
155+
- an object with an `allowed: bool` field (for richer decision payloads).
156+
157+
```python
158+
from predicate.agent_runtime import AgentRuntime
159+
from predicate.runtime_agent import RuntimeAgent, RuntimeStep
160+
161+
# Optional: your authority client can be local guard, sidecar client, or remote API client.
162+
def pre_action_authorizer(action_request):
163+
# Example: call your authority service
164+
# resp = authority_client.authorize(action_request)
165+
# return resp
166+
return True
167+
168+
169+
runtime = AgentRuntime(backend=backend, tracer=tracer)
170+
agent = RuntimeAgent(
171+
runtime=runtime,
172+
executor=executor,
173+
pre_action_authorizer=pre_action_authorizer,
174+
authority_principal_id="agent:web-checkout",
175+
authority_tenant_id="tenant-a",
176+
authority_session_id="session-123",
177+
authority_fail_closed=True, # deny/authorizer errors block action execution
178+
)
179+
180+
ok = await agent.run_step(
181+
task_goal="Complete checkout",
182+
step=RuntimeStep(goal="Click submit order"),
183+
)
184+
```
185+
186+
Fail-open option (not recommended for sensitive actions):
187+
188+
```python
189+
agent = RuntimeAgent(
190+
runtime=runtime,
191+
executor=executor,
192+
pre_action_authorizer=pre_action_authorizer,
193+
authority_fail_closed=False, # authorizer errors allow action to proceed
194+
)
195+
```
196+
146197
## Capabilities (lifecycle guarantees)
147198

148199
### Controlled perception

predicate/agent_runtime.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
from .backends.protocol import BrowserBackend
9999
from .browser import AsyncSentienceBrowser
100100
from .tracing import Tracer
101+
from predicate_contracts import ActionRequest
101102

102103

103104
class AgentRuntime:
@@ -980,6 +981,39 @@ def _compute_snapshot_digest(self, snap: Snapshot | None) -> str | None:
980981
except Exception:
981982
return None
982983

984+
def build_authority_action_request(
985+
self,
986+
*,
987+
principal_id: str,
988+
action: str,
989+
resource: str,
990+
intent: str,
991+
tenant_id: str | None = None,
992+
session_id: str | None = None,
993+
state_source: str = "sdk-python",
994+
) -> ActionRequest:
995+
"""
996+
Build a predicate-contracts ActionRequest from current runtime state.
997+
998+
This boundary helper keeps sdk-python internals decoupled from authority
999+
enforcement internals by exporting only shared contract types.
1000+
"""
1001+
from .integrations.authority import (
1002+
AuthorityActionInput,
1003+
build_action_request_from_runtime,
1004+
)
1005+
1006+
action_input = AuthorityActionInput(
1007+
principal_id=principal_id,
1008+
action=action,
1009+
resource=resource,
1010+
intent=intent,
1011+
tenant_id=tenant_id,
1012+
session_id=session_id,
1013+
state_source=state_source,
1014+
)
1015+
return build_action_request_from_runtime(runtime=self, action_input=action_input)
1016+
9831017
async def emit_step_end(
9841018
self,
9851019
*,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from predicate.integrations.authority.contracts_adapter import (
2+
AuthorityActionInput,
3+
build_action_request_from_runtime,
4+
state_evidence_from_runtime,
5+
to_verification_evidence,
6+
)
7+
8+
__all__ = [
9+
"AuthorityActionInput",
10+
"build_action_request_from_runtime",
11+
"state_evidence_from_runtime",
12+
"to_verification_evidence",
13+
]
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
from dataclasses import dataclass
5+
from typing import Any, Mapping, Sequence
6+
7+
# pylint: disable=import-error
8+
9+
from predicate_contracts import (
10+
ActionRequest,
11+
ActionSpec,
12+
PrincipalRef,
13+
StateEvidence,
14+
VerificationEvidence,
15+
VerificationSignal,
16+
VerificationStatus,
17+
)
18+
19+
20+
@dataclass(frozen=True)
21+
class AuthorityActionInput:
22+
principal_id: str
23+
action: str
24+
resource: str
25+
intent: str
26+
tenant_id: str | None = None
27+
session_id: str | None = None
28+
state_source: str = "sdk-python"
29+
30+
31+
def to_verification_evidence(assertions: Sequence[Mapping[str, Any]]) -> VerificationEvidence:
32+
signals: list[VerificationSignal] = []
33+
for assertion in assertions:
34+
label = str(assertion.get("label", "")).strip()
35+
if label == "":
36+
continue
37+
passed = bool(assertion.get("passed", False))
38+
required = bool(assertion.get("required", False))
39+
reason_raw = assertion.get("reason")
40+
reason = str(reason_raw) if isinstance(reason_raw, str) and reason_raw != "" else None
41+
signals.append(
42+
VerificationSignal(
43+
label=label,
44+
status=VerificationStatus.PASSED if passed else VerificationStatus.FAILED,
45+
required=required,
46+
reason=reason,
47+
)
48+
)
49+
return VerificationEvidence(signals=tuple(signals))
50+
51+
52+
def state_evidence_from_runtime(runtime: Any, source: str = "sdk-python") -> StateEvidence:
53+
snapshot = getattr(runtime, "last_snapshot", None)
54+
step_id = getattr(runtime, "step_id", None)
55+
state_hash = _snapshot_state_hash(snapshot=snapshot, step_id=step_id)
56+
return StateEvidence(source=source, state_hash=state_hash)
57+
58+
59+
def build_action_request_from_runtime(runtime: Any, action_input: AuthorityActionInput) -> ActionRequest:
60+
assertions_payload = runtime.get_assertions_for_step_end()
61+
assertions = assertions_payload.get("assertions", [])
62+
verification_evidence = to_verification_evidence(assertions)
63+
state_evidence = state_evidence_from_runtime(runtime=runtime, source=action_input.state_source)
64+
return ActionRequest(
65+
principal=PrincipalRef(
66+
principal_id=action_input.principal_id,
67+
tenant_id=action_input.tenant_id,
68+
session_id=action_input.session_id,
69+
),
70+
action_spec=ActionSpec(
71+
action=action_input.action,
72+
resource=action_input.resource,
73+
intent=action_input.intent,
74+
),
75+
state_evidence=state_evidence,
76+
verification_evidence=verification_evidence,
77+
)
78+
79+
80+
def _snapshot_state_hash(snapshot: Any, step_id: str | None) -> str:
81+
url = str(getattr(snapshot, "url", "") or "")
82+
timestamp = str(getattr(snapshot, "timestamp", "") or "")
83+
if url != "" or timestamp != "":
84+
digest = hashlib.sha256(f"{url}{timestamp}".encode("utf-8")).hexdigest()
85+
return "sha256:" + digest
86+
fallback_material = step_id or "missing_snapshot"
87+
fallback_digest = hashlib.sha256(fallback_material.encode("utf-8")).hexdigest()
88+
return "sha256:" + fallback_digest

predicate/runtime_agent.py

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ class ActOnceResult:
6262
used_vision: bool
6363

6464

65+
@dataclass(frozen=True)
66+
class PreActionAuthorityDecision:
67+
allowed: bool
68+
reason: str | None = None
69+
70+
6571
class RuntimeAgent:
6672
"""
6773
A thin orchestration layer over AgentRuntime:
@@ -79,12 +85,22 @@ def __init__(
7985
vision_executor: LLMProvider | None = None,
8086
vision_verifier: LLMProvider | None = None,
8187
short_circuit_canvas: bool = True,
88+
pre_action_authorizer: Callable[[Any], Any] | None = None,
89+
authority_principal_id: str | None = None,
90+
authority_tenant_id: str | None = None,
91+
authority_session_id: str | None = None,
92+
authority_fail_closed: bool = True,
8293
) -> None:
8394
self.runtime = runtime
8495
self.executor = executor
8596
self.vision_executor = vision_executor
8697
self.vision_verifier = vision_verifier
8798
self.short_circuit_canvas = short_circuit_canvas
99+
self.pre_action_authorizer = pre_action_authorizer
100+
self.authority_principal_id = authority_principal_id
101+
self.authority_tenant_id = authority_tenant_id
102+
self.authority_session_id = authority_session_id
103+
self.authority_fail_closed = authority_fail_closed
88104

89105
self._structured_llm = LLMInteractionHandler(executor)
90106

@@ -120,7 +136,7 @@ async def run_step(
120136

121137
# 1) Structured executor attempt.
122138
action = self._propose_structured_action(task_goal=task_goal, step=step, snap=snap)
123-
await self._execute_action(action=action, snap=snap)
139+
await self._execute_action(action=action, snap=snap, step_goal=step.goal)
124140
ok = await self._apply_verifications(step=step)
125141
if ok:
126142
outcome = "ok"
@@ -268,7 +284,7 @@ async def act_once_result(
268284
temperature=0.0,
269285
)
270286
action = self._extract_action_from_text(resp.content)
271-
await self._execute_action(action=action, snap=snap)
287+
await self._execute_action(action=action, snap=snap, step_goal=step.goal)
272288
return ActOnceResult(action=action, snap=snap, used_vision=True)
273289

274290
# Structured snapshot-first proposal.
@@ -290,7 +306,7 @@ async def act_once_result(
290306
resp = self._structured_llm.query_llm(dom_context, combined_goal)
291307
action = self._structured_llm.extract_action(resp.content)
292308

293-
await self._execute_action(action=action, snap=snap)
309+
await self._execute_action(action=action, snap=snap, step_goal=step.goal)
294310
return ActOnceResult(action=action, snap=snap, used_vision=False)
295311

296312
async def _run_hook(
@@ -367,7 +383,7 @@ async def _vision_executor_attempt(
367383
)
368384

369385
action = self._extract_action_from_text(resp.content)
370-
await self._execute_action(action=action, snap=snap)
386+
await self._execute_action(action=action, snap=snap, step_goal=step.goal)
371387
# Important: vision executor fallback is a *retry* of the same step.
372388
# Clear prior step assertions so required_assertions_passed reflects the final attempt.
373389
self.runtime.flush_assertions()
@@ -397,21 +413,28 @@ async def _apply_verifications(self, *, step: RuntimeStep) -> bool:
397413
# Respect required verifications semantics.
398414
return self.runtime.required_assertions_passed() and all_ok
399415

400-
async def _execute_action(self, *, action: str, snap: Snapshot | None) -> None:
416+
async def _execute_action(self, *, action: str, snap: Snapshot | None, step_goal: str | None) -> None:
401417
url = None
402418
try:
403419
url = await self.runtime.get_url()
404420
except Exception:
405421
url = getattr(snap, "url", None)
406422

407-
await self.runtime.record_action(action, url=url)
408-
409423
# Coordinate-backed execution (by snapshot id or explicit coordinates).
410424
kind, payload = self._parse_action(action)
411425

412426
if kind == "finish":
427+
await self.runtime.record_action(action, url=url)
413428
return
414429

430+
await self._authorize_pre_action_or_raise(
431+
action=action,
432+
kind=kind,
433+
url=url,
434+
step_goal=step_goal,
435+
)
436+
await self.runtime.record_action(action, url=url)
437+
415438
if kind == "press":
416439
await self._press_key_best_effort(payload["key"])
417440
await self._stabilize_best_effort()
@@ -449,6 +472,65 @@ async def _execute_action(self, *, action: str, snap: Snapshot | None) -> None:
449472

450473
raise ValueError(f"Unknown action kind: {kind}")
451474

475+
async def _authorize_pre_action_or_raise(
476+
self,
477+
*,
478+
action: str,
479+
kind: str,
480+
url: str | None,
481+
step_goal: str | None,
482+
) -> None:
483+
if self.pre_action_authorizer is None:
484+
return
485+
principal_id = self.authority_principal_id or "agent:sdk-python"
486+
action_name = self._authority_action_name(kind)
487+
resource = url or "about:blank"
488+
intent = step_goal or action
489+
490+
try:
491+
request = self.runtime.build_authority_action_request(
492+
principal_id=principal_id,
493+
action=action_name,
494+
resource=resource,
495+
intent=intent,
496+
tenant_id=self.authority_tenant_id,
497+
session_id=self.authority_session_id,
498+
)
499+
decision_raw = self.pre_action_authorizer(request)
500+
if inspect.isawaitable(decision_raw):
501+
decision_raw = await decision_raw
502+
decision = self._normalize_authority_decision(decision_raw)
503+
if decision.allowed:
504+
return
505+
raise RuntimeError(
506+
f"pre_action_authority_denied: {decision.reason or 'denied_by_authority'}"
507+
)
508+
except Exception:
509+
if self.authority_fail_closed:
510+
raise
511+
return
512+
513+
def _normalize_authority_decision(self, value: Any) -> PreActionAuthorityDecision:
514+
if isinstance(value, PreActionAuthorityDecision):
515+
return value
516+
allowed_attr = getattr(value, "allowed", None)
517+
if isinstance(allowed_attr, bool):
518+
reason_attr = getattr(value, "reason", None)
519+
reason = str(reason_attr) if isinstance(reason_attr, str) and reason_attr else None
520+
return PreActionAuthorityDecision(allowed=allowed_attr, reason=reason)
521+
if isinstance(value, bool):
522+
return PreActionAuthorityDecision(allowed=value)
523+
raise RuntimeError("invalid_pre_action_authority_decision")
524+
525+
def _authority_action_name(self, kind: str) -> str:
526+
if kind in {"click", "click_xy", "click_rect"}:
527+
return "browser.click"
528+
if kind == "type":
529+
return "browser.type"
530+
if kind == "press":
531+
return "browser.press"
532+
return "browser.unknown"
533+
452534
async def _stabilize_best_effort(self) -> None:
453535
try:
454536
await self.runtime.backend.wait_ready_state(state="interactive", timeout_ms=15000)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
"httpx>=0.25.0", # For async API calls
3030
"playwright-stealth>=1.0.6", # Bot evasion and stealth mode
3131
"markdownify>=0.11.6", # Enhanced HTML to Markdown conversion
32+
"predicate-contracts",
3233
]
3334

3435
[project.urls]

0 commit comments

Comments
 (0)