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.
Files changed (54) hide show
  1. context_driven_llm_scheduler-1.0.0/.gitignore +44 -0
  2. context_driven_llm_scheduler-1.0.0/LICENSE +21 -0
  3. context_driven_llm_scheduler-1.0.0/PKG-INFO +140 -0
  4. context_driven_llm_scheduler-1.0.0/README.md +110 -0
  5. context_driven_llm_scheduler-1.0.0/behave.ini +3 -0
  6. context_driven_llm_scheduler-1.0.0/examples/ai_agent_pulse.py +59 -0
  7. context_driven_llm_scheduler-1.0.0/examples/email_monitor.py +60 -0
  8. context_driven_llm_scheduler-1.0.0/examples/fastapi_bedrock.py +106 -0
  9. context_driven_llm_scheduler-1.0.0/examples/inbox_triage.py +98 -0
  10. context_driven_llm_scheduler-1.0.0/examples/multi_system_reuse.py +53 -0
  11. context_driven_llm_scheduler-1.0.0/examples/pulses/daily_digest.md +6 -0
  12. context_driven_llm_scheduler-1.0.0/examples/pulses/inbox_triage.md +16 -0
  13. context_driven_llm_scheduler-1.0.0/pyproject.toml +49 -0
  14. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/__init__.py +110 -0
  15. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/adapters/__init__.py +39 -0
  16. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/adapters/base.py +42 -0
  17. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/adapters/litellm_adapter.py +144 -0
  18. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/__init__.py +1 -0
  19. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/exceptions.py +13 -0
  20. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/manager.py +389 -0
  21. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/memory.py +227 -0
  22. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/pulse.py +479 -0
  23. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/store.py +68 -0
  24. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/core/types.py +27 -0
  25. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/result_log.py +249 -0
  26. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/schedulers/__init__.py +1 -0
  27. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/schedulers/apscheduler_wrapper.py +184 -0
  28. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/stores/__init__.py +6 -0
  29. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/stores/file.py +106 -0
  30. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/stores/sqlite.py +169 -0
  31. context_driven_llm_scheduler-1.0.0/src/context_driven_llm_scheduler/util.py +130 -0
  32. context_driven_llm_scheduler-1.0.0/tests/features/coalescing.feature +22 -0
  33. context_driven_llm_scheduler-1.0.0/tests/features/concurrency.feature +9 -0
  34. context_driven_llm_scheduler-1.0.0/tests/features/environment.py +23 -0
  35. context_driven_llm_scheduler-1.0.0/tests/features/error_resilience.feature +25 -0
  36. context_driven_llm_scheduler-1.0.0/tests/features/file_store.feature +16 -0
  37. context_driven_llm_scheduler-1.0.0/tests/features/markdown_authoring.feature +40 -0
  38. context_driven_llm_scheduler-1.0.0/tests/features/observability.feature +22 -0
  39. context_driven_llm_scheduler-1.0.0/tests/features/pulse_memory.feature +117 -0
  40. context_driven_llm_scheduler-1.0.0/tests/features/result_log.feature +30 -0
  41. context_driven_llm_scheduler-1.0.0/tests/features/spam_prevention.feature +18 -0
  42. context_driven_llm_scheduler-1.0.0/tests/features/sqlite_store.feature +19 -0
  43. context_driven_llm_scheduler-1.0.0/tests/features/steps/coalescing_steps.py +36 -0
  44. context_driven_llm_scheduler-1.0.0/tests/features/steps/common_steps.py +87 -0
  45. context_driven_llm_scheduler-1.0.0/tests/features/steps/concurrency_steps.py +26 -0
  46. context_driven_llm_scheduler-1.0.0/tests/features/steps/error_resilience_steps.py +59 -0
  47. context_driven_llm_scheduler-1.0.0/tests/features/steps/markdown_authoring_steps.py +38 -0
  48. context_driven_llm_scheduler-1.0.0/tests/features/steps/observability_steps.py +31 -0
  49. context_driven_llm_scheduler-1.0.0/tests/features/steps/pulse_memory_steps.py +202 -0
  50. context_driven_llm_scheduler-1.0.0/tests/features/steps/result_log_steps.py +36 -0
  51. context_driven_llm_scheduler-1.0.0/tests/features/steps/spam_prevention_steps.py +51 -0
  52. context_driven_llm_scheduler-1.0.0/tests/features/steps/sqlite_store_steps.py +10 -0
  53. context_driven_llm_scheduler-1.0.0/tests/features/steps/trigger_cycle_steps.py +16 -0
  54. 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
+ [![PyPI version](https://img.shields.io/pypi/v/context-driven-llm-scheduler.svg)](https://pypi.org/project/context-driven-llm-scheduler/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/context-driven-llm-scheduler.svg)](https://pypi.org/project/context-driven-llm-scheduler/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/context-driven-llm-scheduler.svg)](https://pypi.org/project/context-driven-llm-scheduler/)
6
+ [![Python versions](https://img.shields.io/pypi/pyversions/context-driven-llm-scheduler.svg)](https://pypi.org/project/context-driven-llm-scheduler/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,3 @@
1
+ [behave]
2
+ paths = tests/features
3
+ show_timings = false
@@ -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']}")