Skip to content

Commit ebc06f2

Browse files
authored
feat: add list_subagents and get_subagent_messages session helpers (#825)
Adds two session helpers for reading subagent transcripts: - `list_subagents(session_id, directory=None) -> list[str]` — returns agent IDs for all subagents spawned during a session - `get_subagent_messages(session_id, agent_id, directory=None, limit=None, offset=0) -> list[SessionMessage]` — returns the message chain for a specific subagent Subagent transcripts live at `~/.claude/projects/<project>/<session-id>/subagents/agent-<id>.jsonl` (and may be nested in subdirectories). Both functions return `[]` for missing sessions/agents (consistent with `get_session_messages`).
1 parent bbec84d commit ebc06f2

3 files changed

Lines changed: 555 additions & 1 deletion

File tree

src/claude_agent_sdk/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@
3232
rename_session,
3333
tag_session,
3434
)
35-
from ._internal.sessions import get_session_info, get_session_messages, list_sessions
35+
from ._internal.sessions import (
36+
get_session_info,
37+
get_session_messages,
38+
get_subagent_messages,
39+
list_sessions,
40+
list_subagents,
41+
)
3642
from ._internal.transport import Transport
3743
from ._version import __version__
3844
from .client import ClaudeSDKClient
@@ -571,6 +577,8 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
571577
"list_sessions",
572578
"get_session_info",
573579
"get_session_messages",
580+
"list_subagents",
581+
"get_subagent_messages",
574582
"SDKSessionInfo",
575583
"SessionMessage",
576584
# Session mutations

src/claude_agent_sdk/_internal/sessions.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,3 +1074,268 @@ def get_session_messages(
10741074
if offset > 0:
10751075
return messages[offset:]
10761076
return messages
1077+
1078+
1079+
# ---------------------------------------------------------------------------
1080+
# list_subagents / get_subagent_messages — subagent transcript reading
1081+
# ---------------------------------------------------------------------------
1082+
1083+
1084+
def _resolve_session_file_path(session_id: str, directory: str | None) -> Path | None:
1085+
"""Resolves the on-disk path of a session JSONL file.
1086+
1087+
Directory resolution mirrors ``_read_session_file``: when ``directory``
1088+
is provided, looks in that project directory and its git worktrees;
1089+
otherwise searches all project directories. Returns the path of the
1090+
first non-empty match, or ``None`` if not found.
1091+
"""
1092+
file_name = f"{session_id}.jsonl"
1093+
1094+
def _stat_candidate(project_dir: Path) -> Path | None:
1095+
candidate = project_dir / file_name
1096+
try:
1097+
if candidate.stat().st_size > 0:
1098+
return candidate
1099+
except OSError:
1100+
pass
1101+
return None
1102+
1103+
if directory:
1104+
canonical_dir = _canonicalize_path(directory)
1105+
1106+
project_dir = _find_project_dir(canonical_dir)
1107+
if project_dir is not None:
1108+
found = _stat_candidate(project_dir)
1109+
if found is not None:
1110+
return found
1111+
1112+
try:
1113+
worktree_paths = _get_worktree_paths(canonical_dir)
1114+
except Exception:
1115+
worktree_paths = []
1116+
1117+
for wt in worktree_paths:
1118+
if wt == canonical_dir:
1119+
continue
1120+
wt_project_dir = _find_project_dir(wt)
1121+
if wt_project_dir is not None:
1122+
found = _stat_candidate(wt_project_dir)
1123+
if found is not None:
1124+
return found
1125+
1126+
return None
1127+
1128+
projects_dir = _get_projects_dir()
1129+
try:
1130+
dirents = list(projects_dir.iterdir())
1131+
except OSError:
1132+
return None
1133+
1134+
for entry in dirents:
1135+
if not entry.is_dir():
1136+
continue
1137+
found = _stat_candidate(entry)
1138+
if found is not None:
1139+
return found
1140+
1141+
return None
1142+
1143+
1144+
def _resolve_subagents_dir(session_id: str, directory: str | None) -> Path | None:
1145+
"""Resolves the subagents directory for a given session.
1146+
1147+
The session file lives at ``<projectDir>/<sessionId>.jsonl`` and the
1148+
subagents directory at ``<projectDir>/<sessionId>/subagents/``.
1149+
1150+
Returns ``None`` if the session cannot be found.
1151+
"""
1152+
resolved = _resolve_session_file_path(session_id, directory)
1153+
if resolved is None:
1154+
return None
1155+
# Strip the .jsonl suffix to derive the session directory.
1156+
session_dir = resolved.with_suffix("")
1157+
return session_dir / "subagents"
1158+
1159+
1160+
def _collect_agent_files(base_dir: Path) -> list[tuple[str, Path]]:
1161+
"""Recursively collects ``agent-*.jsonl`` files from a directory tree.
1162+
1163+
Subagent transcripts may live directly in ``subagents/`` or in nested
1164+
subdirectories such as ``subagents/workflows/<runId>/``.
1165+
1166+
Returns a list of ``(agent_id, file_path)`` tuples.
1167+
"""
1168+
results: list[tuple[str, Path]] = []
1169+
1170+
def _walk(current_dir: Path) -> None:
1171+
try:
1172+
dirents = sorted(current_dir.iterdir(), key=lambda p: p.name)
1173+
except OSError:
1174+
return
1175+
for entry in dirents:
1176+
name = entry.name
1177+
if (
1178+
entry.is_file()
1179+
and name.startswith("agent-")
1180+
and name.endswith(".jsonl")
1181+
):
1182+
agent_id = name[len("agent-") : -len(".jsonl")]
1183+
results.append((agent_id, entry))
1184+
elif entry.is_dir():
1185+
_walk(entry)
1186+
1187+
_walk(base_dir)
1188+
return results
1189+
1190+
1191+
def _build_subagent_chain(entries: list[_TranscriptEntry]) -> list[_TranscriptEntry]:
1192+
"""Builds the conversation chain for a subagent transcript.
1193+
1194+
Subagent transcripts are simpler than main sessions — no compaction,
1195+
no sidechains, no preserved segments. Find the last user/assistant
1196+
entry and walk ``parentUuid`` links back to the root.
1197+
"""
1198+
if not entries:
1199+
return []
1200+
1201+
by_uuid: dict[str, _TranscriptEntry] = {}
1202+
for entry in entries:
1203+
by_uuid[entry["uuid"]] = entry
1204+
1205+
# Subagent transcripts are linear — the last user/assistant entry is
1206+
# the leaf.
1207+
leaf: _TranscriptEntry | None = None
1208+
for entry in reversed(entries):
1209+
if entry.get("type") in ("user", "assistant"):
1210+
leaf = entry
1211+
break
1212+
if leaf is None:
1213+
return []
1214+
1215+
chain: list[_TranscriptEntry] = []
1216+
seen: set[str] = set()
1217+
current: _TranscriptEntry | None = leaf
1218+
while current is not None:
1219+
uid = current["uuid"]
1220+
if uid in seen:
1221+
break
1222+
seen.add(uid)
1223+
chain.append(current)
1224+
parent = current.get("parentUuid")
1225+
current = by_uuid.get(parent) if parent else None
1226+
1227+
chain.reverse()
1228+
return chain
1229+
1230+
1231+
def list_subagents(
1232+
session_id: str,
1233+
directory: str | None = None,
1234+
) -> list[str]:
1235+
"""Lists subagent IDs for a given session by scanning the subagents directory.
1236+
1237+
Subagent transcripts are stored at
1238+
``~/.claude/projects/<project>/<sessionId>/subagents/agent-<agentId>.jsonl``
1239+
(and may be nested in subdirectories such as ``workflows/<runId>/``).
1240+
1241+
Args:
1242+
session_id: UUID of the parent session.
1243+
directory: Project directory to find the session in. If omitted,
1244+
searches all project directories under ``~/.claude/projects/``.
1245+
1246+
Returns:
1247+
List of subagent ID strings. Returns an empty list if the session
1248+
is not found, the session_id is not a valid UUID, or the session
1249+
has no subagents.
1250+
1251+
Example:
1252+
List subagent IDs for a session::
1253+
1254+
agent_ids = list_subagents(
1255+
"550e8400-e29b-41d4-a716-446655440000",
1256+
directory="/path/to/project",
1257+
)
1258+
"""
1259+
if not _validate_uuid(session_id):
1260+
return []
1261+
1262+
subagents_dir = _resolve_subagents_dir(session_id, directory)
1263+
if subagents_dir is None:
1264+
return []
1265+
1266+
return [agent_id for agent_id, _ in _collect_agent_files(subagents_dir)]
1267+
1268+
1269+
def get_subagent_messages(
1270+
session_id: str,
1271+
agent_id: str,
1272+
directory: str | None = None,
1273+
limit: int | None = None,
1274+
offset: int = 0,
1275+
) -> list[SessionMessage]:
1276+
"""Reads a subagent's conversation messages from its JSONL transcript file.
1277+
1278+
Parses the subagent transcript, builds the conversation chain via
1279+
``parentUuid`` links, and returns user/assistant messages in
1280+
chronological order.
1281+
1282+
Args:
1283+
session_id: UUID of the parent session.
1284+
agent_id: ID of the subagent (as returned by ``list_subagents``).
1285+
directory: Project directory to find the session in. If omitted,
1286+
searches all project directories under ``~/.claude/projects/``.
1287+
limit: Maximum number of messages to return.
1288+
offset: Number of messages to skip from the start.
1289+
1290+
Returns:
1291+
List of ``SessionMessage`` objects in chronological order. Returns
1292+
an empty list if the session or subagent is not found, the
1293+
session_id is not a valid UUID, or the transcript contains no
1294+
user/assistant messages.
1295+
1296+
Example:
1297+
Read all messages from a subagent::
1298+
1299+
messages = get_subagent_messages(
1300+
"550e8400-e29b-41d4-a716-446655440000",
1301+
"abc123",
1302+
directory="/path/to/project",
1303+
)
1304+
"""
1305+
if not _validate_uuid(session_id):
1306+
return []
1307+
if not agent_id:
1308+
return []
1309+
1310+
subagents_dir = _resolve_subagents_dir(session_id, directory)
1311+
if subagents_dir is None:
1312+
return []
1313+
1314+
# The agent file may be directly in subagents/ or in a nested
1315+
# subdirectory — scan to find it.
1316+
match: Path | None = None
1317+
for found_id, file_path in _collect_agent_files(subagents_dir):
1318+
if found_id == agent_id:
1319+
match = file_path
1320+
break
1321+
if match is None:
1322+
return []
1323+
1324+
try:
1325+
content = match.read_text(encoding="utf-8")
1326+
except OSError:
1327+
return []
1328+
if not content:
1329+
return []
1330+
1331+
entries = _parse_transcript_entries(content)
1332+
chain = _build_subagent_chain(entries)
1333+
messages = [
1334+
_to_session_message(e) for e in chain if e.get("type") in ("user", "assistant")
1335+
]
1336+
1337+
if limit is not None and limit > 0:
1338+
return messages[offset : offset + limit]
1339+
if offset > 0:
1340+
return messages[offset:]
1341+
return messages

0 commit comments

Comments
 (0)