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/effect.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Effect Ladder -- classify and gate action side effects.
|
|
2
|
+
|
|
3
|
+
Hard-rules only:
|
|
4
|
+
1. Hard patterns: commands that are ALWAYS a specific level (regex-based).
|
|
5
|
+
2. Safe commands: explicitly classified as E0 (read-only).
|
|
6
|
+
3. Fallback: unrecognized commands default to E2 (conservative).
|
|
7
|
+
LLM self-reported effect_level is ignored.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
EFFECT_LEVELS = {
|
|
16
|
+
"E0": "observe",
|
|
17
|
+
"E1": "transform",
|
|
18
|
+
"E2": "draft",
|
|
19
|
+
"E3": "stage",
|
|
20
|
+
"E4": "commit",
|
|
21
|
+
"E5": "irreversible",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
DEFAULT_MAX_EFFECT = "E2"
|
|
25
|
+
|
|
26
|
+
_HARD_RULES: list[tuple[str, re.Pattern]] = [
|
|
27
|
+
# E5 -- irreversible
|
|
28
|
+
("E5", re.compile(r"\brm\b\s+.*-[rRf]", re.I)),
|
|
29
|
+
("E5", re.compile(r"\brm\b\s+-[rRf]", re.I)),
|
|
30
|
+
("E5", re.compile(r"\bsudo\b")),
|
|
31
|
+
("E5", re.compile(r"\bmkfs\b")),
|
|
32
|
+
("E5", re.compile(r"\bshutdown\b")),
|
|
33
|
+
("E5", re.compile(r"\breboot\b")),
|
|
34
|
+
("E5", re.compile(r"\bdd\b\s+.*of=")),
|
|
35
|
+
("E5", re.compile(r"\bdrop\s+(table|database)\b", re.I)),
|
|
36
|
+
("E5", re.compile(r"\btruncate\s+table\b", re.I)),
|
|
37
|
+
("E5", re.compile(r"\bchmod\b.*777")),
|
|
38
|
+
("E5", re.compile(r"\bkubectl\s+delete\b")),
|
|
39
|
+
("E5", re.compile(r"\bformat\s+[a-z]:", re.I)),
|
|
40
|
+
# E5 -- Windows irreversible
|
|
41
|
+
("E5", re.compile(r"\brd\s+/s\s+/q\b", re.I)),
|
|
42
|
+
("E5", re.compile(r"\bdel\s+/[sfq]", re.I)),
|
|
43
|
+
("E5", re.compile(r"\bRemove-Item\b.*-Recurse", re.I)),
|
|
44
|
+
# E5 -- shell wrappers hiding arbitrary commands
|
|
45
|
+
("E5", re.compile(r"\bpowershell\b.*-(?:e|EncodedCommand)\s", re.I)),
|
|
46
|
+
("E5", re.compile(r"\bcmd\s+/c\s+.*(?:del|rd|format)\b", re.I)),
|
|
47
|
+
# E4 -- real external writes
|
|
48
|
+
("E4", re.compile(r"\bgit\s+push\s+.*(?:main|master)\b")),
|
|
49
|
+
("E4", re.compile(r"\bgit\s+merge\b")),
|
|
50
|
+
("E4", re.compile(r"\bgh\s+pr\s+merge\b")),
|
|
51
|
+
("E4", re.compile(r"\bgh\s+issue\s+(?:edit|close|delete)\b")),
|
|
52
|
+
("E4", re.compile(r"\bcurl\b.*-X\s*(?:POST|PUT|DELETE|PATCH)", re.I)),
|
|
53
|
+
("E4", re.compile(r"\bcurl\b.*--request\s+(?:POST|PUT|DELETE|PATCH)", re.I)),
|
|
54
|
+
("E4", re.compile(r"\bcurl\b.*(?:-d\s|--data)", re.I)),
|
|
55
|
+
("E4", re.compile(r"\bcurl\b.*(?:-F\s|--form)", re.I)),
|
|
56
|
+
("E4", re.compile(r"\bcurl\b.*(?:-T\s|--upload-file)", re.I)),
|
|
57
|
+
("E4", re.compile(r"\bnpm\s+publish\b")),
|
|
58
|
+
("E4", re.compile(r"\bkubectl\s+(?:apply|create|patch|scale)\b")),
|
|
59
|
+
("E4", re.compile(r"\bterraform\s+(?:apply|destroy)\b")),
|
|
60
|
+
("E4", re.compile(r"\bscp\b")),
|
|
61
|
+
("E4", re.compile(r"\brsync\b.*[^-](?:--delete|--remove)")),
|
|
62
|
+
("E4", re.compile(r"\bpsql\b.*-c\b")),
|
|
63
|
+
("E4", re.compile(r"\bmysql\b.*-e\b")),
|
|
64
|
+
("E4", re.compile(r"\bInvoke-WebRequest\b.*-Method\s+(?:Post|Put|Delete|Patch)", re.I)),
|
|
65
|
+
# E3 -- staging / filesystem writes
|
|
66
|
+
("E3", re.compile(r"\bgit\s+push\b")),
|
|
67
|
+
("E3", re.compile(r"\bdocker\s+push\b")),
|
|
68
|
+
("E3", re.compile(r"\bchmod\b")),
|
|
69
|
+
("E3", re.compile(r"\bchown\b")),
|
|
70
|
+
("E3", re.compile(r"\brsync\b")),
|
|
71
|
+
("E3", re.compile(r"\bpip\s+install\b")),
|
|
72
|
+
("E3", re.compile(r"\bnpm\s+install\b")),
|
|
73
|
+
# PowerShell: read-only cmdlets via wrapper are E0 (must be before generic E3)
|
|
74
|
+
("E0", re.compile(r"\bpowershell\b.*\b(?:Get-Content|Get-ChildItem|Get-Item|Get-Location|Get-Process|Get-Date|Select-String|Test-Path|Measure-Object|Format-Table|Sort-Object|Where-Object|Select-Object|ForEach-Object)\b", re.I)),
|
|
75
|
+
("E3", re.compile(r"\bpowershell\b", re.I)),
|
|
76
|
+
("E3", re.compile(r"\bcmd\s+/c\b", re.I)),
|
|
77
|
+
# E2 -- local writes
|
|
78
|
+
("E2", re.compile(r"\bmv\b")),
|
|
79
|
+
("E2", re.compile(r"\bcp\b")),
|
|
80
|
+
("E2", re.compile(r"\btee\b")),
|
|
81
|
+
("E2", re.compile(r"\bmkdir\b")),
|
|
82
|
+
("E2", re.compile(r"\bmove\b", re.I)), # Windows move
|
|
83
|
+
("E2", re.compile(r"\bcopy\b", re.I)), # Windows copy
|
|
84
|
+
# E0 -- read-only (explicit safe commands, Unix)
|
|
85
|
+
("E0", re.compile(r"^\s*(?:ls|cat|head|tail|wc|file|stat|echo|pwd|whoami|date|which|find|grep|rg|fd|tree|du|df)\b")),
|
|
86
|
+
("E0", re.compile(r"^\s*(?:git\s+(?:status|log|diff|show|branch|remote))\b")),
|
|
87
|
+
# python/node -c can execute arbitrary code including file writes — E2, not E0
|
|
88
|
+
("E2", re.compile(r"^\s*(?:python|python3|node)\s+-c\b")),
|
|
89
|
+
("E0", re.compile(r"^\s*curl\b(?!.*(?:-X|-d\b|--data|--request|-F\b|--form|-T\b|--upload))", re.I)),
|
|
90
|
+
# E0 -- read-only (Windows equivalents)
|
|
91
|
+
("E0", re.compile(r"^\s*(?:dir|type|where|hostname|ver|systeminfo|whoami|set)\b", re.I)),
|
|
92
|
+
("E0", re.compile(r"^\s*(?:Get-Content|Get-ChildItem|Get-Item|Get-Location|Get-Process|Get-Date)\b", re.I)),
|
|
93
|
+
("E0", re.compile(r"^\s*(?:Select-String|Test-Path|Measure-Object)\b", re.I)),
|
|
94
|
+
("E0", re.compile(r"^\s*(?:Invoke-WebRequest|wget|curl)\b(?!.*(?:-Method|-d\b|--data|-F\b|--form|--post))", re.I)),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# Shell meta-characters that separate independent commands
|
|
98
|
+
_PIPE_SPLIT = re.compile(r"\s*(?:\|(?!\|)|&&|\|\||;|\$\(|`)\s*")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _classify_single(segment: str) -> str:
|
|
102
|
+
"""Classify a single command segment against hard rules."""
|
|
103
|
+
for level, pattern in _HARD_RULES:
|
|
104
|
+
if pattern.search(segment):
|
|
105
|
+
return level
|
|
106
|
+
return "E2"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def classify_command(command: str, llm_assessment: str = "") -> str:
|
|
110
|
+
"""Classify a shell command's effect level. Hard rules only -- LLM self-report is ignored.
|
|
111
|
+
|
|
112
|
+
Splits on pipe, &&, ||, ;, $(), and backtick boundaries to prevent
|
|
113
|
+
a dangerous command from hiding behind a safe prefix like 'echo'.
|
|
114
|
+
Returns the highest (most dangerous) level found across all segments.
|
|
115
|
+
"""
|
|
116
|
+
segments = _PIPE_SPLIT.split(command)
|
|
117
|
+
worst = "E0"
|
|
118
|
+
for seg in segments:
|
|
119
|
+
seg = seg.strip()
|
|
120
|
+
if not seg:
|
|
121
|
+
continue
|
|
122
|
+
level = _classify_single(seg)
|
|
123
|
+
if effect_to_int(level) > effect_to_int(worst):
|
|
124
|
+
worst = level
|
|
125
|
+
# Never go below E2 default for unrecognized commands
|
|
126
|
+
if effect_to_int(worst) < effect_to_int("E2") and not segments:
|
|
127
|
+
return "E2"
|
|
128
|
+
return worst
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def effect_to_int(level: str) -> int:
|
|
132
|
+
if level and len(level) == 2 and level[0] == "E" and level[1].isdigit():
|
|
133
|
+
return int(level[1])
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_allowed(command_level: str, max_level: str) -> bool:
|
|
138
|
+
return effect_to_int(command_level) <= effect_to_int(max_level)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_max_effect(cli_max: str | None = None) -> str:
|
|
142
|
+
if cli_max and cli_max in EFFECT_LEVELS:
|
|
143
|
+
return cli_max
|
|
144
|
+
env = os.environ.get("PASCAL_MAX_EFFECT", "")
|
|
145
|
+
if env in EFFECT_LEVELS:
|
|
146
|
+
return env
|
|
147
|
+
return DEFAULT_MAX_EFFECT
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def escalation_message(command: str, level: str, max_level: str) -> str:
|
|
151
|
+
return (
|
|
152
|
+
f"Effect escalation: '{command[:80]}' is {level} ({EFFECT_LEVELS.get(level, '?')}), "
|
|
153
|
+
f"but max allowed is {max_level} ({EFFECT_LEVELS.get(max_level, '?')}). "
|
|
154
|
+
f"Use --max-effect {level} to allow."
|
|
155
|
+
)
|
pascal/eval/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Evaluation harness for Pascal."""
|
pascal/eval/smoke.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Smoke test harness -- 5 core scenarios that must pass for Pascal to be viable.
|
|
2
|
+
|
|
3
|
+
Run: python -m pascal.eval.smoke
|
|
4
|
+
|
|
5
|
+
1. Task lifecycle: receive → plan → execute → complete
|
|
6
|
+
2. Interrupt: notification arrives mid-task → pause → handle → resume
|
|
7
|
+
3. Escalation: agent encounters uncertainty → escalate → loop stops
|
|
8
|
+
4. Self-learning: agent adds a rule → rule visible in next loop
|
|
9
|
+
5. Governor: agent loops → detected → stopped
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
from pascal.loop import run_loop
|
|
20
|
+
from pascal.state import PascalStore
|
|
21
|
+
from pascal.types import LLMResponse
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _MockLLM:
|
|
25
|
+
def __init__(self, responses: list[str]):
|
|
26
|
+
self._responses = list(responses)
|
|
27
|
+
self._i = 0
|
|
28
|
+
|
|
29
|
+
async def chat(self, messages, tools=None):
|
|
30
|
+
if self._i >= len(self._responses):
|
|
31
|
+
return LLMResponse(text=json.dumps({"action": "wait", "reason": "out"}))
|
|
32
|
+
text = self._responses[self._i]
|
|
33
|
+
self._i += 1
|
|
34
|
+
return LLMResponse(text=text)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _j(**kwargs) -> str:
|
|
38
|
+
return json.dumps(kwargs)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _run_scenario(
|
|
42
|
+
name: str,
|
|
43
|
+
store: PascalStore,
|
|
44
|
+
responses: list[str],
|
|
45
|
+
validate: "Callable",
|
|
46
|
+
) -> tuple[bool, str]:
|
|
47
|
+
llm = _MockLLM(responses)
|
|
48
|
+
actions = await run_loop(store, llm, max_iterations=15)
|
|
49
|
+
try:
|
|
50
|
+
validate(store, actions)
|
|
51
|
+
return True, f" PASS: {name}"
|
|
52
|
+
except AssertionError as e:
|
|
53
|
+
return False, f" FAIL: {name} -- {e}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def run_all(tmp_dir: str | None = None) -> dict[str, Any]:
|
|
57
|
+
import tempfile
|
|
58
|
+
base = Path(tmp_dir) if tmp_dir else Path(tempfile.mkdtemp())
|
|
59
|
+
results: list[tuple[bool, str]] = []
|
|
60
|
+
|
|
61
|
+
# 1. Task lifecycle
|
|
62
|
+
store = PascalStore(str(base / "s1.db"))
|
|
63
|
+
t1_id = store.add_task("Write hello.txt")
|
|
64
|
+
|
|
65
|
+
def v1(s, actions):
|
|
66
|
+
types = [a["action"] for a in actions]
|
|
67
|
+
assert "pick_task" in types, "should pick task"
|
|
68
|
+
assert "complete_task" in types or "wait" in types, "should complete or wait"
|
|
69
|
+
|
|
70
|
+
results.append(await _run_scenario("Task lifecycle", store, [
|
|
71
|
+
_j(action="pick_task", task_id=t1_id, reason="start"),
|
|
72
|
+
_j(action="execute", command="echo hello > hello.txt", reason="write"),
|
|
73
|
+
_j(action="complete_task", summary="Created hello.txt", reason="done"),
|
|
74
|
+
_j(action="wait", reason="done"),
|
|
75
|
+
], v1))
|
|
76
|
+
store.close()
|
|
77
|
+
|
|
78
|
+
# 2. Interrupt handling
|
|
79
|
+
store = PascalStore(str(base / "s2.db"))
|
|
80
|
+
task_id = store.add_task("Long work")
|
|
81
|
+
store.activate_task(task_id)
|
|
82
|
+
notif_id = store.push_notification(source="slack", message="Server down!", priority="urgent")
|
|
83
|
+
|
|
84
|
+
def v2(s, actions):
|
|
85
|
+
types = [a["action"] for a in actions]
|
|
86
|
+
assert "pause_task" in types, "should pause"
|
|
87
|
+
assert "handle_notification" in types, "should handle notification"
|
|
88
|
+
|
|
89
|
+
results.append(await _run_scenario("Interrupt", store, [
|
|
90
|
+
_j(action="pause_task", pause_reason="urgent notification", reason="server"),
|
|
91
|
+
_j(action="handle_notification", notification_id=notif_id, response="checking", reason="urgent"),
|
|
92
|
+
_j(action="wait", reason="investigating"),
|
|
93
|
+
], v2))
|
|
94
|
+
store.close()
|
|
95
|
+
|
|
96
|
+
# 3. Escalation
|
|
97
|
+
store = PascalStore(str(base / "s3.db"))
|
|
98
|
+
t3_id = store.add_task("Deploy to prod")
|
|
99
|
+
|
|
100
|
+
def v3(s, actions):
|
|
101
|
+
types = [a["action"] for a in actions]
|
|
102
|
+
assert "escalate" in types, "should escalate"
|
|
103
|
+
|
|
104
|
+
results.append(await _run_scenario("Escalation", store, [
|
|
105
|
+
_j(action="pick_task", task_id=t3_id, reason="start"),
|
|
106
|
+
_j(action="escalate", question="Prod deploy needs approval", reason="policy"),
|
|
107
|
+
], v3))
|
|
108
|
+
store.close()
|
|
109
|
+
|
|
110
|
+
# 4. Self-learning
|
|
111
|
+
store = PascalStore(str(base / "s4.db"))
|
|
112
|
+
t4_id = store.add_task("Learn something")
|
|
113
|
+
|
|
114
|
+
def v4(s, actions):
|
|
115
|
+
rules = s.get_rules()
|
|
116
|
+
assert any("test" in r["rule"].lower() for r in rules), "should have added a rule"
|
|
117
|
+
|
|
118
|
+
results.append(await _run_scenario("Self-learning", store, [
|
|
119
|
+
_j(action="pick_task", task_id=t4_id, reason="start"),
|
|
120
|
+
_j(action="add_rule", rule="Always run tests before deploy", reason="learned"),
|
|
121
|
+
_j(action="complete_task", summary="Learned", reason="done"),
|
|
122
|
+
_j(action="wait", reason="done"),
|
|
123
|
+
], v4))
|
|
124
|
+
store.close()
|
|
125
|
+
|
|
126
|
+
# 5. Governor (loop detection)
|
|
127
|
+
store = PascalStore(str(base / "s5.db"))
|
|
128
|
+
t5_id = store.add_task("Stuck task")
|
|
129
|
+
|
|
130
|
+
def v5(s, actions):
|
|
131
|
+
assert len(actions) < 15, f"should stop early, got {len(actions)} actions"
|
|
132
|
+
|
|
133
|
+
results.append(await _run_scenario("Governor", store, [
|
|
134
|
+
_j(action="pick_task", task_id=t5_id, reason="start"),
|
|
135
|
+
_j(action="execute", command="false", reason="try"),
|
|
136
|
+
_j(action="execute", command="false", reason="retry"),
|
|
137
|
+
_j(action="execute", command="false", reason="retry again"),
|
|
138
|
+
_j(action="execute", command="false", reason="still trying"),
|
|
139
|
+
_j(action="execute", command="false", reason="one more"),
|
|
140
|
+
_j(action="wait", reason="governor should have warned"),
|
|
141
|
+
], v5))
|
|
142
|
+
store.close()
|
|
143
|
+
|
|
144
|
+
# 6. Plan execution (multi-step in one LLM call)
|
|
145
|
+
store = PascalStore(str(base / "s6.db"))
|
|
146
|
+
t6_id = store.add_task("Multi-step work")
|
|
147
|
+
|
|
148
|
+
def v6(s, actions):
|
|
149
|
+
plan_actions = [a for a in actions if a["action"] == "plan"]
|
|
150
|
+
assert plan_actions, "should execute a plan"
|
|
151
|
+
plan_result = plan_actions[0]["result"]
|
|
152
|
+
assert plan_result.get("plan_completed"), "plan should complete"
|
|
153
|
+
assert len(plan_result.get("steps", [])) >= 2, "plan should have multiple steps"
|
|
154
|
+
|
|
155
|
+
results.append(await _run_scenario("Plan execution", store, [
|
|
156
|
+
_j(action="pick_task", task_id=t6_id, reason="start"),
|
|
157
|
+
_j(action="plan", reason="do two things", steps=[
|
|
158
|
+
{"action": "execute", "command": "echo step1"},
|
|
159
|
+
{"action": "execute", "command": "echo step2"},
|
|
160
|
+
]),
|
|
161
|
+
_j(action="complete_task", summary="Done via plan", reason="done"),
|
|
162
|
+
_j(action="wait", reason="done"),
|
|
163
|
+
], v6))
|
|
164
|
+
store.close()
|
|
165
|
+
|
|
166
|
+
# 7. Memory search (FTS5 relevance)
|
|
167
|
+
store = PascalStore(str(base / "s7.db"))
|
|
168
|
+
store.add_memory(kind="fact", content="The deploy server runs on port 8080")
|
|
169
|
+
store.add_memory(kind="lesson", content="Always backup before migration")
|
|
170
|
+
store.add_memory(kind="fact", content="Database password is rotated monthly")
|
|
171
|
+
t7_id = store.add_task("Check deploy config")
|
|
172
|
+
|
|
173
|
+
def v7(s, actions):
|
|
174
|
+
# Verify memory search finds relevant results
|
|
175
|
+
results = s.search_memories("deploy server", limit=3)
|
|
176
|
+
assert any("8080" in m["content"] for m in results), "should find deploy memory"
|
|
177
|
+
|
|
178
|
+
results.append(await _run_scenario("Memory search", store, [
|
|
179
|
+
_j(action="pick_task", task_id=t7_id, reason="start"),
|
|
180
|
+
_j(action="wait", reason="done"),
|
|
181
|
+
], v7))
|
|
182
|
+
store.close()
|
|
183
|
+
|
|
184
|
+
# 8. Conversation thread + dependency gating
|
|
185
|
+
store = PascalStore(str(base / "s8.db"))
|
|
186
|
+
t8a = store.add_task("Build the app")
|
|
187
|
+
t8b = store.add_task("Deploy the app", depends_on=[t8a])
|
|
188
|
+
|
|
189
|
+
def v8(s, actions):
|
|
190
|
+
# t8b should NOT be picked (depends on t8a which isn't done)
|
|
191
|
+
from pascal.desk import Desk
|
|
192
|
+
desk = Desk(s)
|
|
193
|
+
rendered = desk.render()
|
|
194
|
+
# t8b should not appear in actionable queue (dependency not met)
|
|
195
|
+
assert t8b not in rendered or "Deploy" not in rendered.split("Task Queue")[1] if "Task Queue" in rendered else True
|
|
196
|
+
|
|
197
|
+
results.append(await _run_scenario("Dependency gating", store, [
|
|
198
|
+
_j(action="pick_task", task_id=t8a, reason="build first"),
|
|
199
|
+
_j(action="wait", reason="building"),
|
|
200
|
+
], v8))
|
|
201
|
+
store.close()
|
|
202
|
+
|
|
203
|
+
passed = sum(1 for ok, _ in results if ok)
|
|
204
|
+
total = len(results)
|
|
205
|
+
print(f"\nPascal Smoke Test: {passed}/{total} passed\n")
|
|
206
|
+
for _, msg in results:
|
|
207
|
+
print(msg)
|
|
208
|
+
|
|
209
|
+
return {"passed": passed, "total": total, "results": results}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
asyncio.run(run_all())
|
pascal/llm/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LLM abstraction layer."""
|
pascal/llm/anthropic.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""pascal/llm/anthropic.py -- Anthropic 프로바이더."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from anthropic import AsyncAnthropic
|
|
11
|
+
from anthropic.types import (
|
|
12
|
+
Base64ImageSourceParam,
|
|
13
|
+
ImageBlockParam,
|
|
14
|
+
MessageParam,
|
|
15
|
+
TextBlockParam,
|
|
16
|
+
ToolParam,
|
|
17
|
+
ToolResultBlockParam,
|
|
18
|
+
ToolUseBlockParam,
|
|
19
|
+
)
|
|
20
|
+
else:
|
|
21
|
+
AsyncAnthropic = Any
|
|
22
|
+
Base64ImageSourceParam = dict[str, object]
|
|
23
|
+
ImageBlockParam = dict[str, object]
|
|
24
|
+
MessageParam = dict[str, object]
|
|
25
|
+
TextBlockParam = dict[str, object]
|
|
26
|
+
ToolParam = dict[str, object]
|
|
27
|
+
ToolResultBlockParam = dict[str, object]
|
|
28
|
+
ToolUseBlockParam = dict[str, object]
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import anthropic as anthropic_module
|
|
32
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
33
|
+
anthropic: ModuleType | None = None
|
|
34
|
+
else:
|
|
35
|
+
anthropic = anthropic_module
|
|
36
|
+
|
|
37
|
+
from pascal.types import ContentBlock, LLMResponse, Message, Role, ToolCall
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
AnthropicImageMediaType = Literal["image/jpeg", "image/png", "image/gif", "image/webp"]
|
|
42
|
+
AnthropicContentBlock: TypeAlias = TextBlockParam | ImageBlockParam
|
|
43
|
+
AnthropicAssistantBlock: TypeAlias = AnthropicContentBlock | ToolUseBlockParam
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AnthropicProvider:
|
|
47
|
+
"""Anthropic Claude API를 사용하는 LLM 프로바이더."""
|
|
48
|
+
|
|
49
|
+
_client: AsyncAnthropic
|
|
50
|
+
|
|
51
|
+
def __init__(self, model: str = "claude-sonnet-4-20250514", base_url: str = "") -> None:
|
|
52
|
+
if anthropic is None:
|
|
53
|
+
raise ImportError(
|
|
54
|
+
"Anthropic provider requires optional dependency: pip install pascal[anthropic]"
|
|
55
|
+
)
|
|
56
|
+
from anthropic import AsyncAnthropic
|
|
57
|
+
|
|
58
|
+
if base_url:
|
|
59
|
+
self._client = AsyncAnthropic(base_url=base_url)
|
|
60
|
+
else:
|
|
61
|
+
self._client = AsyncAnthropic()
|
|
62
|
+
self._model = model
|
|
63
|
+
|
|
64
|
+
async def chat(
|
|
65
|
+
self,
|
|
66
|
+
messages: list[Message],
|
|
67
|
+
tools: list[dict] | None = None,
|
|
68
|
+
) -> LLMResponse:
|
|
69
|
+
system_parts: list[str] = []
|
|
70
|
+
api_messages: list[MessageParam] = []
|
|
71
|
+
|
|
72
|
+
for m in messages:
|
|
73
|
+
if m.role == Role.SYSTEM:
|
|
74
|
+
system_parts.append(m.content)
|
|
75
|
+
elif m.role == Role.ASSISTANT:
|
|
76
|
+
content = self._build_assistant_content(m)
|
|
77
|
+
assistant_message: MessageParam = {"role": "assistant", "content": content}
|
|
78
|
+
api_messages.append(assistant_message)
|
|
79
|
+
elif m.role == Role.TOOL:
|
|
80
|
+
tool_result: ToolResultBlockParam = {
|
|
81
|
+
"type": "tool_result",
|
|
82
|
+
"tool_use_id": m.tool_call_id,
|
|
83
|
+
"content": m.content,
|
|
84
|
+
}
|
|
85
|
+
tool_result_message: MessageParam = {"role": "user", "content": [tool_result]}
|
|
86
|
+
api_messages.append(tool_result_message)
|
|
87
|
+
else:
|
|
88
|
+
api_messages.append(self._convert_user_message(m))
|
|
89
|
+
|
|
90
|
+
system = "\n\n".join(system_parts) if system_parts else None
|
|
91
|
+
converted_tools = self._convert_tools(tools) if tools else None
|
|
92
|
+
|
|
93
|
+
if system is not None and converted_tools is not None:
|
|
94
|
+
response = await self._client.messages.create(
|
|
95
|
+
model=self._model,
|
|
96
|
+
max_tokens=4096,
|
|
97
|
+
messages=api_messages,
|
|
98
|
+
system=system,
|
|
99
|
+
tools=converted_tools,
|
|
100
|
+
)
|
|
101
|
+
elif system is not None:
|
|
102
|
+
response = await self._client.messages.create(
|
|
103
|
+
model=self._model,
|
|
104
|
+
max_tokens=4096,
|
|
105
|
+
messages=api_messages,
|
|
106
|
+
system=system,
|
|
107
|
+
)
|
|
108
|
+
elif converted_tools is not None:
|
|
109
|
+
response = await self._client.messages.create(
|
|
110
|
+
model=self._model,
|
|
111
|
+
max_tokens=4096,
|
|
112
|
+
messages=api_messages,
|
|
113
|
+
tools=converted_tools,
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
response = await self._client.messages.create(
|
|
117
|
+
model=self._model,
|
|
118
|
+
max_tokens=4096,
|
|
119
|
+
messages=api_messages,
|
|
120
|
+
)
|
|
121
|
+
return self._parse_response(response)
|
|
122
|
+
|
|
123
|
+
def _build_assistant_content(self, msg: Message) -> list[AnthropicAssistantBlock] | str:
|
|
124
|
+
content: list[AnthropicAssistantBlock] = list(self._build_content_blocks(msg))
|
|
125
|
+
tool_calls = getattr(msg, "tool_calls", None) or []
|
|
126
|
+
if not tool_calls:
|
|
127
|
+
return content or msg.content
|
|
128
|
+
|
|
129
|
+
for tc in tool_calls:
|
|
130
|
+
content.append(
|
|
131
|
+
{
|
|
132
|
+
"type": "tool_use",
|
|
133
|
+
"id": tc.id,
|
|
134
|
+
"name": tc.name,
|
|
135
|
+
"input": tc.params,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
return content
|
|
139
|
+
|
|
140
|
+
def _convert_user_message(self, msg: Message) -> MessageParam:
|
|
141
|
+
content = self._build_content_blocks(msg)
|
|
142
|
+
return {"role": "user", "content": content or msg.content}
|
|
143
|
+
|
|
144
|
+
def _build_content_blocks(self, msg: Message) -> list[AnthropicContentBlock]:
|
|
145
|
+
content: list[AnthropicContentBlock] = []
|
|
146
|
+
if msg.content:
|
|
147
|
+
content.append({"type": "text", "text": msg.content})
|
|
148
|
+
for attachment in msg.attachments:
|
|
149
|
+
block = self._convert_attachment(attachment)
|
|
150
|
+
if block is not None:
|
|
151
|
+
content.append(block)
|
|
152
|
+
return content
|
|
153
|
+
|
|
154
|
+
def _convert_attachment(self, attachment: ContentBlock) -> AnthropicContentBlock | None:
|
|
155
|
+
if attachment.type == "image":
|
|
156
|
+
media_type = self._normalize_image_media_type(attachment.mime_type)
|
|
157
|
+
if media_type is None:
|
|
158
|
+
logger.warning("Unsupported Anthropic image media type: %s", attachment.mime_type)
|
|
159
|
+
return None
|
|
160
|
+
source: Base64ImageSourceParam = {
|
|
161
|
+
"type": "base64",
|
|
162
|
+
"media_type": media_type,
|
|
163
|
+
"data": attachment.data,
|
|
164
|
+
}
|
|
165
|
+
image_block: ImageBlockParam = {
|
|
166
|
+
"type": "image",
|
|
167
|
+
"source": source,
|
|
168
|
+
}
|
|
169
|
+
return image_block
|
|
170
|
+
if attachment.type == "text":
|
|
171
|
+
text_block: TextBlockParam = {"type": "text", "text": attachment.data}
|
|
172
|
+
return text_block
|
|
173
|
+
|
|
174
|
+
logger.warning("Unsupported content block for Anthropic provider: %s", attachment.type)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _normalize_image_media_type(mime_type: str) -> AnthropicImageMediaType | None:
|
|
179
|
+
if mime_type == "image/jpeg":
|
|
180
|
+
return "image/jpeg"
|
|
181
|
+
if mime_type == "image/png":
|
|
182
|
+
return "image/png"
|
|
183
|
+
if mime_type == "image/gif":
|
|
184
|
+
return "image/gif"
|
|
185
|
+
if mime_type == "image/webp":
|
|
186
|
+
return "image/webp"
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _convert_tools(tools: list[dict]) -> list[ToolParam]:
|
|
191
|
+
default_schema: dict[str, object] = {"type": "object", "properties": {}}
|
|
192
|
+
result: list[ToolParam] = []
|
|
193
|
+
for t in tools:
|
|
194
|
+
func = t.get("function", t)
|
|
195
|
+
name: str = func["name"]
|
|
196
|
+
description: str = func.get("description", "")
|
|
197
|
+
input_schema: dict[str, object] = func.get("parameters", default_schema)
|
|
198
|
+
result.append(
|
|
199
|
+
{
|
|
200
|
+
"name": name,
|
|
201
|
+
"description": description,
|
|
202
|
+
"input_schema": input_schema,
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _parse_response(response) -> LLMResponse:
|
|
209
|
+
text_parts = []
|
|
210
|
+
tool_calls = []
|
|
211
|
+
|
|
212
|
+
for block in response.content:
|
|
213
|
+
if block.type == "text":
|
|
214
|
+
text_parts.append(block.text)
|
|
215
|
+
elif block.type == "tool_use":
|
|
216
|
+
tool_calls.append(
|
|
217
|
+
ToolCall(
|
|
218
|
+
id=block.id,
|
|
219
|
+
name=block.name,
|
|
220
|
+
params=block.input,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
text = "\n".join(text_parts) if text_parts else None
|
|
225
|
+
return LLMResponse(text=text, tool_calls=tool_calls)
|