scripts/inspect_sessions.py
scripts/inspect_sessions.pyBrowse 2 files
5,244 tokens
24,300 bytes
Token encoding: o200k_base
Snapshot 229ffef
← Back to SKILL.md
1#!/usr/bin/env python32"""Inspect conversations in the local Deep Agents Code session store."""3 4from __future__ import annotations5 6import argparse7import importlib8import json9import os10import shutil11import sqlite312import subprocess # noqa: S404 # Used only to probe resolved dcode Python launchers.13import sys14import warnings15from pathlib import Path16from typing import TYPE_CHECKING, cast17 18if TYPE_CHECKING:19 from collections.abc import Sequence20 21 from langchain_core.messages import MessageLikeRepresentation22 from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer23 24os.environ["LANGGRAPH_STRICT_MSGPACK"] = "true"25 26_RUNTIME_IMPORTS = (27 ("langgraph.checkpoint.serde.jsonplus", "JsonPlusSerializer"),28 ("langgraph.graph.message", "add_messages"),29 ("langgraph.types", "Overwrite"),30)31 32 33def _has_runtime() -> bool:34 try:35 for module, symbol in _RUNTIME_IMPORTS:36 getattr(importlib.import_module(module), symbol)37 except (AttributeError, ImportError):38 return False39 return True40 41 42def _ensure_runtime() -> None:43 if _has_runtime():44 return45 if os.environ.get("DEEPAGENTS_THREAD_INSPECTOR_REEXEC") == "1":46 msg = (47 "The selected Python runtime does not contain Deep Agents Code "48 "dependencies."49 )50 raise SystemExit(msg)51 52 candidates: list[Path] = []53 for command in ("dcode", "deepagents-code"):54 executable = shutil.which(command)55 if not executable:56 continue57 launcher = Path(executable).resolve()58 try:59 first_line = launcher.read_text(encoding="utf-8").splitlines()[0]60 except (OSError, UnicodeDecodeError, IndexError):61 first_line = ""62 if first_line.startswith("#!"):63 candidates.append(Path(first_line[2:].strip()))64 candidates.extend((launcher.parent / "python", launcher.parent / "python3"))65 66 seen: set[str] = set()67 for candidate in candidates:68 candidate_key = str(candidate.absolute())69 if candidate_key in seen or not candidate.is_file():70 continue71 seen.add(candidate_key)72 try:73 check = subprocess.run( # noqa: S603 # Runs a resolved dcode interpreter.74 [75 str(candidate),76 "-c",77 "; ".join(78 f"from {module} import {symbol}"79 for module, symbol in _RUNTIME_IMPORTS80 ),81 ],82 stdout=subprocess.DEVNULL,83 stderr=subprocess.DEVNULL,84 check=False,85 timeout=10,86 )87 except (subprocess.TimeoutExpired, OSError):88 # A hung or non-executable candidate must not abort the search;89 # skip it and fall through to the next one (or the clear error below).90 continue91 if check.returncode == 0:92 env = os.environ.copy()93 env["DEEPAGENTS_THREAD_INSPECTOR_REEXEC"] = "1"94 os.execve( # noqa: S606 # Replaces this process with that interpreter.95 str(candidate),96 [str(candidate), str(Path(__file__).resolve()), *sys.argv[1:]],97 env,98 )99 100 msg = "Could not import Deep Agents Code dependencies or locate dcode on PATH."101 raise SystemExit(msg)102 103 104def _default_db_path() -> Path:105 """Return the sessions DB path dcode itself would use.106 107 Returns:108 The default sessions database path.109 110 Raises:111 SystemExit: If `DEEPAGENTS_HOME` is set to a value dcode rejects, so112 this script never reads a database dcode never writes.113 """114 explicit = os.environ.get("DEEPAGENTS_SESSIONS_DB")115 if explicit:116 return Path(explicit).expanduser()117 try:118 from deepagents_code._paths import get_deepagents_home # noqa: PLC2701119 except ImportError:120 # Standalone fallback. It must apply the same rules as121 # `_paths._resolve_profile_root`: relative paths and `~user` forms are122 # rejected rather than coerced, because a lenient reading here would123 # silently inspect a different profile than the app uses.124 configured = os.environ.get("DEEPAGENTS_HOME")125 if not configured:126 home = Path.home() / ".deepagents"127 elif configured.startswith("~/"):128 home = Path.home() / configured[2:].lstrip("/")129 elif configured.startswith("~") or not Path(configured).is_absolute():130 msg = (131 f"Invalid DEEPAGENTS_HOME {configured!r}: use an absolute path "132 "or a path beginning with '~/'."133 )134 raise SystemExit(msg) from None135 else:136 home = Path(configured)137 else:138 home = get_deepagents_home()139 return home / ".state" / "sessions.db"140 141 142def _connect_read_only(path: Path) -> sqlite3.Connection:143 resolved = path.expanduser().resolve()144 if not resolved.is_file():145 msg = f"Sessions database not found: {resolved}"146 raise SystemExit(msg)147 conn = sqlite3.connect(f"{resolved.as_uri()}?mode=ro", uri=True)148 conn.row_factory = sqlite3.Row149 tables = {150 row[0]151 for row in conn.execute(152 "SELECT name FROM sqlite_master WHERE type = 'table'"153 ).fetchall()154 }155 missing = {"checkpoints", "writes"} - tables156 if missing:157 conn.close()158 names = ", ".join(sorted(missing))159 msg = f"Not a supported sessions database; missing tables: {names}"160 raise SystemExit(msg)161 return conn162 163 164def _resolve_thread_id(conn: sqlite3.Connection, value: str) -> str:165 exact = conn.execute(166 "SELECT 1 FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = '' LIMIT 1",167 (value,),168 ).fetchone()169 if exact:170 return value171 escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")172 rows = conn.execute(173 "SELECT DISTINCT thread_id FROM checkpoints "174 "WHERE checkpoint_ns = '' AND thread_id LIKE ? ESCAPE '\\' "175 "ORDER BY thread_id LIMIT 11",176 (escaped + "%",),177 ).fetchall()178 matches = [str(row[0]) for row in rows]179 if not matches:180 msg = f"Thread not found: {value}"181 raise SystemExit(msg)182 if len(matches) > 1:183 rendered = "\n".join(f" {match}" for match in matches[:10])184 msg = f"Thread prefix is ambiguous:\n{rendered}"185 raise SystemExit(msg)186 return matches[0]187 188 189def _decode_metadata(190 value: object, warnings: list[str] | None = None191) -> dict[str, object]:192 if isinstance(value, bytes):193 try:194 value = value.decode("utf-8")195 except UnicodeDecodeError:196 if warnings is not None:197 warnings.append("Checkpoint metadata was not valid UTF-8.")198 return {}199 if not isinstance(value, str) or not value:200 return {}201 try:202 decoded = json.loads(value)203 except ValueError:204 if warnings is not None:205 warnings.append("Checkpoint metadata was not valid JSON.")206 return {}207 if not isinstance(decoded, dict):208 return {}209 return {str(key): item for key, item in decoded.items()}210 211 212def _thread_summary(213 conn: sqlite3.Connection,214 thread_id: str,215 message_count: int | None = None,216 warnings: list[str] | None = None,217) -> tuple[dict[str, object], dict[str, object]]:218 aggregate = conn.execute(219 "SELECT COUNT(*) AS checkpoint_count, "220 "MIN(json_extract(metadata, '$.updated_at')) AS created_at, "221 "MAX(json_extract(metadata, '$.updated_at')) AS updated_at, "222 "MAX(checkpoint_id) AS latest_checkpoint_id "223 "FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ''",224 (thread_id,),225 ).fetchone()226 latest = conn.execute(227 "SELECT metadata FROM checkpoints "228 "WHERE thread_id = ? AND checkpoint_ns = '' "229 "ORDER BY checkpoint_id DESC LIMIT 1",230 (thread_id,),231 ).fetchone()232 metadata = _decode_metadata(latest[0] if latest else None, warnings)233 writes_count = conn.execute(234 "SELECT COUNT(*) FROM writes WHERE thread_id = ? AND checkpoint_ns = ''",235 (thread_id,),236 ).fetchone()[0]237 summary: dict[str, object] = {238 "thread_id": thread_id,239 "agent_name": metadata.get("agent_name"),240 "created_at": aggregate["created_at"],241 "updated_at": aggregate["updated_at"],242 "latest_checkpoint_id": aggregate["latest_checkpoint_id"],243 "checkpoint_count": aggregate["checkpoint_count"],244 "writes_count": writes_count,245 "git_branch": metadata.get("git_branch"),246 "git_commit_sha": metadata.get("git_commit_sha"),247 "cwd": metadata.get("cwd"),248 "repository_name": metadata.get("repository_name"),249 "repository_url": metadata.get("repository_url"),250 }251 if message_count is not None:252 summary["message_count"] = message_count253 return summary, metadata254 255 256def _list_threads(conn: sqlite3.Connection, limit: int) -> list[dict[str, object]]:257 rows = conn.execute(258 "SELECT thread_id, "259 "MAX(json_extract(metadata, '$.updated_at')) AS updated_at, "260 "MIN(json_extract(metadata, '$.updated_at')) AS created_at, "261 "MAX(json_extract(metadata, '$.agent_name')) AS agent_name, "262 "MAX(json_extract(metadata, '$.git_branch')) AS git_branch, "263 "MAX(json_extract(metadata, '$.cwd')) AS cwd, "264 "COUNT(*) AS checkpoint_count "265 "FROM checkpoints WHERE checkpoint_ns = '' "266 "GROUP BY thread_id ORDER BY updated_at DESC LIMIT ?",267 (limit,),268 ).fetchall()269 return [dict(row) for row in rows]270 271 272def _load_inline_messages(273 conn: sqlite3.Connection,274 thread_id: str,275 serde: JsonPlusSerializer,276 warnings: list[str] | None = None,277) -> tuple[str, list[MessageLikeRepresentation]] | None:278 checkpoint_row = conn.execute(279 "SELECT checkpoint_id, type, checkpoint FROM checkpoints "280 "WHERE thread_id = ? AND checkpoint_ns = '' "281 "ORDER BY checkpoint_id DESC LIMIT 1",282 (thread_id,),283 ).fetchone()284 if (285 not checkpoint_row286 or not checkpoint_row["type"]287 or not checkpoint_row["checkpoint"]288 ):289 return None290 try:291 checkpoint = serde.loads_typed(292 (checkpoint_row["type"], checkpoint_row["checkpoint"])293 )294 except Exception as exc:295 # Corrupt latest checkpoint: fall back to replaying the writes table296 # rather than aborting, and record why the fast path was skipped.297 if warnings is not None:298 warnings.append(299 f"Could not deserialize the latest checkpoint ({exc}); "300 "falling back to the writes table."301 )302 return None303 if not isinstance(checkpoint, dict):304 return None305 channel_values = checkpoint.get("channel_values")306 if not isinstance(channel_values, dict) or "messages" not in channel_values:307 return None308 inline = channel_values["messages"]309 if not isinstance(inline, list):310 # Present but malformed: force the writes fallback instead of reporting311 # an empty conversation for a thread that may hold real messages.312 if warnings is not None:313 warnings.append(314 "Inline checkpoint messages were malformed; "315 "falling back to the writes table."316 )317 return None318 return (319 str(checkpoint_row["checkpoint_id"]),320 list(cast("list[MessageLikeRepresentation]", inline)),321 )322 323 324def _reconstruct_messages(325 conn: sqlite3.Connection,326 thread_id: str,327 warnings: list[str] | None = None,328) -> list[MessageLikeRepresentation]:329 from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer330 from langgraph.graph.message import add_messages331 from langgraph.types import Overwrite332 333 serde = JsonPlusSerializer()334 inline_checkpoint = _load_inline_messages(conn, thread_id, serde, warnings)335 if inline_checkpoint is None:336 messages: list[MessageLikeRepresentation] = []337 rows = conn.execute(338 "SELECT checkpoint_id, task_id, idx, type, value FROM writes "339 "WHERE thread_id = ? AND checkpoint_ns = '' AND channel = 'messages' "340 "ORDER BY checkpoint_id ASC, task_id ASC, idx ASC",341 (thread_id,),342 ).fetchall()343 else:344 checkpoint_id, messages = inline_checkpoint345 rows = conn.execute(346 "SELECT checkpoint_id, task_id, idx, type, value FROM writes "347 "WHERE thread_id = ? AND checkpoint_ns = '' AND checkpoint_id = ? "348 "AND channel = 'messages' ORDER BY task_id ASC, idx ASC",349 (thread_id, checkpoint_id),350 ).fetchall()351 for row in rows:352 type_name = row["type"]353 value = row["value"]354 if not type_name or value is None:355 continue356 try:357 delta = serde.loads_typed((type_name, value))358 except Exception as exc:359 # Skip a single undecodable write and keep replaying the rest.360 if warnings is not None:361 warnings.append(362 f"Skipped an undecodable write for checkpoint "363 f"{row['checkpoint_id']} ({exc})."364 )365 continue366 if isinstance(delta, Overwrite):367 if isinstance(delta.value, list):368 # Overwrite replaces the whole channel with its list payload.369 messages = list(cast("list[MessageLikeRepresentation]", delta.value))370 elif warnings is not None:371 # A non-list payload is malformed; preserve accumulated messages372 # rather than silently discarding earlier history.373 warnings.append(374 f"Ignored a malformed channel overwrite for checkpoint "375 f"{row['checkpoint_id']}."376 )377 else:378 # `add_messages` normalizes both inputs to a list despite its broad alias.379 messages = cast(380 "list[MessageLikeRepresentation]", add_messages(messages, delta)381 )382 return messages383 384 385def _content_text(content: object) -> str:386 if isinstance(content, str):387 return content388 if content is None:389 return ""390 if isinstance(content, list):391 parts: list[str] = []392 for block in content:393 if isinstance(block, str):394 parts.append(block)395 continue396 if not isinstance(block, dict):397 parts.append(str(block))398 continue399 block_type = block.get("type")400 phase = block.get("phase")401 if block_type in {"reasoning", "thinking"} or phase == "analysis":402 continue403 text = block.get("text")404 if isinstance(text, str):405 parts.append(text)406 continue407 nested = block.get("content")408 if isinstance(nested, str):409 parts.append(nested)410 return "\n".join(part for part in parts if part)411 if isinstance(content, dict):412 return json.dumps(content, ensure_ascii=False, default=str)413 return str(content)414 415 416def _truncate(text: str, limit: int) -> tuple[str, bool]:417 if len(text) <= limit:418 return text, False419 return text[:limit] + "…", True420 421 422def _bounded_value(value: object, limit: int) -> tuple[object, bool]:423 try:424 rendered = json.dumps(value, ensure_ascii=False, default=str)425 except TypeError:426 rendered = str(value)427 if len(rendered) <= limit:428 return value, False429 return rendered[:limit] + "…", True430 431 432_ROLE_ALIASES = {"human": "user", "ai": "assistant", "tool": "tool"}433 434 435def _message_role(message: object) -> str:436 if isinstance(message, dict):437 raw = message.get("role") or message.get("type") or "unknown"438 else:439 raw = getattr(message, "type", type(message).__name__)440 return _ROLE_ALIASES.get(str(raw), str(raw))441 442 443def _message_record(444 index: int,445 message: object,446 max_content: int,447 warnings: list[str] | None = None,448) -> dict[str, object]:449 if isinstance(message, dict):450 content = message.get("content")451 name = message.get("name")452 message_id = message.get("id")453 raw_tool_calls = message.get("tool_calls")454 tool_call_id = message.get("tool_call_id")455 status = message.get("status")456 else:457 content = getattr(message, "content", None)458 name = getattr(message, "name", None)459 message_id = getattr(message, "id", None)460 raw_tool_calls = getattr(message, "tool_calls", None)461 tool_call_id = getattr(message, "tool_call_id", None)462 status = getattr(message, "status", None)463 tool_calls = raw_tool_calls if isinstance(raw_tool_calls, list) else []464 malformed_tool_calls = (465 raw_tool_calls466 if raw_tool_calls and not isinstance(raw_tool_calls, list)467 else None468 )469 470 text = _content_text(content)471 bounded_text, content_truncated = _truncate(text, max_content)472 record: dict[str, object] = {473 "index": index,474 "role": _message_role(message),475 "name": name,476 "id": message_id,477 "content": bounded_text,478 "content_chars": len(text),479 "content_truncated": content_truncated,480 }481 if tool_calls:482 rendered_calls: list[dict[str, object]] = []483 for call in tool_calls:484 if isinstance(call, dict):485 args, args_truncated = _bounded_value(call.get("args"), max_content)486 rendered_calls.append(487 {488 "name": call.get("name"),489 "id": call.get("id"),490 "args": args,491 "args_truncated": args_truncated,492 }493 )494 else:495 rendered_calls.append({"value": str(call)})496 record["tool_calls"] = rendered_calls497 if malformed_tool_calls is not None:498 # Present but not a list: preserve it as text rather than hiding tool499 # activity, and flag it for the summarizing agent.500 record["tool_calls_malformed"] = str(malformed_tool_calls)501 if warnings is not None:502 warnings.append(503 f"Message {index} had non-list tool_calls; preserved as raw text."504 )505 if tool_call_id:506 record["tool_call_id"] = tool_call_id507 if status:508 record["status"] = status509 return record510 511 512def _is_user_message(message: object) -> bool:513 role = _message_role(message)514 if role not in {"user", "human"}:515 return False516 content = (517 message.get("content")518 if isinstance(message, dict)519 else getattr(message, "content", None)520 )521 return not _content_text(content).startswith("[SYSTEM]")522 523 524def _turns(525 messages: Sequence[object],526 max_content: int,527 warnings: list[str] | None = None,528) -> tuple[list[dict[str, object]], list[dict[str, object]]]:529 starts = [530 index for index, message in enumerate(messages) if _is_user_message(message)531 ]532 if not starts:533 return [], [534 _message_record(index, message, max_content, warnings)535 for index, message in enumerate(messages)536 ]537 preamble = [538 _message_record(index, message, max_content, warnings)539 for index, message in enumerate(messages[: starts[0]])540 ]541 turns: list[dict[str, object]] = []542 for turn_index, start in enumerate(starts):543 end = starts[turn_index + 1] if turn_index + 1 < len(starts) else len(messages)544 turns.append(545 {546 "number": turn_index + 1,547 "start_message_index": start,548 "end_message_index": end - 1,549 "messages": [550 _message_record(index, messages[index], max_content, warnings)551 for index in range(start, end)552 ],553 }554 )555 return turns, preamble556 557 558def _build_parser() -> argparse.ArgumentParser:559 parser = argparse.ArgumentParser(560 description=(561 "Inspect Deep Agents Code thread state without modifying the database."562 )563 )564 parser.add_argument("thread_id", nargs="?", help="Full thread ID or unique prefix")565 parser.add_argument(566 "--db", type=Path, default=_default_db_path(), help="Path to sessions.db"567 )568 parser.add_argument(569 "--mode",570 choices=("summary", "latest-turn", "transcript"),571 default="latest-turn",572 )573 parser.add_argument(574 "--list",575 type=int,576 metavar="N",577 dest="list_limit",578 help="List recent threads (ignores --mode/--max-content/--include-metadata)",579 )580 parser.add_argument(581 "--max-content",582 type=int,583 default=4000,584 help="Maximum characters retained per message or tool-call argument",585 )586 parser.add_argument(587 "--include-metadata",588 action="store_true",589 help="Include latest checkpoint metadata",590 )591 return parser592 593 594def main() -> None:595 """Parse arguments, inspect the session store, and write JSON to stdout.596 597 Raises:598 SystemExit: If the command-line arguments are invalid, the local session599 store is missing or unsupported, or the Deep Agents Code runtime600 cannot be located.601 """602 args = _build_parser().parse_args()603 if args.max_content < 1:604 msg = "--max-content must be positive"605 raise SystemExit(msg)606 if args.list_limit is not None and args.list_limit < 1:607 msg = "--list must be positive"608 raise SystemExit(msg)609 if args.list_limit is None and not args.thread_id:610 msg = "Provide a thread ID or use --list N"611 raise SystemExit(msg)612 if args.list_limit is not None and args.thread_id:613 msg = "Use either a thread ID or --list N, not both"614 raise SystemExit(msg)615 616 _ensure_runtime()617 warnings.filterwarnings(618 "ignore",619 message=(620 "Core Pydantic V1 functionality isn't compatible with Python 3.14 "621 "or greater.*"622 ),623 )624 collected_warnings: list[str] = []625 result: dict[str, object]626 conn = _connect_read_only(args.db)627 try:628 if args.list_limit is not None:629 if args.include_metadata or args.mode != "latest-turn":630 collected_warnings.append(631 "--mode and --include-metadata are ignored when listing threads."632 )633 result = {634 "database": str(args.db.expanduser().resolve()),635 "threads": _list_threads(conn, args.list_limit),636 }637 else:638 thread_id = _resolve_thread_id(conn, args.thread_id)639 messages = _reconstruct_messages(conn, thread_id, collected_warnings)640 summary, metadata = _thread_summary(641 conn, thread_id, len(messages), collected_warnings642 )643 result = {644 "database": str(args.db.expanduser().resolve()),645 "thread": summary,646 }647 if args.include_metadata:648 result["latest_metadata"] = metadata649 if args.mode != "summary":650 turns, preamble = _turns(messages, args.max_content, collected_warnings)651 result["turn_count"] = len(turns)652 if args.mode == "latest-turn":653 latest_turn = turns[-1] if turns else None654 result["latest_turn"] = latest_turn655 if latest_turn is not None:656 latest_turn["stored_turn_number"] = metadata.get("turn_number")657 latest_turn["turn_id"] = metadata.get("turn_id")658 else:659 result["preamble"] = preamble660 result["turns"] = turns661 if collected_warnings:662 result["warnings"] = collected_warnings663 print(json.dumps(result, indent=2, ensure_ascii=False, default=str))664 finally:665 conn.close()666 667 668if __name__ == "__main__":669 main()670