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.
@@ -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
+ }