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.
Files changed (43) hide show
  1. agentlings/__init__.py +3 -0
  2. agentlings/__main__.py +238 -0
  3. agentlings/cli/__init__.py +1 -0
  4. agentlings/cli/_migrations.py +78 -0
  5. agentlings/cli/_templates.py +57 -0
  6. agentlings/cli/_version.py +33 -0
  7. agentlings/cli/init.py +122 -0
  8. agentlings/cli/upgrade.py +89 -0
  9. agentlings/config.py +260 -0
  10. agentlings/core/__init__.py +1 -0
  11. agentlings/core/completion.py +219 -0
  12. agentlings/core/llm.py +509 -0
  13. agentlings/core/loop.py +134 -0
  14. agentlings/core/memory_models.py +97 -0
  15. agentlings/core/memory_store.py +109 -0
  16. agentlings/core/models.py +231 -0
  17. agentlings/core/prompt.py +122 -0
  18. agentlings/core/scheduler.py +141 -0
  19. agentlings/core/sleep.py +393 -0
  20. agentlings/core/store.py +318 -0
  21. agentlings/core/task.py +1087 -0
  22. agentlings/core/telemetry.py +181 -0
  23. agentlings/log.py +23 -0
  24. agentlings/migrations/__init__.py +37 -0
  25. agentlings/migrations/m0001_seed.py +17 -0
  26. agentlings/protocol/__init__.py +1 -0
  27. agentlings/protocol/a2a.py +220 -0
  28. agentlings/protocol/a2a_task_store.py +150 -0
  29. agentlings/protocol/agent_card.py +83 -0
  30. agentlings/protocol/mcp.py +232 -0
  31. agentlings/server.py +247 -0
  32. agentlings/templates/__init__.py +1 -0
  33. agentlings/templates/default/.env.example +16 -0
  34. agentlings/templates/default/agent.yaml +14 -0
  35. agentlings/tools/__init__.py +1 -0
  36. agentlings/tools/builtins.py +307 -0
  37. agentlings/tools/memory.py +104 -0
  38. agentlings/tools/registry.py +154 -0
  39. agentlings-0.2.0.dist-info/METADATA +406 -0
  40. agentlings-0.2.0.dist-info/RECORD +43 -0
  41. agentlings-0.2.0.dist-info/WHEEL +4 -0
  42. agentlings-0.2.0.dist-info/entry_points.txt +2 -0
  43. agentlings-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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