pascal-agent 0.3.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.
pascal/daemon.py ADDED
@@ -0,0 +1,211 @@
1
+ """Pascal daemon -- always-on mode with Telegram + loop + scheduler."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from pascal.config import create_llm
9
+ from pascal.loop import run_loop
10
+ from pascal.receipts import Ledger
11
+ from pascal.state import PascalStore
12
+ from pascal import tools
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _PASCAL_DIR = Path.home() / ".pascal"
17
+
18
+
19
+ def _check_stop_pause() -> str:
20
+ """Check for STOP/PAUSE control files. Returns 'stop', 'pause', or ''."""
21
+ if (_PASCAL_DIR / "STOP").exists():
22
+ return "stop"
23
+ if (_PASCAL_DIR / "PAUSE").exists():
24
+ return "pause"
25
+ return ""
26
+
27
+
28
+ async def _resilient(coro_fn, name: str, max_retries: int = 10):
29
+ """Run a coroutine with automatic restart on crash."""
30
+ for attempt in range(max_retries):
31
+ try:
32
+ await coro_fn()
33
+ return # clean exit
34
+ except asyncio.CancelledError:
35
+ raise
36
+ except Exception:
37
+ logger.exception("%s crashed, retry %d/%d", name, attempt + 1, max_retries)
38
+ await asyncio.sleep(min(2 ** attempt, 60))
39
+ logger.critical("%s exceeded max retries, giving up", name)
40
+ raise RuntimeError(f"{name} failed after {max_retries} retries")
41
+
42
+
43
+ def _compute_interval(store: PascalStore) -> int:
44
+ """Adaptive heartbeat: 5min if work exists, 30min if idle."""
45
+ if store.get_active_task():
46
+ return 300
47
+ if store.get_pending_tasks():
48
+ return 300
49
+ if store.get_pending_notifications():
50
+ return 300
51
+ return 1800
52
+
53
+
54
+ def _resolve_max_tool_rounds(config, args) -> int:
55
+ """Resolve max_tool_rounds for daemon mode. max_inner_turns is deprecated alias."""
56
+ cli_rounds = getattr(args, "max_tool_rounds", None)
57
+ if cli_rounds is not None:
58
+ return max(1, int(cli_rounds))
59
+ config_rounds = getattr(config, "max_tool_rounds", None)
60
+ if config_rounds is not None:
61
+ return max(1, int(config_rounds))
62
+ cli_turns = getattr(args, "max_inner_turns", None)
63
+ if cli_turns is not None:
64
+ return max(1, int(cli_turns))
65
+ config_turns = getattr(config, "max_inner_turns", None)
66
+ if config_turns is not None:
67
+ return max(1, int(config_turns))
68
+ return 3
69
+
70
+
71
+ async def _scheduler_loop(store: PascalStore, wake_event: asyncio.Event):
72
+ """Periodic scheduler tick with adaptive interval. Wakes loop on events or due routines."""
73
+ from pascal.scheduler import Scheduler
74
+ scheduler = Scheduler(store, emit=logger.info)
75
+ while True:
76
+ interval = _compute_interval(store)
77
+ await asyncio.sleep(interval)
78
+ try:
79
+ result = scheduler.tick()
80
+ if (result.get("overdue") or result.get("stale")
81
+ or result.get("pending_notifications") or result.get("due_routines")):
82
+ wake_event.set()
83
+ except Exception:
84
+ logger.exception("Scheduler tick failed")
85
+
86
+
87
+ async def run_daemon(config, args) -> None:
88
+ """Main daemon entry point. Runs Telegram bot + Pascal loop + scheduler."""
89
+ # Daemon always shows INFO-level logs (need to see messages/actions)
90
+ if not logging.root.handlers:
91
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s", datefmt="%H:%M:%S")
92
+ store = PascalStore(config.db_path)
93
+ # Clear only expired locks from previous crash -- never evict a live lock
94
+ store.release_lock(force=True)
95
+ wake_event = asyncio.Event()
96
+
97
+ # LLM provider
98
+ llm = create_llm(config)
99
+
100
+ # Audit ledger
101
+ ledger_path = Path(config.db_path).parent / "audit.jsonl"
102
+ ledger = Ledger(str(ledger_path))
103
+
104
+ # MCP (optional)
105
+ from pascal.mcp import MCPManager
106
+ mcp_manager = MCPManager()
107
+ try:
108
+ from pascal.__main__ import _load_mcp_configs
109
+ mcp_configs = _load_mcp_configs(config)
110
+ if mcp_configs:
111
+ await mcp_manager.connect_all(mcp_configs)
112
+ except Exception:
113
+ logger.warning("MCP init failed, continuing without MCP")
114
+
115
+ # Telegram
116
+ from pascal.channels.telegram import load_telegram_config, create_telegram_bot
117
+ tg_config = load_telegram_config()
118
+ bot, start_polling, send_approval, owner_chat_id = create_telegram_bot(
119
+ store, wake_event, tg_config,
120
+ )
121
+ tools.set_channel_bot(bot, owner_chat_id, store=store)
122
+ tools.set_approval_callback(send_approval)
123
+
124
+ # Mission
125
+ if getattr(args, "mission", None):
126
+ store.set_context("mission", args.mission)
127
+
128
+ # Identity seeding moved to run_loop() — works in both daemon and one-shot mode
129
+
130
+ # Live status broadcast → Telegram + Discord webhooks
131
+ from pascal.loop import add_status_callback
132
+ from pascal.scheduler import _send_webhook
133
+ import os as _os
134
+ discord_url = _os.environ.get("PASCAL_DISCORD_WEBHOOK", "")
135
+ slack_url = _os.environ.get("PASCAL_SLACK_WEBHOOK", "")
136
+
137
+ def _broadcast_to_messengers(summary: str) -> None:
138
+ """Send live action status to configured channels."""
139
+ # Skip think actions to avoid noise
140
+ if summary.startswith("[💭]"):
141
+ return
142
+ if discord_url:
143
+ _send_webhook(discord_url, summary)
144
+ if slack_url:
145
+ _send_webhook(slack_url, summary)
146
+
147
+ add_status_callback(_broadcast_to_messengers)
148
+
149
+ print(f"Pascal daemon starting (model: {config.model})")
150
+ print(f"Telegram: owner={owner_chat_id}")
151
+ print(f"DB: {config.db_path}")
152
+ if mcp_manager.connected_servers:
153
+ print(f"MCP: {', '.join(mcp_manager.connected_servers)}")
154
+ if discord_url:
155
+ print("Discord webhook: configured")
156
+ if slack_url:
157
+ print("Slack webhook: configured")
158
+
159
+ def _log_action(action: dict) -> None:
160
+ a = action["action"]
161
+ reason = action.get("reason", "")
162
+ result = action.get("result", {})
163
+ status = result.get("status", "") if isinstance(result, dict) else ""
164
+ logger.info(" [%s] %s %s", a, reason, f"({status})" if status else "")
165
+
166
+ base_max_tool_rounds = _resolve_max_tool_rounds(config, args)
167
+ _STRATEGY_ROUNDS = {"fast": base_max_tool_rounds + 2, "careful": max(1, base_max_tool_rounds - 1), "balanced": base_max_tool_rounds}
168
+
169
+ async def loop_task():
170
+ """Run the Pascal loop continuously. Respects STOP/PAUSE control files."""
171
+ while True:
172
+ signal = _check_stop_pause()
173
+ if signal == "stop":
174
+ logger.info("STOP file detected — shutting down gracefully")
175
+ return
176
+ if signal == "pause":
177
+ logger.info("PAUSE file detected — waiting...")
178
+ while _check_stop_pause() == "pause":
179
+ await asyncio.sleep(5)
180
+ if _check_stop_pause() == "stop":
181
+ return
182
+ logger.info("PAUSE cleared — resuming")
183
+ # Apply evolution strategy to max_tool_rounds
184
+ strategy = store.get_context("evolution_strategy") or "balanced"
185
+ effective_rounds = _STRATEGY_ROUNDS.get(strategy, base_max_tool_rounds)
186
+ try:
187
+ await run_loop(
188
+ store, llm,
189
+ verifier_llm=llm,
190
+ ledger=ledger,
191
+ mcp_manager=mcp_manager if mcp_manager.connected_servers else None,
192
+ max_effect=getattr(args, "max_effect", None) or config.max_effect or "E3",
193
+ max_iterations=0,
194
+ max_tool_rounds=effective_rounds,
195
+ wake_event=wake_event,
196
+ on_action=_log_action,
197
+ )
198
+ except Exception:
199
+ logger.exception("Loop cycle error")
200
+ logger.info("Loop cycle complete, restarting")
201
+ await asyncio.sleep(1)
202
+
203
+ try:
204
+ await asyncio.gather(
205
+ _resilient(start_polling, "telegram"),
206
+ _resilient(loop_task, "loop"),
207
+ _resilient(lambda: _scheduler_loop(store, wake_event), "scheduler"), # adaptive interval
208
+ )
209
+ finally:
210
+ await mcp_manager.disconnect_all()
211
+ store.close()