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,393 @@
1
+ """Nightly sleep cycle: journal, consolidate memory, and clean up."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from datetime import datetime, timedelta, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from agentlings.config import AgentConfig, SleepConfig
15
+ from agentlings.core.llm import BaseLLMClient, BatchRequest
16
+ from agentlings.core.memory_models import (
17
+ ConsolidatedMemory,
18
+ ConversationSummary,
19
+ MemoryCandidate,
20
+ strict_json_schema,
21
+ )
22
+ from agentlings.core.telemetry import sleep_span
23
+ from agentlings.core.memory_store import MemoryFileStore
24
+ from agentlings.core.prompt import build_system_prompt
25
+ from agentlings.core.store import JournalStore
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ IDLE_GRACE_SECONDS = 300
30
+
31
+ DEFAULT_SUMMARY_PROMPT = """\
32
+ You are performing a nightly review of a conversation that took place today.
33
+
34
+ Produce a concise summary of what happened: what was asked, what actions were \
35
+ taken, what the outcome was, and anything left unresolved.
36
+
37
+ Extract any facts worth adding to your long-term memory. Only extract NEW facts \
38
+ not already in your current memory. Focus on operational knowledge, patterns, \
39
+ decisions, things that changed. Ignore passing context.
40
+
41
+ If the conversation was trivial or contained nothing new worth remembering, \
42
+ return an empty memory_candidates list."""
43
+
44
+ DEFAULT_CONSOLIDATION_PROMPT = """\
45
+ You are performing nightly memory maintenance.
46
+
47
+ Your job:
48
+ 1. Integrate new candidates that add genuine value. Deduplicate against existing entries.
49
+ 2. Review every existing entry. Is it still relevant? Has it been superseded by \
50
+ something learned today? Would it help you do your job tomorrow?
51
+ 3. Drop anything that is stale, redundant, or no longer operationally useful.
52
+ 4. You have a hard limit of {memory_max_entries} entries.
53
+
54
+ Preserve the recorded timestamp for entries you keep unchanged.
55
+ Set recorded to the current date for new or modified entries."""
56
+
57
+
58
+ class SleepCycle:
59
+ """Orchestrates the four-phase nightly sleep cycle."""
60
+
61
+ def __init__(
62
+ self,
63
+ config: AgentConfig,
64
+ llm: BaseLLMClient,
65
+ memory_store: MemoryFileStore,
66
+ store: JournalStore,
67
+ ) -> None:
68
+ self._config = config
69
+ self._llm = llm
70
+ self._memory_store = memory_store
71
+ self._store = store
72
+ self._sleep_config = config.sleep_config or SleepConfig()
73
+
74
+ async def run(self, date: datetime | None = None) -> None:
75
+ """Execute the full sleep cycle reviewing the previous day's conversations.
76
+
77
+ Args:
78
+ date: Reference timestamp (defaults to now UTC). The cycle reviews
79
+ conversations from the day before this timestamp.
80
+ """
81
+ date = date or datetime.now(timezone.utc)
82
+ review_date = date - timedelta(days=1)
83
+ date_str = review_date.strftime("%Y-%m-%d")
84
+ start = time.monotonic()
85
+ logger.info("[SLEEP] Starting cycle for %s", date_str)
86
+
87
+ with sleep_span("agentling.sleep", {"agent.name": self._config.agent_name, "sleep.date": date_str}) as root:
88
+ with sleep_span("agentling.sleep.light_sleep", {"sleep.phase": "light_sleep", "sleep.date": date_str}) as ls:
89
+ context_ids = self._light_sleep(date)
90
+ ls.set_attribute("sleep.conversations_found", len(context_ids))
91
+ if not context_ids:
92
+ ls.set_attribute("sleep.skipped", True)
93
+ logger.info("[SLEEP:LIGHT] No conversations found, skipping cycle")
94
+ return
95
+
96
+ logger.info("[SLEEP:LIGHT] Found %d conversations, proceeding", len(context_ids))
97
+
98
+ with sleep_span("agentling.sleep.deep_sleep", {"sleep.phase": "deep_sleep", "sleep.date": date_str}):
99
+ summaries, candidates = await self._deep_sleep(context_ids, date_str)
100
+
101
+ if summaries:
102
+ with sleep_span("agentling.sleep.rem", {"sleep.phase": "rem", "sleep.date": date_str}):
103
+ await self._rem(summaries, candidates, date_str)
104
+
105
+ with sleep_span("agentling.sleep.housekeeping", {"sleep.phase": "housekeeping", "sleep.date": date_str}):
106
+ self._housekeeping(date)
107
+
108
+ elapsed = time.monotonic() - start
109
+ logger.info("[SLEEP] Cycle complete in %.1fs", elapsed)
110
+
111
+ def _light_sleep(self, date: datetime) -> list[str]:
112
+ """Phase 1: Discover conversations from the previous 24 hours.
113
+
114
+ Returns context IDs whose parent journal was modified since yesterday's
115
+ midnight and has been idle long enough to be safe to process. Supports
116
+ both the new per-context directory layout and legacy flat ``*.jsonl``
117
+ files; the store's ``iter_context_ids`` also migrates legacy files as a
118
+ side effect.
119
+ """
120
+ data_dir = self._config.agent_data_dir
121
+ cutoff_start = date.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
122
+ grace_cutoff = datetime.now(timezone.utc) - timedelta(seconds=IDLE_GRACE_SECONDS)
123
+
124
+ context_ids: list[str] = []
125
+ for ctx_id in self._store.iter_context_ids():
126
+ path = self._store._path(ctx_id) # noqa: SLF001 — internal path helper
127
+ if not path.exists():
128
+ continue
129
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
130
+ if cutoff_start <= mtime <= grace_cutoff:
131
+ context_ids.append(ctx_id)
132
+
133
+ return context_ids
134
+
135
+ async def _deep_sleep(
136
+ self,
137
+ context_ids: list[str],
138
+ date_str: str,
139
+ ) -> tuple[list[str], list[MemoryCandidate]]:
140
+ """Phase 2: Replay conversations, submit batch summaries, write journal."""
141
+ system = build_system_prompt(self._config)
142
+ memory = self._memory_store.load()
143
+ memory_text = "\n".join(f"- {e.key}: {e.value}" for e in memory.entries)
144
+ sleep_model = self._sleep_config.model
145
+
146
+ summary_prompt = self._sleep_config.summary_prompt or DEFAULT_SUMMARY_PROMPT
147
+
148
+ batch_requests: list[BatchRequest] = []
149
+ for ctx_id in context_ids:
150
+ messages_data = self._store.replay(ctx_id)
151
+ if not messages_data:
152
+ continue
153
+
154
+ conversation_text = self._format_conversation(messages_data)
155
+ user_content = (
156
+ f"{summary_prompt}\n\n"
157
+ f"Current memory:\n{memory_text}\n\n"
158
+ f"Conversation:\n{conversation_text}"
159
+ )
160
+
161
+ batch_requests.append(BatchRequest(
162
+ custom_id=ctx_id,
163
+ system=system,
164
+ messages=[{"role": "user", "content": user_content}],
165
+ max_tokens=4096,
166
+ output_schema=strict_json_schema(ConversationSummary),
167
+ ))
168
+
169
+ if not batch_requests:
170
+ return [], []
171
+
172
+ logger.info("[SLEEP:DEEP] Submitting batch of %d summary requests", len(batch_requests))
173
+
174
+ batch_ids = await self._llm.batch_create(batch_requests, model=sleep_model)
175
+
176
+ all_results = []
177
+ for batch_id in batch_ids:
178
+ results = await self._poll_batch(batch_id)
179
+ all_results.extend(results)
180
+
181
+ summaries: list[str] = []
182
+ all_candidates: list[MemoryCandidate] = []
183
+ succeeded = 0
184
+ failed = 0
185
+
186
+ for item in all_results:
187
+ if item.status == "failed":
188
+ failed += 1
189
+ logger.warning("[SLEEP:DEEP] Failed: %s — %s", item.custom_id, item.error)
190
+ continue
191
+
192
+ succeeded += 1
193
+ text = self._extract_structured_text(item.content)
194
+ try:
195
+ parsed = ConversationSummary.model_validate_json(text)
196
+ summaries.append(f"### {item.custom_id}\n{parsed.summary}")
197
+ all_candidates.extend(parsed.memory_candidates)
198
+ except (json.JSONDecodeError, ValueError) as e:
199
+ logger.warning("[SLEEP:DEEP] Parse error for %s: %s", item.custom_id, e)
200
+ summaries.append(f"### {item.custom_id}\n{text}")
201
+
202
+ logger.info(
203
+ "[SLEEP:DEEP] Batch completed: %d succeeded, %d failed",
204
+ succeeded, failed,
205
+ )
206
+
207
+ journal_content = f"# Journal — {date_str}\n\n" + "\n\n".join(summaries)
208
+ self._write_journal(date_str, journal_content)
209
+
210
+ if all_candidates:
211
+ logger.info("[SLEEP:DEEP] Extracted %d memory candidates", len(all_candidates))
212
+
213
+ return summaries, all_candidates
214
+
215
+ async def _rem(
216
+ self,
217
+ summaries: list[str],
218
+ candidates: list[MemoryCandidate],
219
+ date_str: str,
220
+ ) -> None:
221
+ """Phase 3: Consolidate memory with today's learnings."""
222
+ memory = self._memory_store.load()
223
+ memory_text = "\n".join(
224
+ f"- {e.key}: {e.value} (recorded: {e.recorded.isoformat()})"
225
+ for e in memory.entries
226
+ )
227
+ journal_text = "\n\n".join(summaries)
228
+ candidates_text = "\n".join(
229
+ f"- {c.key}: {c.value}" for c in candidates
230
+ ) if candidates else "(none)"
231
+
232
+ consolidation_prompt = (
233
+ self._sleep_config.consolidation_prompt or DEFAULT_CONSOLIDATION_PROMPT
234
+ ).format(memory_max_entries=self._sleep_config.memory_max_entries)
235
+
236
+ entries_before = len(memory.entries)
237
+ logger.info(
238
+ "[SLEEP:REM] Consolidating memory: %d existing + %d candidates",
239
+ entries_before, len(candidates),
240
+ )
241
+
242
+ system = build_system_prompt(self._config)
243
+ user_content = (
244
+ f"{consolidation_prompt}\n\n"
245
+ f"Current memory:\n{memory_text}\n\n"
246
+ f"Today's journal:\n{journal_text}\n\n"
247
+ f"New candidates:\n{candidates_text}"
248
+ )
249
+
250
+ response = await self._llm.complete(
251
+ system=system,
252
+ messages=[{"role": "user", "content": user_content}],
253
+ tools=[],
254
+ output_schema=strict_json_schema(ConsolidatedMemory),
255
+ )
256
+
257
+ text = self._extract_structured_text(response.content)
258
+ try:
259
+ consolidated = ConsolidatedMemory.model_validate_json(text)
260
+ from agentlings.core.memory_models import MemoryStore
261
+ new_store = MemoryStore(entries=consolidated.entries)
262
+ self._memory_store.save(new_store)
263
+
264
+ entries_after = len(consolidated.entries)
265
+ logger.info(
266
+ "[SLEEP:REM] Memory updated: %d entries (%+d)",
267
+ entries_after, entries_after - entries_before,
268
+ )
269
+ except (json.JSONDecodeError, ValueError) as e:
270
+ logger.error("[SLEEP:REM] Failed to parse consolidated memory: %s", e)
271
+
272
+ def _housekeeping(self, date: datetime) -> None:
273
+ """Phase 4: Delete old conversation and journal files."""
274
+ import shutil
275
+
276
+ data_dir = self._config.agent_data_dir
277
+ journals_dir = data_dir / "journals"
278
+
279
+ conv_cutoff = date - timedelta(days=self._sleep_config.conversation_retention_days)
280
+ journal_cutoff = date - timedelta(days=self._sleep_config.journal_retention_days)
281
+
282
+ conv_deleted = 0
283
+ bytes_reclaimed = 0
284
+
285
+ # New layout: delete the whole context directory if idle past retention.
286
+ for ctx_id in self._store.iter_context_ids():
287
+ ctx_dir = self._store.context_dir(ctx_id)
288
+ parent = self._store._path(ctx_id) # noqa: SLF001
289
+ if parent.exists():
290
+ mtime = datetime.fromtimestamp(parent.stat().st_mtime, tz=timezone.utc)
291
+ if mtime < conv_cutoff:
292
+ size = sum(p.stat().st_size for p in ctx_dir.rglob("*") if p.is_file())
293
+ if ctx_dir.exists():
294
+ shutil.rmtree(ctx_dir)
295
+ conv_deleted += 1
296
+ bytes_reclaimed += size
297
+
298
+ # Legacy flat files that iter_context_ids may have missed because they
299
+ # were never migrated (e.g. context dir still absent).
300
+ for path in data_dir.glob("*.jsonl"):
301
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
302
+ if mtime < conv_cutoff:
303
+ size = path.stat().st_size
304
+ path.unlink()
305
+ conv_deleted += 1
306
+ bytes_reclaimed += size
307
+
308
+ journal_deleted = 0
309
+ if journals_dir.exists():
310
+ for path in journals_dir.glob("*.md"):
311
+ try:
312
+ file_date = datetime.strptime(path.stem, "%Y-%m-%d").replace(
313
+ tzinfo=timezone.utc
314
+ )
315
+ if file_date < journal_cutoff:
316
+ size = path.stat().st_size
317
+ path.unlink()
318
+ journal_deleted += 1
319
+ bytes_reclaimed += size
320
+ except ValueError:
321
+ continue
322
+
323
+ if conv_deleted or journal_deleted:
324
+ logger.info(
325
+ "[SLEEP:HOUSEKEEPING] Deleted %d conversations, %d journals, reclaimed %dKB",
326
+ conv_deleted, journal_deleted, bytes_reclaimed // 1024,
327
+ )
328
+
329
+ async def _poll_batch(
330
+ self,
331
+ batch_id: str,
332
+ timeout: float = 7200,
333
+ initial_interval: float = 5,
334
+ max_interval: float = 60,
335
+ ) -> list[Any]:
336
+ """Poll a batch until completion or timeout, using exponential backoff."""
337
+ deadline = time.monotonic() + timeout
338
+ interval = initial_interval
339
+ while time.monotonic() < deadline:
340
+ status = await self._llm.batch_status(batch_id)
341
+ if status.processing_status == "ended":
342
+ return await self._llm.batch_results(batch_id)
343
+ logger.debug(
344
+ "[SLEEP:DEEP] Batch %s: %s (next poll in %.0fs)",
345
+ batch_id, status.processing_status, interval,
346
+ )
347
+ await asyncio.sleep(interval)
348
+ interval = min(interval * 2, max_interval)
349
+
350
+ logger.warning("[SLEEP:DEEP] Batch %s timed out after %.0fs", batch_id, timeout)
351
+ try:
352
+ return await self._llm.batch_results(batch_id)
353
+ except Exception:
354
+ return []
355
+
356
+ def _write_journal(self, date_str: str, content: str) -> None:
357
+ """Write the daily journal to the journals directory."""
358
+ journals_dir = self._config.agent_data_dir / "journals"
359
+ journals_dir.mkdir(parents=True, exist_ok=True)
360
+ path = journals_dir / f"{date_str}.md"
361
+ path.write_text(content, encoding="utf-8")
362
+ logger.info("[SLEEP:DEEP] Journal written: %s", path)
363
+
364
+ @staticmethod
365
+ def _format_conversation(messages: list[dict[str, Any]]) -> str:
366
+ """Format replayed messages into readable text for the summary prompt."""
367
+ lines = []
368
+ for msg in messages:
369
+ role = msg.get("role", "unknown")
370
+ content = msg.get("content", "")
371
+ if isinstance(content, str):
372
+ lines.append(f"{role}: {content}")
373
+ elif isinstance(content, list):
374
+ parts = []
375
+ for block in content:
376
+ if isinstance(block, dict):
377
+ if block.get("type") == "text":
378
+ parts.append(block.get("text", ""))
379
+ elif block.get("type") == "tool_use":
380
+ parts.append(f"[tool: {block.get('name', '?')}]")
381
+ elif block.get("type") == "tool_result":
382
+ parts.append(f"[result: {str(block.get('content', ''))[:200]}]")
383
+ if parts:
384
+ lines.append(f"{role}: {' '.join(parts)}")
385
+ return "\n".join(lines)
386
+
387
+ @staticmethod
388
+ def _extract_structured_text(content: list[dict[str, Any]]) -> str:
389
+ """Extract text from content blocks (structured output comes as text blocks)."""
390
+ for block in content:
391
+ if block.get("type") == "text":
392
+ return block.get("text", "")
393
+ return ""