agentlings 0.2.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.
- agentlings/__init__.py +3 -0
- agentlings/__main__.py +238 -0
- agentlings/cli/__init__.py +1 -0
- agentlings/cli/_migrations.py +78 -0
- agentlings/cli/_templates.py +57 -0
- agentlings/cli/_version.py +33 -0
- agentlings/cli/init.py +122 -0
- agentlings/cli/upgrade.py +89 -0
- agentlings/config.py +260 -0
- agentlings/core/__init__.py +1 -0
- agentlings/core/completion.py +219 -0
- agentlings/core/llm.py +509 -0
- agentlings/core/loop.py +134 -0
- agentlings/core/memory_models.py +97 -0
- agentlings/core/memory_store.py +109 -0
- agentlings/core/models.py +231 -0
- agentlings/core/prompt.py +122 -0
- agentlings/core/scheduler.py +141 -0
- agentlings/core/sleep.py +393 -0
- agentlings/core/store.py +318 -0
- agentlings/core/task.py +1087 -0
- agentlings/core/telemetry.py +181 -0
- agentlings/log.py +23 -0
- agentlings/migrations/__init__.py +37 -0
- agentlings/migrations/m0001_seed.py +17 -0
- agentlings/protocol/__init__.py +1 -0
- agentlings/protocol/a2a.py +220 -0
- agentlings/protocol/a2a_task_store.py +150 -0
- agentlings/protocol/agent_card.py +83 -0
- agentlings/protocol/mcp.py +232 -0
- agentlings/server.py +247 -0
- agentlings/templates/__init__.py +1 -0
- agentlings/templates/default/.env.example +16 -0
- agentlings/templates/default/agent.yaml +14 -0
- agentlings/tools/__init__.py +1 -0
- agentlings/tools/builtins.py +307 -0
- agentlings/tools/memory.py +104 -0
- agentlings/tools/registry.py +154 -0
- agentlings-0.2.0.dist-info/METADATA +406 -0
- agentlings-0.2.0.dist-info/RECORD +43 -0
- agentlings-0.2.0.dist-info/WHEEL +4 -0
- agentlings-0.2.0.dist-info/entry_points.txt +2 -0
- agentlings-0.2.0.dist-info/licenses/LICENSE +21 -0
agentlings/core/store.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Append-only JSONL conversation journal with compaction-aware replay.
|
|
2
|
+
|
|
3
|
+
Layout (v2 task-based execution):
|
|
4
|
+
|
|
5
|
+
- Parent journal: ``{data_dir}/{ctx_id}/journal.jsonl``
|
|
6
|
+
- Sub-journal: ``{data_dir}/{ctx_id}/tasks/{task_id}.jsonl``
|
|
7
|
+
|
|
8
|
+
Legacy parent journals at ``{data_dir}/{ctx_id}.jsonl`` are auto-migrated
|
|
9
|
+
to the new layout on first access so existing deployments continue to work.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fcntl
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Iterable
|
|
19
|
+
|
|
20
|
+
from agentlings.core.models import (
|
|
21
|
+
AUDIT_MARKER_TYPES,
|
|
22
|
+
CompactionEntry,
|
|
23
|
+
MergeCommitted,
|
|
24
|
+
MergeStarted,
|
|
25
|
+
MessageEntry,
|
|
26
|
+
TaskCancelled,
|
|
27
|
+
TaskDispatched,
|
|
28
|
+
TaskFailed,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Parent journal entry types that can be appended directly to a context journal.
|
|
35
|
+
ParentJournalEntry = (
|
|
36
|
+
MessageEntry
|
|
37
|
+
| CompactionEntry
|
|
38
|
+
| TaskDispatched
|
|
39
|
+
| TaskCancelled
|
|
40
|
+
| TaskFailed
|
|
41
|
+
| MergeStarted
|
|
42
|
+
| MergeCommitted
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ContextNotFoundError(Exception):
|
|
47
|
+
"""Raised when a context ID does not correspond to an existing journal file."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _append_jsonl(path: Path, line: str) -> None:
|
|
51
|
+
"""Append a single JSONL line under an exclusive fcntl lock.
|
|
52
|
+
|
|
53
|
+
Callers must ensure ``path``'s parent directory exists. ``line`` must end
|
|
54
|
+
with a newline. The lock is released before the file handle is closed.
|
|
55
|
+
"""
|
|
56
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
57
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
58
|
+
try:
|
|
59
|
+
f.write(line)
|
|
60
|
+
f.flush()
|
|
61
|
+
finally:
|
|
62
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
66
|
+
"""Read all JSONL entries from a file, returning an empty list if missing."""
|
|
67
|
+
if not path.exists():
|
|
68
|
+
return []
|
|
69
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
70
|
+
if not text:
|
|
71
|
+
return []
|
|
72
|
+
return [json.loads(line) for line in text.splitlines()]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class JournalStore:
|
|
76
|
+
"""Manages per-context JSONL journal files for conversation persistence.
|
|
77
|
+
|
|
78
|
+
Each context gets a directory at ``{data_dir}/{ctx_id}/`` containing
|
|
79
|
+
``journal.jsonl`` (the parent journal) and a ``tasks/`` subdirectory
|
|
80
|
+
for per-task sub-journals. Entries are append-only.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, data_dir: Path) -> None:
|
|
84
|
+
self._data_dir = data_dir
|
|
85
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def data_dir(self) -> Path:
|
|
89
|
+
"""Root data directory."""
|
|
90
|
+
return self._data_dir
|
|
91
|
+
|
|
92
|
+
def context_dir(self, ctx_id: str) -> Path:
|
|
93
|
+
"""Directory holding a context's journal and task sub-journals."""
|
|
94
|
+
return self._data_dir / ctx_id
|
|
95
|
+
|
|
96
|
+
def _path(self, ctx_id: str) -> Path:
|
|
97
|
+
"""Path to a context's parent journal."""
|
|
98
|
+
return self.context_dir(ctx_id) / "journal.jsonl"
|
|
99
|
+
|
|
100
|
+
def _legacy_path(self, ctx_id: str) -> Path:
|
|
101
|
+
"""Pre-v2 parent journal path."""
|
|
102
|
+
return self._data_dir / f"{ctx_id}.jsonl"
|
|
103
|
+
|
|
104
|
+
def task_path(self, ctx_id: str, task_id: str) -> Path:
|
|
105
|
+
"""Path to a task's sub-journal."""
|
|
106
|
+
return self.context_dir(ctx_id) / "tasks" / f"{task_id}.jsonl"
|
|
107
|
+
|
|
108
|
+
def _maybe_migrate_legacy(self, ctx_id: str) -> None:
|
|
109
|
+
"""Move a pre-v2 journal into the new per-context directory layout.
|
|
110
|
+
|
|
111
|
+
Idempotent: does nothing if the new path already exists or the legacy
|
|
112
|
+
path does not.
|
|
113
|
+
"""
|
|
114
|
+
legacy = self._legacy_path(ctx_id)
|
|
115
|
+
new = self._path(ctx_id)
|
|
116
|
+
if not legacy.exists() or new.exists():
|
|
117
|
+
return
|
|
118
|
+
new.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
legacy.rename(new)
|
|
120
|
+
logger.info("migrated legacy journal for context %s", ctx_id)
|
|
121
|
+
|
|
122
|
+
def create(self, ctx_id: str) -> None:
|
|
123
|
+
"""Create an empty journal file for a new context.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
ctx_id: The context identifier.
|
|
127
|
+
"""
|
|
128
|
+
self._maybe_migrate_legacy(ctx_id)
|
|
129
|
+
path = self._path(ctx_id)
|
|
130
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
path.touch(exist_ok=True)
|
|
132
|
+
logger.info("created context %s", ctx_id)
|
|
133
|
+
|
|
134
|
+
def exists(self, ctx_id: str) -> bool:
|
|
135
|
+
"""Check whether a journal file exists for the given context.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
ctx_id: The context identifier.
|
|
139
|
+
"""
|
|
140
|
+
self._maybe_migrate_legacy(ctx_id)
|
|
141
|
+
return self._path(ctx_id).exists()
|
|
142
|
+
|
|
143
|
+
def append(self, ctx_id: str, entry: ParentJournalEntry) -> None:
|
|
144
|
+
"""Append a journal entry to the context's JSONL file.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
ctx_id: The context identifier.
|
|
148
|
+
entry: The journal entry to append.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ContextNotFoundError: If no journal file exists for the context.
|
|
152
|
+
"""
|
|
153
|
+
self._maybe_migrate_legacy(ctx_id)
|
|
154
|
+
path = self._path(ctx_id)
|
|
155
|
+
if not path.exists():
|
|
156
|
+
raise ContextNotFoundError(ctx_id)
|
|
157
|
+
|
|
158
|
+
_append_jsonl(path, entry.model_dump_json() + "\n")
|
|
159
|
+
logger.debug("appended %s entry to context %s", entry.t, ctx_id)
|
|
160
|
+
|
|
161
|
+
def append_many(self, ctx_id: str, entries: Iterable[ParentJournalEntry]) -> None:
|
|
162
|
+
"""Append several entries under a single lock acquisition.
|
|
163
|
+
|
|
164
|
+
Used by merge-back to keep the wrapper + contents atomic under the
|
|
165
|
+
per-context lock. Write order matches iteration order.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
ctx_id: The context identifier.
|
|
169
|
+
entries: Ordered entries to append.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ContextNotFoundError: If no journal file exists for the context.
|
|
173
|
+
"""
|
|
174
|
+
self._maybe_migrate_legacy(ctx_id)
|
|
175
|
+
path = self._path(ctx_id)
|
|
176
|
+
if not path.exists():
|
|
177
|
+
raise ContextNotFoundError(ctx_id)
|
|
178
|
+
|
|
179
|
+
lines = [e.model_dump_json() + "\n" for e in entries]
|
|
180
|
+
if not lines:
|
|
181
|
+
return
|
|
182
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
183
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
184
|
+
try:
|
|
185
|
+
f.writelines(lines)
|
|
186
|
+
f.flush()
|
|
187
|
+
finally:
|
|
188
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
189
|
+
|
|
190
|
+
def read_entries(self, ctx_id: str) -> list[dict[str, Any]]:
|
|
191
|
+
"""Return every parsed journal entry for a context in insertion order.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
ContextNotFoundError: If no journal file exists for the context.
|
|
195
|
+
"""
|
|
196
|
+
self._maybe_migrate_legacy(ctx_id)
|
|
197
|
+
path = self._path(ctx_id)
|
|
198
|
+
if not path.exists():
|
|
199
|
+
raise ContextNotFoundError(ctx_id)
|
|
200
|
+
return _read_jsonl(path)
|
|
201
|
+
|
|
202
|
+
def replay(self, ctx_id: str) -> list[dict[str, Any]]:
|
|
203
|
+
"""Replay the journal from the last compaction marker as LLM messages.
|
|
204
|
+
|
|
205
|
+
Audit markers (``task_dispatch``, ``task_cancel``, ``task_fail``,
|
|
206
|
+
``merge_start``, ``merge_commit``) are stripped unconditionally —
|
|
207
|
+
they carry no conversational content and must never reach the LLM.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
ctx_id: The context identifier.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
List of Anthropic-format message dicts from the latest compaction
|
|
214
|
+
onwards. Returns an empty list if the journal is empty.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
ContextNotFoundError: If no journal file exists for the context.
|
|
218
|
+
"""
|
|
219
|
+
parsed = self.read_entries(ctx_id)
|
|
220
|
+
if not parsed:
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
cursor = 0
|
|
224
|
+
for i in range(len(parsed) - 1, -1, -1):
|
|
225
|
+
if parsed[i]["t"] == "compact":
|
|
226
|
+
cursor = i
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
messages: list[dict[str, Any]] = []
|
|
230
|
+
for entry in parsed[cursor:]:
|
|
231
|
+
t = entry["t"]
|
|
232
|
+
if t in AUDIT_MARKER_TYPES:
|
|
233
|
+
continue
|
|
234
|
+
if t == "compact":
|
|
235
|
+
messages.append({
|
|
236
|
+
"role": "assistant",
|
|
237
|
+
"content": entry["content"],
|
|
238
|
+
})
|
|
239
|
+
elif t == "msg":
|
|
240
|
+
messages.append({
|
|
241
|
+
"role": entry["role"],
|
|
242
|
+
"content": entry["content"],
|
|
243
|
+
})
|
|
244
|
+
return messages
|
|
245
|
+
|
|
246
|
+
def iter_context_ids(self) -> list[str]:
|
|
247
|
+
"""Return all context IDs under the data directory.
|
|
248
|
+
|
|
249
|
+
Includes both the new layout (``{ctx_id}/journal.jsonl``) and legacy
|
|
250
|
+
flat files (``{ctx_id}.jsonl``). Legacy hits are migrated to the new
|
|
251
|
+
layout as a side effect.
|
|
252
|
+
"""
|
|
253
|
+
ids: set[str] = set()
|
|
254
|
+
for path in self._data_dir.glob("*/journal.jsonl"):
|
|
255
|
+
ids.add(path.parent.name)
|
|
256
|
+
for path in self._data_dir.glob("*.jsonl"):
|
|
257
|
+
ctx_id = path.stem
|
|
258
|
+
# Migrate lazily so the next access uses the new layout.
|
|
259
|
+
self._maybe_migrate_legacy(ctx_id)
|
|
260
|
+
ids.add(ctx_id)
|
|
261
|
+
return sorted(ids)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class TaskJournal:
|
|
265
|
+
"""Append-only JSONL sub-journal for a single task's execution trace.
|
|
266
|
+
|
|
267
|
+
Sub-journals are task-local (only the worker writes; the engine tail-reads).
|
|
268
|
+
fcntl locking protects concurrent readers from torn writes.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
def __init__(self, path: Path) -> None:
|
|
272
|
+
self._path = path
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def path(self) -> Path:
|
|
276
|
+
"""Filesystem path of this sub-journal."""
|
|
277
|
+
return self._path
|
|
278
|
+
|
|
279
|
+
def exists(self) -> bool:
|
|
280
|
+
"""Whether the sub-journal file exists."""
|
|
281
|
+
return self._path.exists()
|
|
282
|
+
|
|
283
|
+
def create(self) -> None:
|
|
284
|
+
"""Create the sub-journal's parent directory and the (empty) file."""
|
|
285
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
self._path.touch(exist_ok=True)
|
|
287
|
+
|
|
288
|
+
def append(self, entry: Any) -> None:
|
|
289
|
+
"""Append an entry (any Pydantic ``BaseModel`` with ``model_dump_json``)."""
|
|
290
|
+
if not self._path.exists():
|
|
291
|
+
self.create()
|
|
292
|
+
_append_jsonl(self._path, entry.model_dump_json() + "\n")
|
|
293
|
+
|
|
294
|
+
def read_entries(self) -> list[dict[str, Any]]:
|
|
295
|
+
"""Return all parsed entries in insertion order."""
|
|
296
|
+
return _read_jsonl(self._path)
|
|
297
|
+
|
|
298
|
+
def tail(self) -> dict[str, Any] | None:
|
|
299
|
+
"""Return the last entry as a dict, or ``None`` if empty."""
|
|
300
|
+
entries = self.read_entries()
|
|
301
|
+
return entries[-1] if entries else None
|
|
302
|
+
|
|
303
|
+
def latest_compaction(self) -> dict[str, Any] | None:
|
|
304
|
+
"""Return the most recent compaction entry as a dict, or ``None``."""
|
|
305
|
+
entries = self.read_entries()
|
|
306
|
+
for entry in reversed(entries):
|
|
307
|
+
if entry.get("t") == "compact":
|
|
308
|
+
return entry
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
def terminal_entry(self) -> dict[str, Any] | None:
|
|
312
|
+
"""Return a terminal entry (done/fail/cancel) if the task is done."""
|
|
313
|
+
from agentlings.core.models import TASK_TERMINAL_TYPES
|
|
314
|
+
entries = self.read_entries()
|
|
315
|
+
for entry in reversed(entries):
|
|
316
|
+
if entry.get("t") in TASK_TERMINAL_TYPES:
|
|
317
|
+
return entry
|
|
318
|
+
return None
|