context-driven-llm-scheduler 1.0.0__tar.gz
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.
- context_driven_llm_scheduler-1.0.0/.gitignore +44 -0
- context_driven_llm_scheduler-1.0.0/LICENSE +21 -0
- context_driven_llm_scheduler-1.0.0/PKG-INFO +140 -0
- context_driven_llm_scheduler-1.0.0/README.md +110 -0
- context_driven_llm_scheduler-1.0.0/behave.ini +3 -0
- context_driven_llm_scheduler-1.0.0/examples/ai_agent_pulse.py +59 -0
- context_driven_llm_scheduler-1.0.0/examples/email_monitor.py +60 -0
- context_driven_llm_scheduler-1.0.0/examples/fastapi_bedrock.py +106 -0
- context_driven_llm_scheduler-1.0.0/examples/inbox_triage.py +98 -0
- context_driven_llm_scheduler-1.0.0/examples/multi_system_reuse.py +53 -0
- context_driven_llm_scheduler-1.0.0/examples/pulses/daily_digest.md +6 -0
- context_driven_llm_scheduler-1.0.0/examples/pulses/inbox_triage.md +16 -0
- context_driven_llm_scheduler-1.0.0/pyproject.toml +49 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/__init__.py +110 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/adapters/__init__.py +39 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/adapters/base.py +42 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/adapters/litellm_adapter.py +144 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/__init__.py +1 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/exceptions.py +13 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/manager.py +389 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/memory.py +227 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/pulse.py +479 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/store.py +68 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/types.py +27 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/result_log.py +249 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/schedulers/__init__.py +1 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/schedulers/apscheduler_wrapper.py +184 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/stores/__init__.py +6 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/stores/file.py +106 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/stores/sqlite.py +169 -0
- context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/util.py +130 -0
- context_driven_llm_scheduler-1.0.0/tests/features/coalescing.feature +22 -0
- context_driven_llm_scheduler-1.0.0/tests/features/concurrency.feature +9 -0
- context_driven_llm_scheduler-1.0.0/tests/features/environment.py +23 -0
- context_driven_llm_scheduler-1.0.0/tests/features/error_resilience.feature +25 -0
- context_driven_llm_scheduler-1.0.0/tests/features/file_store.feature +16 -0
- context_driven_llm_scheduler-1.0.0/tests/features/markdown_authoring.feature +40 -0
- context_driven_llm_scheduler-1.0.0/tests/features/observability.feature +22 -0
- context_driven_llm_scheduler-1.0.0/tests/features/pulse_memory.feature +117 -0
- context_driven_llm_scheduler-1.0.0/tests/features/result_log.feature +30 -0
- context_driven_llm_scheduler-1.0.0/tests/features/spam_prevention.feature +18 -0
- context_driven_llm_scheduler-1.0.0/tests/features/sqlite_store.feature +19 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/coalescing_steps.py +36 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/common_steps.py +87 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/concurrency_steps.py +26 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/error_resilience_steps.py +59 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/markdown_authoring_steps.py +38 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/observability_steps.py +31 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/pulse_memory_steps.py +202 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/result_log_steps.py +36 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/spam_prevention_steps.py +51 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/sqlite_store_steps.py +10 -0
- context_driven_llm_scheduler-1.0.0/tests/features/steps/trigger_cycle_steps.py +16 -0
- context_driven_llm_scheduler-1.0.0/tests/features/trigger_cycle.feature +20 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Miscellaneous
|
|
2
|
+
*.log
|
|
3
|
+
*.swp
|
|
4
|
+
.DS_Store
|
|
5
|
+
.history
|
|
6
|
+
|
|
7
|
+
# IntelliJ related
|
|
8
|
+
*.iml
|
|
9
|
+
*.ipr
|
|
10
|
+
*.iws
|
|
11
|
+
.idea/
|
|
12
|
+
|
|
13
|
+
# The .vscode folder contains launch configuration and tasks you configure in
|
|
14
|
+
# VS Code which you may wish to be included in version control, so this line
|
|
15
|
+
# is commented out by default.
|
|
16
|
+
.vscode/
|
|
17
|
+
|
|
18
|
+
# Python
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
dist/
|
|
22
|
+
build/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
__pycache__/
|
|
27
|
+
*.py[cod]
|
|
28
|
+
|
|
29
|
+
#don't commit uv.lock for libraries:
|
|
30
|
+
uv.lock
|
|
31
|
+
.uv/
|
|
32
|
+
|
|
33
|
+
# Test / coverage artifacts
|
|
34
|
+
.coverage
|
|
35
|
+
htmlcov/
|
|
36
|
+
|
|
37
|
+
# Secrets and local env
|
|
38
|
+
*.env
|
|
39
|
+
!.env.example
|
|
40
|
+
*-gcp-service-account-key.json
|
|
41
|
+
|
|
42
|
+
# AI-assisted development tools
|
|
43
|
+
.continue
|
|
44
|
+
.claude/*.local.*
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CodifyIQ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: context-driven-llm-scheduler
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Context-driven scheduled tasks for LLMs with persistent memory — inspired by OpenClaw's Heartbeat.
|
|
5
|
+
Project-URL: Homepage, https://github.com/codifyiq/context-driven-llm-scheduler
|
|
6
|
+
Author: CodifyIQ
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: context,cron,heartbeat,pulse,scheduler,state
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: portalocker>=2.8
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: apscheduler>=3.10; extra == 'dev'
|
|
19
|
+
Requires-Dist: behave>=1.2.6; extra == 'dev'
|
|
20
|
+
Requires-Dist: litellm>=1.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
22
|
+
Requires-Dist: sqlalchemy>=2.0; extra == 'dev'
|
|
23
|
+
Provides-Extra: litellm
|
|
24
|
+
Requires-Dist: litellm>=1.0; extra == 'litellm'
|
|
25
|
+
Provides-Extra: scheduler
|
|
26
|
+
Requires-Dist: apscheduler>=3.10; extra == 'scheduler'
|
|
27
|
+
Provides-Extra: sqlite
|
|
28
|
+
Requires-Dist: sqlalchemy>=2.0; extra == 'sqlite'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# 🧠 context-driven-llm-scheduler
|
|
32
|
+
|
|
33
|
+
**Context-driven scheduled tasks for LLMs with persistent memory.**
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/context-driven-llm-scheduler/)
|
|
36
|
+
[](https://pypi.org/project/context-driven-llm-scheduler/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
Most scheduled tasks and cron jobs are stateless — they wake up with no memory of prior runs.
|
|
42
|
+
|
|
43
|
+
**context-driven-llm-scheduler** solves this by giving LLM-powered recurring tasks **persistent context and memory** across executions.
|
|
44
|
+
|
|
45
|
+
It lets you build reliable, intelligent scheduled agents that remember what they've seen, what they've done, and what still needs attention.
|
|
46
|
+
|
|
47
|
+
Inspired by the [Heartbeat pattern](https://docs.openclaw.ai/gateway/heartbeat) from OpenClaw.
|
|
48
|
+
|
|
49
|
+
## What is context-driven-llm-scheduler?
|
|
50
|
+
|
|
51
|
+
`context-driven-llm-scheduler` is a lightweight Python library for defining and running **memoryful LLM scheduled tasks** (called **pulses**).
|
|
52
|
+
|
|
53
|
+
You define each pulse in a markdown file (schedule, memory rules, model, and instructions). The library handles the complex parts:
|
|
54
|
+
|
|
55
|
+
- Recalling relevant memory and context from previous runs
|
|
56
|
+
- Assembling token-efficient prompts (with transparent trimming)
|
|
57
|
+
- Atomic persistence of the LLM's decisions
|
|
58
|
+
- Built-in deduplication (`seen`) and rate-limiting (`throttle`)
|
|
59
|
+
|
|
60
|
+
You bring your own scheduler, LLM adapter, and storage backend (or use some default options we provide).
|
|
61
|
+
|
|
62
|
+
## Features
|
|
63
|
+
|
|
64
|
+
- **Markdown-first configuration** — prompts, schedules, and rules are easy to edit
|
|
65
|
+
- **True persistent memory** — notes, seen items, key-value facts, throttles
|
|
66
|
+
- **No lock-in** — compatible with cron, APScheduler, Lambda, Airflow, etc.
|
|
67
|
+
- **Production-ready** — concurrency-safe, atomic transactions, honest token budgeting
|
|
68
|
+
- **Minimal dependencies**
|
|
69
|
+
|
|
70
|
+
## Quickstart
|
|
71
|
+
|
|
72
|
+
**1. Create a pulse definition** at `pulses/inbox-triage.md`:
|
|
73
|
+
|
|
74
|
+
```markdown
|
|
75
|
+
---
|
|
76
|
+
id: inbox-triage
|
|
77
|
+
schedule: "*/15 * * * *"
|
|
78
|
+
throttle: { notify: 1h }
|
|
79
|
+
keep: { notes: 20, seen: 500 }
|
|
80
|
+
model: anthropic/claude-sonnet-4-6
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
You are my inbox triage assistant.
|
|
84
|
+
|
|
85
|
+
For each new urgent email:
|
|
86
|
+
- Decide if it genuinely needs my attention.
|
|
87
|
+
- If yes and the `notify` throttle allows, alert me and record the throttle.
|
|
88
|
+
- Always mark the email as `seen`.
|
|
89
|
+
|
|
90
|
+
Be conservative. Never notify about the same thing twice.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**2. Wire it up in Python:**
|
|
94
|
+
```python
|
|
95
|
+
from context_driven_llm_scheduler import PulseManager, FileStore, LiteLLMAdapter
|
|
96
|
+
|
|
97
|
+
manager = PulseManager.from_dir("pulses", FileStore("./state"))
|
|
98
|
+
adapter = LiteLLMAdapter("anthropic/claude-sonnet-4-6")
|
|
99
|
+
|
|
100
|
+
@manager.pulse("inbox-triage")
|
|
101
|
+
def triage(pulse, extra=None):
|
|
102
|
+
prompt = pulse.recall(extra={"emails": fetch_urgent_emails()})
|
|
103
|
+
ops = adapter.propose_memory_ops(prompt)
|
|
104
|
+
return pulse.persist(ops)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**3. Trigger the pulse:**
|
|
108
|
+
```python
|
|
109
|
+
manager.trigger("inbox-triage") # Call from cron, scheduler, Lambda, etc.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Core Concepts
|
|
113
|
+
### The Pulse Interface
|
|
114
|
+
|
|
115
|
+
#### Pull Data into the LLM Context Before the Scheduled Job
|
|
116
|
+
`pulse.recall(...)` — assembles instructions + historical memory + fresh input data
|
|
117
|
+
|
|
118
|
+
#### Store Data After the Scheduled Job
|
|
119
|
+
`pulse.persist(ops)` — atomically applies the LLM's structured memory operations
|
|
120
|
+
|
|
121
|
+
### Supported Memory Operations
|
|
122
|
+
The LLM outputs operations via tool calling:
|
|
123
|
+
|
|
124
|
+
`note` — free-text observation
|
|
125
|
+
`seen` — mark item as handled (deduplication)
|
|
126
|
+
`throttle` — record action for rate limiting
|
|
127
|
+
`set` / `forget` — key/value persistent facts
|
|
128
|
+
|
|
129
|
+
## Installation
|
|
130
|
+
```bash
|
|
131
|
+
pip install context-driven-llm-scheduler
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Optional extras:
|
|
135
|
+
```bash
|
|
136
|
+
pip install "context-driven-llm-scheduler[litellm,sqlite,scheduler]"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Examples
|
|
140
|
+
See the `examples/` directory.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# 🧠 context-driven-llm-scheduler
|
|
2
|
+
|
|
3
|
+
**Context-driven scheduled tasks for LLMs with persistent memory.**
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/context-driven-llm-scheduler/)
|
|
6
|
+
[](https://pypi.org/project/context-driven-llm-scheduler/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Most scheduled tasks and cron jobs are stateless — they wake up with no memory of prior runs.
|
|
12
|
+
|
|
13
|
+
**context-driven-llm-scheduler** solves this by giving LLM-powered recurring tasks **persistent context and memory** across executions.
|
|
14
|
+
|
|
15
|
+
It lets you build reliable, intelligent scheduled agents that remember what they've seen, what they've done, and what still needs attention.
|
|
16
|
+
|
|
17
|
+
Inspired by the [Heartbeat pattern](https://docs.openclaw.ai/gateway/heartbeat) from OpenClaw.
|
|
18
|
+
|
|
19
|
+
## What is context-driven-llm-scheduler?
|
|
20
|
+
|
|
21
|
+
`context-driven-llm-scheduler` is a lightweight Python library for defining and running **memoryful LLM scheduled tasks** (called **pulses**).
|
|
22
|
+
|
|
23
|
+
You define each pulse in a markdown file (schedule, memory rules, model, and instructions). The library handles the complex parts:
|
|
24
|
+
|
|
25
|
+
- Recalling relevant memory and context from previous runs
|
|
26
|
+
- Assembling token-efficient prompts (with transparent trimming)
|
|
27
|
+
- Atomic persistence of the LLM's decisions
|
|
28
|
+
- Built-in deduplication (`seen`) and rate-limiting (`throttle`)
|
|
29
|
+
|
|
30
|
+
You bring your own scheduler, LLM adapter, and storage backend (or use some default options we provide).
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- **Markdown-first configuration** — prompts, schedules, and rules are easy to edit
|
|
35
|
+
- **True persistent memory** — notes, seen items, key-value facts, throttles
|
|
36
|
+
- **No lock-in** — compatible with cron, APScheduler, Lambda, Airflow, etc.
|
|
37
|
+
- **Production-ready** — concurrency-safe, atomic transactions, honest token budgeting
|
|
38
|
+
- **Minimal dependencies**
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
**1. Create a pulse definition** at `pulses/inbox-triage.md`:
|
|
43
|
+
|
|
44
|
+
```markdown
|
|
45
|
+
---
|
|
46
|
+
id: inbox-triage
|
|
47
|
+
schedule: "*/15 * * * *"
|
|
48
|
+
throttle: { notify: 1h }
|
|
49
|
+
keep: { notes: 20, seen: 500 }
|
|
50
|
+
model: anthropic/claude-sonnet-4-6
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
You are my inbox triage assistant.
|
|
54
|
+
|
|
55
|
+
For each new urgent email:
|
|
56
|
+
- Decide if it genuinely needs my attention.
|
|
57
|
+
- If yes and the `notify` throttle allows, alert me and record the throttle.
|
|
58
|
+
- Always mark the email as `seen`.
|
|
59
|
+
|
|
60
|
+
Be conservative. Never notify about the same thing twice.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**2. Wire it up in Python:**
|
|
64
|
+
```python
|
|
65
|
+
from context_driven_llm_scheduler import PulseManager, FileStore, LiteLLMAdapter
|
|
66
|
+
|
|
67
|
+
manager = PulseManager.from_dir("pulses", FileStore("./state"))
|
|
68
|
+
adapter = LiteLLMAdapter("anthropic/claude-sonnet-4-6")
|
|
69
|
+
|
|
70
|
+
@manager.pulse("inbox-triage")
|
|
71
|
+
def triage(pulse, extra=None):
|
|
72
|
+
prompt = pulse.recall(extra={"emails": fetch_urgent_emails()})
|
|
73
|
+
ops = adapter.propose_memory_ops(prompt)
|
|
74
|
+
return pulse.persist(ops)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**3. Trigger the pulse:**
|
|
78
|
+
```python
|
|
79
|
+
manager.trigger("inbox-triage") # Call from cron, scheduler, Lambda, etc.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Core Concepts
|
|
83
|
+
### The Pulse Interface
|
|
84
|
+
|
|
85
|
+
#### Pull Data into the LLM Context Before the Scheduled Job
|
|
86
|
+
`pulse.recall(...)` — assembles instructions + historical memory + fresh input data
|
|
87
|
+
|
|
88
|
+
#### Store Data After the Scheduled Job
|
|
89
|
+
`pulse.persist(ops)` — atomically applies the LLM's structured memory operations
|
|
90
|
+
|
|
91
|
+
### Supported Memory Operations
|
|
92
|
+
The LLM outputs operations via tool calling:
|
|
93
|
+
|
|
94
|
+
`note` — free-text observation
|
|
95
|
+
`seen` — mark item as handled (deduplication)
|
|
96
|
+
`throttle` — record action for rate limiting
|
|
97
|
+
`set` / `forget` — key/value persistent facts
|
|
98
|
+
|
|
99
|
+
## Installation
|
|
100
|
+
```bash
|
|
101
|
+
pip install context-driven-llm-scheduler
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Optional extras:
|
|
105
|
+
```bash
|
|
106
|
+
pip install "context-driven-llm-scheduler[litellm,sqlite,scheduler]"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Examples
|
|
110
|
+
See the `examples/` directory.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""AI-agent pulse: a tiny state machine with cheap-checks-first.
|
|
2
|
+
|
|
3
|
+
Demonstrates how a handler advances a persisted state machine
|
|
4
|
+
(idle → working → escalate) across wake-ups, doing the cheapest check first
|
|
5
|
+
and only "escalating" after repeated stalls. No real LLM/agent calls — the
|
|
6
|
+
library deliberately ships no agent logic. State lives in the pulse's memory
|
|
7
|
+
facts, so it carries across wake-ups with no external bookkeeping.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from context_driven_llm_scheduler import (
|
|
14
|
+
FileStore,
|
|
15
|
+
Pulse,
|
|
16
|
+
PulseDefinition,
|
|
17
|
+
PulseManager,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
STORE_DIR = Path(tempfile.gettempdir()) / "context_driven_llm_scheduler-ai-agent"
|
|
21
|
+
manager = PulseManager(FileStore(STORE_DIR))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def job_is_done() -> bool:
|
|
25
|
+
"""Cheap stand-in health check. Pretend the job is still running."""
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@manager.pulse(
|
|
30
|
+
PulseDefinition(id="agent-heartbeat", instructions="Watch the job.")
|
|
31
|
+
)
|
|
32
|
+
def handle(pulse: Pulse, extra: dict | None = None) -> None:
|
|
33
|
+
"""Advance the agent state machine, escalating after 3 stalls."""
|
|
34
|
+
facts = pulse.memory.facts
|
|
35
|
+
facts.setdefault("state", "idle")
|
|
36
|
+
facts.setdefault("stall_count", 0)
|
|
37
|
+
|
|
38
|
+
# Cheapest possible check first — bail before any expensive work.
|
|
39
|
+
if job_is_done():
|
|
40
|
+
facts["state"] = "idle"
|
|
41
|
+
facts["stall_count"] = 0
|
|
42
|
+
print("[idle] nothing to do")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if facts["state"] == "idle":
|
|
46
|
+
facts["state"] = "working"
|
|
47
|
+
|
|
48
|
+
facts["stall_count"] += 1
|
|
49
|
+
if facts["stall_count"] >= 3 and facts["state"] != "escalate":
|
|
50
|
+
facts["state"] = "escalate"
|
|
51
|
+
print("[ESCALATE] job stalled 3+ times — paging a human")
|
|
52
|
+
else:
|
|
53
|
+
print(f"[working] stall_count={facts['stall_count']}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
for _ in range(4):
|
|
58
|
+
result = manager.trigger("agent-heartbeat")
|
|
59
|
+
print(f" -> state={result['memory']['facts']['state']}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Email-monitor pulse: notify on new urgent mail, with spam prevention.
|
|
2
|
+
|
|
3
|
+
Run it twice in a row::
|
|
4
|
+
|
|
5
|
+
uv run python examples/email_monitor.py
|
|
6
|
+
uv run python examples/email_monitor.py
|
|
7
|
+
|
|
8
|
+
The first run "notifies" about the simulated urgent email; the second run sees
|
|
9
|
+
it in the pulse's seen-set / throttle and stays quiet — proving state persisted
|
|
10
|
+
across wake-ups via the FileStore. No model is involved: the handler returns
|
|
11
|
+
the same memory operations a model would emit, so de-dup and throttling run
|
|
12
|
+
through the standard memory protocol.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from context_driven_llm_scheduler import (
|
|
19
|
+
FileStore,
|
|
20
|
+
Pulse,
|
|
21
|
+
PulseDefinition,
|
|
22
|
+
PulseManager,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
STORE_DIR = Path(tempfile.gettempdir()) / "context_driven_llm_scheduler-email-monitor"
|
|
26
|
+
|
|
27
|
+
DEFINITION = PulseDefinition(
|
|
28
|
+
id="email-monitor",
|
|
29
|
+
instructions="Notify me about new urgent emails, at most once a minute.",
|
|
30
|
+
throttles={"alert": 60.0},
|
|
31
|
+
keep={"seen": 500},
|
|
32
|
+
)
|
|
33
|
+
manager = PulseManager(FileStore(STORE_DIR))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fetch_urgent_emails() -> list[dict[str, str]]:
|
|
37
|
+
"""Stand-in inbox fetch. Returns one fixed urgent email."""
|
|
38
|
+
return [{"id": "msg-42", "subject": "Server on fire"}]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@manager.pulse(DEFINITION)
|
|
42
|
+
def handle(pulse: Pulse, extra: dict | None = None) -> list[dict[str, object]]:
|
|
43
|
+
"""Notify once per urgent email, throttled to one alert per minute."""
|
|
44
|
+
ops: list[dict[str, object]] = []
|
|
45
|
+
for email in fetch_urgent_emails():
|
|
46
|
+
if email["id"] in pulse.memory.seen.get("notified", []):
|
|
47
|
+
print(f"[skip] already notified about {email['id']}")
|
|
48
|
+
continue
|
|
49
|
+
ops.append({"op": "seen", "field": "notified", "id": email["id"]})
|
|
50
|
+
if pulse.seconds_until_available("alert") > 0:
|
|
51
|
+
print("[skip] cooling down — not alerting yet")
|
|
52
|
+
continue
|
|
53
|
+
print(f"[ALERT] {email['subject']} ({email['id']})")
|
|
54
|
+
ops.append({"op": "throttle", "field": "alert"})
|
|
55
|
+
return ops
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
result = manager.trigger("email-monitor")
|
|
60
|
+
print(f"trigger_count={result['trigger_count']} store={STORE_DIR}")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""FastAPI + Bedrock + markdown jobs + markdown results, wired end to end.
|
|
2
|
+
|
|
3
|
+
Shows the shape for running unattended, markdown-defined LLM jobs inside a
|
|
4
|
+
FastAPI backend:
|
|
5
|
+
|
|
6
|
+
* **Markdown jobs, no frontmatter.** ``pulses/daily_digest.md`` is pure prose;
|
|
7
|
+
its id (``daily_digest``) comes from the filename. Scheduling lives in code,
|
|
8
|
+
where an engineer is already typing — not in the markdown.
|
|
9
|
+
* **Bedrock via LiteLLM.** ``LiteLLMAdapter("bedrock/...")`` needs only AWS
|
|
10
|
+
credentials from the usual boto3 chain. The membrane stays model-agnostic.
|
|
11
|
+
* **Markdown results.** A :class:`ResultLog` records each run — result, the
|
|
12
|
+
context the model saw, and the memory changes persisted for next time — to
|
|
13
|
+
``results/<id>.md``.
|
|
14
|
+
* **Safe under multiple workers.** The scheduler runs in ONE process (guarded
|
|
15
|
+
by an env flag), and ``add_cron`` defaults a coalesce window on, so even if
|
|
16
|
+
the flag is misconfigured the same tick won't double-fire.
|
|
17
|
+
|
|
18
|
+
This is a sketch: it has no real route handlers and won't call Bedrock without
|
|
19
|
+
credentials. The wiring is the point.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from contextlib import asynccontextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from fastapi import FastAPI
|
|
27
|
+
|
|
28
|
+
from context_driven_llm_scheduler import (
|
|
29
|
+
APSchedulerPulse,
|
|
30
|
+
FileStore,
|
|
31
|
+
LiteLLMAdapter,
|
|
32
|
+
Pulse,
|
|
33
|
+
PulseManager,
|
|
34
|
+
ResultLog,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
PULSES_DIR = Path(__file__).parent / "pulses"
|
|
38
|
+
STATE_DIR = Path(os.environ.get("PULSE_STATE_DIR", "/var/lib/pulses"))
|
|
39
|
+
|
|
40
|
+
# One adapter for every pulse; swap the model string for any Bedrock model.
|
|
41
|
+
adapter = LiteLLMAdapter(
|
|
42
|
+
"bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
|
|
43
|
+
timeout=30,
|
|
44
|
+
num_retries=2,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Markdown state (machine) and markdown results (human) live side by side.
|
|
48
|
+
store = FileStore(STATE_DIR / "store")
|
|
49
|
+
result_log = ResultLog(STATE_DIR / "results")
|
|
50
|
+
manager = PulseManager.from_dir(PULSES_DIR, store, result_log=result_log)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fetch_recent_events() -> list[dict[str, str]]:
|
|
54
|
+
"""Stand-in for whatever the job should look at this run."""
|
|
55
|
+
return [{"id": "evt-1", "summary": "deploy finished"}]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@manager.pulse("daily_digest")
|
|
59
|
+
def run_digest(pulse: Pulse, extra: dict | None = None) -> list[dict[str, object]]:
|
|
60
|
+
"""Recall memory, ask Bedrock to decide, record the result and memory ops.
|
|
61
|
+
|
|
62
|
+
The model call is ours, exactly as the membrane intends: recall the prompt,
|
|
63
|
+
call the adapter, record the human-readable result, and return the memory
|
|
64
|
+
operations the model emitted for the library to persist.
|
|
65
|
+
"""
|
|
66
|
+
events = fetch_recent_events()
|
|
67
|
+
prompt = pulse.recall(extra={"events": events}, adapter=adapter, max_tokens=8000)
|
|
68
|
+
digest = adapter.complete(prompt)
|
|
69
|
+
pulse.record_result(digest, events=len(events))
|
|
70
|
+
return adapter.propose_memory_ops(prompt)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@asynccontextmanager
|
|
74
|
+
async def lifespan(app: FastAPI):
|
|
75
|
+
"""Start the pulse scheduler in exactly one process.
|
|
76
|
+
|
|
77
|
+
Run the API with many workers if you like, but set ``RUN_SCHEDULER=1`` for
|
|
78
|
+
only one of them (e.g. a dedicated worker, or a sidecar started with a
|
|
79
|
+
single worker). The coalesce window is a second line of defense, not a
|
|
80
|
+
substitute for this.
|
|
81
|
+
"""
|
|
82
|
+
pulses: APSchedulerPulse | None = None
|
|
83
|
+
if os.environ.get("RUN_SCHEDULER") == "1":
|
|
84
|
+
pulses = APSchedulerPulse(manager)
|
|
85
|
+
# Coalesce window defaults to the schedule's period, so duplicate
|
|
86
|
+
# fires across processes collapse to a single run.
|
|
87
|
+
pulses.add_cron("daily_digest", "0 9 * * *")
|
|
88
|
+
pulses.start()
|
|
89
|
+
try:
|
|
90
|
+
yield
|
|
91
|
+
finally:
|
|
92
|
+
if pulses is not None:
|
|
93
|
+
pulses.shutdown()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
app = FastAPI(lifespan=lifespan)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.post("/pulses/{pulse_id}/trigger")
|
|
100
|
+
def trigger_now(pulse_id: str) -> dict[str, object]:
|
|
101
|
+
"""Trigger a pulse on demand (e.g. for testing or a manual re-run).
|
|
102
|
+
|
|
103
|
+
No coalesce window here: a manual trigger should always run.
|
|
104
|
+
"""
|
|
105
|
+
context = manager.trigger(pulse_id)
|
|
106
|
+
return {"trigger_count": context.get("trigger_count")}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Markdown-defined pulse: the membrane in action, runnable offline.
|
|
2
|
+
|
|
3
|
+
The pulse is defined entirely in ``pulses/inbox_triage.md`` — schedule, memory
|
|
4
|
+
policy, and the standing instructions. context_driven_llm_scheduler assembles the prompt from
|
|
5
|
+
those instructions plus what the pulse remembers (:meth:`Pulse.recall`), the
|
|
6
|
+
model decides what to do, and context_driven_llm_scheduler folds the model's memory operations
|
|
7
|
+
back into stored state (:meth:`Pulse.persist`).
|
|
8
|
+
|
|
9
|
+
This example ships a fake adapter so it runs with no API key. The real thing is
|
|
10
|
+
one line — see ``REAL_ADAPTER`` below. Run it twice::
|
|
11
|
+
|
|
12
|
+
uv run python examples/inbox_triage.py
|
|
13
|
+
uv run python examples/inbox_triage.py
|
|
14
|
+
|
|
15
|
+
The first run "notifies"; the second sees the email in memory and stays quiet.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from context_driven_llm_scheduler import FileStore, Pulse, PulseManager
|
|
22
|
+
|
|
23
|
+
PULSES_DIR = Path(__file__).parent / "pulses"
|
|
24
|
+
STORE_DIR = Path(tempfile.gettempdir()) / "context_driven_llm_scheduler-inbox-triage"
|
|
25
|
+
|
|
26
|
+
manager = PulseManager.from_dir(PULSES_DIR, FileStore(STORE_DIR))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fetch_urgent_emails() -> list[dict[str, str]]:
|
|
30
|
+
"""Stand-in inbox fetch. Returns one fixed urgent email."""
|
|
31
|
+
return [{"id": "msg-42", "subject": "Server on fire"}]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FakeAdapter:
|
|
35
|
+
"""Offline stand-in for a real model: emits ops by simple rules.
|
|
36
|
+
|
|
37
|
+
Mirrors what a model would return through ``update_memory`` so the example
|
|
38
|
+
runs deterministically without network or keys.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def complete(self, prompt: str, **kwargs: object) -> str:
|
|
42
|
+
"""Return the prompt unchanged (unused here)."""
|
|
43
|
+
return prompt
|
|
44
|
+
|
|
45
|
+
def count_tokens(self, text: str) -> int:
|
|
46
|
+
"""Rough token estimate for budgeting demos."""
|
|
47
|
+
return len(text) // 4
|
|
48
|
+
|
|
49
|
+
def propose_memory_ops(
|
|
50
|
+
self, pulse: Pulse, emails: list[dict[str, str]]
|
|
51
|
+
) -> list[dict[str, object]]:
|
|
52
|
+
"""Decide ops the way the instructions describe."""
|
|
53
|
+
ops: list[dict[str, object]] = []
|
|
54
|
+
for email in emails:
|
|
55
|
+
if email["id"] in pulse.memory.seen.get("emails", []):
|
|
56
|
+
continue
|
|
57
|
+
ops.append({"op": "seen", "field": "emails", "id": email["id"]})
|
|
58
|
+
if pulse.seconds_until_available("notify") > 0:
|
|
59
|
+
ops.append({"op": "note", "text": "Held alert (throttle)."})
|
|
60
|
+
continue
|
|
61
|
+
print(f"[ALERT] {email['subject']} ({email['id']})")
|
|
62
|
+
ops.append({"op": "throttle", "field": "notify"})
|
|
63
|
+
ops.append(
|
|
64
|
+
{"op": "note", "text": f"Notified about {email['id']}."}
|
|
65
|
+
)
|
|
66
|
+
return ops
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
adapter = FakeAdapter()
|
|
70
|
+
|
|
71
|
+
# REAL_ADAPTER: swap the block below in (and
|
|
72
|
+
# `pip install context_driven_llm_scheduler[litellm]`) to run on any provider — the model emits
|
|
73
|
+
# the same ops. Call defaults (timeout, retries, ...) are configured once at
|
|
74
|
+
# construction, not per call:
|
|
75
|
+
#
|
|
76
|
+
# from context_driven_llm_scheduler import LiteLLMAdapter
|
|
77
|
+
# adapter = LiteLLMAdapter(
|
|
78
|
+
# "anthropic/claude-sonnet-4-6", timeout=30, num_retries=2,
|
|
79
|
+
# )
|
|
80
|
+
# # inside the handler:
|
|
81
|
+
# prompt = pulse.recall(extra={"emails": emails})
|
|
82
|
+
# return adapter.propose_memory_ops(prompt)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@manager.pulse("inbox-triage")
|
|
86
|
+
def triage(pulse: Pulse, extra: dict | None = None) -> list[dict[str, object]]:
|
|
87
|
+
"""Recall memory, let the model decide, return memory ops to persist."""
|
|
88
|
+
emails = fetch_urgent_emails()
|
|
89
|
+
prompt = pulse.recall(extra={"emails": emails})
|
|
90
|
+
print("--- prompt the model would see ---")
|
|
91
|
+
print(prompt)
|
|
92
|
+
print("----------------------------------")
|
|
93
|
+
return adapter.propose_memory_ops(pulse, emails)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
result = manager.trigger("inbox-triage")
|
|
98
|
+
print(f"notes={result['memory']['notes']}")
|