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
|
@@ -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
|