agencode 0.1.0__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.
- agencli/__init__.py +5 -0
- agencli/__main__.py +9 -0
- agencli/agents/__init__.py +1 -0
- agencli/agents/editor.py +110 -0
- agencli/agents/factory.py +335 -0
- agencli/agents/management_tools.py +277 -0
- agencli/agents/prebuilt/__init__.py +1 -0
- agencli/agents/prebuilt/catalog.py +66 -0
- agencli/agents/registry.py +50 -0
- agencli/agents/runtime.py +266 -0
- agencli/agents/supervisor.py +67 -0
- agencli/cli.py +561 -0
- agencli/core/__init__.py +1 -0
- agencli/core/config.py +179 -0
- agencli/core/keystore.py +14 -0
- agencli/core/logger.py +17 -0
- agencli/core/paths.py +37 -0
- agencli/core/session.py +513 -0
- agencli/mcp/__init__.py +1 -0
- agencli/mcp/client.py +33 -0
- agencli/mcp/config.py +99 -0
- agencli/providers/__init__.py +1 -0
- agencli/providers/model.py +180 -0
- agencli/skills/__init__.py +37 -0
- agencli/skills/cli_backend.py +446 -0
- agencli/skills/loader.py +77 -0
- agencli/skills/manager.py +153 -0
- agencli/tools/__init__.py +1 -0
- agencli/tools/mcp.py +106 -0
- agencli/tui/__init__.py +1 -0
- agencli/tui/app.py +4274 -0
- agencli/tui/commands.py +86 -0
- agencli/tui/screens.py +939 -0
- agencli/tui/trace.py +334 -0
- agencli/tui/voice.py +77 -0
- agencode-0.1.0.dist-info/METADATA +44 -0
- agencode-0.1.0.dist-info/RECORD +39 -0
- agencode-0.1.0.dist-info/WHEEL +4 -0
- agencode-0.1.0.dist-info/entry_points.txt +3 -0
agencli/core/session.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import inspect
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sqlite3
|
|
9
|
+
from threading import RLock
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from langgraph.checkpoint.memory import InMemorySaver, PersistentDict
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
17
|
+
except ImportError: # pragma: no cover - exercised via fallback tests in this env.
|
|
18
|
+
SqliteSaver = None
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import aiosqlite
|
|
22
|
+
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
23
|
+
except ImportError: # pragma: no cover - exercised via fallback tests in this env.
|
|
24
|
+
aiosqlite = None
|
|
25
|
+
AsyncSqliteSaver = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class ChatTurn:
|
|
30
|
+
role: str
|
|
31
|
+
content: str
|
|
32
|
+
created_at: str
|
|
33
|
+
agent_name: str | None = None
|
|
34
|
+
thread_id: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class ChatThreadSummary:
|
|
39
|
+
agent_name: str | None
|
|
40
|
+
thread_id: str | None
|
|
41
|
+
turn_count: int
|
|
42
|
+
last_role: str
|
|
43
|
+
last_content: str
|
|
44
|
+
last_created_at: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_CHECKPOINTERS: dict[Path, Any] = {}
|
|
48
|
+
_ASYNC_CHECKPOINTERS: dict[Path, Any] = {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PersistentInMemoryCheckpointer(InMemorySaver):
|
|
52
|
+
"""File-backed LangGraph checkpointer using LangGraph's persistent dict helper."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, base_path: Path) -> None:
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.base_path = base_path
|
|
57
|
+
self.base_path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self._sync_lock = RLock()
|
|
59
|
+
self.storage = self._load_dict(
|
|
60
|
+
self.base_path.with_suffix(".storage.pkl"),
|
|
61
|
+
lambda: defaultdict(dict),
|
|
62
|
+
)
|
|
63
|
+
self.writes = self._load_dict(
|
|
64
|
+
self.base_path.with_suffix(".writes.pkl"),
|
|
65
|
+
dict,
|
|
66
|
+
)
|
|
67
|
+
self.blobs = self._load_dict(
|
|
68
|
+
self.base_path.with_suffix(".blobs.pkl"),
|
|
69
|
+
dict,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def put(
|
|
73
|
+
self,
|
|
74
|
+
config: dict[str, Any],
|
|
75
|
+
checkpoint: Any,
|
|
76
|
+
metadata: Any,
|
|
77
|
+
new_versions: Any,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
with self._sync_lock:
|
|
80
|
+
updated = super().put(config, checkpoint, metadata, new_versions)
|
|
81
|
+
self._sync()
|
|
82
|
+
return updated
|
|
83
|
+
|
|
84
|
+
def put_writes(
|
|
85
|
+
self,
|
|
86
|
+
config: dict[str, Any],
|
|
87
|
+
writes: Any,
|
|
88
|
+
task_id: str,
|
|
89
|
+
task_path: str = "",
|
|
90
|
+
) -> None:
|
|
91
|
+
with self._sync_lock:
|
|
92
|
+
super().put_writes(config, writes, task_id, task_path=task_path)
|
|
93
|
+
self._sync()
|
|
94
|
+
|
|
95
|
+
def delete_thread(self, thread_id: str) -> None:
|
|
96
|
+
with self._sync_lock:
|
|
97
|
+
super().delete_thread(thread_id)
|
|
98
|
+
self._sync()
|
|
99
|
+
|
|
100
|
+
def _load_dict(self, path: Path, default_factory: Any) -> PersistentDict:
|
|
101
|
+
store = PersistentDict(default_factory, filename=str(path))
|
|
102
|
+
if path.exists():
|
|
103
|
+
store.load()
|
|
104
|
+
return store
|
|
105
|
+
|
|
106
|
+
def _sync(self) -> None:
|
|
107
|
+
self.storage.sync()
|
|
108
|
+
self.writes.sync()
|
|
109
|
+
self.blobs.sync()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ensure_session_db(sessions_dir: str, session_name: str = "default") -> Path:
|
|
113
|
+
sessions_path = Path(sessions_dir).expanduser()
|
|
114
|
+
sessions_path.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
path = sessions_path / f"{session_name}.sqlite3"
|
|
116
|
+
connection = sqlite3.connect(path)
|
|
117
|
+
try:
|
|
118
|
+
connection.execute("CREATE TABLE IF NOT EXISTS session_meta (key TEXT PRIMARY KEY, value TEXT)")
|
|
119
|
+
connection.execute(
|
|
120
|
+
"""
|
|
121
|
+
CREATE TABLE IF NOT EXISTS chat_history (
|
|
122
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
123
|
+
role TEXT NOT NULL,
|
|
124
|
+
content TEXT NOT NULL,
|
|
125
|
+
created_at TEXT NOT NULL
|
|
126
|
+
)
|
|
127
|
+
"""
|
|
128
|
+
)
|
|
129
|
+
columns = {
|
|
130
|
+
row[1]
|
|
131
|
+
for row in connection.execute("PRAGMA table_info(chat_history)").fetchall()
|
|
132
|
+
}
|
|
133
|
+
if "agent_name" not in columns:
|
|
134
|
+
connection.execute("ALTER TABLE chat_history ADD COLUMN agent_name TEXT")
|
|
135
|
+
if "thread_id" not in columns:
|
|
136
|
+
connection.execute("ALTER TABLE chat_history ADD COLUMN thread_id TEXT")
|
|
137
|
+
connection.commit()
|
|
138
|
+
finally:
|
|
139
|
+
connection.close()
|
|
140
|
+
return path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def append_chat_turn(
|
|
144
|
+
sessions_dir: str,
|
|
145
|
+
role: str,
|
|
146
|
+
content: str,
|
|
147
|
+
session_name: str = "default",
|
|
148
|
+
*,
|
|
149
|
+
agent_name: str | None = None,
|
|
150
|
+
thread_id: str | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
path = ensure_session_db(sessions_dir, session_name=session_name)
|
|
153
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
154
|
+
connection = sqlite3.connect(path)
|
|
155
|
+
try:
|
|
156
|
+
connection.execute(
|
|
157
|
+
"""
|
|
158
|
+
INSERT INTO chat_history(role, content, created_at, agent_name, thread_id)
|
|
159
|
+
VALUES (?, ?, ?, ?, ?)
|
|
160
|
+
""",
|
|
161
|
+
(role, content, created_at, agent_name, thread_id),
|
|
162
|
+
)
|
|
163
|
+
connection.commit()
|
|
164
|
+
finally:
|
|
165
|
+
connection.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def delete_chat_thread(
|
|
169
|
+
sessions_dir: str,
|
|
170
|
+
session_name: str = "default",
|
|
171
|
+
*,
|
|
172
|
+
agent_name: str | None = None,
|
|
173
|
+
thread_id: str | None = None,
|
|
174
|
+
) -> int:
|
|
175
|
+
if thread_id is None:
|
|
176
|
+
return 0
|
|
177
|
+
path = ensure_session_db(sessions_dir, session_name=session_name)
|
|
178
|
+
connection = sqlite3.connect(path)
|
|
179
|
+
try:
|
|
180
|
+
where_clauses = ["thread_id = ?"]
|
|
181
|
+
params: list[Any] = [thread_id]
|
|
182
|
+
if agent_name is not None:
|
|
183
|
+
where_clauses.append("agent_name = ?")
|
|
184
|
+
params.append(agent_name)
|
|
185
|
+
cursor = connection.execute(
|
|
186
|
+
f"DELETE FROM chat_history WHERE {' AND '.join(where_clauses)}",
|
|
187
|
+
params,
|
|
188
|
+
)
|
|
189
|
+
connection.commit()
|
|
190
|
+
return int(cursor.rowcount or 0)
|
|
191
|
+
finally:
|
|
192
|
+
connection.close()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_chat_history(
|
|
196
|
+
sessions_dir: str,
|
|
197
|
+
session_name: str = "default",
|
|
198
|
+
limit: int = 50,
|
|
199
|
+
*,
|
|
200
|
+
agent_name: str | None = None,
|
|
201
|
+
thread_id: str | None = None,
|
|
202
|
+
) -> list[ChatTurn]:
|
|
203
|
+
path = ensure_session_db(sessions_dir, session_name=session_name)
|
|
204
|
+
connection = sqlite3.connect(path)
|
|
205
|
+
try:
|
|
206
|
+
where_clauses: list[str] = []
|
|
207
|
+
params: list[Any] = []
|
|
208
|
+
if agent_name is not None:
|
|
209
|
+
where_clauses.append("agent_name = ?")
|
|
210
|
+
params.append(agent_name)
|
|
211
|
+
if thread_id is not None:
|
|
212
|
+
where_clauses.append("thread_id = ?")
|
|
213
|
+
params.append(thread_id)
|
|
214
|
+
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
215
|
+
rows = connection.execute(
|
|
216
|
+
f"""
|
|
217
|
+
SELECT role, content, created_at, agent_name, thread_id
|
|
218
|
+
FROM chat_history
|
|
219
|
+
{where_sql}
|
|
220
|
+
ORDER BY id DESC
|
|
221
|
+
LIMIT ?
|
|
222
|
+
""",
|
|
223
|
+
[*params, limit],
|
|
224
|
+
).fetchall()
|
|
225
|
+
finally:
|
|
226
|
+
connection.close()
|
|
227
|
+
return [
|
|
228
|
+
ChatTurn(
|
|
229
|
+
role=row[0],
|
|
230
|
+
content=row[1],
|
|
231
|
+
created_at=row[2],
|
|
232
|
+
agent_name=row[3],
|
|
233
|
+
thread_id=row[4],
|
|
234
|
+
)
|
|
235
|
+
for row in reversed(rows)
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def list_chat_threads(
|
|
240
|
+
sessions_dir: str,
|
|
241
|
+
session_name: str = "default",
|
|
242
|
+
limit: int = 50,
|
|
243
|
+
*,
|
|
244
|
+
agent_name: str | None = None,
|
|
245
|
+
thread_id: str | None = None,
|
|
246
|
+
query: str | None = None,
|
|
247
|
+
) -> list[ChatThreadSummary]:
|
|
248
|
+
path = ensure_session_db(sessions_dir, session_name=session_name)
|
|
249
|
+
connection = sqlite3.connect(path)
|
|
250
|
+
try:
|
|
251
|
+
grouped_where_clauses: list[str] = []
|
|
252
|
+
grouped_params: list[Any] = []
|
|
253
|
+
if agent_name is not None:
|
|
254
|
+
grouped_where_clauses.append("agent_name = ?")
|
|
255
|
+
grouped_params.append(agent_name)
|
|
256
|
+
if thread_id is not None:
|
|
257
|
+
grouped_where_clauses.append("thread_id = ?")
|
|
258
|
+
grouped_params.append(thread_id)
|
|
259
|
+
grouped_where_sql = f"WHERE {' AND '.join(grouped_where_clauses)}" if grouped_where_clauses else ""
|
|
260
|
+
|
|
261
|
+
latest_where_clauses: list[str] = []
|
|
262
|
+
latest_params: list[Any] = []
|
|
263
|
+
if query:
|
|
264
|
+
pattern = f"%{query}%"
|
|
265
|
+
latest_where_clauses.append(
|
|
266
|
+
"(COALESCE(latest.agent_name, '') LIKE ? OR COALESCE(latest.thread_id, '') LIKE ? OR latest.content LIKE ?)"
|
|
267
|
+
)
|
|
268
|
+
latest_params.extend([pattern, pattern, pattern])
|
|
269
|
+
latest_where_sql = f"WHERE {' AND '.join(latest_where_clauses)}" if latest_where_clauses else ""
|
|
270
|
+
rows = connection.execute(
|
|
271
|
+
f"""
|
|
272
|
+
SELECT latest.agent_name, latest.thread_id, grouped.turn_count, latest.role, latest.content, latest.created_at
|
|
273
|
+
FROM (
|
|
274
|
+
SELECT agent_name, thread_id, COUNT(*) AS turn_count, MAX(id) AS last_id
|
|
275
|
+
FROM chat_history
|
|
276
|
+
{grouped_where_sql}
|
|
277
|
+
GROUP BY agent_name, thread_id
|
|
278
|
+
) AS grouped
|
|
279
|
+
JOIN chat_history AS latest ON latest.id = grouped.last_id
|
|
280
|
+
{latest_where_sql}
|
|
281
|
+
ORDER BY latest.id DESC
|
|
282
|
+
LIMIT ?
|
|
283
|
+
""",
|
|
284
|
+
[*grouped_params, *latest_params, limit],
|
|
285
|
+
).fetchall()
|
|
286
|
+
finally:
|
|
287
|
+
connection.close()
|
|
288
|
+
return [
|
|
289
|
+
ChatThreadSummary(
|
|
290
|
+
agent_name=row[0],
|
|
291
|
+
thread_id=row[1],
|
|
292
|
+
turn_count=row[2],
|
|
293
|
+
last_role=row[3],
|
|
294
|
+
last_content=row[4],
|
|
295
|
+
last_created_at=row[5],
|
|
296
|
+
)
|
|
297
|
+
for row in rows
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def get_thread_id(agent_name: str, session_name: str = "default") -> str:
|
|
302
|
+
return f"{session_name}:{agent_name}"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def create_thread_id(agent_name: str, session_name: str = "default") -> str:
|
|
306
|
+
return f"{get_thread_id(agent_name, session_name=session_name)}:{uuid4().hex[:8]}"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def resolve_thread_id(
|
|
310
|
+
agent_name: str,
|
|
311
|
+
session_name: str = "default",
|
|
312
|
+
*,
|
|
313
|
+
thread_id: str | None = None,
|
|
314
|
+
new_thread: bool = False,
|
|
315
|
+
) -> str:
|
|
316
|
+
if thread_id is not None and new_thread:
|
|
317
|
+
raise ValueError("thread_id and new_thread are mutually exclusive")
|
|
318
|
+
if thread_id is not None:
|
|
319
|
+
return thread_id
|
|
320
|
+
if new_thread:
|
|
321
|
+
return create_thread_id(agent_name, session_name=session_name)
|
|
322
|
+
return get_thread_id(agent_name, session_name=session_name)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def build_thread_config(
|
|
326
|
+
agent_name: str,
|
|
327
|
+
session_name: str = "default",
|
|
328
|
+
*,
|
|
329
|
+
thread_id: str | None = None,
|
|
330
|
+
) -> dict[str, dict[str, str]]:
|
|
331
|
+
return {"configurable": {"thread_id": resolve_thread_id(agent_name, session_name=session_name, thread_id=thread_id)}}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def describe_langgraph_checkpointer(
|
|
335
|
+
sessions_dir: str,
|
|
336
|
+
session_name: str = "default",
|
|
337
|
+
) -> str:
|
|
338
|
+
if _should_use_sqlite_checkpointer(sessions_dir, session_name=session_name):
|
|
339
|
+
return "sqlite"
|
|
340
|
+
return "persistent-memory"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def clear_langgraph_checkpointer_cache() -> None:
|
|
344
|
+
for checkpointer in _CHECKPOINTERS.values():
|
|
345
|
+
_close_checkpointer_connection(checkpointer)
|
|
346
|
+
for checkpointer in _ASYNC_CHECKPOINTERS.values():
|
|
347
|
+
_close_checkpointer_connection(checkpointer)
|
|
348
|
+
_CHECKPOINTERS.clear()
|
|
349
|
+
_ASYNC_CHECKPOINTERS.clear()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def clear_langgraph_checkpointer_cache_async() -> None:
|
|
353
|
+
for checkpointer in _CHECKPOINTERS.values():
|
|
354
|
+
_close_checkpointer_connection(checkpointer)
|
|
355
|
+
for checkpointer in _ASYNC_CHECKPOINTERS.values():
|
|
356
|
+
await _aclose_checkpointer_connection(checkpointer)
|
|
357
|
+
_CHECKPOINTERS.clear()
|
|
358
|
+
_ASYNC_CHECKPOINTERS.clear()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def ensure_langgraph_checkpointer(
|
|
362
|
+
sessions_dir: str,
|
|
363
|
+
session_name: str = "default",
|
|
364
|
+
) -> Any:
|
|
365
|
+
if _should_use_sqlite_checkpointer(sessions_dir, session_name=session_name):
|
|
366
|
+
sqlite_path = _sqlite_checkpointer_path(sessions_dir, session_name=session_name)
|
|
367
|
+
cached = _CHECKPOINTERS.get(sqlite_path)
|
|
368
|
+
if cached is not None:
|
|
369
|
+
return cached
|
|
370
|
+
checkpointer = SqliteSaver(sqlite3.connect(sqlite_path, check_same_thread=False))
|
|
371
|
+
_CHECKPOINTERS[sqlite_path] = checkpointer
|
|
372
|
+
return checkpointer
|
|
373
|
+
|
|
374
|
+
base_path = _legacy_checkpointer_base_path(sessions_dir, session_name=session_name)
|
|
375
|
+
cached = _CHECKPOINTERS.get(base_path)
|
|
376
|
+
if cached is not None:
|
|
377
|
+
return cached
|
|
378
|
+
checkpointer = PersistentInMemoryCheckpointer(base_path)
|
|
379
|
+
_CHECKPOINTERS[base_path] = checkpointer
|
|
380
|
+
return checkpointer
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def ensure_langgraph_checkpointer_async(
|
|
384
|
+
sessions_dir: str,
|
|
385
|
+
session_name: str = "default",
|
|
386
|
+
) -> Any:
|
|
387
|
+
if _should_use_sqlite_checkpointer(sessions_dir, session_name=session_name) and AsyncSqliteSaver is not None and aiosqlite is not None:
|
|
388
|
+
sqlite_path = _sqlite_checkpointer_path(sessions_dir, session_name=session_name)
|
|
389
|
+
cached = _ASYNC_CHECKPOINTERS.get(sqlite_path)
|
|
390
|
+
if cached is not None:
|
|
391
|
+
return cached
|
|
392
|
+
connection = await aiosqlite.connect(sqlite_path)
|
|
393
|
+
checkpointer = AsyncSqliteSaver(connection)
|
|
394
|
+
_ASYNC_CHECKPOINTERS[sqlite_path] = checkpointer
|
|
395
|
+
return checkpointer
|
|
396
|
+
return ensure_langgraph_checkpointer(sessions_dir, session_name=session_name)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def has_thread_checkpoint(
|
|
400
|
+
sessions_dir: str,
|
|
401
|
+
agent_name: str,
|
|
402
|
+
session_name: str = "default",
|
|
403
|
+
*,
|
|
404
|
+
thread_id: str | None = None,
|
|
405
|
+
) -> bool:
|
|
406
|
+
checkpointer = ensure_langgraph_checkpointer(sessions_dir, session_name=session_name)
|
|
407
|
+
return checkpointer.get_tuple(
|
|
408
|
+
build_thread_config(agent_name, session_name=session_name, thread_id=thread_id)
|
|
409
|
+
) is not None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
async def has_thread_checkpoint_async(
|
|
413
|
+
sessions_dir: str,
|
|
414
|
+
agent_name: str,
|
|
415
|
+
session_name: str = "default",
|
|
416
|
+
*,
|
|
417
|
+
thread_id: str | None = None,
|
|
418
|
+
) -> bool:
|
|
419
|
+
checkpointer = await ensure_langgraph_checkpointer_async(sessions_dir, session_name=session_name)
|
|
420
|
+
config = build_thread_config(agent_name, session_name=session_name, thread_id=thread_id)
|
|
421
|
+
aget_tuple = getattr(checkpointer, "aget_tuple", None)
|
|
422
|
+
if aget_tuple is not None:
|
|
423
|
+
return await aget_tuple(config) is not None
|
|
424
|
+
return checkpointer.get_tuple(config) is not None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def delete_langgraph_thread(
|
|
428
|
+
sessions_dir: str,
|
|
429
|
+
thread_id: str,
|
|
430
|
+
session_name: str = "default",
|
|
431
|
+
) -> bool:
|
|
432
|
+
checkpointer = ensure_langgraph_checkpointer(sessions_dir, session_name=session_name)
|
|
433
|
+
delete_thread = getattr(checkpointer, "delete_thread", None)
|
|
434
|
+
if delete_thread is None:
|
|
435
|
+
return False
|
|
436
|
+
delete_thread(thread_id)
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async def delete_langgraph_thread_async(
|
|
441
|
+
sessions_dir: str,
|
|
442
|
+
thread_id: str,
|
|
443
|
+
session_name: str = "default",
|
|
444
|
+
) -> bool:
|
|
445
|
+
checkpointer = await ensure_langgraph_checkpointer_async(sessions_dir, session_name=session_name)
|
|
446
|
+
adelete_thread = getattr(checkpointer, "adelete_thread", None)
|
|
447
|
+
if adelete_thread is not None:
|
|
448
|
+
await adelete_thread(thread_id)
|
|
449
|
+
return True
|
|
450
|
+
delete_thread = getattr(checkpointer, "delete_thread", None)
|
|
451
|
+
if delete_thread is None:
|
|
452
|
+
return False
|
|
453
|
+
delete_thread(thread_id)
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _should_use_sqlite_checkpointer(sessions_dir: str, session_name: str = "default") -> bool:
|
|
458
|
+
if SqliteSaver is None:
|
|
459
|
+
return False
|
|
460
|
+
sqlite_path = _sqlite_checkpointer_path(sessions_dir, session_name=session_name)
|
|
461
|
+
if sqlite_path.exists():
|
|
462
|
+
return True
|
|
463
|
+
return not _has_legacy_checkpoint_artifacts(_legacy_checkpointer_base_path(sessions_dir, session_name=session_name))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _legacy_checkpointer_base_path(sessions_dir: str, session_name: str = "default") -> Path:
|
|
467
|
+
return Path(sessions_dir).expanduser() / f"{session_name}.langgraph"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _sqlite_checkpointer_path(sessions_dir: str, session_name: str = "default") -> Path:
|
|
471
|
+
base_dir = Path(sessions_dir).expanduser()
|
|
472
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
473
|
+
return base_dir / f"{session_name}.langgraph.sqlite3"
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _has_legacy_checkpoint_artifacts(base_path: Path) -> bool:
|
|
477
|
+
return any(
|
|
478
|
+
artifact.exists()
|
|
479
|
+
for artifact in (
|
|
480
|
+
base_path.with_suffix(".storage.pkl"),
|
|
481
|
+
base_path.with_suffix(".writes.pkl"),
|
|
482
|
+
base_path.with_suffix(".blobs.pkl"),
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _close_checkpointer_connection(checkpointer: Any) -> None:
|
|
488
|
+
connection = getattr(checkpointer, "connection", None) or getattr(checkpointer, "conn", None)
|
|
489
|
+
if connection is None:
|
|
490
|
+
return
|
|
491
|
+
try:
|
|
492
|
+
result = connection.close()
|
|
493
|
+
if inspect.isawaitable(result):
|
|
494
|
+
try:
|
|
495
|
+
import asyncio
|
|
496
|
+
|
|
497
|
+
asyncio.run(result)
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
async def _aclose_checkpointer_connection(checkpointer: Any) -> None:
|
|
505
|
+
connection = getattr(checkpointer, "connection", None) or getattr(checkpointer, "conn", None)
|
|
506
|
+
if connection is None:
|
|
507
|
+
return
|
|
508
|
+
try:
|
|
509
|
+
result = connection.close()
|
|
510
|
+
if inspect.isawaitable(result):
|
|
511
|
+
await result
|
|
512
|
+
except Exception:
|
|
513
|
+
pass
|
agencli/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP configuration and client helpers."""
|
agencli/mcp/client.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from agencli.mcp.config import load_mcp_servers
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MCPToolClient:
|
|
9
|
+
"""Thin wrapper around MultiServerMCPClient for AgenCLI."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, mcp_config_path: str) -> None:
|
|
12
|
+
self.mcp_config_path = mcp_config_path
|
|
13
|
+
|
|
14
|
+
def load_server_map(self) -> dict[str, dict[str, Any]]:
|
|
15
|
+
servers = load_mcp_servers(self.mcp_config_path)
|
|
16
|
+
return {name: server.to_client_dict() for name, server in servers.items()}
|
|
17
|
+
|
|
18
|
+
async def get_tools(self, server_names: list[str] | None = None) -> list[Any]:
|
|
19
|
+
try:
|
|
20
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
21
|
+
except ImportError as exc:
|
|
22
|
+
raise RuntimeError(
|
|
23
|
+
"langchain-mcp-adapters is not installed yet. Run `uv sync` after dependencies are added."
|
|
24
|
+
) from exc
|
|
25
|
+
|
|
26
|
+
server_map = self.load_server_map()
|
|
27
|
+
if server_names is not None:
|
|
28
|
+
server_map = {name: server_map[name] for name in server_names if name in server_map}
|
|
29
|
+
if not server_map:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
client = MultiServerMCPClient(server_map)
|
|
33
|
+
return await client.get_tools()
|
agencli/mcp/config.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class MCPServerConfig:
|
|
13
|
+
transport: str
|
|
14
|
+
command: str | None = None
|
|
15
|
+
args: list[str] = field(default_factory=list)
|
|
16
|
+
url: str | None = None
|
|
17
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_dict(cls, raw: dict[str, Any]) -> "MCPServerConfig":
|
|
21
|
+
return cls(
|
|
22
|
+
transport=raw["transport"],
|
|
23
|
+
command=raw.get("command"),
|
|
24
|
+
args=list(raw.get("args", [])),
|
|
25
|
+
url=raw.get("url"),
|
|
26
|
+
env=dict(raw.get("env", {})),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def to_client_dict(self) -> dict[str, Any]:
|
|
30
|
+
payload: dict[str, Any] = {"transport": self.transport}
|
|
31
|
+
if self.command:
|
|
32
|
+
payload["command"] = _resolve_command(self.command)
|
|
33
|
+
if self.args:
|
|
34
|
+
payload["args"] = [_resolve_template(arg) for arg in self.args]
|
|
35
|
+
if self.url:
|
|
36
|
+
payload["url"] = _resolve_template(self.url)
|
|
37
|
+
if self.env:
|
|
38
|
+
payload["env"] = {name: _resolve_template(value) for name, value in self.env.items()}
|
|
39
|
+
return payload
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_template(value: str) -> str:
|
|
43
|
+
for key, env_value in os.environ.items():
|
|
44
|
+
value = value.replace(f"{{{key}}}", env_value)
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_command(command: str) -> str:
|
|
49
|
+
if os.name != "nt":
|
|
50
|
+
return command
|
|
51
|
+
if shutil.which(command):
|
|
52
|
+
return command
|
|
53
|
+
|
|
54
|
+
for suffix in (".cmd", ".exe", ".bat"):
|
|
55
|
+
candidate = command if command.lower().endswith(suffix) else f"{command}{suffix}"
|
|
56
|
+
if shutil.which(candidate):
|
|
57
|
+
return candidate
|
|
58
|
+
return command
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_mcp_servers(mcp_config_path: str) -> dict[str, MCPServerConfig]:
|
|
62
|
+
path = Path(mcp_config_path).expanduser()
|
|
63
|
+
if not path.exists():
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
if "mcpServers" in raw:
|
|
68
|
+
raw = raw["mcpServers"]
|
|
69
|
+
return {name: MCPServerConfig.from_dict(server) for name, server in raw.items()}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_mcp_servers(mcp_config_path: str, servers: dict[str, MCPServerConfig]) -> Path:
|
|
73
|
+
path = Path(mcp_config_path).expanduser()
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
payload = {"mcpServers": {name: asdict(server) for name, server in servers.items()}}
|
|
76
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
77
|
+
return path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def upsert_mcp_server(mcp_config_path: str, name: str, server: MCPServerConfig) -> Path:
|
|
81
|
+
servers = load_mcp_servers(mcp_config_path)
|
|
82
|
+
servers[name] = server
|
|
83
|
+
return save_mcp_servers(mcp_config_path, servers)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def default_mcp_servers(workspace_dir: str) -> dict[str, MCPServerConfig]:
|
|
87
|
+
return {
|
|
88
|
+
"search": MCPServerConfig(
|
|
89
|
+
transport="http",
|
|
90
|
+
url="https://mcp.tavily.com/mcp/?tavilyApiKey={TAVILY_API_KEY}",
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def bootstrap_default_mcp_servers(mcp_config_path: str, workspace_dir: str) -> Path:
|
|
96
|
+
servers = load_mcp_servers(mcp_config_path)
|
|
97
|
+
for name, server in default_mcp_servers(workspace_dir).items():
|
|
98
|
+
servers.setdefault(name, server)
|
|
99
|
+
return save_mcp_servers(mcp_config_path, servers)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Model provider utilities."""
|