afv-library/skills/investigating-agentforce-d360/scripts/resolve_session.py

261 lines
10 KiB
Python
Raw Normal View History

@W-22707610 feat: add investigating-agentforce-d360 skill Data Cloud 360° view of a single Agentforce session — DC-only, zero Splunk dependency. Pulls 24 STDM + GenAI DMOs via the Data Cloud Query REST API, assembles a hierarchical session tree (Interaction → Step → Generation → GatewayRequest), and renders a human-readable markdown summary with transcript + per-turn topic/action invocations + LLM generations + tool calls + audit chain. Migrated as a standalone Apache-2.0 skill from an internal hub plugin — self-contained, no sibling-skill or plugin dependencies. What this skill answers: - "Trace session <uuid>" / "Summarize what happened in <0Mw…>" - "Find escalated sessions today on Messaging in <org>" - Session discovery by time / agent / channel / outcome / conversation text when the user has no session id What it does NOT answer (use a different surface): - Design-time architecture — use investigating-agentforce-architecture - Runtime planner availability — DC alone can't tell you which topic/action was eligible for the classifier on a given turn Skill layout: - 8 Python pipeline modules (fetch_dc, assemble_dc, render_dc, discover_sessions, resolve_session, dc, storage, config) - 4 _shared helpers (paths, fs_guard, sql, __init__) with skill-scoped DATA_ROOT (~/.claude/data/investigating-agentforce-d360/) - 26 SQL templates under assets/dc/ - 27 test files (367 tests + 18 subtests, 100% passing) - 3 reference docs (artifacts.md, dc_dmo_fields.md, dc_pipeline_contract.md) - SKILL.md (sf-skills frontmatter, license: Apache-2.0, metadata.version: "1.0") - README.md (external-facing quick-start) - tools/grant_allowlist.py (idempotent first-run permission grant) - tools/archive_data_dir.sh (opt-in stop-hook tarballer) Quality gates: - pytest scripts/tests/: 367 passed + 18 subtests, 0 failures - npm run validate:skills: 62 of 62 skill(s) checked, 0 errors - Live end-to-end runs against 3 real Salesforce sessions exercising both the full-tree and STDM-lag gateway-direct render branches - 4 independent code-review rounds (correctness, security, markdown, architecture-critic) — all findings addressed Customer-data hygiene: no live tenant ids, no internal sprint markers, no hub/sibling-skill references. Synthetic fixtures look obviously synthetic (`019dface-…` UUIDs, `0MwTESTMSG…` MessagingSession ids, `00DTESTORG…` org ids, `MyAgent` placeholder agent name). Sibling skill: investigating-agentforce-architecture (PR #278) — same migration pattern, design-time metadata; complementary scope.
2026-05-28 18:57:52 +08:00
"""Resolve either an AI-agent session UUID or a Messaging session id (0Mw...)
to the canonical AI-agent session UUID that the rest of the skill uses.
## Why this exists
`ssot__AIAgentSession__dlm.ssot__RelatedMessagingSessionId__c` stores the
Service Cloud `MessagingSession.Id` (18-char Salesforce id, prefix `0Mw`).
Users often only have that messaging id on hand, not the AI-agent session
UUID (`019dface-0000-7000-8000-000000000002`). This resolver lets every
entry point in the skill accept either form it normalises to the UUID,
and artifacts continue to land under `DATA_ROOT/<uuid>/` as before.
## Many-to-one is real
Live-verified on an internal test org: 7 distinct messaging ids map to
multiple agent sessions; one id mapped to 5 separate agent sessions.
"Most recent by start_ts" is NOT a safe default for disambiguation
the user could legitimately want any of them. On multi-match we list
every candidate and exit non-zero; the user re-invokes with the
specific UUID.
## Two resolution modes
1. **Live** query DC. Requires `--org`. Used by `fetch_dc.py` at the
top of the pipeline (before any artifacts exist on disk).
2. **Disk-first** scan `DATA_ROOT/*/dc.sessions.json` for a row whose
`ssot__RelatedMessagingSessionId__c` matches. No DC call, no `--org`
needed. Used by `assemble_dc.py` and `render_dc.py`. If the session
has never been fetched, returns None and the caller errors with a
pointer to `fetch_dc.py`.
## CLI
python3 scripts/resolve_session.py --id <uuid|msg_id> --org <alias>
Prints the resolved UUID to stdout on single-match (or pass-through).
Non-zero exit with diagnostics on zero-match and multi-match.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import List, Optional
sys.path.insert(0, str(Path(__file__).parent))
from config import DATA_ROOT
from dc import load_sql, post, resolve_org
# NOT_SET sentinel set. Matches fetch_dc.py:41; keep in sync.
_NOT_SET = {"", "NOT_SET", None}
def is_messaging_id(s: str) -> bool:
"""Shape check for a Salesforce MessagingSession id. The official key
prefix is `0Mw`; the id is 15 or 18 chars. UUIDs are 36 chars with
dashes, so they can't accidentally match."""
if not s:
return False
return s.startswith("0Mw") and len(s) in (15, 18)
# ---- live resolution (queries Data Cloud) --------------------------------
def _live_lookup(msg_id: str, org: str) -> List[dict]:
"""Query ssot__AIAgentSession__dlm for rows where RelatedMessagingSessionId
matches `msg_id`. Returns raw row dicts (caller decides how to handle
N=0, N=1, N>1).
SQL comes from `assets/dc/messaging_session.sql` same templating
convention as every other DMO query in this skill. The `MSG_ID`
placeholder is interpolated as a literal string, but the caller
guarantees (via is_messaging_id) that the value is a Salesforce
MessagingSession id: exact 15/18 chars, `0Mw` key prefix. No quote,
no wildcard, no semicolon can pass that shape check.
"""
if not is_messaging_id(msg_id):
raise ValueError(
f"_live_lookup: msg_id {msg_id!r} failed is_messaging_id() shape check"
)
instance_url, token = resolve_org(org)
sql = load_sql("messaging_session", MSG_ID=msg_id)
return post(sql, instance_url, token, "resolve_session")
def _format_multi_match(msg_id: str, rows: List[dict]) -> str:
"""Human-readable error for the N>1 case. Lists every candidate so
the user can pick and re-invoke."""
lines = [
f"resolve_session: messaging id {msg_id!r} matches {len(rows)} "
f"ssot__AIAgentSession__dlm rows — pick one and re-invoke with its UUID:",
"",
]
for r in rows:
uuid = r.get("ssot__Id__c")
start = r.get("ssot__StartTimestamp__c") or ""
end = r.get("ssot__EndTimestamp__c") or ""
end_type = r.get("ssot__AiAgentSessionEndType__c") or ""
channel = r.get("ssot__AiAgentChannelType__c") or ""
lines.append(
f" {uuid} start={start} end={end} end_type={end_type} channel={channel}"
)
lines.append("")
lines.append(
f"Example: python3 scripts/fetch_dc.py --session {rows[0].get('ssot__Id__c')} --org <alias>"
)
return "\n".join(lines)
def resolve(sid_or_msg: str, *, org: str) -> str:
"""Resolve an AI-agent session UUID from either form (live DC query).
Exits the process on zero- and multi-match with a diagnostic message.
Pass-through for UUID input."""
if not is_messaging_id(sid_or_msg):
return sid_or_msg # already a UUID (or unknown form — let caller fail)
rows = _live_lookup(sid_or_msg, org)
if len(rows) == 0:
raise SystemExit(
f"resolve_session: no ssot__AIAgentSession__dlm row with "
f"RelatedMessagingSessionId={sid_or_msg!r} in org {org!r}"
)
if len(rows) > 1:
raise SystemExit(_format_multi_match(sid_or_msg, rows))
return rows[0]["ssot__Id__c"]
# ---- disk-first resolution (no DC call) ----------------------------------
def resolve_from_disk(sid_or_msg: str) -> Optional[str]:
"""For scripts that don't have `--org` wired up. Scans every
``dc.sessions.json`` under ``DATA_ROOT`` for a row whose
``ssot__RelatedMessagingSessionId__c`` matches ``sid_or_msg``. Returns
the UUID (from the matching row's ``ssot__Id__c``) or None if not found.
Layout: the nested scheme is ``DATA_ROOT/<org_id15>/<agent>__<ver>/<uuid>/
dc.sessions.json``. Historic flat runs (``DATA_ROOT/<uuid>/...``) are
picked up by the same ``rglob`` walk, so this resolver works across
both layouts without caller awareness.
Archive suffix: user-created duplicate dirs named like
``<uuid> - archive 1/`` carry stale copies of the same rows and would
otherwise trigger spurious multi-match exits. They are skipped at
scan time.
Pass-through if input is already a UUID; returns input unchanged
regardless of disk presence (caller validates with its own load-
artifact check).
"""
if not is_messaging_id(sid_or_msg):
return sid_or_msg # UUID — pass through (caller validates disk presence)
matches: List[str] = [] # uuids
if not DATA_ROOT.is_dir():
return None
# Nested layout is 4-deep (<org>/<agent>/<uuid>/dc.sessions.json), flat
# is 2-deep (<uuid>/dc.sessions.json). rglob handles both without a
# hard-coded depth.
for sessions_p in sorted(DATA_ROOT.rglob("dc.sessions.json")):
if not sessions_p.is_file():
continue
# Skip `<uuid> - archive N/` duplicate dirs — they hold stale copies
# of the same rows and would produce spurious multi-match exits.
if " - archive" in sessions_p.parent.name:
continue
try:
data = json.loads(sessions_p.read_text())
except (json.JSONDecodeError, OSError):
continue
# `dc.sessions.json` is a list of rows (the skill saves raw DC rows).
if not isinstance(data, list):
continue
for row in data:
row_msg = (row.get("ssot__RelatedMessagingSessionId__c") or "").strip()
if row_msg in _NOT_SET:
continue
if row_msg == sid_or_msg:
uuid = row.get("ssot__Id__c")
if uuid:
matches.append(uuid)
if not matches:
return None
# De-duplicate across multiple dc.sessions.json files that happen to
# contain the same row (possible if a user copies artifacts between
# agent dirs). Distinct UUIDs remain a multi-match.
unique = sorted(set(matches))
if len(unique) > 1:
raise SystemExit(
f"resolve_session: messaging id {sid_or_msg!r} matches {len(unique)} "
f"session directories on disk — pick a UUID and re-invoke:\n "
+ "\n ".join(unique)
)
return unique[0]
def resolve_disk_or_live(sid_or_msg: str, org: Optional[str] = None) -> str:
"""Combined path used by entry points that support both. Tries disk
first (cheap); falls back to live DC only if `org` is supplied.
Raises with a useful message if disk-miss and no org."""
if not is_messaging_id(sid_or_msg):
return sid_or_msg # UUID — no resolution needed
disk = resolve_from_disk(sid_or_msg)
if disk is not None:
return disk
if org is None:
raise SystemExit(
f"resolve_session: cannot resolve messaging id {sid_or_msg!r} from "
f"disk (no dc.sessions.json under DATA_ROOT has a matching "
f"RelatedMessagingSessionId). Run `python3 scripts/fetch_dc.py "
f"--session {sid_or_msg} --org <alias>` first, or re-invoke with "
f"the AI-agent session UUID directly."
)
return resolve(sid_or_msg, org=org)
# ---- CLI -----------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser(
description="Resolve a Salesforce messaging session id (0Mw...) to the "
"AI-agent session UUID the skill uses. Passes UUID input through unchanged."
)
ap.add_argument("--id", required=True,
help="Either the AI-agent session UUID or the MessagingSession id (0Mw...)")
ap.add_argument("--org", help="sf org alias (required for live DC lookup when input is 0Mw...)")
ap.add_argument("--disk-only", action="store_true",
help="Only scan DATA_ROOT/*/dc.sessions.json; no DC query")
args = ap.parse_args()
if not is_messaging_id(args.id):
print(args.id)
return 0
if args.disk_only:
found = resolve_from_disk(args.id)
if found is None:
print(f"resolve_session: no local session dir matches {args.id!r}", file=sys.stderr)
return 1
print(found)
return 0
if not args.org:
# Try disk first as a convenience; only error if that also misses.
disk = resolve_from_disk(args.id)
if disk is not None:
print(disk)
return 0
print(
f"resolve_session: {args.id!r} not found on disk; --org <alias> required "
f"for a live DC lookup",
file=sys.stderr,
)
return 2
uuid = resolve(args.id, org=args.org)
print(uuid)
return 0
if __name__ == "__main__":
raise SystemExit(main())