Skip to content

Commit dfc78c1

Browse files
committed
Reuse of consent when it can be reused (same Role for different
operations). Decoding JWT so UI can see Roles in plain text. (TODO check if right place for this decode or if we already have it)
1 parent eea9062 commit dfc78c1

3 files changed

Lines changed: 199 additions & 63 deletions

File tree

src/agent/components/nodes.py

Lines changed: 148 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import json
22
import uuid
33
import os
4+
import time
5+
import base64
46
import logging
57

6-
from typing import List, Dict
8+
from typing import List, Dict, Optional
79

810
from langchain_core.messages import ToolMessage, SystemMessage, RemoveMessage, AIMessage, trim_messages
911
from langchain_core.runnables import RunnableConfig
@@ -507,14 +509,70 @@ def _find_tool_call_for_message(messages: List, tool_call_id: str) -> Dict | Non
507509
return None
508510

509511

512+
def _jwt_exp(jwt: str) -> Optional[int]:
513+
"""Decode the `exp` claim from a JWT (Unix seconds). Returns None on any failure.
514+
Used to cache a Consent-JWT for its real lifetime instead of a fixed safety TTL."""
515+
try:
516+
parts = jwt.split(".")
517+
if len(parts) < 2:
518+
return None
519+
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
520+
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
521+
exp = payload.get("exp")
522+
return int(exp) if exp is not None else None
523+
except Exception:
524+
return None
525+
526+
527+
def _consent_cache_key(required_roles: list, bank_id: Optional[str]) -> str:
528+
"""Stable, order-independent cache key from the SET of required entitlements.
529+
530+
OBP consents are scoped to roles/entitlements, not operations — so two different
531+
operations needing the same role(s) can share one Consent-JWT. Keying by the set
532+
of `(role, bank?)` pairs lets the cache reuse the JWT across distinct operations.
533+
Bank scope is attached only to roles that declare `requires_bank_id`; system-level
534+
roles ignore the per-call bank.
535+
"""
536+
if not required_roles:
537+
return f"@{bank_id or ''}"
538+
parts = sorted({
539+
f"{r.get('role', '')}::{(bank_id or '') if r.get('requires_bank_id') else '-'}"
540+
for r in required_roles
541+
if r.get("role")
542+
})
543+
return ",".join(parts) if parts else f"@{bank_id or ''}"
544+
545+
546+
def _consent_retry_ok(result) -> bool:
547+
"""Best-effort check that a consent retry actually succeeded — i.e. the Consent-JWT
548+
was accepted. Returns False on a fresh consent_required or a 401/403, which means a
549+
(cached) JWT no longer works and should be evicted."""
550+
try:
551+
parsed = json.loads(result) if isinstance(result, str) else result
552+
except Exception:
553+
return True # non-JSON output — assume the call went through
554+
if not isinstance(parsed, dict):
555+
return True
556+
if parsed.get("error") == "consent_required":
557+
return False
558+
status_code = parsed.get("status_code")
559+
if isinstance(status_code, int) and status_code in (401, 403):
560+
return False
561+
return True
562+
563+
510564
async def _retry_tool_with_consent(
511565
tool_call_id: str,
512566
error_msg_id: str,
513567
original_tc: Dict,
514568
consent_jwt: str,
515569
tools_by_name: Dict,
516-
) -> ToolMessage:
517-
"""Retry a single tool call with an injected Consent-JWT. Returns a replacement ToolMessage."""
570+
) -> tuple[ToolMessage, bool]:
571+
"""Retry a single tool call with an injected Consent-JWT.
572+
573+
Returns (replacement ToolMessage, ok) — ok is False if the retry still looks like
574+
an auth/consent failure, so the caller can evict a stale cached JWT.
575+
"""
518576
tool_name = original_tc["name"]
519577
tool_fn = tools_by_name.get(tool_name)
520578
if not tool_fn:
@@ -524,7 +582,7 @@ async def _retry_tool_with_consent(
524582
tool_call_id=tool_call_id,
525583
id=error_msg_id,
526584
status="error",
527-
)
585+
), False
528586

529587
original_args = dict(original_tc.get("args", {}))
530588
existing_headers = original_args.get("headers", {}) or {}
@@ -538,15 +596,15 @@ async def _retry_tool_with_consent(
538596
tool_call_id=tool_call_id,
539597
id=error_msg_id,
540598
status="success",
541-
)
599+
), _consent_retry_ok(result)
542600
except Exception as e:
543601
logger.error(f"🔐 CONSENT_FLOW: Consent retry failed for '{tool_name}': {e}", exc_info=True)
544602
return ToolMessage(
545603
content=f"Consent retry failed: {str(e)}",
546604
tool_call_id=tool_call_id,
547605
id=error_msg_id,
548606
status="error",
549-
)
607+
), False
550608

551609

552610
async def consent_check_node(state: OpeyGraphState, config: RunnableConfig):
@@ -627,38 +685,84 @@ async def consent_check_node(state: OpeyGraphState, config: RunnableConfig):
627685
configurable = config.get("configurable", {}) if config else {}
628686
tools_by_name = configurable.get("tools_by_name", {})
629687

688+
# Consent-JWT cache, keyed by "operation_id::bank_id". Reusing a still-valid JWT
689+
# lets a repeated operation skip the consent prompt entirely.
690+
consent_cache: Dict[str, dict] = dict(state.get("consent_jwts") or {})
691+
now = time.time()
692+
# Fallback safety TTL — only used if the JWT's own `exp` claim can't be decoded.
693+
# Real consent lifetime comes from the JWT itself, so this is purely a guard rail.
694+
CONSENT_JWT_FALLBACK_TTL_SECONDS = 3300
695+
630696
all_replacements = []
631697

632698
for op_id, group in groups.items():
633699
first = group[0]
634700
required_roles = first["consent_info"].get("required_roles", [])
701+
bank_id = first["consent_info"].get("bank_id")
635702
tool_call_ids = [ce["error_msg"].tool_call_id for ce in group]
636703

637-
# Build the interrupt payload — one per distinct operation
638-
consent_payload = {
639-
"consent_type": "consent_required",
640-
# Primary fields (used by stream_manager to emit ConsentRequestEvent)
641-
"tool_call_id": first["error_msg"].tool_call_id,
642-
"tool_name": first["original_tc"]["name"],
643-
"operation_id": op_id if op_id != "__unknown__" else None,
644-
"required_roles": required_roles,
645-
"bank_id": first["consent_info"].get("bank_id"),
646-
# Batch context so the frontend can show "N tool calls need this consent"
647-
"tool_call_count": len(group),
648-
"tool_call_ids": tool_call_ids,
649-
}
704+
# Only cache real operations — an unknown operation_id can't be keyed safely.
705+
# Key by the SET of required entitlements (not operation_id) so two different
706+
# operations that need the same role(s) reuse one consent. Skip caching only if
707+
# there are no roles AND no bank scope — that key would be too generic.
708+
cache_key = _consent_cache_key(required_roles, bank_id)
709+
if cache_key == "@":
710+
cache_key = None
711+
712+
# Reuse a cached, still-valid Consent-JWT for this operation if we have one.
713+
consent_jwt = None
714+
used_cached_jwt = False
715+
if cache_key:
716+
cached = consent_cache.get(cache_key)
717+
if cached:
718+
expires_at = cached.get("expires_at")
719+
cache_valid = False
720+
if expires_at:
721+
# Use the JWT's real expiry (decoded `exp` claim) with a 60s margin.
722+
cache_valid = expires_at > now + 60
723+
else:
724+
# JWT exp unknown — fall back to a conservative safety TTL.
725+
cache_valid = (now - cached.get("created_at", 0)) < CONSENT_JWT_FALLBACK_TTL_SECONDS
726+
if cache_valid:
727+
consent_jwt = cached.get("jwt")
728+
used_cached_jwt = True
729+
logger.info(
730+
f"🔐 CONSENT_FLOW: Reusing cached Consent-JWT for operation '{op_id}' "
731+
f"(cache_key={cache_key}) — skipping consent prompt"
732+
)
650733

651-
logger.info(
652-
f"🔐 CONSENT_FLOW: Interrupting for operation '{op_id}' "
653-
f"({len(group)} tool call(s): {tool_call_ids})"
654-
)
655-
user_response = interrupt(consent_payload)
656-
logger.info(
657-
f"🔐 CONSENT_FLOW: Resumed for operation '{op_id}', "
658-
f"response keys: {list(user_response.keys()) if isinstance(user_response, dict) else type(user_response)}"
659-
)
734+
# No usable cached JWT → prompt the user (interrupt → consent card → resume).
735+
if not consent_jwt:
736+
consent_payload = {
737+
"consent_type": "consent_required",
738+
# Primary fields (used by stream_manager to emit ConsentRequestEvent)
739+
"tool_call_id": first["error_msg"].tool_call_id,
740+
"tool_name": first["original_tc"]["name"],
741+
"operation_id": op_id if op_id != "__unknown__" else None,
742+
"required_roles": required_roles,
743+
"bank_id": bank_id,
744+
# Batch context so the frontend can show "N tool calls need this consent"
745+
"tool_call_count": len(group),
746+
"tool_call_ids": tool_call_ids,
747+
}
660748

661-
consent_jwt = user_response.get("consent_jwt")
749+
logger.info(
750+
f"🔐 CONSENT_FLOW: Interrupting for operation '{op_id}' "
751+
f"({len(group)} tool call(s): {tool_call_ids})"
752+
)
753+
user_response = interrupt(consent_payload)
754+
logger.info(
755+
f"🔐 CONSENT_FLOW: Resumed for operation '{op_id}', "
756+
f"response keys: {list(user_response.keys()) if isinstance(user_response, dict) else type(user_response)}"
757+
)
758+
759+
consent_jwt = user_response.get("consent_jwt")
760+
if consent_jwt and cache_key:
761+
consent_cache[cache_key] = {
762+
"jwt": consent_jwt,
763+
"created_at": now,
764+
"expires_at": _jwt_exp(consent_jwt),
765+
}
662766

663767
if not consent_jwt:
664768
logger.warning(f"🔐 CONSENT_FLOW: Consent denied for operation '{op_id}' — denying {len(group)} tool call(s)")
@@ -672,18 +776,29 @@ async def consent_check_node(state: OpeyGraphState, config: RunnableConfig):
672776
else:
673777
jwt_preview = consent_jwt[:50] + "..." if len(consent_jwt) > 50 else consent_jwt
674778
logger.info(
675-
f"🔐 CONSENT_FLOW: Consent approved for operation '{op_id}' (JWT preview: {jwt_preview}) "
676-
f"— retrying {len(group)} tool call(s)"
779+
f"🔐 CONSENT_FLOW: Consent {'(cached) ' if used_cached_jwt else ''}available for operation "
780+
f"'{op_id}' (JWT preview: {jwt_preview}) — retrying {len(group)} tool call(s)"
677781
)
782+
op_retry_failed = False
678783
for ce in group:
679-
replacement = await _retry_tool_with_consent(
784+
replacement, ok = await _retry_tool_with_consent(
680785
tool_call_id=ce["error_msg"].tool_call_id,
681786
error_msg_id=ce["error_msg"].id,
682787
original_tc=ce["original_tc"],
683788
consent_jwt=consent_jwt,
684789
tools_by_name=tools_by_name,
685790
)
686791
all_replacements.append(replacement)
792+
if not ok:
793+
op_retry_failed = True
794+
795+
# A cached JWT that no longer works (expired/revoked server-side) must be
796+
# evicted so the next attempt re-prompts instead of looping on a dead JWT.
797+
if used_cached_jwt and op_retry_failed and cache_key:
798+
logger.warning(
799+
f"🔐 CONSENT_FLOW: Cached Consent-JWT for '{op_id}' failed on retry — evicting from cache"
800+
)
801+
consent_cache.pop(cache_key, None)
687802

688-
return {"messages": all_replacements}
803+
return {"messages": all_replacements, "consent_jwts": consent_cache}
689804

0 commit comments

Comments
 (0)