From 1e36f13fca73740cd918e8f93cd51126a352821f Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Wed, 15 Jul 2026 17:37:54 -0400 Subject: [PATCH 1/5] feat(code): add built-in thread inspector skill --- .../deepagents-thread-inspector/SKILL.md | 49 ++ .../scripts/inspect_sessions.py | 492 ++++++++++++++++++ .../code/tests/unit_tests/skills/test_load.py | 23 + .../skills/test_thread_inspector.py | 158 ++++++ .../system_prompt_interactive_local.md | 2 + 5 files changed, 724 insertions(+) create mode 100644 libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md create mode 100755 libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py create mode 100644 libs/code/tests/unit_tests/skills/test_thread_inspector.py diff --git a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md new file mode 100644 index 0000000000..9b9fe44c8a --- /dev/null +++ b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md @@ -0,0 +1,49 @@ +--- +name: deepagents-thread-inspector +description: Inspect and explain conversations stored in the local Deep Agents Code SQLite session store. Use when asked to identify a dcode thread, summarize what happened in a thread or its latest turn, recover conversation/tool activity, inspect checkpoint metadata, list recent local threads, or parse ~/.deepagents/.state/sessions.db and a thread UUID or prefix. +license: MIT +compatibility: designed for deepagents-code +--- + +# Deep Agents Thread Inspector + +Use `scripts/inspect_sessions.py` instead of manually decoding database blobs. It opens the database read-only, deserializes the root message channel with LangGraph's strict MsgPack loader, replays writes in checkpoint order, and emits JSON. + +## Inspect a thread + +Resolve `SKILL_DIR` to the directory containing this `SKILL.md`; do not assume a user, project, or installation-specific location. Start with the smallest useful view: + +```bash +python3 "$SKILL_DIR/scripts/inspect_sessions.py" THREAD_ID --mode latest-turn +``` + +A unique thread-ID prefix is accepted. Select another view when needed: + +```bash +python3 "$SKILL_DIR/scripts/inspect_sessions.py" THREAD_ID --mode summary +python3 "$SKILL_DIR/scripts/inspect_sessions.py" THREAD_ID --mode transcript +``` + +Use `--include-metadata` only when run, repository, model, checkpoint, or LangGraph metadata matters. Use `--max-content N` to raise or lower the default 4,000-character limit per message, tool result, or tool-call argument. + +If the user does not know the ID, list recent threads first: + +```bash +python3 "$SKILL_DIR/scripts/inspect_sessions.py" --list 20 +``` + +Pass `--db PATH` only for a non-default session store. The default is `~/.deepagents/.state/sessions.db`; `DEEPAGENTS_SESSIONS_DB` can override it. + +## Explain the result + +Synthesize the JSON rather than pasting it verbatim. + +- State the user's request, the assistant's conclusion, and significant tool actions or failures. +- Distinguish stored facts from your interpretation. +- For the latest turn, describe only the final user message and subsequent activity unless earlier context is required to make it understandable. +- Mention truncation when a relevant record has `content_truncated` or `args_truncated` set. +- Do not expose unrelated credentials, tokens, personal data, or hidden reasoning that may appear in local records. + +## Safety + +Keep inspection read-only. Do not deserialize an untrusted database: checkpoint deserialization is intended for trusted local Deep Agents state. Do not mutate or delete session rows unless the user separately and explicitly requests it. diff --git a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py new file mode 100755 index 0000000000..3da0fbc53f --- /dev/null +++ b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +"""Inspect conversations in the local Deep Agents Code session store.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import shutil +import sqlite3 +import subprocess # noqa: S404 # Used only to probe resolved dcode Python launchers. +import sys +import warnings +from pathlib import Path +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from collections.abc import Sequence + + from langchain_core.messages import MessageLikeRepresentation + +os.environ["LANGGRAPH_STRICT_MSGPACK"] = "true" + + +def _has_runtime() -> bool: + try: + return ( + importlib.util.find_spec("langgraph.checkpoint.serde.jsonplus") is not None + ) + except ModuleNotFoundError: + return False + + +def _ensure_runtime() -> None: + if _has_runtime(): + return + if os.environ.get("DEEPAGENTS_THREAD_INSPECTOR_REEXEC") == "1": + msg = ( + "The selected Python runtime does not contain Deep Agents Code " + "dependencies." + ) + raise SystemExit(msg) + + candidates: list[Path] = [] + for command in ("dcode", "deepagents-code"): + executable = shutil.which(command) + if not executable: + continue + launcher = Path(executable).resolve() + try: + first_line = launcher.read_text(encoding="utf-8").splitlines()[0] + except (OSError, UnicodeDecodeError, IndexError): + first_line = "" + if first_line.startswith("#!"): + candidates.append(Path(first_line[2:].strip())) + candidates.extend((launcher.parent / "python", launcher.parent / "python3")) + + seen: set[str] = set() + for candidate in candidates: + candidate_key = str(candidate.absolute()) + if candidate_key in seen or not candidate.is_file(): + continue + seen.add(candidate_key) + check = subprocess.run( # noqa: S603 # Runs a resolved dcode interpreter. + [ + str(candidate), + "-c", + "from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=10, + ) + if check.returncode == 0: + env = os.environ.copy() + env["DEEPAGENTS_THREAD_INSPECTOR_REEXEC"] = "1" + os.execve( # noqa: S606 # Replaces this process with that interpreter. + str(candidate), + [str(candidate), str(Path(__file__).resolve()), *sys.argv[1:]], + env, + ) + + msg = "Could not import Deep Agents Code dependencies or locate dcode on PATH." + raise SystemExit(msg) + + +def _default_db_path() -> Path: + explicit = os.environ.get("DEEPAGENTS_SESSIONS_DB") + if explicit: + return Path(explicit).expanduser() + return Path.home() / ".deepagents" / ".state" / "sessions.db" + + +def _connect_read_only(path: Path) -> sqlite3.Connection: + resolved = path.expanduser().resolve() + if not resolved.is_file(): + msg = f"Sessions database not found: {resolved}" + raise SystemExit(msg) + conn = sqlite3.connect(f"{resolved.as_uri()}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + missing = {"checkpoints", "writes"} - tables + if missing: + conn.close() + names = ", ".join(sorted(missing)) + msg = f"Not a supported sessions database; missing tables: {names}" + raise SystemExit(msg) + return conn + + +def _resolve_thread_id(conn: sqlite3.Connection, value: str) -> str: + exact = conn.execute( + "SELECT 1 FROM checkpoints WHERE thread_id = ? LIMIT 1", (value,) + ).fetchone() + if exact: + return value + escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + rows = conn.execute( + "SELECT DISTINCT thread_id FROM checkpoints " + "WHERE thread_id LIKE ? ESCAPE '\\' ORDER BY thread_id LIMIT 11", + (escaped + "%",), + ).fetchall() + matches = [str(row[0]) for row in rows] + if not matches: + msg = f"Thread not found: {value}" + raise SystemExit(msg) + if len(matches) > 1: + rendered = "\n".join(f" {match}" for match in matches[:10]) + msg = f"Thread prefix is ambiguous:\n{rendered}" + raise SystemExit(msg) + return matches[0] + + +def _decode_metadata(value: object) -> dict[str, object]: + if isinstance(value, bytes): + value = value.decode("utf-8") + if not isinstance(value, str) or not value: + return {} + decoded = json.loads(value) + if not isinstance(decoded, dict): + return {} + return {str(key): item for key, item in decoded.items()} + + +def _thread_summary( + conn: sqlite3.Connection, + thread_id: str, + message_count: int | None = None, +) -> tuple[dict[str, object], dict[str, object]]: + aggregate = conn.execute( + "SELECT COUNT(*) AS checkpoint_count, " + "MIN(json_extract(metadata, '$.updated_at')) AS created_at, " + "MAX(json_extract(metadata, '$.updated_at')) AS updated_at, " + "MAX(checkpoint_id) AS latest_checkpoint_id " + "FROM checkpoints WHERE thread_id = ?", + (thread_id,), + ).fetchone() + latest = conn.execute( + "SELECT metadata FROM checkpoints WHERE thread_id = ? " + "ORDER BY checkpoint_id DESC LIMIT 1", + (thread_id,), + ).fetchone() + metadata = _decode_metadata(latest[0] if latest else None) + writes_count = conn.execute( + "SELECT COUNT(*) FROM writes WHERE thread_id = ?", (thread_id,) + ).fetchone()[0] + summary: dict[str, object] = { + "thread_id": thread_id, + "agent_name": metadata.get("agent_name"), + "created_at": aggregate["created_at"], + "updated_at": aggregate["updated_at"], + "latest_checkpoint_id": aggregate["latest_checkpoint_id"], + "checkpoint_count": aggregate["checkpoint_count"], + "writes_count": writes_count, + "git_branch": metadata.get("git_branch"), + "git_commit_sha": metadata.get("git_commit_sha"), + "cwd": metadata.get("cwd"), + "repository_name": metadata.get("repository_name"), + "repository_url": metadata.get("repository_url"), + } + if message_count is not None: + summary["message_count"] = message_count + return summary, metadata + + +def _list_threads(conn: sqlite3.Connection, limit: int) -> list[dict[str, object]]: + rows = conn.execute( + "SELECT thread_id, " + "MAX(json_extract(metadata, '$.updated_at')) AS updated_at, " + "MIN(json_extract(metadata, '$.updated_at')) AS created_at, " + "MAX(json_extract(metadata, '$.agent_name')) AS agent_name, " + "MAX(json_extract(metadata, '$.git_branch')) AS git_branch, " + "MAX(json_extract(metadata, '$.cwd')) AS cwd, " + "COUNT(*) AS checkpoint_count " + "FROM checkpoints GROUP BY thread_id ORDER BY updated_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + +def _reconstruct_messages( + conn: sqlite3.Connection, thread_id: str +) -> list[MessageLikeRepresentation]: + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + from langgraph.graph.message import add_messages + from langgraph.types import Overwrite + + rows = conn.execute( + "SELECT checkpoint_id, task_id, idx, type, value FROM writes " + "WHERE thread_id = ? AND checkpoint_ns = '' AND channel = 'messages' " + "ORDER BY checkpoint_id ASC, task_id ASC, idx ASC", + (thread_id,), + ).fetchall() + serde = JsonPlusSerializer() + messages: list[MessageLikeRepresentation] = [] + for row in rows: + type_name = row["type"] + value = row["value"] + if not type_name or value is None: + continue + delta = serde.loads_typed((type_name, value)) + if isinstance(delta, Overwrite): + # Message-channel overwrites contain a list; ignore malformed values. + messages = ( + list(cast("list[MessageLikeRepresentation]", delta.value)) + if isinstance(delta.value, list) + else [] + ) + else: + # `add_messages` normalizes both inputs to a list despite its broad alias. + messages = cast( + "list[MessageLikeRepresentation]", add_messages(messages, delta) + ) + return messages + + +def _content_text(content: object) -> str: + if isinstance(content, str): + return content + if content is None: + return "" + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, str): + parts.append(block) + continue + if not isinstance(block, dict): + parts.append(str(block)) + continue + block_type = block.get("type") + phase = block.get("phase") + if block_type in {"reasoning", "thinking"} or phase == "analysis": + continue + text = block.get("text") + if isinstance(text, str): + parts.append(text) + continue + nested = block.get("content") + if isinstance(nested, str): + parts.append(nested) + return "\n".join(part for part in parts if part) + if isinstance(content, dict): + return json.dumps(content, ensure_ascii=False, default=str) + return str(content) + + +def _truncate(text: str, limit: int) -> tuple[str, bool]: + if len(text) <= limit: + return text, False + return text[:limit] + "…", True + + +def _bounded_value(value: object, limit: int) -> tuple[object, bool]: + try: + rendered = json.dumps(value, ensure_ascii=False, default=str) + except TypeError: + rendered = str(value) + if len(rendered) <= limit: + return value, False + return rendered[:limit] + "…", True + + +def _message_role(message: object) -> str: + if isinstance(message, dict): + return str(message.get("role") or message.get("type") or "unknown") + type_name = getattr(message, "type", type(message).__name__) + return { + "human": "user", + "ai": "assistant", + "tool": "tool", + }.get(type_name, str(type_name)) + + +def _message_record(index: int, message: object, max_content: int) -> dict[str, object]: + if isinstance(message, dict): + content = message.get("content") + name = message.get("name") + message_id = message.get("id") + raw_tool_calls = message.get("tool_calls") + tool_call_id = message.get("tool_call_id") + status = message.get("status") + else: + content = getattr(message, "content", None) + name = getattr(message, "name", None) + message_id = getattr(message, "id", None) + raw_tool_calls = getattr(message, "tool_calls", None) + tool_call_id = getattr(message, "tool_call_id", None) + status = getattr(message, "status", None) + tool_calls = raw_tool_calls if isinstance(raw_tool_calls, list) else [] + + text = _content_text(content) + bounded_text, content_truncated = _truncate(text, max_content) + record: dict[str, object] = { + "index": index, + "role": _message_role(message), + "name": name, + "id": message_id, + "content": bounded_text, + "content_chars": len(text), + "content_truncated": content_truncated, + } + if tool_calls: + rendered_calls: list[dict[str, object]] = [] + for call in tool_calls: + if isinstance(call, dict): + args, args_truncated = _bounded_value(call.get("args"), max_content) + rendered_calls.append( + { + "name": call.get("name"), + "id": call.get("id"), + "args": args, + "args_truncated": args_truncated, + } + ) + else: + rendered_calls.append({"value": str(call)}) + record["tool_calls"] = rendered_calls + if tool_call_id: + record["tool_call_id"] = tool_call_id + if status: + record["status"] = status + return record + + +def _is_user_message(message: object) -> bool: + role = _message_role(message) + if role not in {"user", "human"}: + return False + content = ( + message.get("content") + if isinstance(message, dict) + else getattr(message, "content", None) + ) + return not _content_text(content).startswith("[SYSTEM]") + + +def _turns( + messages: Sequence[object], max_content: int +) -> tuple[list[dict[str, object]], list[dict[str, object]]]: + starts = [ + index for index, message in enumerate(messages) if _is_user_message(message) + ] + if not starts: + return [], [ + _message_record(index, message, max_content) + for index, message in enumerate(messages) + ] + preamble = [ + _message_record(index, message, max_content) + for index, message in enumerate(messages[: starts[0]]) + ] + turns: list[dict[str, object]] = [] + for turn_index, start in enumerate(starts): + end = starts[turn_index + 1] if turn_index + 1 < len(starts) else len(messages) + turns.append( + { + "number": turn_index + 1, + "start_message_index": start, + "end_message_index": end - 1, + "messages": [ + _message_record(index, messages[index], max_content) + for index in range(start, end) + ], + } + ) + return turns, preamble + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Inspect Deep Agents Code thread state without modifying the database." + ) + ) + parser.add_argument("thread_id", nargs="?", help="Full thread ID or unique prefix") + parser.add_argument( + "--db", type=Path, default=_default_db_path(), help="Path to sessions.db" + ) + parser.add_argument( + "--mode", + choices=("summary", "latest-turn", "transcript"), + default="latest-turn", + ) + parser.add_argument( + "--list", type=int, metavar="N", dest="list_limit", help="List recent threads" + ) + parser.add_argument( + "--max-content", + type=int, + default=4000, + help="Maximum characters retained per message or tool-call argument", + ) + parser.add_argument( + "--include-metadata", + action="store_true", + help="Include latest checkpoint metadata", + ) + return parser + + +def main() -> None: + """Parse arguments, inspect the session store, and write JSON to stdout. + + Raises: + SystemExit: If arguments or the local session store are invalid. + """ + args = _build_parser().parse_args() + if args.max_content < 1: + msg = "--max-content must be positive" + raise SystemExit(msg) + if args.list_limit is not None and args.list_limit < 1: + msg = "--list must be positive" + raise SystemExit(msg) + if args.list_limit is None and not args.thread_id: + msg = "Provide a thread ID or use --list N" + raise SystemExit(msg) + if args.list_limit is not None and args.thread_id: + msg = "Use either a thread ID or --list N, not both" + raise SystemExit(msg) + + _ensure_runtime() + warnings.filterwarnings( + "ignore", + message=( + "Core Pydantic V1 functionality isn't compatible with Python 3.14 " + "or greater.*" + ), + ) + conn = _connect_read_only(args.db) + try: + if args.list_limit is not None: + result: dict[str, object] = { + "database": str(args.db.expanduser().resolve()), + "threads": _list_threads(conn, args.list_limit), + } + else: + thread_id = _resolve_thread_id(conn, args.thread_id) + messages = _reconstruct_messages(conn, thread_id) + summary, metadata = _thread_summary(conn, thread_id, len(messages)) + result = { + "database": str(args.db.expanduser().resolve()), + "thread": summary, + } + if args.include_metadata: + result["latest_metadata"] = metadata + if args.mode != "summary": + turns, preamble = _turns(messages, args.max_content) + result["turn_count"] = len(turns) + if args.mode == "latest-turn": + latest_turn = turns[-1] if turns else None + result["latest_turn"] = latest_turn + if latest_turn is not None: + latest_turn["stored_turn_number"] = metadata.get("turn_number") + latest_turn["turn_id"] = metadata.get("turn_id") + else: + result["preamble"] = preamble + result["turns"] = turns + print(json.dumps(result, indent=2, ensure_ascii=False, default=str)) + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/libs/code/tests/unit_tests/skills/test_load.py b/libs/code/tests/unit_tests/skills/test_load.py index 7e1d98a4db..b6c3080f1e 100644 --- a/libs/code/tests/unit_tests/skills/test_load.py +++ b/libs/code/tests/unit_tests/skills/test_load.py @@ -678,6 +678,29 @@ def test_real_remember_skill_ships(self) -> None: assert "deepagents-code-version" in remember["metadata"] assert remember["metadata"]["deepagents-code-version"] == _cli_version + def test_real_thread_inspector_skill_ships(self) -> None: + """Verify the built-in thread inspector and its script load from the package.""" + built_in_dir = Settings.get_built_in_skills_dir() + skill_dir = built_in_dir / "deepagents-thread-inspector" + skill_md = skill_dir / "SKILL.md" + script = skill_dir / "scripts" / "inspect_sessions.py" + assert skill_md.exists(), f"Expected {skill_md} to exist" + assert script.exists(), f"Expected {script} to exist" + + skills = list_skills( + built_in_skills_dir=built_in_dir, + user_skills_dir=None, + project_skills_dir=None, + ) + inspector = next( + skill for skill in skills if skill["name"] == "deepagents-thread-inspector" + ) + assert inspector["source"] == "built-in" + assert len(inspector["description"]) > 0 + assert inspector["license"] == "MIT" + assert inspector["compatibility"] == "designed for deepagents-code" + assert inspector["metadata"]["deepagents-code-version"] == _cli_version + def test_oserror_in_one_source_does_not_break_others(self, tmp_path: Path) -> None: """An OSError in one source should not prevent other sources from loading. diff --git a/libs/code/tests/unit_tests/skills/test_thread_inspector.py b/libs/code/tests/unit_tests/skills/test_thread_inspector.py new file mode 100644 index 0000000000..7313897aa8 --- /dev/null +++ b/libs/code/tests/unit_tests/skills/test_thread_inspector.py @@ -0,0 +1,158 @@ +"""Tests for the built-in Deep Agents thread inspector script.""" + +import importlib.util +import json +import sqlite3 +from pathlib import Path +from types import ModuleType + +import pytest + +_SCRIPT = ( + Path(__file__).resolve().parents[3] + / "deepagents_code" + / "built_in_skills" + / "deepagents-thread-inspector" + / "scripts" + / "inspect_sessions.py" +) + + +@pytest.fixture +def inspector(monkeypatch: pytest.MonkeyPatch) -> ModuleType: + """Load the standalone script without requiring it to be importable as a module.""" + monkeypatch.delenv("LANGGRAPH_STRICT_MSGPACK", raising=False) + spec = importlib.util.spec_from_file_location("inspect_sessions", _SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _create_database(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + metadata TEXT + ); + CREATE TABLE writes ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + task_id TEXT NOT NULL, + idx INTEGER NOT NULL, + channel TEXT NOT NULL, + type TEXT, + value BLOB + ); + """ + ) + return conn + + +def test_connects_read_only_and_resolves_thread_prefix( + inspector: ModuleType, tmp_path: Path +) -> None: + db = tmp_path / "sessions.db" + writable = _create_database(db) + writable.executemany( + "INSERT INTO checkpoints VALUES (?, ?, ?)", + [ + ("thread-123", "001", "{}"), + ("thread-456", "002", "{}"), + ], + ) + writable.commit() + writable.close() + + conn = inspector._connect_read_only(db) + try: + assert inspector._resolve_thread_id(conn, "thread-1") == "thread-123" + with pytest.raises(SystemExit, match="ambiguous"): + inspector._resolve_thread_id(conn, "thread-") + with pytest.raises(sqlite3.OperationalError, match="readonly"): + conn.execute( + "INSERT INTO checkpoints VALUES (?, ?, ?)", + ("new-thread", "003", "{}"), + ) + finally: + conn.close() + + +def test_reconstructs_messages_and_latest_turn( + inspector: ModuleType, tmp_path: Path +) -> None: + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + + db = tmp_path / "sessions.db" + thread_id = "thread-123" + writable = _create_database(db) + serde = JsonPlusSerializer() + writes = [ + [HumanMessage(content="first", id="user-1")], + [AIMessage(content="answer", id="assistant-1")], + [HumanMessage(content="second", id="user-2")], + [AIMessage(content="done", id="assistant-2")], + ] + for index, messages in enumerate(writes, start=1): + checkpoint_id = f"{index:03d}" + metadata = json.dumps( + { + "updated_at": f"2026-01-01T00:00:0{index}Z", + "turn_number": 2, + "turn_id": "turn-2", + } + ) + writable.execute( + "INSERT INTO checkpoints VALUES (?, ?, ?)", + (thread_id, checkpoint_id, metadata), + ) + type_name, value = serde.dumps_typed(messages) + writable.execute( + "INSERT INTO writes VALUES (?, '', ?, 'task', 0, 'messages', ?, ?)", + (thread_id, checkpoint_id, type_name, value), + ) + writable.commit() + writable.close() + + conn = inspector._connect_read_only(db) + try: + messages = inspector._reconstruct_messages(conn, thread_id) + turns, preamble = inspector._turns(messages, 100) + finally: + conn.close() + + assert preamble == [] + assert len(turns) == 2 + assert [message["content"] for message in turns[-1]["messages"]] == [ + "second", + "done", + ] + + +def test_message_record_hides_reasoning_and_marks_truncation( + inspector: ModuleType, +) -> None: + record = inspector._message_record( + 0, + { + "role": "assistant", + "content": [ + {"type": "reasoning", "text": "hidden"}, + {"type": "text", "text": "visible"}, + ], + "tool_calls": [ + {"name": "example", "id": "call-1", "args": {"value": "long"}} + ], + }, + 4, + ) + + assert record["content"] == "visi…" + assert record["content_truncated"] is True + assert record["tool_calls"][0]["args_truncated"] is True diff --git a/libs/code/tests/unit_tests/smoke_tests/snapshots/system_prompt_interactive_local.md b/libs/code/tests/unit_tests/smoke_tests/snapshots/system_prompt_interactive_local.md index 65fe7190ac..01c32bc11a 100644 --- a/libs/code/tests/unit_tests/smoke_tests/snapshots/system_prompt_interactive_local.md +++ b/libs/code/tests/unit_tests/smoke_tests/snapshots/system_prompt_interactive_local.md @@ -457,6 +457,8 @@ Sources labeled "Deepagents" are specific to this agent tool; sources labeled "A **Available Skills:** +- **deepagents-thread-inspector**: Inspect and explain conversations stored in the local Deep Agents Code SQLite session store. Use when asked to identify a dcode thread, summarize what happened in a thread or its latest turn, recover conversation/tool activity, inspect checkpoint metadata, list recent local threads, or parse ~/.deepagents/.state/sessions.db and a thread UUID or prefix. (License: MIT, Compatibility: designed for deepagents-code) + -> Read `/deepagents-thread-inspector/SKILL.md` for full instructions - **remember**: Review the current conversation and capture valuable knowledge — best practices, coding conventions, architecture decisions, workflows, and user feedback — into persistent memory (AGENTS.md) or reusable skills. Use when the user says: (1) remember this, (2) save what we learned, (3) update memory, (4) capture learnings. (License: MIT, Compatibility: designed for deepagents-code) -> Read `/remember/SKILL.md` for full instructions - **skill-creator**: Guide for creating effective skills that extend agent capabilities with specialized knowledge, workflows, or tool integrations. Use this skill when the user asks to: (1) create a new skill, (2) make a skill, (3) build a skill, (4) set up a skill, (5) initialize a skill, (6) scaffold a skill, (7) update or modify an existing skill, (8) validate a skill, (9) learn about skill structure, (10) understand how skills work, or (11) get guidance on skill design patterns. Trigger on phrases like "create a skill", "new skill", "make a skill", "skill for X", "how do I create a skill", or "help me build a skill". (License: MIT, Compatibility: designed for deepagents-code) From 99bad6f0a38a066cb27d22f545cb439cdcf21c7e Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Fri, 17 Jul 2026 16:22:44 -0400 Subject: [PATCH 2/5] fix(code): exclude subagent checkpoints from thread inspection --- .../scripts/inspect_sessions.py | 17 ++-- .../skills/test_thread_inspector.py | 80 ++++++++++++++++++- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py index 3da0fbc53f..6da8535c96 100755 --- a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py +++ b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py @@ -117,14 +117,16 @@ def _connect_read_only(path: Path) -> sqlite3.Connection: def _resolve_thread_id(conn: sqlite3.Connection, value: str) -> str: exact = conn.execute( - "SELECT 1 FROM checkpoints WHERE thread_id = ? LIMIT 1", (value,) + "SELECT 1 FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = '' LIMIT 1", + (value,), ).fetchone() if exact: return value escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") rows = conn.execute( "SELECT DISTINCT thread_id FROM checkpoints " - "WHERE thread_id LIKE ? ESCAPE '\\' ORDER BY thread_id LIMIT 11", + "WHERE checkpoint_ns = '' AND thread_id LIKE ? ESCAPE '\\' " + "ORDER BY thread_id LIMIT 11", (escaped + "%",), ).fetchall() matches = [str(row[0]) for row in rows] @@ -159,17 +161,19 @@ def _thread_summary( "MIN(json_extract(metadata, '$.updated_at')) AS created_at, " "MAX(json_extract(metadata, '$.updated_at')) AS updated_at, " "MAX(checkpoint_id) AS latest_checkpoint_id " - "FROM checkpoints WHERE thread_id = ?", + "FROM checkpoints WHERE thread_id = ? AND checkpoint_ns = ''", (thread_id,), ).fetchone() latest = conn.execute( - "SELECT metadata FROM checkpoints WHERE thread_id = ? " + "SELECT metadata FROM checkpoints " + "WHERE thread_id = ? AND checkpoint_ns = '' " "ORDER BY checkpoint_id DESC LIMIT 1", (thread_id,), ).fetchone() metadata = _decode_metadata(latest[0] if latest else None) writes_count = conn.execute( - "SELECT COUNT(*) FROM writes WHERE thread_id = ?", (thread_id,) + "SELECT COUNT(*) FROM writes WHERE thread_id = ? AND checkpoint_ns = ''", + (thread_id,), ).fetchone()[0] summary: dict[str, object] = { "thread_id": thread_id, @@ -199,7 +203,8 @@ def _list_threads(conn: sqlite3.Connection, limit: int) -> list[dict[str, object "MAX(json_extract(metadata, '$.git_branch')) AS git_branch, " "MAX(json_extract(metadata, '$.cwd')) AS cwd, " "COUNT(*) AS checkpoint_count " - "FROM checkpoints GROUP BY thread_id ORDER BY updated_at DESC LIMIT ?", + "FROM checkpoints WHERE checkpoint_ns = '' " + "GROUP BY thread_id ORDER BY updated_at DESC LIMIT ?", (limit,), ).fetchall() return [dict(row) for row in rows] diff --git a/libs/code/tests/unit_tests/skills/test_thread_inspector.py b/libs/code/tests/unit_tests/skills/test_thread_inspector.py index 7313897aa8..a1524f66ad 100644 --- a/libs/code/tests/unit_tests/skills/test_thread_inspector.py +++ b/libs/code/tests/unit_tests/skills/test_thread_inspector.py @@ -36,6 +36,7 @@ def _create_database(path: Path) -> sqlite3.Connection: """ CREATE TABLE checkpoints ( thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL, checkpoint_id TEXT NOT NULL, metadata TEXT ); @@ -60,7 +61,7 @@ def test_connects_read_only_and_resolves_thread_prefix( db = tmp_path / "sessions.db" writable = _create_database(db) writable.executemany( - "INSERT INTO checkpoints VALUES (?, ?, ?)", + "INSERT INTO checkpoints VALUES (?, '', ?, ?)", [ ("thread-123", "001", "{}"), ("thread-456", "002", "{}"), @@ -76,13 +77,86 @@ def test_connects_read_only_and_resolves_thread_prefix( inspector._resolve_thread_id(conn, "thread-") with pytest.raises(sqlite3.OperationalError, match="readonly"): conn.execute( - "INSERT INTO checkpoints VALUES (?, ?, ?)", + "INSERT INTO checkpoints VALUES (?, '', ?, ?)", ("new-thread", "003", "{}"), ) finally: conn.close() +def test_thread_queries_exclude_subagent_namespaces( + inspector: ModuleType, tmp_path: Path +) -> None: + db = tmp_path / "sessions.db" + writable = _create_database(db) + root_metadata = json.dumps( + { + "updated_at": "2026-01-01T00:00:01Z", + "agent_name": "root-agent", + "git_branch": "root-branch", + "cwd": "/root", + "turn_number": 1, + "turn_id": "root-turn", + } + ) + subagent_metadata = json.dumps( + { + "updated_at": "2026-01-01T00:00:02Z", + "agent_name": "subagent", + "git_branch": "subagent-branch", + "cwd": "/subagent", + "turn_number": 99, + "turn_id": "subagent-turn", + } + ) + writable.executemany( + "INSERT INTO checkpoints VALUES (?, ?, ?, ?)", + [ + ("thread-123", "", "001", root_metadata), + ("thread-123", "subagent:abc", "999", subagent_metadata), + ("subagent-only", "subagent:def", "999", subagent_metadata), + ], + ) + writable.executemany( + "INSERT INTO writes VALUES (?, ?, ?, 'task', 0, 'messages', NULL, NULL)", + [ + ("thread-123", "", "001"), + ("thread-123", "subagent:abc", "999"), + ], + ) + writable.commit() + writable.close() + + conn = inspector._connect_read_only(db) + try: + summary, metadata = inspector._thread_summary(conn, "thread-123") + threads = inspector._list_threads(conn, 10) + with pytest.raises(SystemExit, match="Thread not found"): + inspector._resolve_thread_id(conn, "subagent-only") + finally: + conn.close() + + assert metadata["turn_number"] == 1 + assert metadata["turn_id"] == "root-turn" + assert summary["agent_name"] == "root-agent" + assert summary["created_at"] == "2026-01-01T00:00:01Z" + assert summary["updated_at"] == "2026-01-01T00:00:01Z" + assert summary["latest_checkpoint_id"] == "001" + assert summary["checkpoint_count"] == 1 + assert summary["writes_count"] == 1 + assert threads == [ + { + "thread_id": "thread-123", + "updated_at": "2026-01-01T00:00:01Z", + "created_at": "2026-01-01T00:00:01Z", + "agent_name": "root-agent", + "git_branch": "root-branch", + "cwd": "/root", + "checkpoint_count": 1, + } + ] + + def test_reconstructs_messages_and_latest_turn( inspector: ModuleType, tmp_path: Path ) -> None: @@ -109,7 +183,7 @@ def test_reconstructs_messages_and_latest_turn( } ) writable.execute( - "INSERT INTO checkpoints VALUES (?, ?, ?)", + "INSERT INTO checkpoints VALUES (?, '', ?, ?)", (thread_id, checkpoint_id, metadata), ) type_name, value = serde.dumps_typed(messages) From 74811ef3293ba37d65c9d7f3591997ebdbcb2f08 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Fri, 17 Jul 2026 16:58:15 -0400 Subject: [PATCH 3/5] fix(code): harden thread inspector reconstruction --- .../deepagents-thread-inspector/SKILL.md | 3 +- .../scripts/inspect_sessions.py | 226 ++++++++-- .../skills/test_thread_inspector.py | 402 +++++++++++++++++- 3 files changed, 578 insertions(+), 53 deletions(-) diff --git a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md index 9b9fe44c8a..bd9f6c0ae0 100644 --- a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md +++ b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md @@ -7,7 +7,7 @@ compatibility: designed for deepagents-code # Deep Agents Thread Inspector -Use `scripts/inspect_sessions.py` instead of manually decoding database blobs. It opens the database read-only, deserializes the root message channel with LangGraph's strict MsgPack loader, replays writes in checkpoint order, and emits JSON. +Use `scripts/inspect_sessions.py` instead of manually decoding database blobs. It opens the database read-only and deserializes the root message channel with LangGraph's strict MsgPack loader — reading the materialized messages from the latest checkpoint, or replaying writes in checkpoint order when that fast path is unavailable — and emits JSON. ## Inspect a thread @@ -42,6 +42,7 @@ Synthesize the JSON rather than pasting it verbatim. - Distinguish stored facts from your interpretation. - For the latest turn, describe only the final user message and subsequent activity unless earlier context is required to make it understandable. - Mention truncation when a relevant record has `content_truncated` or `args_truncated` set. +- Surface reconstruction problems when the result includes a top-level `warnings` array (for example, a corrupt checkpoint, a skipped write, or malformed metadata) so conclusions are appropriately hedged. - Do not expose unrelated credentials, tokens, personal data, or hidden reasoning that may appear in local records. ## Safety diff --git a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py index 6da8535c96..bb209a83f1 100755 --- a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py +++ b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py @@ -4,7 +4,7 @@ from __future__ import annotations import argparse -import importlib.util +import importlib import json import os import shutil @@ -19,17 +19,24 @@ from collections.abc import Sequence from langchain_core.messages import MessageLikeRepresentation + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer os.environ["LANGGRAPH_STRICT_MSGPACK"] = "true" +_RUNTIME_IMPORTS = ( + ("langgraph.checkpoint.serde.jsonplus", "JsonPlusSerializer"), + ("langgraph.graph.message", "add_messages"), + ("langgraph.types", "Overwrite"), +) + def _has_runtime() -> bool: try: - return ( - importlib.util.find_spec("langgraph.checkpoint.serde.jsonplus") is not None - ) - except ModuleNotFoundError: + for module, symbol in _RUNTIME_IMPORTS: + getattr(importlib.import_module(module), symbol) + except (AttributeError, ImportError): return False + return True def _ensure_runtime() -> None: @@ -62,17 +69,25 @@ def _ensure_runtime() -> None: if candidate_key in seen or not candidate.is_file(): continue seen.add(candidate_key) - check = subprocess.run( # noqa: S603 # Runs a resolved dcode interpreter. - [ - str(candidate), - "-c", - "from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer", - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - timeout=10, - ) + try: + check = subprocess.run( # noqa: S603 # Runs a resolved dcode interpreter. + [ + str(candidate), + "-c", + "; ".join( + f"from {module} import {symbol}" + for module, symbol in _RUNTIME_IMPORTS + ), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=10, + ) + except (subprocess.TimeoutExpired, OSError): + # A hung or non-executable candidate must not abort the search; + # skip it and fall through to the next one (or the clear error below). + continue if check.returncode == 0: env = os.environ.copy() env["DEEPAGENTS_THREAD_INSPECTOR_REEXEC"] = "1" @@ -140,12 +155,24 @@ def _resolve_thread_id(conn: sqlite3.Connection, value: str) -> str: return matches[0] -def _decode_metadata(value: object) -> dict[str, object]: +def _decode_metadata( + value: object, warnings: list[str] | None = None +) -> dict[str, object]: if isinstance(value, bytes): - value = value.decode("utf-8") + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + if warnings is not None: + warnings.append("Checkpoint metadata was not valid UTF-8.") + return {} if not isinstance(value, str) or not value: return {} - decoded = json.loads(value) + try: + decoded = json.loads(value) + except ValueError: + if warnings is not None: + warnings.append("Checkpoint metadata was not valid JSON.") + return {} if not isinstance(decoded, dict): return {} return {str(key): item for key, item in decoded.items()} @@ -155,6 +182,7 @@ def _thread_summary( conn: sqlite3.Connection, thread_id: str, message_count: int | None = None, + warnings: list[str] | None = None, ) -> tuple[dict[str, object], dict[str, object]]: aggregate = conn.execute( "SELECT COUNT(*) AS checkpoint_count, " @@ -170,7 +198,7 @@ def _thread_summary( "ORDER BY checkpoint_id DESC LIMIT 1", (thread_id,), ).fetchone() - metadata = _decode_metadata(latest[0] if latest else None) + metadata = _decode_metadata(latest[0] if latest else None, warnings) writes_count = conn.execute( "SELECT COUNT(*) FROM writes WHERE thread_id = ? AND checkpoint_ns = ''", (thread_id,), @@ -210,34 +238,102 @@ def _list_threads(conn: sqlite3.Connection, limit: int) -> list[dict[str, object return [dict(row) for row in rows] +def _load_inline_messages( + conn: sqlite3.Connection, + thread_id: str, + serde: JsonPlusSerializer, + warnings: list[str] | None = None, +) -> list[MessageLikeRepresentation] | None: + checkpoint_row = conn.execute( + "SELECT type, checkpoint FROM checkpoints " + "WHERE thread_id = ? AND checkpoint_ns = '' " + "ORDER BY checkpoint_id DESC LIMIT 1", + (thread_id,), + ).fetchone() + if ( + not checkpoint_row + or not checkpoint_row["type"] + or not checkpoint_row["checkpoint"] + ): + return None + try: + checkpoint = serde.loads_typed( + (checkpoint_row["type"], checkpoint_row["checkpoint"]) + ) + except Exception as exc: + # Corrupt latest checkpoint: fall back to replaying the writes table + # rather than aborting, and record why the fast path was skipped. + if warnings is not None: + warnings.append( + f"Could not deserialize the latest checkpoint ({exc}); " + "falling back to the writes table." + ) + return None + if not isinstance(checkpoint, dict): + return None + channel_values = checkpoint.get("channel_values") + if not isinstance(channel_values, dict) or "messages" not in channel_values: + return None + inline = channel_values["messages"] + if not isinstance(inline, list): + # Present but malformed: force the writes fallback instead of reporting + # an empty conversation for a thread that may hold real messages. + if warnings is not None: + warnings.append( + "Inline checkpoint messages were malformed; " + "falling back to the writes table." + ) + return None + return list(cast("list[MessageLikeRepresentation]", inline)) + + def _reconstruct_messages( - conn: sqlite3.Connection, thread_id: str + conn: sqlite3.Connection, + thread_id: str, + warnings: list[str] | None = None, ) -> list[MessageLikeRepresentation]: from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.graph.message import add_messages from langgraph.types import Overwrite + serde = JsonPlusSerializer() + inline = _load_inline_messages(conn, thread_id, serde, warnings) + if inline is not None: + return inline + rows = conn.execute( "SELECT checkpoint_id, task_id, idx, type, value FROM writes " "WHERE thread_id = ? AND checkpoint_ns = '' AND channel = 'messages' " "ORDER BY checkpoint_id ASC, task_id ASC, idx ASC", (thread_id,), ).fetchall() - serde = JsonPlusSerializer() messages: list[MessageLikeRepresentation] = [] for row in rows: type_name = row["type"] value = row["value"] if not type_name or value is None: continue - delta = serde.loads_typed((type_name, value)) + try: + delta = serde.loads_typed((type_name, value)) + except Exception as exc: + # Skip a single undecodable write and keep replaying the rest. + if warnings is not None: + warnings.append( + f"Skipped an undecodable write for checkpoint " + f"{row['checkpoint_id']} ({exc})." + ) + continue if isinstance(delta, Overwrite): - # Message-channel overwrites contain a list; ignore malformed values. - messages = ( - list(cast("list[MessageLikeRepresentation]", delta.value)) - if isinstance(delta.value, list) - else [] - ) + if isinstance(delta.value, list): + # Overwrite replaces the whole channel with its list payload. + messages = list(cast("list[MessageLikeRepresentation]", delta.value)) + elif warnings is not None: + # A non-list payload is malformed; preserve accumulated messages + # rather than silently discarding earlier history. + warnings.append( + f"Ignored a malformed channel overwrite for checkpoint " + f"{row['checkpoint_id']}." + ) else: # `add_messages` normalizes both inputs to a list despite its broad alias. messages = cast( @@ -293,18 +389,23 @@ def _bounded_value(value: object, limit: int) -> tuple[object, bool]: return rendered[:limit] + "…", True +_ROLE_ALIASES = {"human": "user", "ai": "assistant", "tool": "tool"} + + def _message_role(message: object) -> str: if isinstance(message, dict): - return str(message.get("role") or message.get("type") or "unknown") - type_name = getattr(message, "type", type(message).__name__) - return { - "human": "user", - "ai": "assistant", - "tool": "tool", - }.get(type_name, str(type_name)) + raw = message.get("role") or message.get("type") or "unknown" + else: + raw = getattr(message, "type", type(message).__name__) + return _ROLE_ALIASES.get(str(raw), str(raw)) -def _message_record(index: int, message: object, max_content: int) -> dict[str, object]: +def _message_record( + index: int, + message: object, + max_content: int, + warnings: list[str] | None = None, +) -> dict[str, object]: if isinstance(message, dict): content = message.get("content") name = message.get("name") @@ -320,6 +421,11 @@ def _message_record(index: int, message: object, max_content: int) -> dict[str, tool_call_id = getattr(message, "tool_call_id", None) status = getattr(message, "status", None) tool_calls = raw_tool_calls if isinstance(raw_tool_calls, list) else [] + malformed_tool_calls = ( + raw_tool_calls + if raw_tool_calls and not isinstance(raw_tool_calls, list) + else None + ) text = _content_text(content) bounded_text, content_truncated = _truncate(text, max_content) @@ -348,6 +454,14 @@ def _message_record(index: int, message: object, max_content: int) -> dict[str, else: rendered_calls.append({"value": str(call)}) record["tool_calls"] = rendered_calls + if malformed_tool_calls is not None: + # Present but not a list: preserve it as text rather than hiding tool + # activity, and flag it for the summarizing agent. + record["tool_calls_malformed"] = str(malformed_tool_calls) + if warnings is not None: + warnings.append( + f"Message {index} had non-list tool_calls; preserved as raw text." + ) if tool_call_id: record["tool_call_id"] = tool_call_id if status: @@ -368,18 +482,20 @@ def _is_user_message(message: object) -> bool: def _turns( - messages: Sequence[object], max_content: int + messages: Sequence[object], + max_content: int, + warnings: list[str] | None = None, ) -> tuple[list[dict[str, object]], list[dict[str, object]]]: starts = [ index for index, message in enumerate(messages) if _is_user_message(message) ] if not starts: return [], [ - _message_record(index, message, max_content) + _message_record(index, message, max_content, warnings) for index, message in enumerate(messages) ] preamble = [ - _message_record(index, message, max_content) + _message_record(index, message, max_content, warnings) for index, message in enumerate(messages[: starts[0]]) ] turns: list[dict[str, object]] = [] @@ -391,7 +507,7 @@ def _turns( "start_message_index": start, "end_message_index": end - 1, "messages": [ - _message_record(index, messages[index], max_content) + _message_record(index, messages[index], max_content, warnings) for index in range(start, end) ], } @@ -415,7 +531,11 @@ def _build_parser() -> argparse.ArgumentParser: default="latest-turn", ) parser.add_argument( - "--list", type=int, metavar="N", dest="list_limit", help="List recent threads" + "--list", + type=int, + metavar="N", + dest="list_limit", + help="List recent threads (ignores --mode/--max-content/--include-metadata)", ) parser.add_argument( "--max-content", @@ -435,7 +555,9 @@ def main() -> None: """Parse arguments, inspect the session store, and write JSON to stdout. Raises: - SystemExit: If arguments or the local session store are invalid. + SystemExit: If the command-line arguments are invalid, the local session + store is missing or unsupported, or the Deep Agents Code runtime + cannot be located. """ args = _build_parser().parse_args() if args.max_content < 1: @@ -459,17 +581,25 @@ def main() -> None: "or greater.*" ), ) + collected_warnings: list[str] = [] + result: dict[str, object] conn = _connect_read_only(args.db) try: if args.list_limit is not None: - result: dict[str, object] = { + if args.include_metadata or args.mode != "latest-turn": + collected_warnings.append( + "--mode and --include-metadata are ignored when listing threads." + ) + result = { "database": str(args.db.expanduser().resolve()), "threads": _list_threads(conn, args.list_limit), } else: thread_id = _resolve_thread_id(conn, args.thread_id) - messages = _reconstruct_messages(conn, thread_id) - summary, metadata = _thread_summary(conn, thread_id, len(messages)) + messages = _reconstruct_messages(conn, thread_id, collected_warnings) + summary, metadata = _thread_summary( + conn, thread_id, len(messages), collected_warnings + ) result = { "database": str(args.db.expanduser().resolve()), "thread": summary, @@ -477,7 +607,7 @@ def main() -> None: if args.include_metadata: result["latest_metadata"] = metadata if args.mode != "summary": - turns, preamble = _turns(messages, args.max_content) + turns, preamble = _turns(messages, args.max_content, collected_warnings) result["turn_count"] = len(turns) if args.mode == "latest-turn": latest_turn = turns[-1] if turns else None @@ -488,6 +618,8 @@ def main() -> None: else: result["preamble"] = preamble result["turns"] = turns + if collected_warnings: + result["warnings"] = collected_warnings print(json.dumps(result, indent=2, ensure_ascii=False, default=str)) finally: conn.close() diff --git a/libs/code/tests/unit_tests/skills/test_thread_inspector.py b/libs/code/tests/unit_tests/skills/test_thread_inspector.py index a1524f66ad..3f2a729a93 100644 --- a/libs/code/tests/unit_tests/skills/test_thread_inspector.py +++ b/libs/code/tests/unit_tests/skills/test_thread_inspector.py @@ -38,7 +38,9 @@ def _create_database(path: Path) -> sqlite3.Connection: thread_id TEXT NOT NULL, checkpoint_ns TEXT NOT NULL, checkpoint_id TEXT NOT NULL, - metadata TEXT + metadata TEXT, + type TEXT, + checkpoint BLOB ); CREATE TABLE writes ( thread_id TEXT NOT NULL, @@ -55,13 +57,30 @@ def _create_database(path: Path) -> sqlite3.Connection: return conn +def test_runtime_probe_requires_all_langgraph_modules( + inspector: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + real_import = inspector.importlib.import_module + + def import_module(name: str) -> ModuleType: + if name == "langgraph.graph.message": + msg = "No module named 'langgraph.graph.message'" + raise ModuleNotFoundError(msg) + return real_import(name) + + monkeypatch.setattr(inspector.importlib, "import_module", import_module) + + assert inspector._has_runtime() is False + + def test_connects_read_only_and_resolves_thread_prefix( inspector: ModuleType, tmp_path: Path ) -> None: db = tmp_path / "sessions.db" writable = _create_database(db) writable.executemany( - "INSERT INTO checkpoints VALUES (?, '', ?, ?)", + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata) VALUES (?, '', ?, ?)", [ ("thread-123", "001", "{}"), ("thread-456", "002", "{}"), @@ -77,7 +96,9 @@ def test_connects_read_only_and_resolves_thread_prefix( inspector._resolve_thread_id(conn, "thread-") with pytest.raises(sqlite3.OperationalError, match="readonly"): conn.execute( - "INSERT INTO checkpoints VALUES (?, '', ?, ?)", + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata) " + "VALUES (?, '', ?, ?)", ("new-thread", "003", "{}"), ) finally: @@ -110,7 +131,8 @@ def test_thread_queries_exclude_subagent_namespaces( } ) writable.executemany( - "INSERT INTO checkpoints VALUES (?, ?, ?, ?)", + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata) VALUES (?, ?, ?, ?)", [ ("thread-123", "", "001", root_metadata), ("thread-123", "subagent:abc", "999", subagent_metadata), @@ -183,7 +205,9 @@ def test_reconstructs_messages_and_latest_turn( } ) writable.execute( - "INSERT INTO checkpoints VALUES (?, '', ?, ?)", + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata) " + "VALUES (?, '', ?, ?)", (thread_id, checkpoint_id, metadata), ) type_name, value = serde.dumps_typed(messages) @@ -209,6 +233,46 @@ def test_reconstructs_messages_and_latest_turn( ] +def test_reconstructs_messages_from_latest_inline_checkpoint( + inspector: ModuleType, tmp_path: Path +) -> None: + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + + db = tmp_path / "sessions.db" + thread_id = "historical-thread" + writable = _create_database(db) + serde = JsonPlusSerializer() + checkpoint = { + "channel_values": { + "messages": [ + HumanMessage(content="historical question"), + AIMessage(content="historical answer"), + ] + } + } + type_name, value = serde.dumps_typed(checkpoint) + writable.execute( + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata, type, checkpoint) " + "VALUES (?, '', '001', '{}', ?, ?)", + (thread_id, type_name, value), + ) + writable.commit() + writable.close() + + conn = inspector._connect_read_only(db) + try: + messages = inspector._reconstruct_messages(conn, thread_id) + finally: + conn.close() + + assert [message.content for message in messages] == [ + "historical question", + "historical answer", + ] + + def test_message_record_hides_reasoning_and_marks_truncation( inspector: ModuleType, ) -> None: @@ -230,3 +294,331 @@ def test_message_record_hides_reasoning_and_marks_truncation( assert record["content"] == "visi…" assert record["content_truncated"] is True assert record["tool_calls"][0]["args_truncated"] is True + + +def test_message_role_normalizes_dict_and_object(inspector: ModuleType) -> None: + from langchain_core.messages import AIMessage, HumanMessage + + assert inspector._message_role({"type": "human"}) == "user" + assert inspector._message_role({"role": "ai"}) == "assistant" + assert inspector._message_role({"role": "user"}) == "user" + assert inspector._message_role(HumanMessage(content="x")) == "user" + assert inspector._message_role(AIMessage(content="x")) == "assistant" + + +def test_message_record_surfaces_malformed_tool_calls(inspector: ModuleType) -> None: + warnings: list[str] = [] + record = inspector._message_record( + 3, + {"role": "assistant", "content": "x", "tool_calls": "oops"}, + 100, + warnings, + ) + + assert "tool_calls" not in record + assert record["tool_calls_malformed"] == "oops" + assert any("tool_calls" in warning for warning in warnings) + + +def test_message_record_reports_tool_call_id_and_status(inspector: ModuleType) -> None: + record = inspector._message_record( + 0, + {"role": "tool", "content": "result", "tool_call_id": "call-9", "status": "ok"}, + 100, + ) + + assert record["role"] == "tool" + assert record["tool_call_id"] == "call-9" + assert record["status"] == "ok" + + +def test_content_text_handles_varied_shapes(inspector: ModuleType) -> None: + assert inspector._content_text(None) == "" + assert inspector._content_text("plain") == "plain" + assert inspector._content_text({"a": 1}) == '{"a": 1}' + combined = inspector._content_text( + [ + {"type": "thinking", "text": "hidden"}, + {"phase": "analysis", "text": "hidden"}, + {"type": "text", "text": "shown"}, + {"content": "nested"}, + ] + ) + assert combined == "shown\nnested" + + +def test_turns_skips_system_user_messages(inspector: ModuleType) -> None: + turns, preamble = inspector._turns( + [ + {"role": "user", "content": "[SYSTEM] injected"}, + {"role": "assistant", "content": "hi"}, + ], + 100, + ) + + assert turns == [] + assert [record["content"] for record in preamble] == ["[SYSTEM] injected", "hi"] + + +def test_turns_captures_preamble_before_first_user_message( + inspector: ModuleType, +) -> None: + turns, preamble = inspector._turns( + [ + {"role": "assistant", "content": "warmup"}, + {"role": "user", "content": "real question"}, + {"role": "assistant", "content": "answer"}, + ], + 100, + ) + + assert [record["content"] for record in preamble] == ["warmup"] + assert len(turns) == 1 + assert turns[0]["start_message_index"] == 1 + assert [record["content"] for record in turns[0]["messages"]] == [ + "real question", + "answer", + ] + + +def test_resolve_thread_id_escapes_like_metacharacters( + inspector: ModuleType, tmp_path: Path +) -> None: + db = tmp_path / "sessions.db" + writable = _create_database(db) + writable.executemany( + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata) VALUES (?, '', ?, '{}')", + [("a_bc", "001"), ("axbc", "002"), ("a%d", "003"), ("aXd", "004")], + ) + writable.commit() + writable.close() + + conn = inspector._connect_read_only(db) + try: + # "_" and "%" must be treated literally, not as SQL LIKE wildcards. + assert inspector._resolve_thread_id(conn, "a_b") == "a_bc" + assert inspector._resolve_thread_id(conn, "a%") == "a%d" + finally: + conn.close() + + +def test_connect_read_only_rejects_missing_file( + inspector: ModuleType, tmp_path: Path +) -> None: + with pytest.raises(SystemExit, match="not found"): + inspector._connect_read_only(tmp_path / "missing.db") + + +def test_connect_read_only_rejects_unsupported_schema( + inspector: ModuleType, tmp_path: Path +) -> None: + db = tmp_path / "sessions.db" + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE checkpoints (thread_id TEXT)") + conn.commit() + conn.close() + + with pytest.raises(SystemExit, match="missing tables: writes"): + inspector._connect_read_only(db) + + +def test_default_db_path_prefers_env_override( + inspector: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("DEEPAGENTS_SESSIONS_DB", "/tmp/custom/sessions.db") + assert inspector._default_db_path() == Path("/tmp/custom/sessions.db") + monkeypatch.delenv("DEEPAGENTS_SESSIONS_DB") + assert inspector._default_db_path() == Path.home() / ".deepagents" / ".state" / ( + "sessions.db" + ) + + +def test_decode_metadata_warns_on_corrupt_json(inspector: ModuleType) -> None: + warnings: list[str] = [] + assert inspector._decode_metadata("not json", warnings) == {} + assert any("JSON" in warning for warning in warnings) + + +def test_reconstruct_applies_and_skips_malformed_overwrite( + inspector: ModuleType, tmp_path: Path +) -> None: + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + from langgraph.types import Overwrite + + db = tmp_path / "sessions.db" + thread_id = "thread-ow" + writable = _create_database(db) + serde = JsonPlusSerializer() + for checkpoint_id in ("001", "002", "003"): + writable.execute( + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata) " + "VALUES (?, '', ?, '{}')", + (thread_id, checkpoint_id), + ) + first_type, first_value = serde.dumps_typed( + [HumanMessage(content="a", id="u1"), AIMessage(content="b", id="a1")] + ) + ow_type, ow_value = serde.dumps_typed( + Overwrite(value=[HumanMessage(content="c", id="u2")]) + ) + bad_type, bad_value = serde.dumps_typed(Overwrite(value="not-a-list")) + writable.executemany( + "INSERT INTO writes VALUES (?, '', ?, 'task', 0, 'messages', ?, ?)", + [ + (thread_id, "001", first_type, first_value), + (thread_id, "002", ow_type, ow_value), + (thread_id, "003", bad_type, bad_value), + ], + ) + writable.commit() + writable.close() + + conn = inspector._connect_read_only(db) + warnings: list[str] = [] + try: + messages = inspector._reconstruct_messages(conn, thread_id, warnings) + finally: + conn.close() + + # 001 seeds [a, b]; 002 overwrites to [c]; 003 is malformed and preserves [c]. + assert [message.content for message in messages] == ["c"] + assert any("malformed channel overwrite" in warning for warning in warnings) + + +def test_load_inline_messages_falls_back_when_malformed( + inspector: ModuleType, tmp_path: Path +) -> None: + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + + db = tmp_path / "sessions.db" + writable = _create_database(db) + serde = JsonPlusSerializer() + type_name, value = serde.dumps_typed( + {"channel_values": {"messages": {"not": "a list"}}} + ) + writable.execute( + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata, type, checkpoint) " + "VALUES ('t', '', '001', '{}', ?, ?)", + (type_name, value), + ) + writable.commit() + writable.close() + + conn = inspector._connect_read_only(db) + warnings: list[str] = [] + try: + result = inspector._load_inline_messages(conn, "t", serde, warnings) + finally: + conn.close() + + assert result is None + assert any("malformed" in warning.lower() for warning in warnings) + + +@pytest.mark.parametrize( + ("argv", "match"), + [ + (["prog", "thread", "--max-content", "0"], "must be positive"), + (["prog", "--list", "0"], "--list must be positive"), + (["prog"], "Provide a thread ID or use --list"), + (["prog", "thread", "--list", "5"], "not both"), + ], +) +def test_main_rejects_invalid_arguments( + inspector: ModuleType, + monkeypatch: pytest.MonkeyPatch, + argv: list[str], + match: str, +) -> None: + monkeypatch.setattr(inspector.sys, "argv", argv) + with pytest.raises(SystemExit, match=match): + inspector.main() + + +def test_main_lists_threads( + inspector: ModuleType, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + db = tmp_path / "sessions.db" + writable = _create_database(db) + writable.execute( + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata) VALUES (?, '', ?, ?)", + ("thread-abc", "001", json.dumps({"updated_at": "2026-01-01T00:00:01Z"})), + ) + writable.commit() + writable.close() + + monkeypatch.setattr( + inspector.sys, + "argv", + ["prog", "--db", str(db), "--list", "5", "--include-metadata"], + ) + inspector.main() + output = json.loads(capsys.readouterr().out) + + assert [thread["thread_id"] for thread in output["threads"]] == ["thread-abc"] + # --include-metadata is not meaningful with --list; surfaced as a warning. + assert any("ignored when listing" in warning for warning in output["warnings"]) + + +def test_main_latest_turn_end_to_end( + inspector: ModuleType, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + + db = tmp_path / "sessions.db" + writable = _create_database(db) + serde = JsonPlusSerializer() + checkpoint = { + "channel_values": { + "messages": [ + HumanMessage(content="hi", id="u1"), + AIMessage(content="hello", id="a1"), + ] + } + } + type_name, value = serde.dumps_typed(checkpoint) + writable.execute( + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata, type, checkpoint) " + "VALUES (?, '', '001', ?, ?, ?)", + ( + "thread-xyz", + json.dumps( + { + "updated_at": "2026-01-01T00:00:01Z", + "turn_number": 1, + "turn_id": "turn-1", + } + ), + type_name, + value, + ), + ) + writable.commit() + writable.close() + + monkeypatch.setattr(inspector.sys, "argv", ["prog", "thread-xyz", "--db", str(db)]) + inspector.main() + output = json.loads(capsys.readouterr().out) + + assert output["thread"]["thread_id"] == "thread-xyz" + assert output["turn_count"] == 1 + assert output["latest_turn"]["turn_id"] == "turn-1" + assert output["latest_turn"]["stored_turn_number"] == 1 + assert [record["content"] for record in output["latest_turn"]["messages"]] == [ + "hi", + "hello", + ] + assert "warnings" not in output From 6f0250ae4d97724a040d8e96377d6d74a37751e9 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 21 Jul 2026 11:23:11 -0400 Subject: [PATCH 4/5] fix(code): include pending writes in thread inspection --- .../scripts/inspect_sessions.py | 37 ++++++++----- .../skills/test_thread_inspector.py | 52 +++++++++++++++++++ 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py index bb209a83f1..bc0614ed8d 100755 --- a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py +++ b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/scripts/inspect_sessions.py @@ -243,9 +243,9 @@ def _load_inline_messages( thread_id: str, serde: JsonPlusSerializer, warnings: list[str] | None = None, -) -> list[MessageLikeRepresentation] | None: +) -> tuple[str, list[MessageLikeRepresentation]] | None: checkpoint_row = conn.execute( - "SELECT type, checkpoint FROM checkpoints " + "SELECT checkpoint_id, type, checkpoint FROM checkpoints " "WHERE thread_id = ? AND checkpoint_ns = '' " "ORDER BY checkpoint_id DESC LIMIT 1", (thread_id,), @@ -284,7 +284,10 @@ def _load_inline_messages( "falling back to the writes table." ) return None - return list(cast("list[MessageLikeRepresentation]", inline)) + return ( + str(checkpoint_row["checkpoint_id"]), + list(cast("list[MessageLikeRepresentation]", inline)), + ) def _reconstruct_messages( @@ -297,17 +300,23 @@ def _reconstruct_messages( from langgraph.types import Overwrite serde = JsonPlusSerializer() - inline = _load_inline_messages(conn, thread_id, serde, warnings) - if inline is not None: - return inline - - rows = conn.execute( - "SELECT checkpoint_id, task_id, idx, type, value FROM writes " - "WHERE thread_id = ? AND checkpoint_ns = '' AND channel = 'messages' " - "ORDER BY checkpoint_id ASC, task_id ASC, idx ASC", - (thread_id,), - ).fetchall() - messages: list[MessageLikeRepresentation] = [] + inline_checkpoint = _load_inline_messages(conn, thread_id, serde, warnings) + if inline_checkpoint is None: + messages: list[MessageLikeRepresentation] = [] + rows = conn.execute( + "SELECT checkpoint_id, task_id, idx, type, value FROM writes " + "WHERE thread_id = ? AND checkpoint_ns = '' AND channel = 'messages' " + "ORDER BY checkpoint_id ASC, task_id ASC, idx ASC", + (thread_id,), + ).fetchall() + else: + checkpoint_id, messages = inline_checkpoint + rows = conn.execute( + "SELECT checkpoint_id, task_id, idx, type, value FROM writes " + "WHERE thread_id = ? AND checkpoint_ns = '' AND checkpoint_id = ? " + "AND channel = 'messages' ORDER BY task_id ASC, idx ASC", + (thread_id, checkpoint_id), + ).fetchall() for row in rows: type_name = row["type"] value = row["value"] diff --git a/libs/code/tests/unit_tests/skills/test_thread_inspector.py b/libs/code/tests/unit_tests/skills/test_thread_inspector.py index 3f2a729a93..b6d54e67ff 100644 --- a/libs/code/tests/unit_tests/skills/test_thread_inspector.py +++ b/libs/code/tests/unit_tests/skills/test_thread_inspector.py @@ -273,6 +273,58 @@ def test_reconstructs_messages_from_latest_inline_checkpoint( ] +def test_reconstructs_pending_writes_from_latest_inline_checkpoint( + inspector: ModuleType, tmp_path: Path +) -> None: + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + + db = tmp_path / "sessions.db" + thread_id = "active-thread" + writable = _create_database(db) + serde = JsonPlusSerializer() + checkpoint = { + "channel_values": { + "messages": [ + HumanMessage(content="historical question", id="user-1"), + AIMessage(content="historical answer", id="assistant-1"), + ] + } + } + type_name, value = serde.dumps_typed(checkpoint) + writable.execute( + "INSERT INTO checkpoints " + "(thread_id, checkpoint_ns, checkpoint_id, metadata, type, checkpoint) " + "VALUES (?, '', '002', '{}', ?, ?)", + (thread_id, type_name, value), + ) + pending = [ + [HumanMessage(content="current question", id="user-2")], + [AIMessage(content="current answer", id="assistant-2")], + ] + for index, messages in enumerate(pending): + write_type, write_value = serde.dumps_typed(messages) + writable.execute( + "INSERT INTO writes VALUES (?, '', '002', 'task', ?, 'messages', ?, ?)", + (thread_id, index, write_type, write_value), + ) + writable.commit() + writable.close() + + conn = inspector._connect_read_only(db) + try: + messages = inspector._reconstruct_messages(conn, thread_id) + finally: + conn.close() + + assert [message.content for message in messages] == [ + "historical question", + "historical answer", + "current question", + "current answer", + ] + + def test_message_record_hides_reasoning_and_marks_truncation( inspector: ModuleType, ) -> None: From 16021d9776f4b1246c3c3bfd2128f8d4e79b7cf5 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 21 Jul 2026 11:49:33 -0400 Subject: [PATCH 5/5] fix(code): prefer LangSmith for traced thread inspection --- .../built_in_skills/deepagents-thread-inspector/SKILL.md | 6 +++--- .../snapshots/system_prompt_interactive_local.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md index bd9f6c0ae0..a59c8d8a7f 100644 --- a/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md +++ b/libs/code/deepagents_code/built_in_skills/deepagents-thread-inspector/SKILL.md @@ -1,15 +1,15 @@ --- name: deepagents-thread-inspector -description: Inspect and explain conversations stored in the local Deep Agents Code SQLite session store. Use when asked to identify a dcode thread, summarize what happened in a thread or its latest turn, recover conversation/tool activity, inspect checkpoint metadata, list recent local threads, or parse ~/.deepagents/.state/sessions.db and a thread UUID or prefix. +description: Inspect and explain conversations in the local Deep Agents Code SQLite session store. Use as a fallback when LangSmith trace tooling is unavailable, for offline or untraced sessions, or when asked to identify or summarize a local dcode thread, inspect checkpoint metadata, list recent local threads, or parse ~/.deepagents/.state/sessions.db and a thread UUID or prefix. license: MIT compatibility: designed for deepagents-code --- # Deep Agents Thread Inspector -Use `scripts/inspect_sessions.py` instead of manually decoding database blobs. It opens the database read-only and deserializes the root message channel with LangGraph's strict MsgPack loader — reading the materialized messages from the latest checkpoint, or replaying writes in checkpoint order when that fast path is unavailable — and emits JSON. +If LangSmith tooling is available for a traced thread, prefer it. Otherwise, use `scripts/inspect_sessions.py` instead of manually decoding database blobs. It opens the database read-only and deserializes the root message channel with LangGraph's strict MsgPack loader — reading the materialized messages from the latest checkpoint, or replaying writes in checkpoint order when that fast path is unavailable — and emits JSON. -## Inspect a thread +## Inspect local state Resolve `SKILL_DIR` to the directory containing this `SKILL.md`; do not assume a user, project, or installation-specific location. Start with the smallest useful view: diff --git a/libs/code/tests/unit_tests/smoke_tests/snapshots/system_prompt_interactive_local.md b/libs/code/tests/unit_tests/smoke_tests/snapshots/system_prompt_interactive_local.md index f972028b11..1e24ffe0e6 100644 --- a/libs/code/tests/unit_tests/smoke_tests/snapshots/system_prompt_interactive_local.md +++ b/libs/code/tests/unit_tests/smoke_tests/snapshots/system_prompt_interactive_local.md @@ -459,7 +459,7 @@ Sources labeled "Deepagents" are specific to this agent tool; sources labeled "A **Available Skills:** -- **deepagents-thread-inspector**: Inspect and explain conversations stored in the local Deep Agents Code SQLite session store. Use when asked to identify a dcode thread, summarize what happened in a thread or its latest turn, recover conversation/tool activity, inspect checkpoint metadata, list recent local threads, or parse ~/.deepagents/.state/sessions.db and a thread UUID or prefix. (License: MIT, Compatibility: designed for deepagents-code) +- **deepagents-thread-inspector**: Inspect and explain conversations in the local Deep Agents Code SQLite session store. Use as a fallback when LangSmith trace tooling is unavailable, for offline or untraced sessions, or when asked to identify or summarize a local dcode thread, inspect checkpoint metadata, list recent local threads, or parse ~/.deepagents/.state/sessions.db and a thread UUID or prefix. (License: MIT, Compatibility: designed for deepagents-code) -> Read `/deepagents-thread-inspector/SKILL.md` for full instructions - **remember**: Review the current conversation and capture valuable knowledge — best practices, coding conventions, architecture decisions, workflows, and user feedback — into persistent memory (AGENTS.md) or reusable skills. Use when the user says: (1) remember this, (2) save what we learned, (3) update memory, (4) capture learnings. (License: MIT, Compatibility: designed for deepagents-code) -> Read `/remember/SKILL.md` for full instructions