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/__init__.py +3 -0
- pascal/__main__.py +880 -0
- pascal/actions.py +1066 -0
- pascal/capability.py +218 -0
- pascal/channels/__init__.py +0 -0
- pascal/channels/telegram.py +108 -0
- pascal/clipboard.py +38 -0
- pascal/config.py +134 -0
- pascal/daemon.py +211 -0
- pascal/desk.py +633 -0
- pascal/effect.py +155 -0
- pascal/eval/__init__.py +1 -0
- pascal/eval/smoke.py +213 -0
- pascal/llm/__init__.py +1 -0
- pascal/llm/anthropic.py +225 -0
- pascal/llm/codex.py +331 -0
- pascal/llm/openai.py +224 -0
- pascal/loop.py +1037 -0
- pascal/mcp.py +206 -0
- pascal/prompt.py +141 -0
- pascal/receipts.py +147 -0
- pascal/sandbox.py +287 -0
- pascal/scheduler.py +243 -0
- pascal/schemas.py +183 -0
- pascal/state.py +790 -0
- pascal/tools.py +672 -0
- pascal/trust.py +150 -0
- pascal/types.py +337 -0
- pascal/uia.py +316 -0
- pascal_agent-0.3.0.dist-info/METADATA +262 -0
- pascal_agent-0.3.0.dist-info/RECORD +33 -0
- pascal_agent-0.3.0.dist-info/WHEEL +4 -0
- pascal_agent-0.3.0.dist-info/entry_points.txt +2 -0
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()
|