from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
import httpx
from ldp_protocol.types.contract import DelegationContract, FailurePolicy
from ldp_protocol.types.error import LdpError
from ldp_protocol.types.identity import LdpIdentityCard
from ldp_protocol.types.messages import LdpEnvelope, LdpMessageBody
from ldp_protocol.types.payload import PayloadMode
from ldp_protocol.types.provenance import Provenance
from ldp_protocol.types.session import LdpSession, SessionConfig, SessionState
from ldp_protocol.types.trust import TrustDomain
def _validate_contract(
contract: DelegationContract,
provenance: Provenance,
) -> list[str]:
violations: list[str] = []
if contract.deadline:
try:
deadline = datetime.fromisoformat(contract.deadline)
if datetime.now(timezone.utc) > deadline:
violations.append("deadline_exceeded")
except ValueError:
pass
if contract.policy.budget:
budget = contract.policy.budget
if budget.max_tokens is not None and provenance.tokens_used is not None:
if provenance.tokens_used > budget.max_tokens:
violations.append("budget_tokens_exceeded")
if budget.max_cost_usd is not None and provenance.cost_usd is not None:
if provenance.cost_usd > budget.max_cost_usd:
violations.append("budget_cost_exceeded")
return violations
class ContractViolationError(RuntimeError):
def __init__(self, message: str, result: dict):
super().__init__(message)
self.result = result
class LdpClient:
def __init__(
self,
delegate_id: str = "ldp:client:default",
config: SessionConfig | None = None,
timeout: float = 30.0,
trust_domain: TrustDomain | None = None,
enforce_trust_domains: bool = True,
signing_secret: str | None = None,
):
self.delegate_id = delegate_id
self.config = config or SessionConfig()
self.trust_domain = trust_domain or TrustDomain(name="default")
self.enforce_trust_domains = enforce_trust_domains
self.signing_secret = signing_secret
self._http = httpx.AsyncClient(timeout=timeout)
self._sessions: dict[str, LdpSession] = {}
async def __aenter__(self) -> LdpClient:
return self
async def __aexit__(self, *args) -> None:
await self.close()
async def close(self) -> None:
await self._http.aclose()
async def discover(self, url: str) -> LdpIdentityCard:
endpoint = f"{url.rstrip('/')}/ldp/identity"
resp = await self._http.get(endpoint)
resp.raise_for_status()
identity = LdpIdentityCard.model_validate(resp.json())
if self.enforce_trust_domains:
if not self.trust_domain.trusts(identity.trust_domain.name):
raise ConnectionError(
f"Trust domain '{identity.trust_domain.name}' "
f"is not trusted by '{self.trust_domain.name}'"
)
return identity
async def discover_wellknown(self, url: str) -> LdpIdentityCard:
wellknown = f"{url.rstrip('/')}/.well-known/ldp-identity"
try:
resp = await self._http.get(wellknown)
resp.raise_for_status()
identity = LdpIdentityCard.model_validate(resp.json())
if self.enforce_trust_domains:
if not self.trust_domain.trusts(identity.trust_domain.name):
raise ConnectionError(
f"Trust domain '{identity.trust_domain.name}' "
f"is not trusted by '{self.trust_domain.name}'"
)
return identity
except Exception:
return await self.discover(url)
async def send_message(self, url: str, envelope: LdpEnvelope) -> LdpEnvelope:
if self.signing_secret:
from ldp_protocol.signing import apply_signature
apply_signature(envelope, self.signing_secret)
endpoint = f"{url.rstrip('/')}/ldp/messages"
resp = await self._http.post(
endpoint,
json=envelope.model_dump(by_alias=True),
)
resp.raise_for_status()
return LdpEnvelope.model_validate(resp.json())
async def establish_session(self, url: str) -> LdpSession:
hello = LdpEnvelope.create(
session_id="",
from_id=self.delegate_id,
to_id=url,
body=LdpMessageBody.hello(
delegate_id=self.delegate_id,
supported_modes=self.config.preferred_payload_modes,
),
)
await self.send_message(url, hello)
session_id = str(uuid4())
propose = LdpEnvelope.create(
session_id=session_id,
from_id=self.delegate_id,
to_id=url,
body=LdpMessageBody.session_propose(
config={
"preferred_payload_modes": [
m.value for m in self.config.preferred_payload_modes
],
"ttl_secs": self.config.ttl_secs,
"trust_domain": self.trust_domain.name,
}
),
)
propose_resp = await self.send_message(url, propose)
if propose_resp.body.type == "SESSION_REJECT":
error = propose_resp.body.error
if isinstance(error, LdpError):
raise ConnectionError(f"Session rejected [{error.code}]: {error.message}")
raise ConnectionError(f"Session rejected: {propose_resp.body.reason}")
negotiated_mode = propose_resp.body.negotiated_mode or PayloadMode.TEXT
identity = await self.discover(url)
session = LdpSession(
session_id=propose_resp.body.session_id or session_id,
remote_url=url,
remote_delegate_id=identity.delegate_id,
state=SessionState.ACTIVE,
trust_domain=identity.trust_domain,
ttl_secs=self.config.ttl_secs,
)
session.payload.mode = negotiated_mode
self._sessions[url] = session
return session
async def get_or_establish_session(self, url: str) -> LdpSession:
session = self._sessions.get(url)
if session and session.is_active:
return session
return await self.establish_session(url)
async def submit_task(
self,
url: str,
*,
skill: str,
input_data: Any,
session: LdpSession | None = None,
contract: DelegationContract | None = None,
) -> dict[str, Any]:
if session is None:
session = await self.get_or_establish_session(url)
task_id = str(uuid4())
submit = LdpEnvelope.create(
session_id=session.session_id,
from_id=self.delegate_id,
to_id=session.remote_delegate_id,
body=LdpMessageBody.task_submit(
task_id=task_id,
skill=skill,
input=input_data,
contract=contract,
),
payload_mode=session.payload.mode,
)
response = await self.send_message(url, submit)
session.touch()
session.task_count += 1
if response.body.type == "TASK_RESULT":
result = {
"task_id": response.body.task_id,
"output": response.body.output,
"provenance": (
response.body.provenance.model_dump() if response.body.provenance else None
),
}
if contract and response.body.provenance:
violations = _validate_contract(contract, response.body.provenance)
result["contract_id"] = contract.contract_id
result["contract_satisfied"] = len(violations) == 0
result["contract_violations"] = violations
if violations and contract.policy.failure_policy == FailurePolicy.FAIL_CLOSED:
raise ContractViolationError(
f"Contract violated [{', '.join(violations)}]",
result=result,
)
return result
elif response.body.type == "TASK_FAILED":
error = response.body.error
if isinstance(error, LdpError):
raise RuntimeError(f"Task failed [{error.code}]: {error.message}")
elif isinstance(error, str):
raise RuntimeError(f"Task failed: {error}")
else:
raise RuntimeError("Task failed: unknown error")
else:
return {
"task_id": task_id,
"status": response.body.type,
"message": response.body.message,
}
@property
def active_sessions(self) -> int:
return sum(1 for s in self._sessions.values() if s.is_active)