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/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
+ )
@@ -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."""
@@ -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)