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/capability.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Capability tracking for Pascal's domain-level trust map."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, ClassVar, Literal
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_DOMAINS: dict[str, float] = {
|
|
12
|
+
"email": 0.5,
|
|
13
|
+
"scheduling": 0.5,
|
|
14
|
+
"file_ops": 0.5,
|
|
15
|
+
"communication": 0.5,
|
|
16
|
+
"analysis": 0.5,
|
|
17
|
+
"planning": 0.5,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_CAPABILITY_SCHEMA = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS capability (
|
|
22
|
+
key TEXT PRIMARY KEY,
|
|
23
|
+
value TEXT NOT NULL,
|
|
24
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
25
|
+
);
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_CAPABILITY_KEY = "trust_map"
|
|
29
|
+
_SUCCESS_DELTA = 0.02
|
|
30
|
+
_JUDGMENT_FAILURE_DELTA = 0.10
|
|
31
|
+
_NEUTRAL_TRUST = 0.5
|
|
32
|
+
_HIGH_ERROR_STREAK = 3
|
|
33
|
+
_VERY_HIGH_ERROR_STREAK = 5
|
|
34
|
+
_HIGH_ERROR_PENALTY = 0.15
|
|
35
|
+
_VERY_HIGH_ERROR_PENALTY = 0.25
|
|
36
|
+
|
|
37
|
+
Decision = Literal["go", "caution", "block"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def infer_domain(action_type: str | None, tool_name: str | None) -> str:
|
|
41
|
+
"""Infer a capability domain from an action and tool name."""
|
|
42
|
+
haystack = f"{action_type or ''} {tool_name or ''}".lower()
|
|
43
|
+
|
|
44
|
+
if _contains_any(haystack, ("email", "gmail", "mail", "inbox")):
|
|
45
|
+
return "email"
|
|
46
|
+
if _contains_any(haystack, ("schedule", "calendar", "meeting", "reminder", "cron")):
|
|
47
|
+
return "scheduling"
|
|
48
|
+
if _contains_any(haystack, ("file", "filesystem", "directory", "path", "read", "write", "edit")):
|
|
49
|
+
return "file_ops"
|
|
50
|
+
if _contains_any(haystack, ("telegram", "message", "chat", "notify", "slack", "discord", "sms")):
|
|
51
|
+
return "communication"
|
|
52
|
+
if _contains_any(haystack, ("plan", "todo", "task", "roadmap", "outline", "planner")):
|
|
53
|
+
return "planning"
|
|
54
|
+
return "analysis"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class TrustMap:
|
|
59
|
+
"""Domain-specific trust scores with asymmetric learning."""
|
|
60
|
+
|
|
61
|
+
domains: dict[str, float] = field(default_factory=lambda: dict(DEFAULT_DOMAINS))
|
|
62
|
+
|
|
63
|
+
DOMAIN_NAMES: ClassVar[tuple[str, ...]] = tuple(DEFAULT_DOMAINS)
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
merged_domains = dict(DEFAULT_DOMAINS)
|
|
67
|
+
merged_domains.update(self.domains)
|
|
68
|
+
self.domains = {name: _clamp_score(score) for name, score in merged_domains.items()}
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def overall(self) -> float:
|
|
72
|
+
"""Average domain score for backward compatibility."""
|
|
73
|
+
return sum(self.domains.values()) / len(self.domains)
|
|
74
|
+
|
|
75
|
+
def update_on_success(self, domain: str) -> float:
|
|
76
|
+
"""Increase trust slowly after a successful action."""
|
|
77
|
+
key = _require_domain(domain)
|
|
78
|
+
self.domains[key] = _clamp_score(self.domains[key] + _SUCCESS_DELTA)
|
|
79
|
+
return self.domains[key]
|
|
80
|
+
|
|
81
|
+
def update_on_failure(self, domain: str, is_judgment_error: bool) -> float:
|
|
82
|
+
"""Penalize only Pascal's judgment mistakes, not tool or env failures."""
|
|
83
|
+
key = _require_domain(domain)
|
|
84
|
+
if is_judgment_error:
|
|
85
|
+
self.domains[key] = _clamp_score(self.domains[key] - _JUDGMENT_FAILURE_DELTA)
|
|
86
|
+
return self.domains[key]
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
return {"domains": dict(self.domains)}
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "TrustMap":
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
return cls()
|
|
95
|
+
domains = data.get("domains")
|
|
96
|
+
return cls(
|
|
97
|
+
domains=domains if isinstance(domains, dict) else dict(DEFAULT_DOMAINS),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def save(self, conn: sqlite3.Connection) -> None:
|
|
101
|
+
"""Persist the trust map as JSON in the capability table."""
|
|
102
|
+
_ensure_capability_table(conn)
|
|
103
|
+
payload = json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True)
|
|
104
|
+
conn.execute(
|
|
105
|
+
"""INSERT INTO capability (key, value, updated_at)
|
|
106
|
+
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
107
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
108
|
+
value = excluded.value,
|
|
109
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
|
|
110
|
+
(_CAPABILITY_KEY, payload),
|
|
111
|
+
)
|
|
112
|
+
conn.commit()
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def load(cls, conn: sqlite3.Connection) -> "TrustMap":
|
|
116
|
+
"""Load the persisted trust map or return defaults."""
|
|
117
|
+
_ensure_capability_table(conn)
|
|
118
|
+
row = conn.execute(
|
|
119
|
+
"SELECT value FROM capability WHERE key = ?",
|
|
120
|
+
(_CAPABILITY_KEY,),
|
|
121
|
+
).fetchone()
|
|
122
|
+
if row is None:
|
|
123
|
+
return cls()
|
|
124
|
+
try:
|
|
125
|
+
return cls.from_dict(json.loads(row[0]))
|
|
126
|
+
except (json.JSONDecodeError, TypeError):
|
|
127
|
+
return cls()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def compute_threshold(
|
|
131
|
+
effect_level: str,
|
|
132
|
+
*,
|
|
133
|
+
trust_map: TrustMap | None = None,
|
|
134
|
+
domain: str | None = None,
|
|
135
|
+
recent_error_streak: int = 0,
|
|
136
|
+
) -> Decision:
|
|
137
|
+
"""Return a simple action gate from effect level, trust, and recent failures.
|
|
138
|
+
|
|
139
|
+
This heuristic intentionally ignores any LLM self-reported confidence.
|
|
140
|
+
"""
|
|
141
|
+
level = _normalize_effect_level(effect_level)
|
|
142
|
+
if level == "E0":
|
|
143
|
+
return "go"
|
|
144
|
+
if level == "E5":
|
|
145
|
+
return "block"
|
|
146
|
+
|
|
147
|
+
readiness_score = _readiness_score(
|
|
148
|
+
trust_map=trust_map,
|
|
149
|
+
domain=domain,
|
|
150
|
+
recent_error_streak=recent_error_streak,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if level == "E1":
|
|
154
|
+
return "go" if readiness_score >= 0.3 else "caution"
|
|
155
|
+
if level == "E2":
|
|
156
|
+
return "go" if readiness_score >= 0.4 else "caution"
|
|
157
|
+
if level == "E3":
|
|
158
|
+
return "go" if readiness_score >= 0.6 else "caution"
|
|
159
|
+
if readiness_score < 0.4:
|
|
160
|
+
return "block"
|
|
161
|
+
return "caution"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _ensure_capability_table(conn: sqlite3.Connection) -> None:
|
|
165
|
+
conn.execute(_CAPABILITY_SCHEMA)
|
|
166
|
+
conn.commit()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _normalize_effect_level(effect_level: str) -> str:
|
|
170
|
+
if effect_level in {"E0", "E1", "E2", "E3", "E4", "E5"}:
|
|
171
|
+
return effect_level
|
|
172
|
+
return "E2"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _readiness_score(
|
|
176
|
+
*,
|
|
177
|
+
trust_map: TrustMap | None,
|
|
178
|
+
domain: str | None,
|
|
179
|
+
recent_error_streak: int,
|
|
180
|
+
) -> float:
|
|
181
|
+
trust_score = _domain_trust(trust_map, domain)
|
|
182
|
+
penalty = _error_streak_penalty(recent_error_streak)
|
|
183
|
+
return _clamp_score(trust_score - penalty)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _domain_trust(trust_map: TrustMap | None, domain: str | None) -> float:
|
|
187
|
+
if trust_map is None:
|
|
188
|
+
return _NEUTRAL_TRUST
|
|
189
|
+
if domain is not None and domain in trust_map.domains:
|
|
190
|
+
return trust_map.domains[domain]
|
|
191
|
+
return trust_map.overall
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _error_streak_penalty(recent_error_streak: int) -> float:
|
|
195
|
+
streak = max(0, int(recent_error_streak))
|
|
196
|
+
if streak >= _VERY_HIGH_ERROR_STREAK:
|
|
197
|
+
return _VERY_HIGH_ERROR_PENALTY
|
|
198
|
+
if streak >= _HIGH_ERROR_STREAK:
|
|
199
|
+
return _HIGH_ERROR_PENALTY
|
|
200
|
+
return 0.0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _require_domain(domain: str) -> str:
|
|
204
|
+
if domain not in DEFAULT_DOMAINS:
|
|
205
|
+
raise KeyError(f"Unknown capability domain: {domain}")
|
|
206
|
+
return domain
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _contains_any(text: str, needles: tuple[str, ...]) -> bool:
|
|
210
|
+
return any(needle in text for needle in needles)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _clamp_score(value: Any) -> float:
|
|
214
|
+
try:
|
|
215
|
+
number = float(value)
|
|
216
|
+
except (TypeError, ValueError):
|
|
217
|
+
number = 0.0
|
|
218
|
+
return round(min(1.0, max(0.0, number)), 6)
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Telegram channel adapter -- aiogram 3.x, DM-only, single user."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_telegram_config() -> dict[str, Any]:
|
|
13
|
+
"""Load bot_token and owner_chat_id from ~/.pascal/telegram.json."""
|
|
14
|
+
config_path = Path.home() / ".pascal" / "telegram.json"
|
|
15
|
+
if not config_path.exists():
|
|
16
|
+
raise FileNotFoundError(
|
|
17
|
+
f"Telegram config not found at {config_path}. "
|
|
18
|
+
'Create it with: {"bot_token": "...", "owner_chat_id": 12345}'
|
|
19
|
+
)
|
|
20
|
+
return json.loads(config_path.read_text(encoding="utf-8"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_telegram_bot(store, wake_event, config: dict[str, Any]):
|
|
24
|
+
"""Create aiogram Bot + Dispatcher. Returns (bot, start_coro, send_approval, owner_chat_id).
|
|
25
|
+
|
|
26
|
+
start_coro is an async function that runs the long poll loop.
|
|
27
|
+
send_approval is an async function for approval request buttons.
|
|
28
|
+
"""
|
|
29
|
+
from aiogram import Bot, Dispatcher, Router, F
|
|
30
|
+
from aiogram.types import (
|
|
31
|
+
Message, CallbackQuery,
|
|
32
|
+
InlineKeyboardMarkup, InlineKeyboardButton,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
bot_token = config["bot_token"]
|
|
36
|
+
owner_chat_id = int(config["owner_chat_id"])
|
|
37
|
+
|
|
38
|
+
bot = Bot(token=bot_token)
|
|
39
|
+
dp = Dispatcher()
|
|
40
|
+
router = Router()
|
|
41
|
+
dp.include_router(router)
|
|
42
|
+
|
|
43
|
+
@router.message()
|
|
44
|
+
async def on_message(message: Message):
|
|
45
|
+
if message.chat.id != owner_chat_id:
|
|
46
|
+
return
|
|
47
|
+
if not message.text:
|
|
48
|
+
return
|
|
49
|
+
# Immediate ack -- user knows we received it
|
|
50
|
+
try:
|
|
51
|
+
from aiogram.types import ReactionTypeEmoji
|
|
52
|
+
await message.react([ReactionTypeEmoji(emoji="👀")])
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
store.push_conversation_turn("telegram", "user", message.text)
|
|
56
|
+
store.push_notification(
|
|
57
|
+
source="telegram",
|
|
58
|
+
message=message.text,
|
|
59
|
+
priority="normal",
|
|
60
|
+
metadata={
|
|
61
|
+
"chat_id": str(message.chat.id),
|
|
62
|
+
"message_id": str(message.message_id),
|
|
63
|
+
"user": message.from_user.full_name if message.from_user else "unknown",
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
wake_event.set()
|
|
67
|
+
logger.info("Telegram message -> notification: %s", message.text[:80])
|
|
68
|
+
|
|
69
|
+
@router.callback_query(F.data.startswith("approve:") | F.data.startswith("deny:"))
|
|
70
|
+
async def on_approval(callback: CallbackQuery):
|
|
71
|
+
if callback.message and callback.message.chat.id != owner_chat_id:
|
|
72
|
+
return
|
|
73
|
+
parts = (callback.data or "").split(":", 1)
|
|
74
|
+
if len(parts) != 2:
|
|
75
|
+
return
|
|
76
|
+
action_type, task_id = parts
|
|
77
|
+
approved = action_type == "approve"
|
|
78
|
+
store.push_notification(
|
|
79
|
+
source="approval",
|
|
80
|
+
message=f"{'approved' if approved else 'denied'}: {task_id}",
|
|
81
|
+
priority="urgent",
|
|
82
|
+
metadata={"task_id": task_id, "approved": approved},
|
|
83
|
+
)
|
|
84
|
+
await callback.answer("Approved!" if approved else "Denied.")
|
|
85
|
+
try:
|
|
86
|
+
msg = callback.message
|
|
87
|
+
if msg is not None and hasattr(msg, "edit_reply_markup"):
|
|
88
|
+
await msg.edit_reply_markup(reply_markup=None)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
wake_event.set()
|
|
92
|
+
logger.info("Approval %s for task %s", action_type, task_id)
|
|
93
|
+
|
|
94
|
+
async def send_approval_request(chat_id: int, task_id: str, question: str):
|
|
95
|
+
keyboard = InlineKeyboardMarkup(inline_keyboard=[[
|
|
96
|
+
InlineKeyboardButton(text="Approve", callback_data=f"approve:{task_id}"),
|
|
97
|
+
InlineKeyboardButton(text="Deny", callback_data=f"deny:{task_id}"),
|
|
98
|
+
]])
|
|
99
|
+
await bot.send_message(chat_id, f"Approval needed:\n{question}", reply_markup=keyboard)
|
|
100
|
+
|
|
101
|
+
async def start_polling():
|
|
102
|
+
logger.info("Telegram bot starting (owner: %d)", owner_chat_id)
|
|
103
|
+
try:
|
|
104
|
+
await dp.start_polling(bot, handle_signals=False)
|
|
105
|
+
finally:
|
|
106
|
+
await bot.session.close()
|
|
107
|
+
|
|
108
|
+
return bot, start_polling, send_approval_request, owner_chat_id
|
pascal/clipboard.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Clipboard helper -- text-only get/set via pywin32 with pyperclip fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_text() -> str:
|
|
7
|
+
"""Read text from clipboard."""
|
|
8
|
+
try:
|
|
9
|
+
import win32clipboard
|
|
10
|
+
except ImportError:
|
|
11
|
+
import pyperclip
|
|
12
|
+
return str(pyperclip.paste() or "")
|
|
13
|
+
|
|
14
|
+
win32clipboard.OpenClipboard()
|
|
15
|
+
try:
|
|
16
|
+
if win32clipboard.IsClipboardFormatAvailable(win32clipboard.CF_UNICODETEXT):
|
|
17
|
+
data = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT)
|
|
18
|
+
return str(data) if data else ""
|
|
19
|
+
return ""
|
|
20
|
+
finally:
|
|
21
|
+
win32clipboard.CloseClipboard()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def set_text(text: str) -> None:
|
|
25
|
+
"""Write text to clipboard."""
|
|
26
|
+
try:
|
|
27
|
+
import win32clipboard
|
|
28
|
+
except ImportError:
|
|
29
|
+
import pyperclip
|
|
30
|
+
pyperclip.copy(text)
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
win32clipboard.OpenClipboard()
|
|
34
|
+
try:
|
|
35
|
+
win32clipboard.EmptyClipboard()
|
|
36
|
+
win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT)
|
|
37
|
+
finally:
|
|
38
|
+
win32clipboard.CloseClipboard()
|
pascal/config.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""pascal/config.py -- PascalConfig 로드 (환경변수 + 기본값)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pascal.types import PascalConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_dotenv() -> None:
|
|
13
|
+
"""Load ~/.pascal/.env into os.environ (won't overwrite existing vars)."""
|
|
14
|
+
env_path = Path.home() / ".pascal" / ".env"
|
|
15
|
+
if not env_path.is_file():
|
|
16
|
+
return
|
|
17
|
+
try:
|
|
18
|
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
19
|
+
line = line.strip()
|
|
20
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
21
|
+
continue
|
|
22
|
+
key, _, value = line.partition("=")
|
|
23
|
+
key = key.strip()
|
|
24
|
+
value = value.strip().strip("'\"")
|
|
25
|
+
if key and key not in os.environ: # don't overwrite
|
|
26
|
+
os.environ[key] = value
|
|
27
|
+
except OSError:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Auto-load on import
|
|
32
|
+
_load_dotenv()
|
|
33
|
+
|
|
34
|
+
_ENV_MAP: dict[str, tuple[str, type]] = {
|
|
35
|
+
"model": ("PASCAL_MODEL", str),
|
|
36
|
+
"provider": ("PASCAL_PROVIDER", str),
|
|
37
|
+
"base_url": ("PASCAL_BASE_URL", str),
|
|
38
|
+
"db_path": ("PASCAL_DB_PATH", str),
|
|
39
|
+
"max_effect": ("PASCAL_MAX_EFFECT", str),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_EXTRA_ENV_MAP: dict[str, tuple[str, type]] = {
|
|
43
|
+
"max_inner_turns": ("PASCAL_MAX_INNER_TURNS", int),
|
|
44
|
+
"max_tool_rounds": ("PASCAL_MAX_TOOL_ROUNDS", int),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _config_paths() -> list[Path]:
|
|
49
|
+
seen: set[Path] = set()
|
|
50
|
+
paths: list[Path] = []
|
|
51
|
+
for path in (Path.cwd() / "pascal.toml", Path.home() / ".pascal" / "pascal.toml"):
|
|
52
|
+
try:
|
|
53
|
+
key = path.resolve()
|
|
54
|
+
except OSError:
|
|
55
|
+
key = path.absolute()
|
|
56
|
+
if key in seen:
|
|
57
|
+
continue
|
|
58
|
+
seen.add(key)
|
|
59
|
+
paths.append(path)
|
|
60
|
+
return paths
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_toml_config() -> dict[str, object]:
|
|
64
|
+
for path in _config_paths():
|
|
65
|
+
if not path.is_file():
|
|
66
|
+
continue
|
|
67
|
+
with path.open("rb") as f:
|
|
68
|
+
payload = tomllib.load(f)
|
|
69
|
+
section = payload.get("pascal")
|
|
70
|
+
if isinstance(section, dict):
|
|
71
|
+
return section
|
|
72
|
+
return {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_typed_values(raw: dict[str, object], mapping: dict[str, tuple[str, type]]) -> dict[str, object]:
|
|
76
|
+
values: dict[str, object] = {}
|
|
77
|
+
for field_name, (_, conv) in mapping.items():
|
|
78
|
+
if field_name not in raw or raw[field_name] is None:
|
|
79
|
+
continue
|
|
80
|
+
values[field_name] = conv(raw[field_name])
|
|
81
|
+
return values
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_extra_value(
|
|
85
|
+
name: str,
|
|
86
|
+
*,
|
|
87
|
+
toml_values: dict[str, object],
|
|
88
|
+
overrides: dict[str, object],
|
|
89
|
+
) -> int | None:
|
|
90
|
+
value = toml_values.get(name)
|
|
91
|
+
env_key, conv = _EXTRA_ENV_MAP[name]
|
|
92
|
+
env_val = os.environ.get(env_key)
|
|
93
|
+
if env_val is not None:
|
|
94
|
+
value = conv(env_val)
|
|
95
|
+
if name in overrides and overrides[name] is not None:
|
|
96
|
+
value = overrides[name]
|
|
97
|
+
if value is None:
|
|
98
|
+
return None
|
|
99
|
+
return max(1, int(str(value)))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_config(**overrides) -> PascalConfig:
|
|
103
|
+
"""PascalConfig를 생성한다. 우선순위: overrides > 환경변수 > pascal.toml > 기본값."""
|
|
104
|
+
toml_values = _load_toml_config()
|
|
105
|
+
kwargs: dict[str, object] = _load_typed_values(toml_values, _ENV_MAP)
|
|
106
|
+
for field_name, (env_key, conv) in _ENV_MAP.items():
|
|
107
|
+
env_val = os.environ.get(env_key)
|
|
108
|
+
if env_val is not None:
|
|
109
|
+
kwargs[field_name] = conv(env_val)
|
|
110
|
+
kwargs.update({k: v for k, v in overrides.items() if k in _ENV_MAP and v is not None})
|
|
111
|
+
|
|
112
|
+
config = PascalConfig(**kwargs) # type: ignore[arg-type] # kwargs built from validated ENV_MAP
|
|
113
|
+
max_inner = _resolve_extra_value("max_inner_turns", toml_values=toml_values, overrides=overrides)
|
|
114
|
+
if max_inner is not None:
|
|
115
|
+
config.max_inner_turns = max_inner
|
|
116
|
+
max_tool = _resolve_extra_value("max_tool_rounds", toml_values=toml_values, overrides=overrides)
|
|
117
|
+
if max_tool is not None:
|
|
118
|
+
config.max_tool_rounds = max_tool
|
|
119
|
+
return config
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def create_llm(config: PascalConfig):
|
|
123
|
+
"""Create an LLM provider from config. Avoids duplication between __main__ and daemon."""
|
|
124
|
+
if config.provider == "codex":
|
|
125
|
+
from pascal.llm.codex import CodexProvider
|
|
126
|
+
return CodexProvider(model=config.model)
|
|
127
|
+
elif config.provider == "openai":
|
|
128
|
+
from pascal.llm.openai import OpenAIProvider
|
|
129
|
+
return OpenAIProvider(model=config.model, base_url=config.base_url)
|
|
130
|
+
elif config.provider == "anthropic":
|
|
131
|
+
from pascal.llm.anthropic import AnthropicProvider
|
|
132
|
+
return AnthropicProvider(model=config.model, base_url=config.base_url)
|
|
133
|
+
else:
|
|
134
|
+
raise ValueError(f"Unknown provider: {config.provider}")
|