zjcode 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1716 @@
|
|
|
1
|
+
"""Thread management using LangGraph's built-in checkpoint persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any, NamedTuple, NotRequired, TypedDict, cast
|
|
13
|
+
|
|
14
|
+
from deepagents_code._constants import SYSTEM_MESSAGE_PREFIX
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import AsyncIterator
|
|
18
|
+
|
|
19
|
+
import aiosqlite
|
|
20
|
+
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
21
|
+
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
22
|
+
|
|
23
|
+
from deepagents_code.output import OutputFormat
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_aiosqlite_patched = False
|
|
28
|
+
_jsonplus_serializer: JsonPlusSerializer | None = None
|
|
29
|
+
_message_count_cache: dict[str, tuple[str | None, int]] = {}
|
|
30
|
+
_MAX_MESSAGE_COUNT_CACHE = 4096
|
|
31
|
+
_initial_prompt_cache: dict[str, tuple[str | None, str | None]] = {}
|
|
32
|
+
_MAX_INITIAL_PROMPT_CACHE = 4096
|
|
33
|
+
_recent_threads_cache: dict[tuple[str | None, int], list[ThreadInfo]] = {}
|
|
34
|
+
_MAX_RECENT_THREADS_CACHE_KEYS = 16
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _patch_aiosqlite() -> None:
|
|
38
|
+
"""Patch aiosqlite.Connection with `is_alive()` if missing.
|
|
39
|
+
|
|
40
|
+
Required by langgraph-checkpoint>=2.1.0.
|
|
41
|
+
See: https://github.com/langchain-ai/langgraph/issues/6583
|
|
42
|
+
"""
|
|
43
|
+
global _aiosqlite_patched # noqa: PLW0603 # Module-level flag requires global statement
|
|
44
|
+
if _aiosqlite_patched:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
import aiosqlite as _aiosqlite
|
|
48
|
+
|
|
49
|
+
if not hasattr(_aiosqlite.Connection, "is_alive"):
|
|
50
|
+
|
|
51
|
+
def _is_alive(self: _aiosqlite.Connection) -> bool:
|
|
52
|
+
"""Check if the connection is still alive.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True if connection is alive, False otherwise.
|
|
56
|
+
"""
|
|
57
|
+
return bool(self._running and self._connection is not None)
|
|
58
|
+
|
|
59
|
+
# Dynamically adding a method to aiosqlite.Connection at runtime.
|
|
60
|
+
# Type checkers can't understand this monkey-patch, so we suppress the
|
|
61
|
+
# "attr-defined" error that would otherwise be raised.
|
|
62
|
+
_aiosqlite.Connection.is_alive = _is_alive # ty: ignore[unresolved-attribute]
|
|
63
|
+
|
|
64
|
+
_aiosqlite_patched = True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _drain_aiosqlite_worker(conn: aiosqlite.Connection) -> None:
|
|
68
|
+
"""Join the aiosqlite worker thread after its connection is closed.
|
|
69
|
+
|
|
70
|
+
`aiosqlite.Connection` wraps a daemon `Thread` (`conn._thread`) that
|
|
71
|
+
drains its tx queue independently of the caller's event loop. The
|
|
72
|
+
library's `close()` puts a stop sentinel on the queue and awaits the
|
|
73
|
+
sentinel's future, but does not explicitly join the worker thread.
|
|
74
|
+
|
|
75
|
+
If the connection is leaked (no explicit close) and the surrounding
|
|
76
|
+
event loop has already shut down, the worker can still pop a queued
|
|
77
|
+
item (typically from `Connection.__del__` calling `stop()`) and call
|
|
78
|
+
`future.get_loop().call_soon_threadsafe(...)` on the closed loop. That
|
|
79
|
+
raises `RuntimeError: Event loop is closed`, which pytest surfaces as
|
|
80
|
+
`PytestUnhandledThreadExceptionWarning` (and GitHub Actions then
|
|
81
|
+
surfaces as a workflow annotation).
|
|
82
|
+
|
|
83
|
+
Explicitly joining the worker thread after close guarantees it has
|
|
84
|
+
exited before this coroutine returns, eliminating the race for any
|
|
85
|
+
connection routed through `_connect` / `get_checkpointer`.
|
|
86
|
+
"""
|
|
87
|
+
worker = getattr(conn, "_thread", None)
|
|
88
|
+
if worker is None or not worker.is_alive():
|
|
89
|
+
return
|
|
90
|
+
# `RuntimeError` covers the "thread was never started" case; treat as
|
|
91
|
+
# already drained.
|
|
92
|
+
with contextlib.suppress(RuntimeError):
|
|
93
|
+
await asyncio.to_thread(worker.join, 5.0)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@asynccontextmanager
|
|
97
|
+
async def _connect() -> AsyncIterator[aiosqlite.Connection]:
|
|
98
|
+
"""Import aiosqlite, apply the compatibility patch, and connect.
|
|
99
|
+
|
|
100
|
+
Centralizes the deferred import + patch + connect sequence used by every
|
|
101
|
+
database function in this module.
|
|
102
|
+
|
|
103
|
+
Yields:
|
|
104
|
+
An open aiosqlite connection to the sessions database.
|
|
105
|
+
"""
|
|
106
|
+
import aiosqlite as _aiosqlite
|
|
107
|
+
|
|
108
|
+
_patch_aiosqlite()
|
|
109
|
+
|
|
110
|
+
conn: aiosqlite.Connection | None = None
|
|
111
|
+
try:
|
|
112
|
+
async with _aiosqlite.connect(str(get_db_path()), timeout=30.0) as opened:
|
|
113
|
+
conn = opened
|
|
114
|
+
yield opened
|
|
115
|
+
finally:
|
|
116
|
+
if conn is not None:
|
|
117
|
+
await _drain_aiosqlite_worker(conn)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ThreadInfo(TypedDict):
|
|
121
|
+
"""Thread metadata returned by `list_threads`."""
|
|
122
|
+
|
|
123
|
+
thread_id: str
|
|
124
|
+
"""Unique identifier for the thread."""
|
|
125
|
+
|
|
126
|
+
agent_name: str | None
|
|
127
|
+
"""Name of the agent that owns the thread."""
|
|
128
|
+
|
|
129
|
+
updated_at: str | None
|
|
130
|
+
"""ISO timestamp of the last update."""
|
|
131
|
+
|
|
132
|
+
created_at: NotRequired[str | None]
|
|
133
|
+
"""ISO timestamp of thread creation (earliest checkpoint)."""
|
|
134
|
+
|
|
135
|
+
git_branch: NotRequired[str | None]
|
|
136
|
+
"""Git branch active when the thread was created."""
|
|
137
|
+
|
|
138
|
+
initial_prompt: NotRequired[str | None]
|
|
139
|
+
"""First human message in the thread."""
|
|
140
|
+
|
|
141
|
+
message_count: NotRequired[int]
|
|
142
|
+
"""Number of messages in the thread."""
|
|
143
|
+
|
|
144
|
+
latest_checkpoint_id: NotRequired[str | None]
|
|
145
|
+
"""Most recent checkpoint ID for cache invalidation."""
|
|
146
|
+
|
|
147
|
+
cwd: NotRequired[str | None]
|
|
148
|
+
"""Working directory where the thread was last used."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _CheckpointSummary(NamedTuple):
|
|
152
|
+
"""Structured data extracted from a thread's latest checkpoint."""
|
|
153
|
+
|
|
154
|
+
message_count: int | None
|
|
155
|
+
"""Number of messages inlined in the latest checkpoint, or `None`.
|
|
156
|
+
|
|
157
|
+
`None` means the latest checkpoint did not inline the `messages` channel
|
|
158
|
+
value, so the count is unknown and must be reconstructed from the `writes`
|
|
159
|
+
table. This happens when `messages` uses a `DeltaChannel` (a LangGraph
|
|
160
|
+
channel the deepagents SDK applies to `messages` as of v0.6) and the latest
|
|
161
|
+
checkpoint falls between periodic snapshots. An `int` (including `0`) is a
|
|
162
|
+
trustworthy count.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
initial_prompt: str | None
|
|
166
|
+
"""First human prompt recovered from the latest checkpoint."""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def format_timestamp(iso_timestamp: str | None) -> str:
|
|
170
|
+
"""Format ISO timestamp for display (e.g., 'Dec 30, 6:10pm').
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
iso_timestamp: ISO 8601 timestamp string, or `None`.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Formatted timestamp string or empty string if invalid.
|
|
177
|
+
"""
|
|
178
|
+
if not iso_timestamp:
|
|
179
|
+
return ""
|
|
180
|
+
try:
|
|
181
|
+
dt = datetime.fromisoformat(iso_timestamp).astimezone()
|
|
182
|
+
return (
|
|
183
|
+
dt.strftime("%b %d, %-I:%M%p")
|
|
184
|
+
.lower()
|
|
185
|
+
.replace("am", "am")
|
|
186
|
+
.replace("pm", "pm")
|
|
187
|
+
)
|
|
188
|
+
except (ValueError, TypeError):
|
|
189
|
+
logger.debug(
|
|
190
|
+
"Failed to parse timestamp %r; displaying as blank",
|
|
191
|
+
iso_timestamp,
|
|
192
|
+
exc_info=True,
|
|
193
|
+
)
|
|
194
|
+
return ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def format_relative_timestamp(iso_timestamp: str | None) -> str:
|
|
198
|
+
"""Format ISO timestamp as relative time (e.g., '5m ago', '2h ago').
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
iso_timestamp: ISO 8601 timestamp string, or `None`.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Relative time string or empty string if invalid.
|
|
205
|
+
"""
|
|
206
|
+
if not iso_timestamp:
|
|
207
|
+
return ""
|
|
208
|
+
try:
|
|
209
|
+
dt = datetime.fromisoformat(iso_timestamp).astimezone()
|
|
210
|
+
except (ValueError, TypeError):
|
|
211
|
+
logger.debug(
|
|
212
|
+
"Failed to parse timestamp %r; displaying as blank",
|
|
213
|
+
iso_timestamp,
|
|
214
|
+
exc_info=True,
|
|
215
|
+
)
|
|
216
|
+
return ""
|
|
217
|
+
|
|
218
|
+
delta = datetime.now(tz=dt.tzinfo) - dt
|
|
219
|
+
seconds = int(delta.total_seconds())
|
|
220
|
+
if seconds < 0:
|
|
221
|
+
return "just now"
|
|
222
|
+
if seconds < 60: # noqa: PLR2004
|
|
223
|
+
return f"{seconds}s ago"
|
|
224
|
+
minutes = seconds // 60
|
|
225
|
+
if minutes < 60: # noqa: PLR2004
|
|
226
|
+
return f"{minutes}m ago"
|
|
227
|
+
hours = minutes // 60
|
|
228
|
+
if hours < 24: # noqa: PLR2004
|
|
229
|
+
return f"{hours}h ago"
|
|
230
|
+
days = hours // 24
|
|
231
|
+
if days < 30: # noqa: PLR2004
|
|
232
|
+
return f"{days}d ago"
|
|
233
|
+
if days < 365: # noqa: PLR2004
|
|
234
|
+
months = days // 30
|
|
235
|
+
return f"{months}mo ago"
|
|
236
|
+
years = days // 365
|
|
237
|
+
return f"{years}y ago"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def format_path(path: str | None) -> str:
|
|
241
|
+
"""Format a filesystem path for display.
|
|
242
|
+
|
|
243
|
+
Paths under the user's home directory are shown relative to `~`.
|
|
244
|
+
All other paths are returned as-is.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
path: Absolute filesystem path, or `None`.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Formatted path string, or empty string if path is falsy.
|
|
251
|
+
"""
|
|
252
|
+
if not path:
|
|
253
|
+
return ""
|
|
254
|
+
try:
|
|
255
|
+
home = str(Path.home())
|
|
256
|
+
if path == home:
|
|
257
|
+
return "~"
|
|
258
|
+
prefix = home + "/"
|
|
259
|
+
if path.startswith(prefix):
|
|
260
|
+
return "~/" + path[len(prefix) :]
|
|
261
|
+
except (RuntimeError, KeyError, OSError):
|
|
262
|
+
logger.debug(
|
|
263
|
+
"Could not resolve home directory for path formatting", exc_info=True
|
|
264
|
+
)
|
|
265
|
+
return path
|
|
266
|
+
else:
|
|
267
|
+
return path
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_db_path: Path | None = None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def get_db_path() -> Path:
|
|
274
|
+
"""Get path to global database.
|
|
275
|
+
|
|
276
|
+
The result is cached after the first successful call to avoid repeated
|
|
277
|
+
filesystem operations.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Path to the SQLite database file.
|
|
281
|
+
"""
|
|
282
|
+
global _db_path # noqa: PLW0603 # Module-level cache requires global statement
|
|
283
|
+
if _db_path is not None:
|
|
284
|
+
return _db_path
|
|
285
|
+
from deepagents_code.model_config import DEFAULT_STATE_DIR
|
|
286
|
+
|
|
287
|
+
DEFAULT_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
_db_path = DEFAULT_STATE_DIR / "sessions.db"
|
|
289
|
+
return _db_path
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def generate_thread_id() -> str:
|
|
293
|
+
"""Generate a new thread ID as a full UUID7 string.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
UUID7 string (time-ordered for natural sort by creation time).
|
|
297
|
+
"""
|
|
298
|
+
from uuid_utils import uuid7
|
|
299
|
+
|
|
300
|
+
return str(uuid7())
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _table_exists(conn: aiosqlite.Connection, table: str) -> bool:
|
|
304
|
+
"""Check if a table exists in the database.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
True if table exists, False otherwise.
|
|
308
|
+
"""
|
|
309
|
+
query = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?"
|
|
310
|
+
async with conn.execute(query, (table,)) as cursor:
|
|
311
|
+
return await cursor.fetchone() is not None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
_THREADS_LIST_INDEX = "idx_dcode_threads_list"
|
|
315
|
+
"""Covering index that makes the `list_threads` GROUP BY an index-only scan.
|
|
316
|
+
|
|
317
|
+
LangGraph's `SqliteSaver` stores each checkpoint's full state blob inline in the
|
|
318
|
+
`checkpoints` row alongside the small `metadata` field. The thread-list query
|
|
319
|
+
only needs `metadata` (per-thread latest `updated_at`, `agent_name`, etc.), but
|
|
320
|
+
without a covering index SQLite scans the whole table — dragging every state
|
|
321
|
+
blob through I/O. On a large profile (e.g. ~12 GB of blobs) that scan takes
|
|
322
|
+
tens of seconds. This index carries exactly the expressions the query reads, so
|
|
323
|
+
the planner satisfies the GROUP BY from the index alone and never touches the
|
|
324
|
+
blob-bearing rows, turning a ~60 s scan into a sub-second lookup.
|
|
325
|
+
|
|
326
|
+
The column order (leading `thread_id`) also lets the GROUP BY consume the index
|
|
327
|
+
in order. Keep the indexed expressions in sync with the `list_threads` query.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def _ensure_threads_list_index(conn: aiosqlite.Connection) -> None:
|
|
332
|
+
"""Create the `list_threads` covering index if it does not already exist.
|
|
333
|
+
|
|
334
|
+
Idempotent: `CREATE INDEX IF NOT EXISTS` is a near-instant catalog check once
|
|
335
|
+
the index exists. The one-time build on a pre-existing large database costs a
|
|
336
|
+
single full table scan (seconds to tens of seconds), after which every
|
|
337
|
+
`list_threads` call is a sub-second index-only scan. Runs in the aiosqlite
|
|
338
|
+
worker thread, so it does not block the event loop.
|
|
339
|
+
|
|
340
|
+
A failure here is non-fatal: the list query still returns correct results via
|
|
341
|
+
the slower table scan, so we log and continue rather than break `threads
|
|
342
|
+
list` (e.g. on a read-only database or under write-lock contention).
|
|
343
|
+
"""
|
|
344
|
+
try:
|
|
345
|
+
await conn.execute(
|
|
346
|
+
f"CREATE INDEX IF NOT EXISTS {_THREADS_LIST_INDEX} ON checkpoints("
|
|
347
|
+
"thread_id, "
|
|
348
|
+
"json_extract(metadata, '$.updated_at'), "
|
|
349
|
+
"checkpoint_id, "
|
|
350
|
+
"json_extract(metadata, '$.agent_name'), "
|
|
351
|
+
"json_extract(metadata, '$.git_branch'), "
|
|
352
|
+
"json_extract(metadata, '$.cwd'))"
|
|
353
|
+
)
|
|
354
|
+
await conn.commit()
|
|
355
|
+
except Exception:
|
|
356
|
+
logger.warning(
|
|
357
|
+
"Failed to create the %s index; `threads list` will fall back to a "
|
|
358
|
+
"full table scan and may be slow on large databases",
|
|
359
|
+
_THREADS_LIST_INDEX,
|
|
360
|
+
exc_info=True,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def list_threads(
|
|
365
|
+
agent_name: str | None = None,
|
|
366
|
+
limit: int = 20,
|
|
367
|
+
include_message_count: bool = False,
|
|
368
|
+
sort_by: str = "updated",
|
|
369
|
+
branch: str | None = None,
|
|
370
|
+
cwd: str | None = None,
|
|
371
|
+
) -> list[ThreadInfo]:
|
|
372
|
+
"""List threads from checkpoints table.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
agent_name: Optional filter by agent name.
|
|
376
|
+
limit: Maximum number of threads to return.
|
|
377
|
+
include_message_count: Whether to include message counts.
|
|
378
|
+
sort_by: Sort field — `"updated"` or `"created"`.
|
|
379
|
+
branch: Optional filter by git branch name.
|
|
380
|
+
cwd: Optional filter by working directory. Only threads whose stored
|
|
381
|
+
`cwd` metadata equals this path are returned. Matching is an
|
|
382
|
+
exact string comparison — no path normalization, symlink
|
|
383
|
+
resolution, or prefix matching. Threads without a stored `cwd`
|
|
384
|
+
(older rows) are excluded.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
List of `ThreadInfo` dicts with `thread_id`, `agent_name`,
|
|
388
|
+
`updated_at`, `created_at`, `latest_checkpoint_id`, `git_branch`,
|
|
389
|
+
`cwd`, and optionally `message_count`.
|
|
390
|
+
|
|
391
|
+
Raises:
|
|
392
|
+
ValueError: If `sort_by` is not `"updated"` or `"created"`.
|
|
393
|
+
"""
|
|
394
|
+
async with _connect() as conn:
|
|
395
|
+
if not await _table_exists(conn, "checkpoints"):
|
|
396
|
+
return []
|
|
397
|
+
|
|
398
|
+
# Ensure the covering index exists before the GROUP BY below, so the
|
|
399
|
+
# query is an index-only scan instead of a full scan over the (large,
|
|
400
|
+
# blob-bearing) checkpoints table.
|
|
401
|
+
await _ensure_threads_list_index(conn)
|
|
402
|
+
|
|
403
|
+
if sort_by not in {"updated", "created"}:
|
|
404
|
+
msg = f"Invalid sort_by {sort_by!r}; expected 'updated' or 'created'"
|
|
405
|
+
raise ValueError(msg)
|
|
406
|
+
order_col = "created_at" if sort_by == "created" else "updated_at"
|
|
407
|
+
|
|
408
|
+
where_clauses: list[str] = []
|
|
409
|
+
params_list: list[str | int] = []
|
|
410
|
+
|
|
411
|
+
if agent_name:
|
|
412
|
+
where_clauses.append("json_extract(metadata, '$.agent_name') = ?")
|
|
413
|
+
params_list.append(agent_name)
|
|
414
|
+
if branch:
|
|
415
|
+
where_clauses.append("json_extract(metadata, '$.git_branch') = ?")
|
|
416
|
+
params_list.append(branch)
|
|
417
|
+
if cwd:
|
|
418
|
+
where_clauses.append("json_extract(metadata, '$.cwd') = ?")
|
|
419
|
+
params_list.append(cwd)
|
|
420
|
+
|
|
421
|
+
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
422
|
+
|
|
423
|
+
query = f"""
|
|
424
|
+
SELECT thread_id,
|
|
425
|
+
json_extract(metadata, '$.agent_name') as agent_name,
|
|
426
|
+
MAX(json_extract(metadata, '$.updated_at')) as updated_at,
|
|
427
|
+
MAX(checkpoint_id) as latest_checkpoint_id,
|
|
428
|
+
MIN(json_extract(metadata, '$.updated_at')) as created_at,
|
|
429
|
+
MAX(json_extract(metadata, '$.git_branch')) as git_branch,
|
|
430
|
+
MAX(json_extract(metadata, '$.cwd')) as cwd
|
|
431
|
+
FROM checkpoints
|
|
432
|
+
{where_sql}
|
|
433
|
+
GROUP BY thread_id
|
|
434
|
+
ORDER BY {order_col} DESC
|
|
435
|
+
LIMIT ?
|
|
436
|
+
""" # noqa: S608 # where_sql/order_col derived from controlled internal values; user values use ? placeholders
|
|
437
|
+
params: tuple = (*params_list, limit)
|
|
438
|
+
|
|
439
|
+
async with conn.execute(query, params) as cursor:
|
|
440
|
+
rows = await cursor.fetchall()
|
|
441
|
+
threads: list[ThreadInfo] = [
|
|
442
|
+
ThreadInfo(
|
|
443
|
+
thread_id=r[0],
|
|
444
|
+
agent_name=r[1],
|
|
445
|
+
updated_at=r[2],
|
|
446
|
+
latest_checkpoint_id=r[3],
|
|
447
|
+
created_at=r[4],
|
|
448
|
+
git_branch=r[5],
|
|
449
|
+
cwd=r[6],
|
|
450
|
+
)
|
|
451
|
+
for r in rows
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
# Fetch message counts if requested
|
|
455
|
+
if include_message_count and threads:
|
|
456
|
+
await _populate_message_counts(conn, threads)
|
|
457
|
+
|
|
458
|
+
# Only cache unfiltered results so the thread selector modal
|
|
459
|
+
# doesn't receive branch-/cwd-filtered or differently-sorted data.
|
|
460
|
+
if sort_by == "updated" and branch is None and cwd is None:
|
|
461
|
+
_cache_recent_threads(agent_name, limit, threads)
|
|
462
|
+
return threads
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
async def populate_thread_checkpoint_details(
|
|
466
|
+
threads: list[ThreadInfo],
|
|
467
|
+
*,
|
|
468
|
+
include_message_count: bool = True,
|
|
469
|
+
include_initial_prompt: bool = True,
|
|
470
|
+
) -> list[ThreadInfo]:
|
|
471
|
+
"""Populate checkpoint-derived fields for an existing thread list.
|
|
472
|
+
|
|
473
|
+
This is used by the `/threads` modal to enrich rows in one background pass,
|
|
474
|
+
so the latest checkpoint is fetched and deserialized at most once per row.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
threads: Thread rows to enrich in place.
|
|
478
|
+
include_message_count: Whether to populate `message_count`.
|
|
479
|
+
include_initial_prompt: Whether to populate `initial_prompt`.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
The same list object with missing checkpoint-derived fields populated.
|
|
483
|
+
"""
|
|
484
|
+
if not threads or (not include_message_count and not include_initial_prompt):
|
|
485
|
+
return threads
|
|
486
|
+
|
|
487
|
+
async with _connect() as conn:
|
|
488
|
+
await _populate_checkpoint_fields(
|
|
489
|
+
conn,
|
|
490
|
+
threads,
|
|
491
|
+
include_message_count=include_message_count,
|
|
492
|
+
include_initial_prompt=include_initial_prompt,
|
|
493
|
+
)
|
|
494
|
+
return threads
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def prewarm_thread_message_counts(limit: int | None = None) -> None:
|
|
498
|
+
"""Prewarm thread selector cache for faster `/threads` open.
|
|
499
|
+
|
|
500
|
+
Fetches a bounded list of recent threads and populates checkpoint-derived
|
|
501
|
+
fields for currently visible columns into the in-memory cache. Intended to
|
|
502
|
+
run in a background worker during app startup.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
limit: Maximum threads to prewarm. Uses `get_thread_limit()` when `None`.
|
|
506
|
+
"""
|
|
507
|
+
thread_limit = limit if limit is not None else get_thread_limit()
|
|
508
|
+
if thread_limit < 1:
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
from deepagents_code.model_config import load_thread_config
|
|
513
|
+
|
|
514
|
+
cfg = load_thread_config()
|
|
515
|
+
threads = await list_threads(limit=thread_limit, include_message_count=False)
|
|
516
|
+
if threads:
|
|
517
|
+
await populate_thread_checkpoint_details(
|
|
518
|
+
threads,
|
|
519
|
+
include_message_count=cfg.columns.get("messages", False),
|
|
520
|
+
include_initial_prompt=cfg.columns.get("initial_prompt", False),
|
|
521
|
+
)
|
|
522
|
+
_cache_recent_threads(None, thread_limit, threads)
|
|
523
|
+
except (OSError, sqlite3.Error):
|
|
524
|
+
logger.debug("Could not prewarm thread selector cache", exc_info=True)
|
|
525
|
+
except Exception:
|
|
526
|
+
logger.warning(
|
|
527
|
+
"Unexpected error while prewarming thread selector cache",
|
|
528
|
+
exc_info=True,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def get_cached_threads(
|
|
533
|
+
agent_name: str | None = None,
|
|
534
|
+
limit: int | None = None,
|
|
535
|
+
) -> list[ThreadInfo] | None:
|
|
536
|
+
"""Get cached recent threads, if available.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
agent_name: Optional agent-name filter key.
|
|
540
|
+
limit: Maximum rows requested. Uses `get_thread_limit()` when `None`.
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Copy of cached rows when available, otherwise `None`.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
def _copy_with_cached_counts(rows: list[ThreadInfo]) -> list[ThreadInfo]:
|
|
547
|
+
copied_rows = _copy_threads(rows)
|
|
548
|
+
apply_cached_thread_message_counts(copied_rows)
|
|
549
|
+
apply_cached_thread_initial_prompts(copied_rows)
|
|
550
|
+
return copied_rows
|
|
551
|
+
|
|
552
|
+
thread_limit = limit if limit is not None else get_thread_limit()
|
|
553
|
+
if thread_limit < 1:
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
exact = _recent_threads_cache.get((agent_name, thread_limit))
|
|
557
|
+
if exact is not None:
|
|
558
|
+
return _copy_with_cached_counts(exact)
|
|
559
|
+
|
|
560
|
+
best_key: tuple[str | None, int] | None = None
|
|
561
|
+
for key in _recent_threads_cache:
|
|
562
|
+
cache_agent, cache_limit = key
|
|
563
|
+
if cache_agent != agent_name or cache_limit < thread_limit:
|
|
564
|
+
continue
|
|
565
|
+
if best_key is None or cache_limit < best_key[1]:
|
|
566
|
+
best_key = key
|
|
567
|
+
|
|
568
|
+
if best_key is None:
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
return _copy_with_cached_counts(_recent_threads_cache[best_key][:thread_limit])
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def apply_cached_thread_message_counts(threads: list[ThreadInfo]) -> int:
|
|
575
|
+
"""Apply cached message counts onto thread rows when freshness matches.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
threads: Thread rows to mutate in place.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
Number of rows that were populated from cache.
|
|
582
|
+
"""
|
|
583
|
+
populated = 0
|
|
584
|
+
for thread in threads:
|
|
585
|
+
if "message_count" in thread:
|
|
586
|
+
continue
|
|
587
|
+
thread_id = thread["thread_id"]
|
|
588
|
+
freshness = _thread_freshness(thread)
|
|
589
|
+
cached = _message_count_cache.get(thread_id)
|
|
590
|
+
if cached is None or cached[0] != freshness:
|
|
591
|
+
continue
|
|
592
|
+
thread["message_count"] = cached[1]
|
|
593
|
+
populated += 1
|
|
594
|
+
return populated
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def apply_cached_thread_initial_prompts(threads: list[ThreadInfo]) -> int:
|
|
598
|
+
"""Apply cached initial prompts onto thread rows when freshness matches.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
threads: Thread rows to mutate in place.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
Number of rows that were populated from cache.
|
|
605
|
+
"""
|
|
606
|
+
populated = 0
|
|
607
|
+
for thread in threads:
|
|
608
|
+
if "initial_prompt" in thread:
|
|
609
|
+
continue
|
|
610
|
+
thread_id = thread["thread_id"]
|
|
611
|
+
freshness = _thread_freshness(thread)
|
|
612
|
+
cached = _initial_prompt_cache.get(thread_id)
|
|
613
|
+
if cached is None or cached[0] != freshness:
|
|
614
|
+
continue
|
|
615
|
+
thread["initial_prompt"] = cached[1]
|
|
616
|
+
populated += 1
|
|
617
|
+
return populated
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
async def _populate_message_counts(
|
|
621
|
+
conn: aiosqlite.Connection,
|
|
622
|
+
threads: list[ThreadInfo],
|
|
623
|
+
) -> None:
|
|
624
|
+
"""Fill `message_count` on thread rows with cache-aware lookup."""
|
|
625
|
+
await _populate_checkpoint_fields(
|
|
626
|
+
conn,
|
|
627
|
+
threads,
|
|
628
|
+
include_message_count=True,
|
|
629
|
+
include_initial_prompt=False,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
async def _get_jsonplus_serializer() -> JsonPlusSerializer:
|
|
634
|
+
"""Return a cached JsonPlus serializer, loading it off the UI loop."""
|
|
635
|
+
global _jsonplus_serializer # noqa: PLW0603 # Module-level cache requires global statement
|
|
636
|
+
if _jsonplus_serializer is not None:
|
|
637
|
+
return _jsonplus_serializer
|
|
638
|
+
|
|
639
|
+
loop = asyncio.get_running_loop()
|
|
640
|
+
_jsonplus_serializer = await loop.run_in_executor(None, _create_jsonplus_serializer)
|
|
641
|
+
return _jsonplus_serializer
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _create_jsonplus_serializer() -> JsonPlusSerializer:
|
|
645
|
+
"""Import and create a JsonPlus serializer.
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
A ready `JsonPlusSerializer` instance.
|
|
649
|
+
"""
|
|
650
|
+
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
651
|
+
|
|
652
|
+
return JsonPlusSerializer()
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _cache_message_count(thread_id: str, freshness: str | None, count: int) -> None:
|
|
656
|
+
"""Cache a thread's message count with a freshness token."""
|
|
657
|
+
if len(_message_count_cache) >= _MAX_MESSAGE_COUNT_CACHE and (
|
|
658
|
+
thread_id not in _message_count_cache
|
|
659
|
+
):
|
|
660
|
+
oldest = next(iter(_message_count_cache))
|
|
661
|
+
_message_count_cache.pop(oldest, None)
|
|
662
|
+
_message_count_cache[thread_id] = (freshness, count)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _cache_initial_prompt(
|
|
666
|
+
thread_id: str,
|
|
667
|
+
freshness: str | None,
|
|
668
|
+
initial_prompt: str | None,
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Cache a thread's initial prompt with a freshness token."""
|
|
671
|
+
if len(_initial_prompt_cache) >= _MAX_INITIAL_PROMPT_CACHE and (
|
|
672
|
+
thread_id not in _initial_prompt_cache
|
|
673
|
+
):
|
|
674
|
+
oldest = next(iter(_initial_prompt_cache))
|
|
675
|
+
_initial_prompt_cache.pop(oldest, None)
|
|
676
|
+
_initial_prompt_cache[thread_id] = (freshness, initial_prompt)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _thread_freshness(thread: ThreadInfo) -> str | None:
|
|
680
|
+
"""Return a cache freshness token for a thread row.
|
|
681
|
+
|
|
682
|
+
The token is checkpoint-granular (`latest_checkpoint_id`). The
|
|
683
|
+
writes-reconstructed `message_count` includes pending writes on the latest
|
|
684
|
+
checkpoint, which in principle can change without a new checkpoint ID — so
|
|
685
|
+
this token does not capture intra-checkpoint write churn. In practice that
|
|
686
|
+
is benign: dcode only mutates `messages` through the agent graph, and every
|
|
687
|
+
batch of message writes culminates in a new checkpoint (each superstep,
|
|
688
|
+
`aupdate_state`, interrupt, and cancellation all advance
|
|
689
|
+
`latest_checkpoint_id`). The only window where a cached count can lag is
|
|
690
|
+
opening the `/threads` selector mid-superstep against an actively streaming
|
|
691
|
+
thread; the selector does not live-refresh, so that count stays put until
|
|
692
|
+
the modal is reopened (by then a new checkpoint exists and the cache
|
|
693
|
+
refreshes). Making the key write-sensitive would require probing the
|
|
694
|
+
`writes` table for every row on every `list_threads`, which is not worth it
|
|
695
|
+
for a cosmetic count.
|
|
696
|
+
"""
|
|
697
|
+
return thread.get("latest_checkpoint_id") or thread.get("updated_at")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _cache_recent_threads(
|
|
701
|
+
agent_name: str | None,
|
|
702
|
+
limit: int,
|
|
703
|
+
threads: list[ThreadInfo],
|
|
704
|
+
) -> None:
|
|
705
|
+
"""Store a copy of recent thread rows for fast selector startup."""
|
|
706
|
+
key = (agent_name, max(1, limit))
|
|
707
|
+
if len(_recent_threads_cache) >= _MAX_RECENT_THREADS_CACHE_KEYS and (
|
|
708
|
+
key not in _recent_threads_cache
|
|
709
|
+
):
|
|
710
|
+
_recent_threads_cache.clear()
|
|
711
|
+
_recent_threads_cache[key] = _copy_threads(threads)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _copy_threads(threads: list[ThreadInfo]) -> list[ThreadInfo]:
|
|
715
|
+
"""Return shallow-copied thread rows."""
|
|
716
|
+
return [ThreadInfo(**thread) for thread in threads]
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
async def _populate_checkpoint_fields(
|
|
720
|
+
conn: aiosqlite.Connection,
|
|
721
|
+
threads: list[ThreadInfo],
|
|
722
|
+
*,
|
|
723
|
+
include_message_count: bool,
|
|
724
|
+
include_initial_prompt: bool,
|
|
725
|
+
) -> None:
|
|
726
|
+
"""Populate checkpoint-derived thread fields with a batched latest-row pass."""
|
|
727
|
+
serde = await _get_jsonplus_serializer()
|
|
728
|
+
|
|
729
|
+
# Phase 1: apply cache hits, collect threads that need DB fetch.
|
|
730
|
+
uncached: list[ThreadInfo] = []
|
|
731
|
+
for thread in threads:
|
|
732
|
+
thread_id = thread["thread_id"]
|
|
733
|
+
freshness = _thread_freshness(thread)
|
|
734
|
+
needs_count = False
|
|
735
|
+
needs_prompt = False
|
|
736
|
+
|
|
737
|
+
if include_message_count:
|
|
738
|
+
cached = _message_count_cache.get(thread_id)
|
|
739
|
+
if cached is not None and cached[0] == freshness:
|
|
740
|
+
thread["message_count"] = cached[1]
|
|
741
|
+
else:
|
|
742
|
+
needs_count = True
|
|
743
|
+
|
|
744
|
+
if include_initial_prompt and "initial_prompt" not in thread:
|
|
745
|
+
cached_prompt = _initial_prompt_cache.get(thread_id)
|
|
746
|
+
if cached_prompt is not None and cached_prompt[0] == freshness:
|
|
747
|
+
thread["initial_prompt"] = cached_prompt[1]
|
|
748
|
+
else:
|
|
749
|
+
needs_prompt = True
|
|
750
|
+
|
|
751
|
+
if needs_count or needs_prompt:
|
|
752
|
+
uncached.append(thread)
|
|
753
|
+
|
|
754
|
+
if not uncached:
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
# Phase 2: batch-fetch all uncached threads.
|
|
758
|
+
uncached_ids = [t["thread_id"] for t in uncached]
|
|
759
|
+
batch_results: dict[str, _CheckpointSummary] = {}
|
|
760
|
+
if include_message_count or include_initial_prompt:
|
|
761
|
+
batch_results = await _load_latest_checkpoint_summaries_batch(
|
|
762
|
+
conn, uncached_ids, serde
|
|
763
|
+
)
|
|
764
|
+
# `initial_prompt` cannot be recovered from the latest checkpoint alone:
|
|
765
|
+
# `after_model` middleware (e.g., `ResumeStateMiddleware`) writes partial
|
|
766
|
+
# checkpoints whose `channel_values` omit `messages`. Read the very first
|
|
767
|
+
# write to the `messages` channel from the `writes` table instead — that
|
|
768
|
+
# row holds the user's original input.
|
|
769
|
+
prompt_results: dict[str, str | None] = {}
|
|
770
|
+
if include_initial_prompt:
|
|
771
|
+
prompt_results = await _load_initial_prompts_from_writes_batch(
|
|
772
|
+
conn, uncached_ids, serde
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
# Phase 3: apply inline results, deferring threads whose latest checkpoint
|
|
776
|
+
# does not inline the `messages` channel value. When `messages` uses a
|
|
777
|
+
# `DeltaChannel` (LangGraph channel applied by the deepagents SDK as of
|
|
778
|
+
# v0.6) the full list is only snapshotted into `channel_values` periodically,
|
|
779
|
+
# so the latest checkpoint usually omits it; the count must then be
|
|
780
|
+
# reconstructed from the `writes` table.
|
|
781
|
+
needs_writes_count: list[str] = []
|
|
782
|
+
for thread in uncached:
|
|
783
|
+
thread_id = thread["thread_id"]
|
|
784
|
+
freshness = _thread_freshness(thread)
|
|
785
|
+
|
|
786
|
+
if include_message_count and "message_count" not in thread:
|
|
787
|
+
summary = batch_results.get(thread_id)
|
|
788
|
+
if summary is not None and summary.message_count is not None:
|
|
789
|
+
thread["message_count"] = summary.message_count
|
|
790
|
+
_cache_message_count(thread_id, freshness, summary.message_count)
|
|
791
|
+
else:
|
|
792
|
+
needs_writes_count.append(thread_id)
|
|
793
|
+
if include_initial_prompt and "initial_prompt" not in thread:
|
|
794
|
+
if thread_id in prompt_results:
|
|
795
|
+
prompt = prompt_results[thread_id]
|
|
796
|
+
else:
|
|
797
|
+
prompt = batch_results.get(
|
|
798
|
+
thread_id, _CheckpointSummary(None, None)
|
|
799
|
+
).initial_prompt
|
|
800
|
+
thread["initial_prompt"] = prompt
|
|
801
|
+
_cache_initial_prompt(thread_id, freshness, prompt)
|
|
802
|
+
|
|
803
|
+
# Phase 4: reconstruct counts for delta-channel threads from the `writes`
|
|
804
|
+
# table by replaying the `messages` writes through the canonical reducer.
|
|
805
|
+
if needs_writes_count:
|
|
806
|
+
writes_counts = await _load_message_counts_from_writes_batch(
|
|
807
|
+
conn, needs_writes_count, serde
|
|
808
|
+
)
|
|
809
|
+
uncached_by_id = {t["thread_id"]: t for t in uncached}
|
|
810
|
+
for thread_id in needs_writes_count:
|
|
811
|
+
count = writes_counts.get(thread_id, 0)
|
|
812
|
+
thread = uncached_by_id[thread_id]
|
|
813
|
+
thread["message_count"] = count
|
|
814
|
+
_cache_message_count(thread_id, _thread_freshness(thread), count)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
_SQLITE_MAX_VARIABLE_NUMBER = 500
|
|
818
|
+
"""Max `?` placeholders per SQL query.
|
|
819
|
+
|
|
820
|
+
SQLite limits how many `?` parameters a single query can have (default 999,
|
|
821
|
+
lower on some builds). If a user accumulates hundreds of threads and the
|
|
822
|
+
`/threads` modal fetches them all at once, the `IN (?, ?, ...)` clause could
|
|
823
|
+
exceed that limit. We chunk to this size to stay safe.
|
|
824
|
+
"""
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
async def _load_latest_checkpoint_summaries_batch(
|
|
828
|
+
conn: aiosqlite.Connection,
|
|
829
|
+
thread_ids: list[str],
|
|
830
|
+
serde: JsonPlusSerializer,
|
|
831
|
+
) -> dict[str, _CheckpointSummary]:
|
|
832
|
+
"""Batch-load the latest checkpoint summary for multiple threads.
|
|
833
|
+
|
|
834
|
+
Uses a window function to fetch the latest checkpoint per thread, issuing
|
|
835
|
+
one query per chunk for SQLite variable-limit safety.
|
|
836
|
+
|
|
837
|
+
Args:
|
|
838
|
+
conn: Database connection.
|
|
839
|
+
thread_ids: Thread IDs to look up.
|
|
840
|
+
serde: Serializer for decoding checkpoint blobs.
|
|
841
|
+
|
|
842
|
+
Returns:
|
|
843
|
+
Dict mapping thread IDs to their checkpoint summaries.
|
|
844
|
+
"""
|
|
845
|
+
if not thread_ids:
|
|
846
|
+
return {}
|
|
847
|
+
|
|
848
|
+
results: dict[str, _CheckpointSummary] = {}
|
|
849
|
+
|
|
850
|
+
for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
|
|
851
|
+
chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
|
|
852
|
+
placeholders = ",".join("?" * len(chunk))
|
|
853
|
+
query = f"""
|
|
854
|
+
SELECT thread_id, type, checkpoint FROM (
|
|
855
|
+
SELECT thread_id, type, checkpoint,
|
|
856
|
+
ROW_NUMBER() OVER (
|
|
857
|
+
PARTITION BY thread_id ORDER BY checkpoint_id DESC
|
|
858
|
+
) AS rn
|
|
859
|
+
FROM checkpoints
|
|
860
|
+
WHERE thread_id IN ({placeholders})
|
|
861
|
+
) WHERE rn = 1
|
|
862
|
+
""" # noqa: S608 # placeholders built from len(chunk); user values use ? params
|
|
863
|
+
async with conn.execute(query, chunk) as cursor:
|
|
864
|
+
rows = await cursor.fetchall()
|
|
865
|
+
|
|
866
|
+
loop = asyncio.get_running_loop()
|
|
867
|
+
for row in rows:
|
|
868
|
+
tid, type_str, checkpoint_blob = row
|
|
869
|
+
if not type_str or not checkpoint_blob:
|
|
870
|
+
results[tid] = _CheckpointSummary(
|
|
871
|
+
message_count=None, initial_prompt=None
|
|
872
|
+
)
|
|
873
|
+
continue
|
|
874
|
+
try:
|
|
875
|
+
data = await loop.run_in_executor(
|
|
876
|
+
None, serde.loads_typed, (type_str, checkpoint_blob)
|
|
877
|
+
)
|
|
878
|
+
results[tid] = _summarize_checkpoint(data)
|
|
879
|
+
except Exception:
|
|
880
|
+
logger.warning(
|
|
881
|
+
"Failed to deserialize checkpoint for thread %s; "
|
|
882
|
+
"message count and initial prompt may be incomplete",
|
|
883
|
+
tid,
|
|
884
|
+
exc_info=True,
|
|
885
|
+
)
|
|
886
|
+
results[tid] = _CheckpointSummary(
|
|
887
|
+
message_count=None, initial_prompt=None
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
return results
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
async def _load_initial_prompts_from_writes_batch(
|
|
894
|
+
conn: aiosqlite.Connection,
|
|
895
|
+
thread_ids: list[str],
|
|
896
|
+
serde: JsonPlusSerializer,
|
|
897
|
+
) -> dict[str, str | None]:
|
|
898
|
+
"""Batch-load initial prompts from the LangGraph `writes` table.
|
|
899
|
+
|
|
900
|
+
For each thread, returns the first human/user message extracted from the
|
|
901
|
+
earliest write to the `messages` channel (ordered by `checkpoint_id` ASC,
|
|
902
|
+
then `idx` ASC).
|
|
903
|
+
|
|
904
|
+
Args:
|
|
905
|
+
conn: Database connection.
|
|
906
|
+
thread_ids: Thread IDs to look up.
|
|
907
|
+
serde: Serializer for decoding write blobs.
|
|
908
|
+
|
|
909
|
+
Returns:
|
|
910
|
+
Dict mapping thread IDs to their initial prompt text. Threads with no
|
|
911
|
+
write to the `messages` channel are absent from the result; threads
|
|
912
|
+
whose first such write decoded but contained no human/user entry map
|
|
913
|
+
to `None`.
|
|
914
|
+
"""
|
|
915
|
+
if not thread_ids:
|
|
916
|
+
return {}
|
|
917
|
+
|
|
918
|
+
results: dict[str, str | None] = {}
|
|
919
|
+
loop = asyncio.get_running_loop()
|
|
920
|
+
for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
|
|
921
|
+
chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
|
|
922
|
+
placeholders = ",".join("?" * len(chunk))
|
|
923
|
+
query = f"""
|
|
924
|
+
SELECT thread_id, type, value FROM (
|
|
925
|
+
SELECT thread_id, type, value,
|
|
926
|
+
ROW_NUMBER() OVER (
|
|
927
|
+
PARTITION BY thread_id
|
|
928
|
+
ORDER BY checkpoint_id ASC, idx ASC
|
|
929
|
+
) AS rn
|
|
930
|
+
FROM writes
|
|
931
|
+
WHERE thread_id IN ({placeholders}) AND channel = 'messages'
|
|
932
|
+
) WHERE rn = 1
|
|
933
|
+
""" # noqa: S608 # placeholders built from len(chunk); user values use ? params
|
|
934
|
+
async with conn.execute(query, chunk) as cursor:
|
|
935
|
+
rows = await cursor.fetchall()
|
|
936
|
+
|
|
937
|
+
for row in rows:
|
|
938
|
+
tid, type_str, value_blob = row
|
|
939
|
+
if not type_str or not value_blob:
|
|
940
|
+
continue
|
|
941
|
+
try:
|
|
942
|
+
messages = await loop.run_in_executor(
|
|
943
|
+
None, serde.loads_typed, (type_str, value_blob)
|
|
944
|
+
)
|
|
945
|
+
except Exception:
|
|
946
|
+
logger.warning(
|
|
947
|
+
"Failed to deserialize initial messages write for thread %s",
|
|
948
|
+
tid,
|
|
949
|
+
exc_info=True,
|
|
950
|
+
)
|
|
951
|
+
continue
|
|
952
|
+
if not isinstance(messages, list):
|
|
953
|
+
continue
|
|
954
|
+
results[tid] = _initial_prompt_from_messages(cast("list[object]", messages))
|
|
955
|
+
|
|
956
|
+
return results
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
async def _load_message_counts_from_writes_batch(
|
|
960
|
+
conn: aiosqlite.Connection,
|
|
961
|
+
thread_ids: list[str],
|
|
962
|
+
serde: JsonPlusSerializer,
|
|
963
|
+
) -> dict[str, int]:
|
|
964
|
+
"""Reconstruct message counts from the LangGraph `writes` table.
|
|
965
|
+
|
|
966
|
+
For threads whose latest checkpoint does not inline the `messages` channel
|
|
967
|
+
value — a `DeltaChannel` between snapshots, where the deepagents SDK (>= 0.6)
|
|
968
|
+
applies LangGraph's `DeltaChannel` to `messages` — the full list is rebuilt
|
|
969
|
+
by replaying every `messages` write, then counted. We replay through
|
|
970
|
+
`add_messages` as a count-equivalent stand-in for the channel's actual
|
|
971
|
+
reducer (`_messages_delta_reducer`): both dedup by ID and honor
|
|
972
|
+
`RemoveMessage` / `REMOVE_ALL_MESSAGES`, so they produce the same final
|
|
973
|
+
message set. An `Overwrite` write resets the accumulator to its value,
|
|
974
|
+
matching the net effect of `DeltaChannel.replay_writes` (where the last
|
|
975
|
+
`Overwrite` is the reset point).
|
|
976
|
+
|
|
977
|
+
Reduction runs in a single worker-thread hop per chunk (decode is CPU-bound
|
|
978
|
+
and a long thread can have thousands of writes; dispatching per row both
|
|
979
|
+
serialized the work and added an executor round-trip each time). The common
|
|
980
|
+
append-and-clear history folds in one `add_messages` pass (linear), which is
|
|
981
|
+
why a busy thread no longer takes seconds to count. See
|
|
982
|
+
`_count_messages_from_deltas` for the fold and its exact-fold fallback.
|
|
983
|
+
|
|
984
|
+
Only the root namespace (`checkpoint_ns = ''`) is counted, matching both the
|
|
985
|
+
inline path and the conversation the `/threads` selector cares about;
|
|
986
|
+
subgraph (subagent) writes under the same `thread_id` are excluded.
|
|
987
|
+
|
|
988
|
+
Folding the *entire* write history (rather than walking the head
|
|
989
|
+
checkpoint's parent chain) is intentional and matches what dcode shows when
|
|
990
|
+
a thread is opened: it reads state via `aget_state` without a
|
|
991
|
+
`checkpoint_id`, which applies pending writes (`apply_pending_writes=True`),
|
|
992
|
+
so the latest checkpoint's not-yet-committed `messages` writes are part of
|
|
993
|
+
the user-visible list and must be counted. dcode only ever appends to the
|
|
994
|
+
latest checkpoint (no time travel, no `checkpoint_id`-targeted
|
|
995
|
+
`aupdate_state`), so histories are linear and the full fold equals the
|
|
996
|
+
head-of-chain reconstruction. A forked/abandoned branch (which dcode does
|
|
997
|
+
not create) is the only case where this could over-count.
|
|
998
|
+
|
|
999
|
+
Args:
|
|
1000
|
+
conn: Database connection.
|
|
1001
|
+
thread_ids: Thread IDs to look up.
|
|
1002
|
+
serde: Serializer for decoding write blobs.
|
|
1003
|
+
|
|
1004
|
+
Returns:
|
|
1005
|
+
Dict mapping each thread ID with at least one decodable `messages`
|
|
1006
|
+
write to its reconstructed message count. Threads with no such
|
|
1007
|
+
writes are absent from the result.
|
|
1008
|
+
"""
|
|
1009
|
+
if not thread_ids:
|
|
1010
|
+
return {}
|
|
1011
|
+
|
|
1012
|
+
loop = asyncio.get_running_loop()
|
|
1013
|
+
results: dict[str, int] = {}
|
|
1014
|
+
# Chunks partition by thread, so every write for a given thread lands in the
|
|
1015
|
+
# same query; each thread is counted exactly once. Ordering by
|
|
1016
|
+
# (checkpoint_id, task_id, idx) replays deltas oldest-to-newest, matching how
|
|
1017
|
+
# LangGraph applies them on load.
|
|
1018
|
+
for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER):
|
|
1019
|
+
chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER]
|
|
1020
|
+
placeholders = ",".join("?" * len(chunk))
|
|
1021
|
+
query = f"""
|
|
1022
|
+
SELECT thread_id, type, value
|
|
1023
|
+
FROM writes
|
|
1024
|
+
WHERE thread_id IN ({placeholders})
|
|
1025
|
+
AND checkpoint_ns = ''
|
|
1026
|
+
AND channel = 'messages'
|
|
1027
|
+
ORDER BY thread_id, checkpoint_id ASC, task_id ASC, idx ASC
|
|
1028
|
+
""" # noqa: S608 # placeholders built from len(chunk); user values use ? params
|
|
1029
|
+
async with conn.execute(query, chunk) as cursor:
|
|
1030
|
+
rows = await cursor.fetchall()
|
|
1031
|
+
|
|
1032
|
+
chunk_counts = await loop.run_in_executor(
|
|
1033
|
+
None, _reduce_message_write_rows, list(rows), serde
|
|
1034
|
+
)
|
|
1035
|
+
results.update(chunk_counts)
|
|
1036
|
+
|
|
1037
|
+
return results
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _reduce_message_write_rows(
|
|
1041
|
+
rows: list[tuple[str, str | None, bytes | None]],
|
|
1042
|
+
serde: JsonPlusSerializer,
|
|
1043
|
+
) -> dict[str, int]:
|
|
1044
|
+
"""Decode `messages`-channel write rows and count messages per thread.
|
|
1045
|
+
|
|
1046
|
+
Runs synchronously in a worker thread. Rows must be ordered so each thread's
|
|
1047
|
+
deltas are oldest-to-newest. Undecodable rows are skipped (logged), matching
|
|
1048
|
+
the per-row error handling of the previous implementation.
|
|
1049
|
+
|
|
1050
|
+
Returns:
|
|
1051
|
+
Mapping of thread ID to reconstructed message count.
|
|
1052
|
+
"""
|
|
1053
|
+
deltas_by_thread: dict[str, list[Any]] = {}
|
|
1054
|
+
for tid, type_str, value_blob in rows:
|
|
1055
|
+
if not type_str or not value_blob:
|
|
1056
|
+
continue
|
|
1057
|
+
try:
|
|
1058
|
+
delta = serde.loads_typed((type_str, value_blob))
|
|
1059
|
+
except Exception:
|
|
1060
|
+
logger.warning(
|
|
1061
|
+
"Failed to replay messages write for thread %s; "
|
|
1062
|
+
"message count may be inaccurate",
|
|
1063
|
+
tid,
|
|
1064
|
+
exc_info=True,
|
|
1065
|
+
)
|
|
1066
|
+
continue
|
|
1067
|
+
deltas_by_thread.setdefault(tid, []).append(delta)
|
|
1068
|
+
|
|
1069
|
+
counts: dict[str, int] = {}
|
|
1070
|
+
for tid, deltas in deltas_by_thread.items():
|
|
1071
|
+
try:
|
|
1072
|
+
counts[tid] = _count_messages_from_deltas(deltas)
|
|
1073
|
+
except Exception:
|
|
1074
|
+
# Keep one malformed thread from failing the whole `threads list`
|
|
1075
|
+
# load: skip it (its count is simply absent) rather than propagating.
|
|
1076
|
+
logger.warning(
|
|
1077
|
+
"Failed to count messages for thread %s; omitting its count",
|
|
1078
|
+
tid,
|
|
1079
|
+
exc_info=True,
|
|
1080
|
+
)
|
|
1081
|
+
return counts
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _count_messages_from_deltas(deltas: list[Any]) -> int:
|
|
1085
|
+
"""Count messages from an ordered list of `messages`-channel write deltas.
|
|
1086
|
+
|
|
1087
|
+
Fast path: appends and full-clears (`REMOVE_ALL_MESSAGES`, `Overwrite`) fold
|
|
1088
|
+
into one `add_messages` pass — O(n) instead of the O(n^2) incremental fold,
|
|
1089
|
+
so threads with thousands of writes count in milliseconds. For these ops the
|
|
1090
|
+
single-pass result is count-equivalent to the sequential fold (both dedup by
|
|
1091
|
+
ID, and clears collapse to the post-clear tail).
|
|
1092
|
+
|
|
1093
|
+
Slow path: a specific `RemoveMessage` (delete-by-ID) or any reducer error
|
|
1094
|
+
falls back to the exact sequential fold as a conservative measure. A
|
|
1095
|
+
delete-by-ID concatenated into the single `buffer` can make batch
|
|
1096
|
+
`add_messages` raise (the target ID may be absent at that buffer position),
|
|
1097
|
+
and we do not rely on unproven count-equivalence of batched removal. In
|
|
1098
|
+
practice the two folds still agree on the count for these histories; the
|
|
1099
|
+
sequential fold simply guarantees it. Such deletes are rare in linear dcode
|
|
1100
|
+
histories, so the common case stays on the fast path.
|
|
1101
|
+
|
|
1102
|
+
Returns:
|
|
1103
|
+
Number of messages after reducing the deltas.
|
|
1104
|
+
"""
|
|
1105
|
+
from langchain_core.messages import RemoveMessage
|
|
1106
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages
|
|
1107
|
+
from langgraph.types import Overwrite
|
|
1108
|
+
|
|
1109
|
+
buffer: list[Any] = []
|
|
1110
|
+
needs_exact_fold = False
|
|
1111
|
+
for delta in deltas:
|
|
1112
|
+
if isinstance(delta, Overwrite):
|
|
1113
|
+
value = delta.value
|
|
1114
|
+
buffer = list(value) if isinstance(value, list) else []
|
|
1115
|
+
continue
|
|
1116
|
+
items = delta if isinstance(delta, list) else [delta]
|
|
1117
|
+
for item in items:
|
|
1118
|
+
if isinstance(item, RemoveMessage):
|
|
1119
|
+
if item.id == REMOVE_ALL_MESSAGES:
|
|
1120
|
+
buffer = []
|
|
1121
|
+
else:
|
|
1122
|
+
needs_exact_fold = True
|
|
1123
|
+
break
|
|
1124
|
+
else:
|
|
1125
|
+
buffer.append(item)
|
|
1126
|
+
if needs_exact_fold:
|
|
1127
|
+
break
|
|
1128
|
+
|
|
1129
|
+
if not needs_exact_fold:
|
|
1130
|
+
try:
|
|
1131
|
+
return len(cast("list[Any]", add_messages([], buffer)))
|
|
1132
|
+
except Exception:
|
|
1133
|
+
logger.debug(
|
|
1134
|
+
"Batched message-count fold failed; using sequential fold",
|
|
1135
|
+
exc_info=True,
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
return _incremental_message_count(deltas)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _incremental_message_count(deltas: list[Any]) -> int:
|
|
1142
|
+
"""Count messages by folding deltas sequentially through `add_messages`.
|
|
1143
|
+
|
|
1144
|
+
Exact reference reduction: applies one delta at a time, resetting on
|
|
1145
|
+
`Overwrite` and skipping any delta the reducer rejects (e.g. a delete for an
|
|
1146
|
+
absent ID). Used as the fallback when the batched fast path cannot guarantee
|
|
1147
|
+
a matching count.
|
|
1148
|
+
|
|
1149
|
+
Returns:
|
|
1150
|
+
Number of messages after the sequential fold.
|
|
1151
|
+
"""
|
|
1152
|
+
from langgraph.graph.message import add_messages
|
|
1153
|
+
from langgraph.types import Overwrite
|
|
1154
|
+
|
|
1155
|
+
reduced: list[Any] = []
|
|
1156
|
+
for delta in deltas:
|
|
1157
|
+
if isinstance(delta, Overwrite):
|
|
1158
|
+
value = delta.value
|
|
1159
|
+
reduced = list(value) if isinstance(value, list) else []
|
|
1160
|
+
continue
|
|
1161
|
+
try:
|
|
1162
|
+
reduced = cast("list[Any]", add_messages(reduced, delta))
|
|
1163
|
+
except Exception:
|
|
1164
|
+
logger.warning(
|
|
1165
|
+
"Failed to replay messages write; message count may be inaccurate",
|
|
1166
|
+
exc_info=True,
|
|
1167
|
+
)
|
|
1168
|
+
continue
|
|
1169
|
+
return len(reduced)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _summarize_checkpoint(data: object) -> _CheckpointSummary:
|
|
1173
|
+
"""Extract message count and initial human prompt from checkpoint data.
|
|
1174
|
+
|
|
1175
|
+
Returns:
|
|
1176
|
+
Structured summary for the decoded checkpoint payload.
|
|
1177
|
+
"""
|
|
1178
|
+
messages = _checkpoint_messages(data)
|
|
1179
|
+
return _CheckpointSummary(
|
|
1180
|
+
message_count=len(messages) if messages is not None else None,
|
|
1181
|
+
initial_prompt=_initial_prompt_from_messages(messages or []),
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _checkpoint_messages(data: object) -> list[object] | None:
|
|
1186
|
+
"""Return inlined checkpoint messages, or `None` when not inlined.
|
|
1187
|
+
|
|
1188
|
+
A `None` return distinguishes a checkpoint that omits the `messages`
|
|
1189
|
+
channel entirely (a `DeltaChannel` between snapshots, where the deepagents
|
|
1190
|
+
SDK applies LangGraph's `DeltaChannel` to `messages` as of v0.6) from one
|
|
1191
|
+
that inlines an empty list. The former requires reconstructing the count
|
|
1192
|
+
from the `writes` table; the latter is a genuine zero.
|
|
1193
|
+
"""
|
|
1194
|
+
if not isinstance(data, dict):
|
|
1195
|
+
return None
|
|
1196
|
+
|
|
1197
|
+
payload = cast("dict[str, object]", data)
|
|
1198
|
+
channel_values = payload.get("channel_values")
|
|
1199
|
+
if not isinstance(channel_values, dict):
|
|
1200
|
+
return None
|
|
1201
|
+
|
|
1202
|
+
channel_values_dict = cast("dict[str, object]", channel_values)
|
|
1203
|
+
messages = channel_values_dict.get("messages")
|
|
1204
|
+
if not isinstance(messages, list):
|
|
1205
|
+
return None
|
|
1206
|
+
|
|
1207
|
+
return cast("list[object]", messages)
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _initial_prompt_from_messages(messages: list[object]) -> str | None:
|
|
1211
|
+
"""Return the first non-system human message content from a message list.
|
|
1212
|
+
|
|
1213
|
+
Accepts both LangChain `HumanMessage` objects (with `type == "human"`) and
|
|
1214
|
+
plain dicts in OpenAI chat shape (`{"role": "user", "content": ...}`). The
|
|
1215
|
+
first write to the `messages` channel is the raw user input passed to the
|
|
1216
|
+
agent, which is preserved verbatim as a dict; subsequent writes are
|
|
1217
|
+
serialized `BaseMessage` instances produced after the model runs.
|
|
1218
|
+
|
|
1219
|
+
Synthetic `[SYSTEM]`-prefixed human messages (e.g. an interrupt
|
|
1220
|
+
cancellation notice) are skipped so they never surface as a thread's prompt.
|
|
1221
|
+
"""
|
|
1222
|
+
for msg in messages:
|
|
1223
|
+
if getattr(msg, "type", None) == "human":
|
|
1224
|
+
prompt = _coerce_prompt_text(getattr(msg, "content", None))
|
|
1225
|
+
elif isinstance(msg, dict):
|
|
1226
|
+
msg_dict = cast("dict[str, object]", msg)
|
|
1227
|
+
role = msg_dict.get("role")
|
|
1228
|
+
type_ = msg_dict.get("type")
|
|
1229
|
+
if role not in {"user", "human"} and type_ != "human":
|
|
1230
|
+
continue
|
|
1231
|
+
prompt = _coerce_prompt_text(msg_dict.get("content"))
|
|
1232
|
+
else:
|
|
1233
|
+
continue
|
|
1234
|
+
if prompt is not None and prompt.startswith(SYSTEM_MESSAGE_PREFIX):
|
|
1235
|
+
continue
|
|
1236
|
+
return prompt
|
|
1237
|
+
return None
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _coerce_prompt_text(content: object) -> str | None:
|
|
1241
|
+
"""Normalize checkpoint message content into displayable text.
|
|
1242
|
+
|
|
1243
|
+
Returns:
|
|
1244
|
+
Displayable prompt text, or `None` when the content is empty.
|
|
1245
|
+
"""
|
|
1246
|
+
if isinstance(content, str):
|
|
1247
|
+
return content
|
|
1248
|
+
if isinstance(content, list):
|
|
1249
|
+
parts: list[str] = []
|
|
1250
|
+
for part in content:
|
|
1251
|
+
if isinstance(part, dict):
|
|
1252
|
+
part_dict = cast("dict[str, object]", part)
|
|
1253
|
+
text = part_dict.get("text")
|
|
1254
|
+
parts.append(text if isinstance(text, str) else "")
|
|
1255
|
+
else:
|
|
1256
|
+
parts.append(str(part))
|
|
1257
|
+
joined = " ".join(parts).strip()
|
|
1258
|
+
return joined or None
|
|
1259
|
+
if content is None:
|
|
1260
|
+
return None
|
|
1261
|
+
return str(content)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
async def get_most_recent(
|
|
1265
|
+
agent_name: str | None = None,
|
|
1266
|
+
*,
|
|
1267
|
+
exclude_thread_id: str | None = None,
|
|
1268
|
+
) -> str | None:
|
|
1269
|
+
"""Get the most recent thread, optionally agent-filtered and/or excluding a thread.
|
|
1270
|
+
|
|
1271
|
+
Args:
|
|
1272
|
+
agent_name: Return only threads created by this agent.
|
|
1273
|
+
exclude_thread_id: Ignore this thread when selecting the most recent one.
|
|
1274
|
+
|
|
1275
|
+
Returns:
|
|
1276
|
+
Most recent thread ID, or `None` if no matching threads exist.
|
|
1277
|
+
"""
|
|
1278
|
+
async with _connect() as conn:
|
|
1279
|
+
if not await _table_exists(conn, "checkpoints"):
|
|
1280
|
+
return None
|
|
1281
|
+
|
|
1282
|
+
if agent_name and exclude_thread_id:
|
|
1283
|
+
query = """
|
|
1284
|
+
SELECT thread_id FROM checkpoints
|
|
1285
|
+
WHERE json_extract(metadata, '$.agent_name') = ?
|
|
1286
|
+
AND thread_id != ?
|
|
1287
|
+
ORDER BY checkpoint_id DESC
|
|
1288
|
+
LIMIT 1
|
|
1289
|
+
"""
|
|
1290
|
+
params: tuple[str, ...] = (agent_name, exclude_thread_id)
|
|
1291
|
+
elif agent_name:
|
|
1292
|
+
query = """
|
|
1293
|
+
SELECT thread_id FROM checkpoints
|
|
1294
|
+
WHERE json_extract(metadata, '$.agent_name') = ?
|
|
1295
|
+
ORDER BY checkpoint_id DESC
|
|
1296
|
+
LIMIT 1
|
|
1297
|
+
"""
|
|
1298
|
+
params = (agent_name,)
|
|
1299
|
+
elif exclude_thread_id:
|
|
1300
|
+
query = """
|
|
1301
|
+
SELECT thread_id FROM checkpoints
|
|
1302
|
+
WHERE thread_id != ?
|
|
1303
|
+
ORDER BY checkpoint_id DESC
|
|
1304
|
+
LIMIT 1
|
|
1305
|
+
"""
|
|
1306
|
+
params = (exclude_thread_id,)
|
|
1307
|
+
else:
|
|
1308
|
+
query = (
|
|
1309
|
+
"SELECT thread_id FROM checkpoints ORDER BY checkpoint_id DESC LIMIT 1"
|
|
1310
|
+
)
|
|
1311
|
+
params = ()
|
|
1312
|
+
|
|
1313
|
+
async with conn.execute(query, params) as cursor:
|
|
1314
|
+
row = await cursor.fetchone()
|
|
1315
|
+
return row[0] if row else None
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
async def get_thread_agent(thread_id: str) -> str | None:
|
|
1319
|
+
"""Get agent_name for a thread.
|
|
1320
|
+
|
|
1321
|
+
Returns:
|
|
1322
|
+
Agent name associated with the thread, or None if not found.
|
|
1323
|
+
"""
|
|
1324
|
+
async with _connect() as conn:
|
|
1325
|
+
if not await _table_exists(conn, "checkpoints"):
|
|
1326
|
+
return None
|
|
1327
|
+
|
|
1328
|
+
query = """
|
|
1329
|
+
SELECT json_extract(metadata, '$.agent_name')
|
|
1330
|
+
FROM checkpoints
|
|
1331
|
+
WHERE thread_id = ?
|
|
1332
|
+
LIMIT 1
|
|
1333
|
+
"""
|
|
1334
|
+
async with conn.execute(query, (thread_id,)) as cursor:
|
|
1335
|
+
row = await cursor.fetchone()
|
|
1336
|
+
return row[0] if row else None
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
async def get_thread_cwd(thread_id: str) -> str | None:
|
|
1340
|
+
"""Get the most recently stored cwd for a thread.
|
|
1341
|
+
|
|
1342
|
+
Args:
|
|
1343
|
+
thread_id: The thread whose stored cwd to look up.
|
|
1344
|
+
|
|
1345
|
+
Returns:
|
|
1346
|
+
Most recent cwd for the thread, or None if not found.
|
|
1347
|
+
"""
|
|
1348
|
+
async with _connect() as conn:
|
|
1349
|
+
if not await _table_exists(conn, "checkpoints"):
|
|
1350
|
+
return None
|
|
1351
|
+
|
|
1352
|
+
query = """
|
|
1353
|
+
SELECT json_extract(metadata, '$.cwd')
|
|
1354
|
+
FROM checkpoints
|
|
1355
|
+
WHERE thread_id = ? AND json_extract(metadata, '$.cwd') IS NOT NULL
|
|
1356
|
+
ORDER BY checkpoint_id DESC
|
|
1357
|
+
LIMIT 1
|
|
1358
|
+
"""
|
|
1359
|
+
async with conn.execute(query, (thread_id,)) as cursor:
|
|
1360
|
+
row = await cursor.fetchone()
|
|
1361
|
+
value = row[0] if row else None
|
|
1362
|
+
return value if isinstance(value, str) and value else None
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
async def thread_exists(thread_id: str) -> bool:
|
|
1366
|
+
"""Check if a thread exists in checkpoints.
|
|
1367
|
+
|
|
1368
|
+
Returns:
|
|
1369
|
+
True if thread exists, False otherwise.
|
|
1370
|
+
"""
|
|
1371
|
+
async with _connect() as conn:
|
|
1372
|
+
if not await _table_exists(conn, "checkpoints"):
|
|
1373
|
+
return False
|
|
1374
|
+
|
|
1375
|
+
query = "SELECT 1 FROM checkpoints WHERE thread_id = ? LIMIT 1"
|
|
1376
|
+
async with conn.execute(query, (thread_id,)) as cursor:
|
|
1377
|
+
row = await cursor.fetchone()
|
|
1378
|
+
return row is not None
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
async def find_similar_threads(thread_id: str, limit: int = 3) -> list[str]:
|
|
1382
|
+
"""Find threads whose IDs start with the given prefix.
|
|
1383
|
+
|
|
1384
|
+
Args:
|
|
1385
|
+
thread_id: Prefix to match against thread IDs.
|
|
1386
|
+
limit: Maximum number of matching threads to return.
|
|
1387
|
+
|
|
1388
|
+
Returns:
|
|
1389
|
+
List of thread IDs that begin with the given prefix.
|
|
1390
|
+
"""
|
|
1391
|
+
async with _connect() as conn:
|
|
1392
|
+
if not await _table_exists(conn, "checkpoints"):
|
|
1393
|
+
return []
|
|
1394
|
+
|
|
1395
|
+
query = """
|
|
1396
|
+
SELECT DISTINCT thread_id
|
|
1397
|
+
FROM checkpoints
|
|
1398
|
+
WHERE thread_id LIKE ?
|
|
1399
|
+
ORDER BY thread_id
|
|
1400
|
+
LIMIT ?
|
|
1401
|
+
"""
|
|
1402
|
+
prefix = thread_id + "%"
|
|
1403
|
+
async with conn.execute(query, (prefix, limit)) as cursor:
|
|
1404
|
+
rows = await cursor.fetchall()
|
|
1405
|
+
return [r[0] for r in rows]
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
async def delete_thread(thread_id: str) -> bool:
|
|
1409
|
+
"""Delete thread checkpoints.
|
|
1410
|
+
|
|
1411
|
+
Returns:
|
|
1412
|
+
True if thread was deleted, False if not found.
|
|
1413
|
+
"""
|
|
1414
|
+
async with _connect() as conn:
|
|
1415
|
+
if not await _table_exists(conn, "checkpoints"):
|
|
1416
|
+
return False
|
|
1417
|
+
|
|
1418
|
+
cursor = await conn.execute(
|
|
1419
|
+
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
|
|
1420
|
+
)
|
|
1421
|
+
deleted = cursor.rowcount > 0
|
|
1422
|
+
if await _table_exists(conn, "writes"):
|
|
1423
|
+
await conn.execute("DELETE FROM writes WHERE thread_id = ?", (thread_id,))
|
|
1424
|
+
await conn.commit()
|
|
1425
|
+
if deleted:
|
|
1426
|
+
_message_count_cache.pop(thread_id, None)
|
|
1427
|
+
for key, rows in list(_recent_threads_cache.items()):
|
|
1428
|
+
filtered = [row for row in rows if row["thread_id"] != thread_id]
|
|
1429
|
+
_recent_threads_cache[key] = filtered
|
|
1430
|
+
return deleted
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
@asynccontextmanager
|
|
1434
|
+
async def get_checkpointer() -> AsyncIterator[AsyncSqliteSaver]:
|
|
1435
|
+
"""Get AsyncSqliteSaver for the global database.
|
|
1436
|
+
|
|
1437
|
+
Yields:
|
|
1438
|
+
AsyncSqliteSaver instance for checkpoint persistence.
|
|
1439
|
+
"""
|
|
1440
|
+
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
1441
|
+
|
|
1442
|
+
_patch_aiosqlite()
|
|
1443
|
+
|
|
1444
|
+
saver: AsyncSqliteSaver | None = None
|
|
1445
|
+
try:
|
|
1446
|
+
async with AsyncSqliteSaver.from_conn_string(
|
|
1447
|
+
str(get_db_path())
|
|
1448
|
+
) as checkpointer:
|
|
1449
|
+
saver = checkpointer
|
|
1450
|
+
yield checkpointer
|
|
1451
|
+
finally:
|
|
1452
|
+
if saver is not None:
|
|
1453
|
+
conn = getattr(saver, "conn", None)
|
|
1454
|
+
if conn is not None:
|
|
1455
|
+
await _drain_aiosqlite_worker(conn)
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
_DEFAULT_THREAD_LIMIT = 20
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def get_thread_limit() -> int:
|
|
1462
|
+
"""Read the thread listing limit from `DA_CLI_RECENT_THREADS`.
|
|
1463
|
+
|
|
1464
|
+
Falls back to `_DEFAULT_THREAD_LIMIT` when the variable is unset or contains
|
|
1465
|
+
a non-integer value. The result is clamped to a minimum of 1.
|
|
1466
|
+
|
|
1467
|
+
Returns:
|
|
1468
|
+
Number of threads to display.
|
|
1469
|
+
"""
|
|
1470
|
+
import os
|
|
1471
|
+
|
|
1472
|
+
raw = os.environ.get("DA_CLI_RECENT_THREADS")
|
|
1473
|
+
if raw is None:
|
|
1474
|
+
return _DEFAULT_THREAD_LIMIT
|
|
1475
|
+
try:
|
|
1476
|
+
return max(1, int(raw))
|
|
1477
|
+
except ValueError:
|
|
1478
|
+
logger.warning(
|
|
1479
|
+
"Invalid DA_CLI_RECENT_THREADS value %r, using default %d",
|
|
1480
|
+
raw,
|
|
1481
|
+
_DEFAULT_THREAD_LIMIT,
|
|
1482
|
+
)
|
|
1483
|
+
return _DEFAULT_THREAD_LIMIT
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
async def list_threads_command(
|
|
1487
|
+
agent_name: str | None = None,
|
|
1488
|
+
limit: int | None = None,
|
|
1489
|
+
sort_by: str | None = None,
|
|
1490
|
+
branch: str | None = None,
|
|
1491
|
+
cwd: str | None = None,
|
|
1492
|
+
verbose: bool = False,
|
|
1493
|
+
relative: bool | None = None,
|
|
1494
|
+
*,
|
|
1495
|
+
output_format: OutputFormat = "text",
|
|
1496
|
+
) -> None:
|
|
1497
|
+
"""CLI handler for `deepagents threads list`.
|
|
1498
|
+
|
|
1499
|
+
Fetches and displays a table of recent conversation threads, optionally
|
|
1500
|
+
filtered by agent name, git branch, or working directory.
|
|
1501
|
+
|
|
1502
|
+
Args:
|
|
1503
|
+
agent_name: Only show threads belonging to this agent.
|
|
1504
|
+
|
|
1505
|
+
When `None`, threads for all agents are shown.
|
|
1506
|
+
limit: Maximum number of threads to display.
|
|
1507
|
+
|
|
1508
|
+
When `None`, reads from `DA_CLI_RECENT_THREADS` or falls back to
|
|
1509
|
+
the default.
|
|
1510
|
+
sort_by: Sort field — `"updated"` or `"created"`.
|
|
1511
|
+
|
|
1512
|
+
When `None`, reads from config (`~/.deepagents/config.toml`).
|
|
1513
|
+
branch: Only show threads from this git branch.
|
|
1514
|
+
cwd: Only show threads whose stored `cwd` metadata equals this path
|
|
1515
|
+
(exact string match — no normalization or prefix matching). When
|
|
1516
|
+
`None`, no cwd filter is applied. Threads without a stored `cwd`
|
|
1517
|
+
(older rows) are excluded when this is set.
|
|
1518
|
+
verbose: When `True`, show all columns (branch, created, prompt).
|
|
1519
|
+
relative: Show timestamps as relative time (e.g., '5m ago').
|
|
1520
|
+
|
|
1521
|
+
When `None`, reads from config (`~/.deepagents/config.toml`).
|
|
1522
|
+
output_format: Output format — `'text'` (Rich) or `'json'`.
|
|
1523
|
+
"""
|
|
1524
|
+
from deepagents_code.model_config import (
|
|
1525
|
+
load_thread_relative_time,
|
|
1526
|
+
load_thread_sort_order,
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
if sort_by is None:
|
|
1530
|
+
raw = load_thread_sort_order()
|
|
1531
|
+
sort_by = "created" if raw == "created_at" else "updated"
|
|
1532
|
+
if relative is None:
|
|
1533
|
+
relative = load_thread_relative_time()
|
|
1534
|
+
|
|
1535
|
+
fmt_ts = format_relative_timestamp if relative else format_timestamp
|
|
1536
|
+
|
|
1537
|
+
limit = get_thread_limit() if limit is None else max(1, limit)
|
|
1538
|
+
|
|
1539
|
+
threads = await list_threads(
|
|
1540
|
+
agent_name,
|
|
1541
|
+
limit=limit,
|
|
1542
|
+
include_message_count=True,
|
|
1543
|
+
sort_by=sort_by,
|
|
1544
|
+
branch=branch,
|
|
1545
|
+
cwd=cwd,
|
|
1546
|
+
)
|
|
1547
|
+
|
|
1548
|
+
if verbose and threads:
|
|
1549
|
+
await populate_thread_checkpoint_details(
|
|
1550
|
+
threads, include_message_count=False, include_initial_prompt=True
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
if output_format == "json":
|
|
1554
|
+
from deepagents_code.output import write_json
|
|
1555
|
+
|
|
1556
|
+
write_json("threads list", list(threads))
|
|
1557
|
+
return
|
|
1558
|
+
|
|
1559
|
+
from rich.markup import escape as escape_markup
|
|
1560
|
+
from rich.table import Table
|
|
1561
|
+
|
|
1562
|
+
from deepagents_code import theme
|
|
1563
|
+
from deepagents_code.config import console
|
|
1564
|
+
|
|
1565
|
+
if not threads:
|
|
1566
|
+
filters = []
|
|
1567
|
+
if agent_name:
|
|
1568
|
+
filters.append(f"agent '{escape_markup(agent_name)}'")
|
|
1569
|
+
if branch:
|
|
1570
|
+
filters.append(f"branch '{escape_markup(branch)}'")
|
|
1571
|
+
if cwd:
|
|
1572
|
+
filters.append(f"cwd '{escape_markup(cwd)}'")
|
|
1573
|
+
if filters:
|
|
1574
|
+
console.print(
|
|
1575
|
+
f"[yellow]No threads found for {' and '.join(filters)}.[/yellow]"
|
|
1576
|
+
)
|
|
1577
|
+
else:
|
|
1578
|
+
console.print("[yellow]No threads found.[/yellow]")
|
|
1579
|
+
if cwd:
|
|
1580
|
+
# Older threads predating cwd-metadata storage are silently
|
|
1581
|
+
# filtered out by the `json_extract` equality match. Tell the
|
|
1582
|
+
# user explicitly when unfiltered rows exist so they don't think
|
|
1583
|
+
# their history is gone.
|
|
1584
|
+
unfiltered = await list_threads(
|
|
1585
|
+
agent_name,
|
|
1586
|
+
limit=limit,
|
|
1587
|
+
include_message_count=False,
|
|
1588
|
+
sort_by=sort_by,
|
|
1589
|
+
branch=branch,
|
|
1590
|
+
)
|
|
1591
|
+
legacy_count = sum(1 for t in unfiltered if not t.get("cwd"))
|
|
1592
|
+
if legacy_count:
|
|
1593
|
+
console.print(
|
|
1594
|
+
f"[dim]{legacy_count} older thread"
|
|
1595
|
+
f"{'s have' if legacy_count != 1 else ' has'} "
|
|
1596
|
+
"no cwd metadata; run without --cwd to see "
|
|
1597
|
+
f"{'them' if legacy_count != 1 else 'it'}.[/dim]"
|
|
1598
|
+
)
|
|
1599
|
+
console.print("[dim]Start a conversation with: deepagents[/dim]")
|
|
1600
|
+
return
|
|
1601
|
+
|
|
1602
|
+
title_parts = []
|
|
1603
|
+
if agent_name:
|
|
1604
|
+
title_parts.append(f"agent '{escape_markup(agent_name)}'")
|
|
1605
|
+
if branch:
|
|
1606
|
+
title_parts.append(f"branch '{escape_markup(branch)}'")
|
|
1607
|
+
if cwd:
|
|
1608
|
+
title_parts.append(f"cwd '{escape_markup(cwd)}'")
|
|
1609
|
+
|
|
1610
|
+
title_filter = f" for {' and '.join(title_parts)}" if title_parts else ""
|
|
1611
|
+
sort_label = "created" if sort_by == "created" else "updated"
|
|
1612
|
+
title = f"Recent Threads{title_filter} (last {limit}, by {sort_label})"
|
|
1613
|
+
|
|
1614
|
+
table = Table(title=title, show_header=True, header_style=f"bold {theme.PRIMARY}")
|
|
1615
|
+
table.add_column("Thread ID", style="bold")
|
|
1616
|
+
table.add_column("Agent")
|
|
1617
|
+
table.add_column("Messages", justify="right")
|
|
1618
|
+
if verbose:
|
|
1619
|
+
table.add_column("Created")
|
|
1620
|
+
table.add_column("Updated" if sort_by == "updated" else "Last Used")
|
|
1621
|
+
if verbose:
|
|
1622
|
+
table.add_column("Branch")
|
|
1623
|
+
table.add_column("Location")
|
|
1624
|
+
table.add_column("Prompt", max_width=40, no_wrap=True)
|
|
1625
|
+
|
|
1626
|
+
prompt_max = 40
|
|
1627
|
+
|
|
1628
|
+
for t in threads:
|
|
1629
|
+
row: list[str] = [
|
|
1630
|
+
t["thread_id"],
|
|
1631
|
+
t["agent_name"] or "unknown",
|
|
1632
|
+
str(t.get("message_count", 0)),
|
|
1633
|
+
]
|
|
1634
|
+
if verbose:
|
|
1635
|
+
row.append(fmt_ts(t.get("created_at")))
|
|
1636
|
+
row.append(fmt_ts(t.get("updated_at")))
|
|
1637
|
+
if verbose:
|
|
1638
|
+
prompt = " ".join((t.get("initial_prompt") or "").split())
|
|
1639
|
+
if len(prompt) > prompt_max:
|
|
1640
|
+
prompt = prompt[: prompt_max - 3] + "..."
|
|
1641
|
+
row.extend(
|
|
1642
|
+
[
|
|
1643
|
+
t.get("git_branch") or "",
|
|
1644
|
+
format_path(t.get("cwd")),
|
|
1645
|
+
prompt,
|
|
1646
|
+
]
|
|
1647
|
+
)
|
|
1648
|
+
table.add_row(*row)
|
|
1649
|
+
|
|
1650
|
+
console.print()
|
|
1651
|
+
console.print(table)
|
|
1652
|
+
if len(threads) >= limit:
|
|
1653
|
+
console.print(
|
|
1654
|
+
f"[dim]Showing last {limit} threads. "
|
|
1655
|
+
"Override with -n/--limit or DA_CLI_RECENT_THREADS.[/dim]"
|
|
1656
|
+
)
|
|
1657
|
+
console.print()
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
async def delete_thread_command(
|
|
1661
|
+
thread_id: str,
|
|
1662
|
+
*,
|
|
1663
|
+
dry_run: bool = False,
|
|
1664
|
+
output_format: OutputFormat = "text",
|
|
1665
|
+
) -> None:
|
|
1666
|
+
"""CLI handler for: deepagents threads delete.
|
|
1667
|
+
|
|
1668
|
+
Args:
|
|
1669
|
+
thread_id: ID of the thread to delete.
|
|
1670
|
+
dry_run: If `True`, print what would happen without making changes.
|
|
1671
|
+
output_format: Output format — `'text'` (Rich) or `'json'`.
|
|
1672
|
+
"""
|
|
1673
|
+
if dry_run:
|
|
1674
|
+
exists = await thread_exists(thread_id)
|
|
1675
|
+
if output_format == "json":
|
|
1676
|
+
from deepagents_code.output import write_json
|
|
1677
|
+
|
|
1678
|
+
write_json(
|
|
1679
|
+
"threads delete",
|
|
1680
|
+
{"thread_id": thread_id, "exists": exists, "dry_run": True},
|
|
1681
|
+
)
|
|
1682
|
+
return
|
|
1683
|
+
|
|
1684
|
+
from rich.markup import escape as escape_markup
|
|
1685
|
+
|
|
1686
|
+
from deepagents_code.config import console
|
|
1687
|
+
|
|
1688
|
+
escaped_id = escape_markup(thread_id)
|
|
1689
|
+
if exists:
|
|
1690
|
+
console.print(f"Would delete thread '{escaped_id}'.")
|
|
1691
|
+
else:
|
|
1692
|
+
console.print(f"Thread '{escaped_id}' not found. Nothing to delete.")
|
|
1693
|
+
console.print("No changes made.", style="dim")
|
|
1694
|
+
return
|
|
1695
|
+
|
|
1696
|
+
deleted = await delete_thread(thread_id)
|
|
1697
|
+
|
|
1698
|
+
if output_format == "json":
|
|
1699
|
+
from deepagents_code.output import write_json
|
|
1700
|
+
|
|
1701
|
+
write_json("threads delete", {"thread_id": thread_id, "deleted": deleted})
|
|
1702
|
+
return
|
|
1703
|
+
|
|
1704
|
+
from rich.markup import escape as escape_markup
|
|
1705
|
+
|
|
1706
|
+
from deepagents_code import theme
|
|
1707
|
+
from deepagents_code.config import console
|
|
1708
|
+
|
|
1709
|
+
escaped_id = escape_markup(thread_id)
|
|
1710
|
+
if deleted:
|
|
1711
|
+
console.print(f"[green]Thread '{escaped_id}' deleted.[/green]")
|
|
1712
|
+
else:
|
|
1713
|
+
console.print(
|
|
1714
|
+
f"Thread '{escaped_id}' not found or already deleted.",
|
|
1715
|
+
style=theme.MUTED,
|
|
1716
|
+
)
|