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,97 @@
1
+ """Pydantic models for memory persistence and sleep cycle structured outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ def strict_json_schema(model: type[BaseModel]) -> dict[str, Any]:
13
+ """Generate a JSON schema with ``additionalProperties: false`` on all objects.
14
+
15
+ The Anthropic structured outputs API requires this. Pydantic's default
16
+ ``model_json_schema()`` does not set it.
17
+
18
+ Args:
19
+ model: The Pydantic model class.
20
+
21
+ Returns:
22
+ A JSON Schema dict suitable for ``output_config.format.schema``.
23
+ """
24
+ schema = deepcopy(model.model_json_schema())
25
+ _patch_additional_properties(schema)
26
+
27
+ if "$defs" in schema:
28
+ for defn in schema["$defs"].values():
29
+ _patch_additional_properties(defn)
30
+
31
+ return schema
32
+
33
+
34
+ def _patch_additional_properties(obj: dict[str, Any]) -> None:
35
+ if obj.get("type") == "object" or "properties" in obj:
36
+ obj["additionalProperties"] = False
37
+
38
+
39
+ class MemoryEntry(BaseModel):
40
+ """A single fact in the agent's long-term memory.
41
+
42
+ Attributes:
43
+ key: Unique identifier for this memory entry (slug-style).
44
+ value: The knowledge or fact to remember.
45
+ recorded: When this entry was created or last updated.
46
+ """
47
+
48
+ key: str
49
+ value: str
50
+ recorded: datetime
51
+
52
+
53
+ class MemoryStore(BaseModel):
54
+ """On-disk format for the agent's memory file (``memory.json``).
55
+
56
+ Attributes:
57
+ entries: All current memory entries.
58
+ """
59
+
60
+ entries: list[MemoryEntry] = Field(default_factory=list)
61
+
62
+
63
+ class MemoryCandidate(BaseModel):
64
+ """A potential new memory entry extracted during sleep.
65
+
66
+ Attributes:
67
+ key: Proposed key for the memory entry.
68
+ value: The candidate fact.
69
+ """
70
+
71
+ key: str
72
+ value: str
73
+
74
+
75
+ class ConversationSummary(BaseModel):
76
+ """Structured output from the deep sleep per-conversation summary call.
77
+
78
+ Attributes:
79
+ summary: Concise narrative of what happened in the conversation.
80
+ memory_candidates: New facts worth adding to long-term memory.
81
+ """
82
+
83
+ summary: str
84
+ memory_candidates: list[MemoryCandidate] = Field(default_factory=list)
85
+
86
+
87
+ class ConsolidatedMemory(BaseModel):
88
+ """Structured output from the REM consolidation call.
89
+
90
+ The LLM integrates new candidates with existing memory, prunes stale
91
+ entries, and returns the complete updated memory store.
92
+
93
+ Attributes:
94
+ entries: The full set of memory entries after consolidation.
95
+ """
96
+
97
+ entries: list[MemoryEntry] = Field(default_factory=list)
@@ -0,0 +1,109 @@
1
+ """File-backed memory store with atomic writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import tempfile
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from agentlings.core.memory_models import MemoryEntry, MemoryStore
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class MemoryFileStore:
17
+ """Manages the agent's persistent memory on disk.
18
+
19
+ Memory lives at ``{data_dir}/memory/memory.json``. Reads and writes
20
+ go through Pydantic models. Writes are atomic (temp file + rename).
21
+
22
+ ``load()`` caches the parsed store keyed by the file's mtime; repeated
23
+ reads against an unchanged file avoid the JSON parse cost. Any write
24
+ through ``save()`` resets the cache on the next read.
25
+ """
26
+
27
+ def __init__(self, data_dir: Path) -> None:
28
+ self._dir = data_dir / "memory"
29
+ self._dir.mkdir(parents=True, exist_ok=True)
30
+ self._path = self._dir / "memory.json"
31
+ self._cache: MemoryStore | None = None
32
+ self._cache_mtime_ns: int | None = None
33
+
34
+ @property
35
+ def path(self) -> Path:
36
+ """Path to the memory file."""
37
+ return self._path
38
+
39
+ def load(self) -> MemoryStore:
40
+ """Load memory from disk, returning an empty store if the file is missing.
41
+
42
+ Uses the file's mtime as a cache key so callers under heavy traffic
43
+ don't re-parse the same JSON on every call.
44
+ """
45
+ if not self._path.exists():
46
+ self._cache = None
47
+ self._cache_mtime_ns = None
48
+ return MemoryStore()
49
+
50
+ mtime_ns = self._path.stat().st_mtime_ns
51
+ if self._cache is not None and self._cache_mtime_ns == mtime_ns:
52
+ return self._cache.model_copy(deep=True)
53
+
54
+ try:
55
+ data = json.loads(self._path.read_text(encoding="utf-8"))
56
+ store = MemoryStore.model_validate(data)
57
+ except (json.JSONDecodeError, ValueError):
58
+ logger.warning("corrupt memory file at %s, starting fresh", self._path)
59
+ store = MemoryStore()
60
+
61
+ self._cache = store.model_copy(deep=True)
62
+ self._cache_mtime_ns = mtime_ns
63
+ return store
64
+
65
+ def save(self, store: MemoryStore) -> None:
66
+ """Atomically write the memory store to disk."""
67
+ import os
68
+
69
+ content = store.model_dump_json(indent=2)
70
+ fd, tmp_path = tempfile.mkstemp(
71
+ dir=self._dir, prefix=".memory_", suffix=".tmp"
72
+ )
73
+ os.close(fd)
74
+ try:
75
+ Path(tmp_path).write_text(content, encoding="utf-8")
76
+ os.replace(tmp_path, self._path)
77
+ except BaseException:
78
+ Path(tmp_path).unlink(missing_ok=True)
79
+ raise
80
+ # Invalidate the cache so the next load() re-reads with the new mtime.
81
+ self._cache = None
82
+ self._cache_mtime_ns = None
83
+ logger.debug("memory saved: %d entries", len(store.entries))
84
+
85
+ def set(self, key: str, value: str) -> MemoryStore:
86
+ """Upsert a memory entry by key. Returns the updated store."""
87
+ store = self.load()
88
+ now = datetime.now(timezone.utc)
89
+ for entry in store.entries:
90
+ if entry.key == key:
91
+ entry.value = value
92
+ entry.recorded = now
93
+ self.save(store)
94
+ return store
95
+
96
+ store.entries.append(MemoryEntry(key=key, value=value, recorded=now))
97
+ self.save(store)
98
+ return store
99
+
100
+ def remove(self, key: str) -> MemoryStore:
101
+ """Remove a memory entry by key. Returns the updated store."""
102
+ store = self.load()
103
+ store.entries = [e for e in store.entries if e.key != key]
104
+ self.save(store)
105
+ return store
106
+
107
+ def list(self) -> list[MemoryEntry]:
108
+ """Return all current memory entries."""
109
+ return self.load().entries
@@ -0,0 +1,231 @@
1
+ """JSONL journal entry types for conversation persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Annotated, Any, Literal
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ def _now_iso() -> str:
12
+ return datetime.now(timezone.utc).isoformat()
13
+
14
+
15
+ class MessageEntry(BaseModel):
16
+ """A user or assistant message recorded in the JSONL journal.
17
+
18
+ Attributes:
19
+ t: Discriminator field, always ``"msg"``.
20
+ ts: ISO 8601 timestamp of when the entry was created.
21
+ ctx: Context ID this entry belongs to.
22
+ role: Whether this is a ``"user"`` or ``"assistant"`` message.
23
+ content: Anthropic-format content blocks.
24
+ via: Protocol that originated the message (``"a2a"`` or ``"mcp"``).
25
+ task_id: Task ID this message was handled under. ``None`` for pre-task-era
26
+ journals or for sub-journal entries where the task context is implicit.
27
+ """
28
+
29
+ t: Literal["msg"] = "msg"
30
+ ts: str = Field(default_factory=_now_iso)
31
+ ctx: str
32
+ role: Literal["user", "assistant"]
33
+ content: list[dict[str, Any]]
34
+ via: Literal["a2a", "mcp"] = "a2a"
35
+ task_id: str | None = None
36
+
37
+
38
+ class CompactionEntry(BaseModel):
39
+ """A compaction marker in the JSONL journal.
40
+
41
+ When the Anthropic API compacts a conversation, the summary is stored
42
+ as a compaction entry. On replay, all entries before the last compaction
43
+ marker are skipped.
44
+
45
+ Attributes:
46
+ t: Discriminator field, always ``"compact"``.
47
+ ts: ISO 8601 timestamp of when compaction occurred.
48
+ ctx: Context ID this entry belongs to.
49
+ content: The compacted summary text.
50
+ """
51
+
52
+ t: Literal["compact"] = "compact"
53
+ ts: str = Field(default_factory=_now_iso)
54
+ ctx: str
55
+ content: str
56
+
57
+
58
+ class TaskStarted(BaseModel):
59
+ """First entry of a task sub-journal recording the request that spawned it.
60
+
61
+ Attributes:
62
+ t: Discriminator field, always ``"task_start"``.
63
+ ts: ISO 8601 timestamp of when the task began.
64
+ ctx: Parent context ID.
65
+ task_id: Unique task identifier.
66
+ message: The original user message text.
67
+ parent_cursor: Byte offset into the parent journal at which the task
68
+ snapshot was taken, used for audit and reproducibility.
69
+ via: Protocol that originated the request.
70
+ """
71
+
72
+ t: Literal["task_start"] = "task_start"
73
+ ts: str = Field(default_factory=_now_iso)
74
+ ctx: str
75
+ task_id: str
76
+ message: str
77
+ parent_cursor: int = 0
78
+ via: Literal["a2a", "mcp"] = "a2a"
79
+
80
+
81
+ class TaskCompleted(BaseModel):
82
+ """Terminal sub-journal entry marking successful task completion.
83
+
84
+ Attributes:
85
+ t: Discriminator field, always ``"task_done"``.
86
+ ts: ISO 8601 timestamp of when the task completed.
87
+ ctx: Parent context ID.
88
+ task_id: Task identifier.
89
+ final_response: Anthropic-format content blocks from the final assistant turn.
90
+ """
91
+
92
+ t: Literal["task_done"] = "task_done"
93
+ ts: str = Field(default_factory=_now_iso)
94
+ ctx: str
95
+ task_id: str
96
+ final_response: list[dict[str, Any]]
97
+
98
+
99
+ class TaskFailed(BaseModel):
100
+ """Marker for a task that terminated with a failure.
101
+
102
+ Appended to both the task sub-journal (terminal state) and the parent
103
+ context journal (audit marker, ignored by replay).
104
+
105
+ Attributes:
106
+ t: Discriminator field, always ``"task_fail"``.
107
+ ts: ISO 8601 timestamp of when the failure was recorded.
108
+ ctx: Parent context ID.
109
+ task_id: Task identifier.
110
+ reason: Short reason code (e.g. ``"process_crash_recovery"``).
111
+ error_details: Optional longer human-readable error text.
112
+ """
113
+
114
+ t: Literal["task_fail"] = "task_fail"
115
+ ts: str = Field(default_factory=_now_iso)
116
+ ctx: str
117
+ task_id: str
118
+ reason: str
119
+ error_details: str | None = None
120
+
121
+
122
+ class TaskCancelled(BaseModel):
123
+ """Marker for a task that was cancelled before completion.
124
+
125
+ Appended to both the task sub-journal (terminal state) and the parent
126
+ context journal (audit marker, ignored by replay).
127
+
128
+ Attributes:
129
+ t: Discriminator field, always ``"task_cancel"``.
130
+ ts: ISO 8601 timestamp of when cancellation was recorded.
131
+ ctx: Parent context ID.
132
+ task_id: Task identifier.
133
+ reason: Short reason code or human-readable text.
134
+ """
135
+
136
+ t: Literal["task_cancel"] = "task_cancel"
137
+ ts: str = Field(default_factory=_now_iso)
138
+ ctx: str
139
+ task_id: str
140
+ reason: str
141
+
142
+
143
+ class TaskDispatched(BaseModel):
144
+ """Audit marker appended to the parent journal at task ingress.
145
+
146
+ Ignored by replay. Provides a durable record that a task was dispatched
147
+ against this context, correlating with the sub-journal even if the task
148
+ later fails or is cancelled.
149
+
150
+ Attributes:
151
+ t: Discriminator field, always ``"task_dispatch"``.
152
+ ts: ISO 8601 timestamp of ingress.
153
+ ctx: Parent context ID.
154
+ task_id: Task identifier.
155
+ """
156
+
157
+ t: Literal["task_dispatch"] = "task_dispatch"
158
+ ts: str = Field(default_factory=_now_iso)
159
+ ctx: str
160
+ task_id: str
161
+
162
+
163
+ class MergeStarted(BaseModel):
164
+ """Wrapper marker opening an atomic merge-back transaction in the parent journal.
165
+
166
+ Paired with ``MergeCommitted`` to let startup crash-recovery detect
167
+ partially-applied merge-backs and complete them idempotently.
168
+
169
+ Attributes:
170
+ t: Discriminator field, always ``"merge_start"``.
171
+ ts: ISO 8601 timestamp.
172
+ ctx: Parent context ID.
173
+ task_id: Task whose merge-back is beginning.
174
+ """
175
+
176
+ t: Literal["merge_start"] = "merge_start"
177
+ ts: str = Field(default_factory=_now_iso)
178
+ ctx: str
179
+ task_id: str
180
+
181
+
182
+ class MergeCommitted(BaseModel):
183
+ """Wrapper marker closing an atomic merge-back transaction in the parent journal.
184
+
185
+ Presence of this entry without a prior ``MergeStarted`` for the same
186
+ ``task_id`` is legal (idempotent repair). Absence of this entry after
187
+ a ``MergeStarted`` signals an incomplete merge-back to be repaired on startup.
188
+
189
+ Attributes:
190
+ t: Discriminator field, always ``"merge_commit"``.
191
+ ts: ISO 8601 timestamp.
192
+ ctx: Parent context ID.
193
+ task_id: Task whose merge-back has committed.
194
+ """
195
+
196
+ t: Literal["merge_commit"] = "merge_commit"
197
+ ts: str = Field(default_factory=_now_iso)
198
+ ctx: str
199
+ task_id: str
200
+
201
+
202
+ JournalEntry = Annotated[
203
+ MessageEntry
204
+ | CompactionEntry
205
+ | TaskStarted
206
+ | TaskCompleted
207
+ | TaskFailed
208
+ | TaskCancelled
209
+ | TaskDispatched
210
+ | MergeStarted
211
+ | MergeCommitted,
212
+ Field(discriminator="t"),
213
+ ]
214
+
215
+
216
+ AUDIT_MARKER_TYPES: frozenset[str] = frozenset({
217
+ "task_dispatch",
218
+ "task_cancel",
219
+ "task_fail",
220
+ "merge_start",
221
+ "merge_commit",
222
+ })
223
+ """Entry types that are audit-only and must be stripped from replay output."""
224
+
225
+
226
+ TASK_TERMINAL_TYPES: frozenset[str] = frozenset({
227
+ "task_done",
228
+ "task_fail",
229
+ "task_cancel",
230
+ })
231
+ """Sub-journal entry types that mark a task's terminal state."""
@@ -0,0 +1,122 @@
1
+ """System prompt construction for the LLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from agentlings.config import AgentConfig
10
+ from agentlings.core.memory_models import MemoryStore
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DEFAULT_MEMORY_INJECTION = """\
15
+ ## Memory
16
+
17
+ The following is your long-term memory — facts you have learned over time:
18
+
19
+ {entries}"""
20
+
21
+ DEFAULT_DATA_DIR_AWARENESS = """\
22
+ ## Data Directory
23
+
24
+ Your data directory is at {data_dir}. It contains:
25
+ - memory/memory.json: your long-term memory (also provided above)
26
+ - journals/YYYY-MM-DD.md: daily summaries of your past activity
27
+ - conversations as *.jsonl: raw conversation logs
28
+
29
+ You can read these files using your filesystem tools to recall past context \
30
+ that is not in your current memory. For example:
31
+ - List journals to see which days you were active
32
+ - Read a journal to recall what happened on a specific day
33
+ - Search across journals to find when an issue first appeared
34
+ - Read conversation logs for full detail on a past interaction"""
35
+
36
+
37
+ def build_system_prompt(
38
+ config: AgentConfig,
39
+ memory: MemoryStore | None = None,
40
+ data_dir: Path | None = None,
41
+ injection_prompt: str | None = None,
42
+ token_budget: int = 2000,
43
+ ) -> list[dict[str, Any]]:
44
+ """Build the system prompt blocks for the Anthropic Messages API.
45
+
46
+ Args:
47
+ config: The agent configuration.
48
+ memory: Current memory store to inject. Omitted if ``None`` or empty.
49
+ data_dir: Path to the agent's data directory for the awareness block.
50
+ injection_prompt: Override for the memory injection template.
51
+ Uses ``DEFAULT_MEMORY_INJECTION`` if not provided.
52
+
53
+ Returns:
54
+ A list of text blocks with ``cache_control`` set to ephemeral.
55
+ """
56
+ if config.system_prompt:
57
+ text = config.system_prompt
58
+ else:
59
+ text = _default_prompt(config)
60
+
61
+ blocks = [
62
+ {
63
+ "type": "text",
64
+ "text": text,
65
+ "cache_control": {"type": "ephemeral"},
66
+ },
67
+ ]
68
+
69
+ if memory and memory.entries:
70
+ template = injection_prompt or DEFAULT_MEMORY_INJECTION
71
+ memory_block = _build_memory_block(template, memory.entries, token_budget)
72
+ if memory_block:
73
+ blocks.append({
74
+ "type": "text",
75
+ "text": memory_block,
76
+ "cache_control": {"type": "ephemeral"},
77
+ })
78
+
79
+ if data_dir is not None and config.definition.data_dir_awareness:
80
+ awareness = DEFAULT_DATA_DIR_AWARENESS.format(data_dir=data_dir)
81
+ blocks.append({
82
+ "type": "text",
83
+ "text": awareness,
84
+ "cache_control": {"type": "ephemeral"},
85
+ })
86
+
87
+ return blocks
88
+
89
+
90
+ CHARS_PER_TOKEN = 4
91
+
92
+
93
+ def _estimate_tokens(text: str) -> int:
94
+ return len(text) // CHARS_PER_TOKEN
95
+
96
+
97
+ def _build_memory_block(
98
+ template: str,
99
+ entries: list,
100
+ token_budget: int,
101
+ ) -> str | None:
102
+ """Build the memory injection block, truncating entries to fit the token budget."""
103
+ included = list(entries)
104
+ while included:
105
+ entries_text = "\n".join(f"- **{e.key}**: {e.value}" for e in included)
106
+ block = template.format(entries=entries_text)
107
+ if _estimate_tokens(block) <= token_budget:
108
+ return block
109
+ included.pop()
110
+ logger.debug("memory block over budget, dropped to %d entries", len(included))
111
+ return None
112
+
113
+
114
+ def _default_prompt(config: AgentConfig) -> str:
115
+ return f"""\
116
+ You are {config.agent_name}, {config.agent_description}.
117
+
118
+ Use the tools available to you to accomplish tasks. When a tool returns output, \
119
+ incorporate it into your response.
120
+
121
+ Be concise. Use code blocks for command output. Respond in plain text unless \
122
+ the user asks for a specific format."""
@@ -0,0 +1,141 @@
1
+ """Asyncio-based cron scheduler for the sleep cycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from datetime import datetime, timezone
8
+ from typing import Callable, Coroutine
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def parse_cron_field(field: str, min_val: int, max_val: int) -> set[int]:
14
+ """Parse a single cron field into a set of matching integer values.
15
+
16
+ Supports ``*``, ranges (``1-5``), steps (``*/2``), and comma-separated lists.
17
+
18
+ Args:
19
+ field: The cron field string.
20
+ min_val: Minimum valid value for this field.
21
+ max_val: Maximum valid value for this field.
22
+
23
+ Returns:
24
+ Set of integer values that match the field expression.
25
+ """
26
+ values: set[int] = set()
27
+
28
+ def _validate(val: int, label: str) -> int:
29
+ if val < min_val or val > max_val:
30
+ raise ValueError(f"{label} {val} out of bounds [{min_val}, {max_val}] in '{field}'")
31
+ return val
32
+
33
+ for part in field.split(","):
34
+ part = part.strip()
35
+ if "/" in part:
36
+ base, step_str = part.split("/", 1)
37
+ step = int(step_str)
38
+ if step <= 0:
39
+ raise ValueError(f"Step must be positive in '{field}', got {step}")
40
+ if base == "*":
41
+ start, end = min_val, max_val
42
+ elif "-" in base:
43
+ lo, hi = base.split("-", 1)
44
+ start = _validate(int(lo), "Range start")
45
+ end = _validate(int(hi), "Range end")
46
+ else:
47
+ start = _validate(int(base), "Value")
48
+ end = max_val
49
+ values.update(range(start, end + 1, step))
50
+ elif part == "*":
51
+ values.update(range(min_val, max_val + 1))
52
+ elif "-" in part:
53
+ lo, hi = part.split("-", 1)
54
+ start = _validate(int(lo), "Range start")
55
+ end = _validate(int(hi), "Range end")
56
+ values.update(range(start, end + 1))
57
+ else:
58
+ values.add(_validate(int(part), "Value"))
59
+
60
+ return values
61
+
62
+
63
+ def cron_matches(expression: str, dt: datetime) -> bool:
64
+ """Check if a datetime matches a 5-field cron expression.
65
+
66
+ Fields: minute hour day-of-month month day-of-week (0=Sunday).
67
+
68
+ Args:
69
+ expression: Standard 5-field cron expression.
70
+ dt: The datetime to check.
71
+
72
+ Returns:
73
+ Whether the datetime matches the expression.
74
+ """
75
+ fields = expression.strip().split()
76
+ if len(fields) != 5:
77
+ raise ValueError(f"Expected 5 cron fields, got {len(fields)}: {expression}")
78
+
79
+ minute = parse_cron_field(fields[0], 0, 59)
80
+ hour = parse_cron_field(fields[1], 0, 23)
81
+ dom = parse_cron_field(fields[2], 1, 31)
82
+ month = parse_cron_field(fields[3], 1, 12)
83
+ dow = parse_cron_field(fields[4], 0, 6)
84
+
85
+ return (
86
+ dt.minute in minute
87
+ and dt.hour in hour
88
+ and dt.day in dom
89
+ and dt.month in month
90
+ and dt.weekday() in _convert_dow(dow)
91
+ )
92
+
93
+
94
+ def _convert_dow(cron_dow: set[int]) -> set[int]:
95
+ """Convert cron day-of-week (0=Sunday) to Python weekday (0=Monday)."""
96
+ mapping = {0: 6, 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5}
97
+ invalid = cron_dow - mapping.keys()
98
+ if invalid:
99
+ raise ValueError(f"Invalid day-of-week values: {sorted(invalid)} (must be 0-6)")
100
+ return {mapping[d] for d in cron_dow}
101
+
102
+
103
+ async def run_scheduler(
104
+ expression: str,
105
+ callback: Callable[[], Coroutine],
106
+ check_interval: float = 60,
107
+ ) -> None:
108
+ """Run an asyncio loop that fires a callback when the cron expression matches.
109
+
110
+ Checks once per ``check_interval`` seconds. Skips if the callback is
111
+ already running. Designed to run as a background ``asyncio.Task``.
112
+
113
+ Args:
114
+ expression: 5-field cron expression.
115
+ callback: Async callable to invoke on match.
116
+ check_interval: Seconds between cron checks.
117
+ """
118
+ last_fire: datetime | None = None
119
+ running = False
120
+
121
+ while True:
122
+ await asyncio.sleep(check_interval)
123
+
124
+ now = datetime.now(timezone.utc)
125
+ if running:
126
+ continue
127
+
128
+ if cron_matches(expression, now):
129
+ fire_key = now.replace(second=0, microsecond=0)
130
+ if last_fire == fire_key:
131
+ continue
132
+
133
+ last_fire = fire_key
134
+ running = True
135
+ try:
136
+ logger.info("cron match at %s, firing callback", now.isoformat())
137
+ await callback()
138
+ except Exception:
139
+ logger.exception("scheduler callback failed")
140
+ finally:
141
+ running = False