agentlog-sdk 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.
- agentlog/__init__.py +117 -0
- agentlog/client.py +441 -0
- agentlog/errors.py +15 -0
- agentlog_sdk-0.1.0.dist-info/METADATA +162 -0
- agentlog_sdk-0.1.0.dist-info/RECORD +7 -0
- agentlog_sdk-0.1.0.dist-info/WHEEL +4 -0
- agentlog_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
agentlog/__init__.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""agentlog - Python SDK for the agentlog decision log daemon.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
import agentlog
|
|
6
|
+
|
|
7
|
+
agentlog.write("decision", "Use PostgreSQL for persistence")
|
|
8
|
+
entries = agentlog.query("database")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .client import AgentlogClient, VALID_ENTRY_TYPES
|
|
14
|
+
from .errors import AgentlogError, ConnectionError, DaemonNotRunning
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AgentlogClient",
|
|
18
|
+
"AgentlogError",
|
|
19
|
+
"ConnectionError",
|
|
20
|
+
"DaemonNotRunning",
|
|
21
|
+
"VALID_ENTRY_TYPES",
|
|
22
|
+
"write",
|
|
23
|
+
"query",
|
|
24
|
+
"log",
|
|
25
|
+
"context",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_default_client: AgentlogClient | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_default_client() -> AgentlogClient:
|
|
32
|
+
"""Return the module-level default client, creating it on first use."""
|
|
33
|
+
global _default_client
|
|
34
|
+
if _default_client is None:
|
|
35
|
+
_default_client = AgentlogClient()
|
|
36
|
+
return _default_client
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write(
|
|
40
|
+
type: str,
|
|
41
|
+
title: str,
|
|
42
|
+
body: str | None = None,
|
|
43
|
+
tags: list[str] | None = None,
|
|
44
|
+
files: list[str] | None = None,
|
|
45
|
+
session: str | None = None,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Write a decision entry to the log.
|
|
48
|
+
|
|
49
|
+
Convenience wrapper around :meth:`AgentlogClient.write` using the
|
|
50
|
+
module-level default client.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
type: Entry type (decision, attempt_failed, deferred, assumption, question).
|
|
54
|
+
title: Short summary of the decision.
|
|
55
|
+
body: Optional longer description.
|
|
56
|
+
tags: Optional list of tags.
|
|
57
|
+
files: Optional list of file references.
|
|
58
|
+
session: Optional session ID.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The ID of the written entry.
|
|
62
|
+
"""
|
|
63
|
+
return _get_default_client().write(
|
|
64
|
+
type=type, title=title, body=body, tags=tags, files=files, session=session,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def query(
|
|
69
|
+
text: str,
|
|
70
|
+
type: str | None = None,
|
|
71
|
+
session: str | None = None,
|
|
72
|
+
tag: str | None = None,
|
|
73
|
+
file: str | None = None,
|
|
74
|
+
since: str | None = None,
|
|
75
|
+
until: str | None = None,
|
|
76
|
+
limit: int = 20,
|
|
77
|
+
) -> list[dict]:
|
|
78
|
+
"""Full-text search for entries.
|
|
79
|
+
|
|
80
|
+
Convenience wrapper around :meth:`AgentlogClient.query`.
|
|
81
|
+
"""
|
|
82
|
+
return _get_default_client().query(
|
|
83
|
+
text=text, type=type, session=session, tag=tag, file=file,
|
|
84
|
+
since=since, until=until, limit=limit,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def log(
|
|
89
|
+
type: str | None = None,
|
|
90
|
+
session: str | None = None,
|
|
91
|
+
tag: str | None = None,
|
|
92
|
+
file: str | None = None,
|
|
93
|
+
since: str | None = None,
|
|
94
|
+
until: str | None = None,
|
|
95
|
+
limit: int = 50,
|
|
96
|
+
offset: int = 0,
|
|
97
|
+
) -> list[dict]:
|
|
98
|
+
"""List entries with filters.
|
|
99
|
+
|
|
100
|
+
Convenience wrapper around :meth:`AgentlogClient.log`.
|
|
101
|
+
"""
|
|
102
|
+
return _get_default_client().log(
|
|
103
|
+
type=type, session=session, tag=tag, file=file,
|
|
104
|
+
since=since, until=until, limit=limit, offset=offset,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def context(
|
|
109
|
+
query: str | None = None,
|
|
110
|
+
session: str | None = None,
|
|
111
|
+
limit: int = 10,
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Return a structured context string suitable for prompt injection.
|
|
114
|
+
|
|
115
|
+
Convenience wrapper around :meth:`AgentlogClient.context`.
|
|
116
|
+
"""
|
|
117
|
+
return _get_default_client().context(query=query, session=session, limit=limit)
|
agentlog/client.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""AgentlogClient - thin client wrapping the agentlog daemon's Unix socket protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .errors import AgentlogError, ConnectionError, DaemonNotRunning
|
|
13
|
+
|
|
14
|
+
# Allowed entry types for validation.
|
|
15
|
+
VALID_ENTRY_TYPES = frozenset({
|
|
16
|
+
"decision",
|
|
17
|
+
"attempt_failed",
|
|
18
|
+
"deferred",
|
|
19
|
+
"assumption",
|
|
20
|
+
"question",
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_duration(value: str) -> timedelta:
|
|
25
|
+
"""Parse a relative duration string like '1h', '7d', '30m' into a timedelta.
|
|
26
|
+
|
|
27
|
+
Supported suffixes: s (seconds), m (minutes), h (hours), d (days), w (weeks).
|
|
28
|
+
"""
|
|
29
|
+
if not value:
|
|
30
|
+
raise ValueError("empty duration string")
|
|
31
|
+
|
|
32
|
+
suffix = value[-1].lower()
|
|
33
|
+
try:
|
|
34
|
+
amount = int(value[:-1])
|
|
35
|
+
except ValueError:
|
|
36
|
+
raise ValueError(f"invalid duration: {value!r}") from None
|
|
37
|
+
|
|
38
|
+
multipliers = {
|
|
39
|
+
"s": timedelta(seconds=1),
|
|
40
|
+
"m": timedelta(minutes=1),
|
|
41
|
+
"h": timedelta(hours=1),
|
|
42
|
+
"d": timedelta(days=1),
|
|
43
|
+
"w": timedelta(weeks=1),
|
|
44
|
+
}
|
|
45
|
+
if suffix not in multipliers:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"unsupported duration suffix {suffix!r} in {value!r}; "
|
|
48
|
+
f"use one of: s, m, h, d, w"
|
|
49
|
+
)
|
|
50
|
+
return amount * multipliers[suffix]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_time(value: str) -> str:
|
|
54
|
+
"""Resolve a time value to an ISO 8601 / RFC 3339 string.
|
|
55
|
+
|
|
56
|
+
Accepts either an ISO 8601 datetime string (passed through) or a relative
|
|
57
|
+
duration like '1h', '7d' (resolved relative to now).
|
|
58
|
+
"""
|
|
59
|
+
# If it looks like a relative duration (digits followed by a letter), parse it.
|
|
60
|
+
stripped = value.strip()
|
|
61
|
+
if stripped and stripped[-1].isalpha() and stripped[:-1].isdigit():
|
|
62
|
+
delta = _parse_duration(stripped)
|
|
63
|
+
resolved = datetime.now(timezone.utc) - delta
|
|
64
|
+
return resolved.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
65
|
+
# Otherwise treat as an ISO 8601 string and pass through.
|
|
66
|
+
return stripped
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AgentlogClient:
|
|
70
|
+
"""Client for communicating with the agentlog daemon over a Unix socket.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
agentlog_dir: Path to the agentlog data directory. If not provided,
|
|
74
|
+
uses the ``AGENTLOG_DIR`` environment variable or defaults to
|
|
75
|
+
``~/.agentlog``.
|
|
76
|
+
socket_path: Explicit path to the daemon socket. Overrides the
|
|
77
|
+
default derived from ``agentlog_dir``.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
agentlog_dir: Optional[str] = None,
|
|
83
|
+
socket_path: Optional[str] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
if agentlog_dir is None:
|
|
86
|
+
agentlog_dir = os.environ.get("AGENTLOG_DIR")
|
|
87
|
+
if agentlog_dir is None:
|
|
88
|
+
agentlog_dir = str(Path.home() / ".agentlog")
|
|
89
|
+
|
|
90
|
+
self._agentlog_dir = agentlog_dir
|
|
91
|
+
|
|
92
|
+
if socket_path is not None:
|
|
93
|
+
self._socket_path = socket_path
|
|
94
|
+
else:
|
|
95
|
+
self._socket_path = os.path.join(agentlog_dir, "agentlogd.sock")
|
|
96
|
+
|
|
97
|
+
self._session_id: Optional[str] = None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def socket_path(self) -> str:
|
|
101
|
+
"""The Unix socket path this client connects to."""
|
|
102
|
+
return self._socket_path
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def session_id(self) -> Optional[str]:
|
|
106
|
+
"""The current session ID, or None if no session has been created yet."""
|
|
107
|
+
return self._session_id
|
|
108
|
+
|
|
109
|
+
# -- low-level transport ------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _send(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
|
|
112
|
+
"""Send a request to the daemon and return the result.
|
|
113
|
+
|
|
114
|
+
Opens a new Unix socket connection, sends a single JSON line, reads a
|
|
115
|
+
single JSON line response, and closes the connection.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
DaemonNotRunning: If the socket file does not exist.
|
|
119
|
+
ConnectionError: If the connection to the daemon fails.
|
|
120
|
+
AgentlogError: If the daemon returns an error response.
|
|
121
|
+
"""
|
|
122
|
+
if not os.path.exists(self._socket_path):
|
|
123
|
+
raise DaemonNotRunning(
|
|
124
|
+
f"daemon socket not found at {self._socket_path}; "
|
|
125
|
+
f"is agentlogd running?"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
request: dict[str, Any] = {"method": method}
|
|
129
|
+
if params is not None:
|
|
130
|
+
request["params"] = params
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
134
|
+
sock.connect(self._socket_path)
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
raise ConnectionError(
|
|
137
|
+
f"failed to connect to daemon at {self._socket_path}: {exc}"
|
|
138
|
+
) from exc
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
payload = json.dumps(request) + "\n"
|
|
142
|
+
sock.sendall(payload.encode("utf-8"))
|
|
143
|
+
|
|
144
|
+
# Read response - accumulate data until we get a newline.
|
|
145
|
+
buf = b""
|
|
146
|
+
while b"\n" not in buf:
|
|
147
|
+
chunk = sock.recv(4096)
|
|
148
|
+
if not chunk:
|
|
149
|
+
break
|
|
150
|
+
buf += chunk
|
|
151
|
+
finally:
|
|
152
|
+
sock.close()
|
|
153
|
+
|
|
154
|
+
if not buf.strip():
|
|
155
|
+
raise AgentlogError("empty response from daemon")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
response = json.loads(buf)
|
|
159
|
+
except json.JSONDecodeError as exc:
|
|
160
|
+
raise AgentlogError(f"invalid JSON response from daemon: {exc}") from exc
|
|
161
|
+
|
|
162
|
+
if not response.get("ok"):
|
|
163
|
+
error_msg = response.get("error", "unknown error")
|
|
164
|
+
raise AgentlogError(f"daemon error: {error_msg}")
|
|
165
|
+
|
|
166
|
+
return response.get("result")
|
|
167
|
+
|
|
168
|
+
# -- session management -------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _ensure_session(self) -> str:
|
|
171
|
+
"""Return the current session ID, creating one if necessary."""
|
|
172
|
+
if self._session_id is None:
|
|
173
|
+
result = self._send("create_session")
|
|
174
|
+
self._session_id = result["session_id"]
|
|
175
|
+
return self._session_id
|
|
176
|
+
|
|
177
|
+
# -- public API ---------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def write(
|
|
180
|
+
self,
|
|
181
|
+
type: str,
|
|
182
|
+
title: str,
|
|
183
|
+
body: Optional[str] = None,
|
|
184
|
+
tags: Optional[list[str]] = None,
|
|
185
|
+
files: Optional[list[str]] = None,
|
|
186
|
+
session: Optional[str] = None,
|
|
187
|
+
) -> str:
|
|
188
|
+
"""Write a decision entry to the log.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
type: Entry type. Must be one of: ``decision``, ``attempt_failed``,
|
|
192
|
+
``deferred``, ``assumption``, ``question``.
|
|
193
|
+
title: Short summary of the decision.
|
|
194
|
+
body: Optional longer description.
|
|
195
|
+
tags: Optional list of tags.
|
|
196
|
+
files: Optional list of file references.
|
|
197
|
+
session: Optional session ID. If not provided, the client creates a
|
|
198
|
+
session automatically on first write and reuses it.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The ID of the written entry.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
ValueError: If ``type`` is not a valid entry type.
|
|
205
|
+
AgentlogError: On daemon communication errors.
|
|
206
|
+
"""
|
|
207
|
+
if type not in VALID_ENTRY_TYPES:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"invalid entry type {type!r}; must be one of: "
|
|
210
|
+
f"{', '.join(sorted(VALID_ENTRY_TYPES))}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
session_id = session if session is not None else self._ensure_session()
|
|
214
|
+
|
|
215
|
+
entry: dict[str, Any] = {
|
|
216
|
+
"session_id": session_id,
|
|
217
|
+
"type": type,
|
|
218
|
+
"title": title,
|
|
219
|
+
}
|
|
220
|
+
if body is not None:
|
|
221
|
+
entry["body"] = body
|
|
222
|
+
if tags:
|
|
223
|
+
entry["tags"] = tags
|
|
224
|
+
if files:
|
|
225
|
+
entry["file_refs"] = files
|
|
226
|
+
|
|
227
|
+
result = self._send("write", {"entry": entry})
|
|
228
|
+
return result["id"]
|
|
229
|
+
|
|
230
|
+
def query(
|
|
231
|
+
self,
|
|
232
|
+
text: str,
|
|
233
|
+
type: Optional[str] = None,
|
|
234
|
+
session: Optional[str] = None,
|
|
235
|
+
tag: Optional[str] = None,
|
|
236
|
+
file: Optional[str] = None,
|
|
237
|
+
since: Optional[str] = None,
|
|
238
|
+
until: Optional[str] = None,
|
|
239
|
+
limit: int = 20,
|
|
240
|
+
) -> list[dict[str, Any]]:
|
|
241
|
+
"""Full-text search for entries.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
text: Search query string.
|
|
245
|
+
type: Filter results by entry type.
|
|
246
|
+
session: Filter results by session ID.
|
|
247
|
+
tag: Filter results by tag.
|
|
248
|
+
file: Filter results by file reference.
|
|
249
|
+
since: Only return entries after this time (ISO 8601 or duration like '1h').
|
|
250
|
+
until: Only return entries before this time (ISO 8601 or duration like '1h').
|
|
251
|
+
limit: Maximum number of results to return.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
List of entry dicts matching the search, in relevance order.
|
|
255
|
+
"""
|
|
256
|
+
result = self._send("search", {"query": text})
|
|
257
|
+
entries = result if isinstance(result, list) else []
|
|
258
|
+
|
|
259
|
+
entries = self._filter_entries(
|
|
260
|
+
entries, type=type, session=session, tag=tag, file=file,
|
|
261
|
+
since=since, until=until,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return entries[:limit]
|
|
265
|
+
|
|
266
|
+
def log(
|
|
267
|
+
self,
|
|
268
|
+
type: Optional[str] = None,
|
|
269
|
+
session: Optional[str] = None,
|
|
270
|
+
tag: Optional[str] = None,
|
|
271
|
+
file: Optional[str] = None,
|
|
272
|
+
since: Optional[str] = None,
|
|
273
|
+
until: Optional[str] = None,
|
|
274
|
+
limit: int = 50,
|
|
275
|
+
offset: int = 0,
|
|
276
|
+
) -> list[dict[str, Any]]:
|
|
277
|
+
"""List entries with filters.
|
|
278
|
+
|
|
279
|
+
At least one filter must be provided, or the method defaults to
|
|
280
|
+
entries from the last 24 hours.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
type: Filter by entry type.
|
|
284
|
+
session: Filter by session ID.
|
|
285
|
+
tag: Filter by tag.
|
|
286
|
+
file: Filter by file reference.
|
|
287
|
+
since: Only entries after this time (ISO 8601 or duration like '1h', '7d').
|
|
288
|
+
until: Only entries before this time (ISO 8601 or duration like '1h', '7d').
|
|
289
|
+
limit: Maximum number of entries to return.
|
|
290
|
+
offset: Number of entries to skip (for pagination).
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of entry dicts matching the filters, sorted by timestamp.
|
|
294
|
+
"""
|
|
295
|
+
has_filter = any([type, session, tag, file, since, until])
|
|
296
|
+
|
|
297
|
+
# Choose the most appropriate daemon query method.
|
|
298
|
+
params: dict[str, Any] = {}
|
|
299
|
+
remaining_filters: dict[str, Any] = {}
|
|
300
|
+
|
|
301
|
+
if session:
|
|
302
|
+
params["session_id"] = session
|
|
303
|
+
remaining_filters = {"type": type, "tag": tag, "file": file,
|
|
304
|
+
"since": since, "until": until}
|
|
305
|
+
elif type:
|
|
306
|
+
params["type"] = type
|
|
307
|
+
remaining_filters = {"session": session, "tag": tag, "file": file,
|
|
308
|
+
"since": since, "until": until}
|
|
309
|
+
elif tag:
|
|
310
|
+
params["tags"] = [tag]
|
|
311
|
+
remaining_filters = {"type": type, "session": session, "file": file,
|
|
312
|
+
"since": since, "until": until}
|
|
313
|
+
elif file:
|
|
314
|
+
params["file_path"] = file
|
|
315
|
+
remaining_filters = {"type": type, "session": session, "tag": tag,
|
|
316
|
+
"since": since, "until": until}
|
|
317
|
+
elif since or until:
|
|
318
|
+
# Use time range query.
|
|
319
|
+
start = _resolve_time(since) if since else "1970-01-01T00:00:00Z"
|
|
320
|
+
end = _resolve_time(until) if until else datetime.now(timezone.utc).strftime(
|
|
321
|
+
"%Y-%m-%dT%H:%M:%S.%fZ"
|
|
322
|
+
)
|
|
323
|
+
params["start"] = start
|
|
324
|
+
params["end"] = end
|
|
325
|
+
remaining_filters = {"type": type, "session": session, "tag": tag,
|
|
326
|
+
"file": file}
|
|
327
|
+
else:
|
|
328
|
+
# No filters - default to last 24 hours.
|
|
329
|
+
now = datetime.now(timezone.utc)
|
|
330
|
+
start = (now - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
331
|
+
end = now.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
332
|
+
params["start"] = start
|
|
333
|
+
params["end"] = end
|
|
334
|
+
|
|
335
|
+
result = self._send("query", params)
|
|
336
|
+
entries = result if isinstance(result, list) else []
|
|
337
|
+
|
|
338
|
+
# Apply any remaining client-side filters.
|
|
339
|
+
entries = self._filter_entries(entries, **remaining_filters)
|
|
340
|
+
|
|
341
|
+
# Apply offset and limit.
|
|
342
|
+
entries = entries[offset:offset + limit]
|
|
343
|
+
|
|
344
|
+
return entries
|
|
345
|
+
|
|
346
|
+
def context(
|
|
347
|
+
self,
|
|
348
|
+
query: Optional[str] = None,
|
|
349
|
+
session: Optional[str] = None,
|
|
350
|
+
limit: int = 10,
|
|
351
|
+
) -> str:
|
|
352
|
+
"""Return a structured context string suitable for prompt injection.
|
|
353
|
+
|
|
354
|
+
Fetches entries via search or session query and formats them as a
|
|
355
|
+
readable text block.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
query: Optional search query for full-text search.
|
|
359
|
+
session: Optional session ID to fetch entries from.
|
|
360
|
+
limit: Maximum number of entries to include.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
A formatted string with entry summaries.
|
|
364
|
+
"""
|
|
365
|
+
if query:
|
|
366
|
+
entries = self.query(query, limit=limit)
|
|
367
|
+
elif session:
|
|
368
|
+
result = self._send("get_session", {"session_id": session})
|
|
369
|
+
entries = result if isinstance(result, list) else []
|
|
370
|
+
entries = entries[:limit]
|
|
371
|
+
else:
|
|
372
|
+
entries = self.log(limit=limit)
|
|
373
|
+
|
|
374
|
+
return self._format_context(entries)
|
|
375
|
+
|
|
376
|
+
# -- internal helpers ---------------------------------------------------
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _filter_entries(
|
|
380
|
+
entries: list[dict[str, Any]],
|
|
381
|
+
type: Optional[str] = None,
|
|
382
|
+
session: Optional[str] = None,
|
|
383
|
+
tag: Optional[str] = None,
|
|
384
|
+
file: Optional[str] = None,
|
|
385
|
+
since: Optional[str] = None,
|
|
386
|
+
until: Optional[str] = None,
|
|
387
|
+
) -> list[dict[str, Any]]:
|
|
388
|
+
"""Apply client-side filters to a list of entry dicts."""
|
|
389
|
+
filtered = entries
|
|
390
|
+
|
|
391
|
+
if type:
|
|
392
|
+
filtered = [e for e in filtered if e.get("type") == type]
|
|
393
|
+
if session:
|
|
394
|
+
filtered = [e for e in filtered if e.get("session_id") == session]
|
|
395
|
+
if tag:
|
|
396
|
+
filtered = [e for e in filtered if tag in (e.get("tags") or [])]
|
|
397
|
+
if file:
|
|
398
|
+
filtered = [e for e in filtered if file in (e.get("file_refs") or [])]
|
|
399
|
+
if since:
|
|
400
|
+
since_dt = _resolve_time(since)
|
|
401
|
+
filtered = [e for e in filtered if e.get("timestamp", "") >= since_dt]
|
|
402
|
+
if until:
|
|
403
|
+
until_dt = _resolve_time(until)
|
|
404
|
+
filtered = [e for e in filtered if e.get("timestamp", "") <= until_dt]
|
|
405
|
+
|
|
406
|
+
return filtered
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _format_context(entries: list[dict[str, Any]]) -> str:
|
|
410
|
+
"""Format entries into a structured text block for prompt injection."""
|
|
411
|
+
if not entries:
|
|
412
|
+
return "# Recent decisions\n\nNo entries found."
|
|
413
|
+
|
|
414
|
+
lines = ["# Recent decisions", ""]
|
|
415
|
+
for entry in entries:
|
|
416
|
+
entry_type = entry.get("type", "unknown")
|
|
417
|
+
title = entry.get("title", "Untitled")
|
|
418
|
+
timestamp = entry.get("timestamp", "")
|
|
419
|
+
# Format timestamp for display - truncate to minute precision.
|
|
420
|
+
if "T" in timestamp:
|
|
421
|
+
display_time = timestamp[:16].replace("T", " ")
|
|
422
|
+
else:
|
|
423
|
+
display_time = timestamp
|
|
424
|
+
|
|
425
|
+
lines.append(f"## [{entry_type}] {title} ({display_time})")
|
|
426
|
+
|
|
427
|
+
body = entry.get("body")
|
|
428
|
+
if body:
|
|
429
|
+
lines.append(body)
|
|
430
|
+
|
|
431
|
+
tags = entry.get("tags")
|
|
432
|
+
if tags:
|
|
433
|
+
lines.append(f"Tags: {', '.join(tags)}")
|
|
434
|
+
|
|
435
|
+
file_refs = entry.get("file_refs")
|
|
436
|
+
if file_refs:
|
|
437
|
+
lines.append(f"Files: {', '.join(file_refs)}")
|
|
438
|
+
|
|
439
|
+
lines.append("")
|
|
440
|
+
|
|
441
|
+
return "\n".join(lines)
|
agentlog/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Exception hierarchy for the agentlog SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgentlogError(Exception):
|
|
7
|
+
"""Base exception for all agentlog SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConnectionError(AgentlogError):
|
|
11
|
+
"""Raised when the SDK cannot connect to the daemon socket."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DaemonNotRunning(AgentlogError):
|
|
15
|
+
"""Raised when the daemon socket does not exist, indicating the daemon is not running."""
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentlog-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for agentlog - a local-first decision log daemon for agentic workflows
|
|
5
|
+
Project-URL: Homepage, https://byronxlg.github.io/agentlog/
|
|
6
|
+
Project-URL: Documentation, https://byronxlg.github.io/agentlog/docs/
|
|
7
|
+
Project-URL: Repository, https://github.com/byronxlg/agentlog
|
|
8
|
+
Project-URL: Issues, https://github.com/byronxlg/agentlog/issues
|
|
9
|
+
Author: agentlog contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,agentic,decision-log,llm,logging
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# agentlog Python SDK
|
|
27
|
+
|
|
28
|
+
Python client for [agentlog](https://github.com/byronxlg/agentlog) - a local-first decision log daemon for agentic workflows.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install agentlog-sdk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import agentlog
|
|
40
|
+
|
|
41
|
+
agentlog.write("decision", "Use PostgreSQL for persistence")
|
|
42
|
+
entries = agentlog.query("database")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- Python 3.9+
|
|
48
|
+
- A running `agentlogd` daemon (see the main project README)
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### Writing entries
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import agentlog
|
|
56
|
+
|
|
57
|
+
# Write a decision entry (session created automatically)
|
|
58
|
+
agentlog.write(
|
|
59
|
+
"decision",
|
|
60
|
+
"Use Redis for caching",
|
|
61
|
+
body="Redis provides sub-millisecond reads and built-in TTL support.",
|
|
62
|
+
tags=["infrastructure", "caching"],
|
|
63
|
+
files=["config/redis.yaml"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Supported entry types: decision, attempt_failed, deferred, assumption, question
|
|
67
|
+
agentlog.write("assumption", "All users have Python 3.9+")
|
|
68
|
+
agentlog.write("question", "Should we use async or sync HTTP client?")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Searching entries
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# Full-text search
|
|
75
|
+
results = agentlog.query("database migration")
|
|
76
|
+
|
|
77
|
+
# Search with filters
|
|
78
|
+
results = agentlog.query("caching", type="decision", limit=5)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Listing entries
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# List entries by type
|
|
85
|
+
entries = agentlog.log(type="decision")
|
|
86
|
+
|
|
87
|
+
# List entries by session
|
|
88
|
+
entries = agentlog.log(session="your-session-id")
|
|
89
|
+
|
|
90
|
+
# List entries by tag
|
|
91
|
+
entries = agentlog.log(tag="infrastructure")
|
|
92
|
+
|
|
93
|
+
# List entries from the last hour
|
|
94
|
+
entries = agentlog.log(since="1h")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Getting context for prompts
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
# Get a formatted text block for prompt injection
|
|
101
|
+
context = agentlog.context(query="authentication")
|
|
102
|
+
print(context)
|
|
103
|
+
# Output:
|
|
104
|
+
# # Recent decisions
|
|
105
|
+
#
|
|
106
|
+
# ## [decision] Use JWT for API auth (2026-03-15 10:30)
|
|
107
|
+
# JWTs are stateless and work well with our microservices architecture.
|
|
108
|
+
# Tags: auth, api
|
|
109
|
+
# Files: internal/auth/jwt.go
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Using the client class directly
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from agentlog import AgentlogClient
|
|
116
|
+
|
|
117
|
+
# Custom socket path
|
|
118
|
+
client = AgentlogClient(agentlog_dir="/custom/path")
|
|
119
|
+
|
|
120
|
+
# Or explicit socket path
|
|
121
|
+
client = AgentlogClient(socket_path="/tmp/agentlogd.sock")
|
|
122
|
+
|
|
123
|
+
# All methods are available on the client instance
|
|
124
|
+
entry_id = client.write("decision", "Use gRPC for internal services")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Configuration
|
|
128
|
+
|
|
129
|
+
The SDK looks for the daemon socket at `~/.agentlog/agentlogd.sock` by default.
|
|
130
|
+
Override this with:
|
|
131
|
+
|
|
132
|
+
- The `AGENTLOG_DIR` environment variable
|
|
133
|
+
- The `agentlog_dir` constructor argument
|
|
134
|
+
- The `socket_path` constructor argument (takes precedence)
|
|
135
|
+
|
|
136
|
+
### Error handling
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from agentlog import AgentlogError, ConnectionError, DaemonNotRunning
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
agentlog.write("decision", "Test entry")
|
|
143
|
+
except DaemonNotRunning:
|
|
144
|
+
print("Start the daemon first: agentlog start")
|
|
145
|
+
except ConnectionError as e:
|
|
146
|
+
print(f"Connection failed: {e}")
|
|
147
|
+
except AgentlogError as e:
|
|
148
|
+
print(f"Unexpected error: {e}")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
# Install in development mode
|
|
155
|
+
pip install -e sdk/python/
|
|
156
|
+
|
|
157
|
+
# Run tests
|
|
158
|
+
python -m pytest sdk/python/tests/ -v
|
|
159
|
+
|
|
160
|
+
# Run only unit tests (no daemon required)
|
|
161
|
+
python -m pytest sdk/python/tests/test_client.py -v
|
|
162
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
agentlog/__init__.py,sha256=oj8TVdXOg6_yshHxjJ-lGv7DxBWtdvPN4peLqz0dsFM,3035
|
|
2
|
+
agentlog/client.py,sha256=DGmOFQkTcl7TQ9_bI38hSf8KrvDM9RNv8XOpWxzZhsE,15399
|
|
3
|
+
agentlog/errors.py,sha256=BwaC-UV1IRILWt5dIJbCNPi28zDBFJ6wY8xxsJtRq2c,414
|
|
4
|
+
agentlog_sdk-0.1.0.dist-info/METADATA,sha256=fWVSor3dREX8hGsCLIPBrn3Nmdhvbc1EndNiG8SYXMI,4200
|
|
5
|
+
agentlog_sdk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
agentlog_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=ZYucAt-f9Gz2S7rUvAIkY2zbDub5tq0YkVA7ymxTBxE,1078
|
|
7
|
+
agentlog_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agentlog contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|