abstractassistant 0.3.4__py3-none-any.whl → 0.4.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.
- abstractassistant/app.py +69 -6
- abstractassistant/cli.py +104 -85
- abstractassistant/core/agent_host.py +583 -0
- abstractassistant/core/llm_manager.py +338 -431
- abstractassistant/core/session_index.py +293 -0
- abstractassistant/core/session_store.py +79 -0
- abstractassistant/core/tool_policy.py +58 -0
- abstractassistant/core/transcript_summary.py +434 -0
- abstractassistant/ui/history_dialog.py +504 -29
- abstractassistant/ui/provider_manager.py +2 -2
- abstractassistant/ui/qt_bubble.py +2289 -489
- abstractassistant-0.4.0.dist-info/METADATA +168 -0
- abstractassistant-0.4.0.dist-info/RECORD +32 -0
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/WHEEL +1 -1
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/entry_points.txt +1 -0
- abstractassistant-0.3.4.dist-info/METADATA +0 -297
- abstractassistant-0.3.4.dist-info/RECORD +0 -27
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {abstractassistant-0.3.4.dist-info → abstractassistant-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Durable multi-session index for AbstractAssistant.
|
|
2
|
+
|
|
3
|
+
AbstractAssistant already persists a single session under the data dir:
|
|
4
|
+
- `session.json` (transcript snapshot + ids)
|
|
5
|
+
- `runtime/` (AbstractRuntime stores)
|
|
6
|
+
|
|
7
|
+
This module adds a light registry so the tray UI can manage *multiple* sessions.
|
|
8
|
+
New sessions are created under:
|
|
9
|
+
<data_dir>/sessions/<session_id>/
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from .session_store import SessionSnapshot, SessionStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _utc_now_iso() -> str:
|
|
25
|
+
return datetime.now(timezone.utc).isoformat()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _safe_title(title: str) -> str:
|
|
29
|
+
t = str(title or "").strip()
|
|
30
|
+
if not t:
|
|
31
|
+
return "New session"
|
|
32
|
+
# Keep dropdown readable.
|
|
33
|
+
t = t.replace("\n", " ").replace("\r", " ").strip()
|
|
34
|
+
return t[:80] if len(t) > 80 else t
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class SessionRecord:
|
|
39
|
+
session_id: str
|
|
40
|
+
actor_id: str
|
|
41
|
+
title: str
|
|
42
|
+
path: str # relative to base_dir, "." for legacy/base session
|
|
43
|
+
created_at: str
|
|
44
|
+
updated_at: str
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"session_id": self.session_id,
|
|
49
|
+
"actor_id": self.actor_id,
|
|
50
|
+
"title": self.title,
|
|
51
|
+
"path": self.path,
|
|
52
|
+
"created_at": self.created_at,
|
|
53
|
+
"updated_at": self.updated_at,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, raw: Dict[str, Any]) -> "SessionRecord":
|
|
58
|
+
sid = str(raw.get("session_id") or "").strip()
|
|
59
|
+
aid = str(raw.get("actor_id") or "").strip()
|
|
60
|
+
if not sid:
|
|
61
|
+
raise ValueError("session_id is required")
|
|
62
|
+
if not aid:
|
|
63
|
+
raise ValueError("actor_id is required")
|
|
64
|
+
title = _safe_title(str(raw.get("title") or ""))
|
|
65
|
+
path = str(raw.get("path") or "").strip() or "."
|
|
66
|
+
created_at = str(raw.get("created_at") or "").strip() or _utc_now_iso()
|
|
67
|
+
updated_at = str(raw.get("updated_at") or "").strip() or created_at
|
|
68
|
+
return cls(
|
|
69
|
+
session_id=sid,
|
|
70
|
+
actor_id=aid,
|
|
71
|
+
title=title,
|
|
72
|
+
path=path,
|
|
73
|
+
created_at=created_at,
|
|
74
|
+
updated_at=updated_at,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SessionIndex:
|
|
79
|
+
"""Stores the list of durable sessions and the active session."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, base_dir: Path):
|
|
82
|
+
self._base_dir = Path(base_dir).expanduser()
|
|
83
|
+
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
self._path = self._base_dir / "sessions.json"
|
|
85
|
+
self._active_session_id: Optional[str] = None
|
|
86
|
+
self._sessions: List[SessionRecord] = []
|
|
87
|
+
self._load_or_bootstrap()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def base_dir(self) -> Path:
|
|
91
|
+
return self._base_dir
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def path(self) -> Path:
|
|
95
|
+
return self._path
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def active_session_id(self) -> str:
|
|
99
|
+
sid = str(self._active_session_id or "").strip()
|
|
100
|
+
if sid:
|
|
101
|
+
return sid
|
|
102
|
+
if self._sessions:
|
|
103
|
+
return self._sessions[0].session_id
|
|
104
|
+
# Should not happen; bootstrap guarantees at least one session.
|
|
105
|
+
return self._ensure_legacy_session().session_id
|
|
106
|
+
|
|
107
|
+
def active_record(self) -> SessionRecord:
|
|
108
|
+
return self.get(self.active_session_id)
|
|
109
|
+
|
|
110
|
+
def records(self) -> List[SessionRecord]:
|
|
111
|
+
# Sort by recency (updated_at is ISO).
|
|
112
|
+
return sorted(self._sessions, key=lambda r: str(r.updated_at), reverse=True)
|
|
113
|
+
|
|
114
|
+
def get(self, session_id: str) -> SessionRecord:
|
|
115
|
+
sid = str(session_id or "").strip()
|
|
116
|
+
for r in self._sessions:
|
|
117
|
+
if r.session_id == sid:
|
|
118
|
+
return r
|
|
119
|
+
raise KeyError(f"Unknown session_id: {sid}")
|
|
120
|
+
|
|
121
|
+
def data_dir_for(self, session_id: str) -> Path:
|
|
122
|
+
rec = self.get(session_id)
|
|
123
|
+
rel = Path(rec.path)
|
|
124
|
+
return (self._base_dir / rel).resolve()
|
|
125
|
+
|
|
126
|
+
def set_active(self, session_id: str) -> None:
|
|
127
|
+
sid = str(session_id or "").strip()
|
|
128
|
+
if not sid:
|
|
129
|
+
raise ValueError("session_id must be non-empty")
|
|
130
|
+
_ = self.get(sid) # validate
|
|
131
|
+
self._active_session_id = sid
|
|
132
|
+
self._save()
|
|
133
|
+
|
|
134
|
+
def touch(self, session_id: str) -> None:
|
|
135
|
+
sid = str(session_id or "").strip()
|
|
136
|
+
if not sid:
|
|
137
|
+
return
|
|
138
|
+
now = _utc_now_iso()
|
|
139
|
+
updated: List[SessionRecord] = []
|
|
140
|
+
for r in self._sessions:
|
|
141
|
+
if r.session_id != sid:
|
|
142
|
+
updated.append(r)
|
|
143
|
+
continue
|
|
144
|
+
updated.append(
|
|
145
|
+
SessionRecord(
|
|
146
|
+
session_id=r.session_id,
|
|
147
|
+
actor_id=r.actor_id,
|
|
148
|
+
title=r.title,
|
|
149
|
+
path=r.path,
|
|
150
|
+
created_at=r.created_at,
|
|
151
|
+
updated_at=now,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
self._sessions = updated
|
|
155
|
+
self._save()
|
|
156
|
+
|
|
157
|
+
def update_title(self, session_id: str, title: str) -> None:
|
|
158
|
+
sid = str(session_id or "").strip()
|
|
159
|
+
if not sid:
|
|
160
|
+
return
|
|
161
|
+
now = _utc_now_iso()
|
|
162
|
+
new_title = _safe_title(title)
|
|
163
|
+
updated: List[SessionRecord] = []
|
|
164
|
+
for r in self._sessions:
|
|
165
|
+
if r.session_id != sid:
|
|
166
|
+
updated.append(r)
|
|
167
|
+
continue
|
|
168
|
+
updated.append(
|
|
169
|
+
SessionRecord(
|
|
170
|
+
session_id=r.session_id,
|
|
171
|
+
actor_id=r.actor_id,
|
|
172
|
+
title=new_title,
|
|
173
|
+
path=r.path,
|
|
174
|
+
created_at=r.created_at,
|
|
175
|
+
updated_at=now,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
self._sessions = updated
|
|
179
|
+
self._save()
|
|
180
|
+
|
|
181
|
+
def create_session(self) -> SessionRecord:
|
|
182
|
+
"""Create a new durable session directory and set it active."""
|
|
183
|
+
session_id = f"sess_{uuid.uuid4().hex}"
|
|
184
|
+
actor_id = f"actor_{uuid.uuid4().hex}"
|
|
185
|
+
now = _utc_now_iso()
|
|
186
|
+
rel_path = Path("sessions") / session_id
|
|
187
|
+
data_dir = self._base_dir / rel_path
|
|
188
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
|
|
190
|
+
# Create an empty snapshot so AgentHost loads the intended ids.
|
|
191
|
+
store = SessionStore(data_dir / "session.json")
|
|
192
|
+
store.save(SessionSnapshot(session_id=session_id, actor_id=actor_id, messages=[], last_run_id=None))
|
|
193
|
+
|
|
194
|
+
rec = SessionRecord(
|
|
195
|
+
session_id=session_id,
|
|
196
|
+
actor_id=actor_id,
|
|
197
|
+
title="New session",
|
|
198
|
+
path=str(rel_path).replace("\\", "/"),
|
|
199
|
+
created_at=now,
|
|
200
|
+
updated_at=now,
|
|
201
|
+
)
|
|
202
|
+
self._sessions.append(rec)
|
|
203
|
+
self._active_session_id = session_id
|
|
204
|
+
self._save()
|
|
205
|
+
return rec
|
|
206
|
+
|
|
207
|
+
def _load_or_bootstrap(self) -> None:
|
|
208
|
+
data = None
|
|
209
|
+
if self._path.exists():
|
|
210
|
+
try:
|
|
211
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
212
|
+
except Exception:
|
|
213
|
+
data = None
|
|
214
|
+
|
|
215
|
+
if not isinstance(data, dict):
|
|
216
|
+
self._bootstrap()
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
sessions_raw = data.get("sessions")
|
|
220
|
+
sessions: List[SessionRecord] = []
|
|
221
|
+
if isinstance(sessions_raw, list):
|
|
222
|
+
for item in sessions_raw:
|
|
223
|
+
if not isinstance(item, dict):
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
sessions.append(SessionRecord.from_dict(item))
|
|
227
|
+
except Exception:
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
# Always ensure legacy/base session is present (for back-compat).
|
|
231
|
+
legacy = self._ensure_legacy_session()
|
|
232
|
+
if not any(r.session_id == legacy.session_id for r in sessions):
|
|
233
|
+
sessions.append(legacy)
|
|
234
|
+
|
|
235
|
+
# Filter out missing directories (except legacy ".").
|
|
236
|
+
filtered: List[SessionRecord] = []
|
|
237
|
+
for r in sessions:
|
|
238
|
+
if r.path == ".":
|
|
239
|
+
filtered.append(r)
|
|
240
|
+
continue
|
|
241
|
+
if (self._base_dir / Path(r.path)).exists():
|
|
242
|
+
filtered.append(r)
|
|
243
|
+
if not filtered:
|
|
244
|
+
filtered = [legacy]
|
|
245
|
+
|
|
246
|
+
active = str(data.get("active_session_id") or "").strip()
|
|
247
|
+
if not active or not any(r.session_id == active for r in filtered):
|
|
248
|
+
active = filtered[0].session_id
|
|
249
|
+
|
|
250
|
+
self._sessions = filtered
|
|
251
|
+
self._active_session_id = active
|
|
252
|
+
self._save()
|
|
253
|
+
|
|
254
|
+
def _bootstrap(self) -> None:
|
|
255
|
+
legacy = self._ensure_legacy_session()
|
|
256
|
+
self._sessions = [legacy]
|
|
257
|
+
self._active_session_id = legacy.session_id
|
|
258
|
+
self._save()
|
|
259
|
+
|
|
260
|
+
def _ensure_legacy_session(self) -> SessionRecord:
|
|
261
|
+
"""Ensure base_dir/session.json exists and return its record."""
|
|
262
|
+
store = SessionStore(self._base_dir / "session.json")
|
|
263
|
+
snap = store.load()
|
|
264
|
+
if snap is None:
|
|
265
|
+
snap = SessionSnapshot(
|
|
266
|
+
session_id=f"sess_{uuid.uuid4().hex}",
|
|
267
|
+
actor_id=f"actor_{uuid.uuid4().hex}",
|
|
268
|
+
messages=[],
|
|
269
|
+
last_run_id=None,
|
|
270
|
+
)
|
|
271
|
+
store.save(snap)
|
|
272
|
+
|
|
273
|
+
now = _utc_now_iso()
|
|
274
|
+
# Keep title best-effort: if an index exists we will override from it.
|
|
275
|
+
return SessionRecord(
|
|
276
|
+
session_id=str(snap.session_id),
|
|
277
|
+
actor_id=str(snap.actor_id),
|
|
278
|
+
title="New session",
|
|
279
|
+
path=".",
|
|
280
|
+
created_at=now,
|
|
281
|
+
updated_at=now,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _save(self) -> None:
|
|
285
|
+
payload = {
|
|
286
|
+
"active_session_id": self.active_session_id,
|
|
287
|
+
"sessions": [r.to_dict() for r in self._sessions],
|
|
288
|
+
}
|
|
289
|
+
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
|
290
|
+
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
292
|
+
tmp.replace(self._path)
|
|
293
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Session persistence for AbstractAssistant.
|
|
2
|
+
|
|
3
|
+
This module stores *host UX state* (session id, actor id, and chat transcript snapshot)
|
|
4
|
+
separately from AbstractRuntime stores. The runtime remains the source of truth for run
|
|
5
|
+
durability; this file is a convenience for fast app startup and UX continuity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SessionSnapshot:
|
|
18
|
+
session_id: str
|
|
19
|
+
actor_id: str
|
|
20
|
+
messages: List[Dict[str, Any]]
|
|
21
|
+
last_run_id: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"session_id": self.session_id,
|
|
26
|
+
"actor_id": self.actor_id,
|
|
27
|
+
"messages": list(self.messages),
|
|
28
|
+
"last_run_id": self.last_run_id,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, raw: Dict[str, Any]) -> "SessionSnapshot":
|
|
33
|
+
session_id = str(raw.get("session_id") or "").strip()
|
|
34
|
+
actor_id = str(raw.get("actor_id") or "").strip()
|
|
35
|
+
if not session_id:
|
|
36
|
+
raise ValueError("session_id is required")
|
|
37
|
+
if not actor_id:
|
|
38
|
+
raise ValueError("actor_id is required")
|
|
39
|
+
messages_raw = raw.get("messages")
|
|
40
|
+
messages: List[Dict[str, Any]] = []
|
|
41
|
+
if isinstance(messages_raw, list):
|
|
42
|
+
for m in messages_raw:
|
|
43
|
+
if isinstance(m, dict):
|
|
44
|
+
messages.append(dict(m))
|
|
45
|
+
last_run_id_raw = raw.get("last_run_id")
|
|
46
|
+
last_run_id = str(last_run_id_raw).strip() if last_run_id_raw is not None else None
|
|
47
|
+
if last_run_id == "":
|
|
48
|
+
last_run_id = None
|
|
49
|
+
return cls(session_id=session_id, actor_id=actor_id, messages=messages, last_run_id=last_run_id)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SessionStore:
|
|
53
|
+
def __init__(self, path: Path):
|
|
54
|
+
self._path = Path(path)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def path(self) -> Path:
|
|
58
|
+
return self._path
|
|
59
|
+
|
|
60
|
+
def load(self) -> Optional[SessionSnapshot]:
|
|
61
|
+
if not self._path.exists():
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
return SessionSnapshot.from_dict(data)
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def save(self, snapshot: SessionSnapshot) -> None:
|
|
75
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
|
77
|
+
tmp.write_text(json.dumps(snapshot.to_dict(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
78
|
+
tmp.replace(self._path)
|
|
79
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Tool approval policy helpers for AbstractAssistant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Dict, List, Sequence, Set
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_DEFAULT_SAFE_AUTO_APPROVE: Set[str] = {
|
|
10
|
+
# Read-only filesystem
|
|
11
|
+
"list_files",
|
|
12
|
+
"skim_folders",
|
|
13
|
+
"analyze_code",
|
|
14
|
+
"read_file",
|
|
15
|
+
"skim_files",
|
|
16
|
+
"search_files",
|
|
17
|
+
# Network read-only
|
|
18
|
+
"web_search",
|
|
19
|
+
"skim_websearch",
|
|
20
|
+
"skim_url",
|
|
21
|
+
"fetch_url",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_DEFAULT_REQUIRE_APPROVAL: Set[str] = {
|
|
25
|
+
# Side effects
|
|
26
|
+
"write_file",
|
|
27
|
+
"edit_file",
|
|
28
|
+
"execute_command",
|
|
29
|
+
# Agent-only side effects
|
|
30
|
+
"execute_python",
|
|
31
|
+
"self_improve",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ToolApprovalPolicy:
|
|
37
|
+
"""Decide whether a batch of tool calls should require user approval."""
|
|
38
|
+
|
|
39
|
+
auto_approve_tools: Set[str] = field(default_factory=lambda: set(_DEFAULT_SAFE_AUTO_APPROVE))
|
|
40
|
+
require_approval_tools: Set[str] = field(default_factory=lambda: set(_DEFAULT_REQUIRE_APPROVAL))
|
|
41
|
+
|
|
42
|
+
def requires_approval(self, tool_calls: Sequence[Dict[str, object]]) -> bool:
|
|
43
|
+
"""Return True if any tool call in the batch requires explicit approval."""
|
|
44
|
+
for tc in tool_calls or []:
|
|
45
|
+
name = str((tc or {}).get("name") or "").strip()
|
|
46
|
+
if not name:
|
|
47
|
+
return True
|
|
48
|
+
if name in self.require_approval_tools:
|
|
49
|
+
return True
|
|
50
|
+
if name not in self.auto_approve_tools:
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
def describe(self) -> Dict[str, List[str]]:
|
|
55
|
+
return {
|
|
56
|
+
"auto_approve_tools": sorted(self.auto_approve_tools),
|
|
57
|
+
"require_approval_tools": sorted(self.require_approval_tools),
|
|
58
|
+
}
|