pretensor 0.1.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.
Files changed (198) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/benchmark/__init__.py +54 -0
  3. pretensor/benchmark/cli.py +294 -0
  4. pretensor/benchmark/fixtures.py +84 -0
  5. pretensor/benchmark/l1/__init__.py +23 -0
  6. pretensor/benchmark/l1/metrics.py +141 -0
  7. pretensor/benchmark/l1/pipeline.py +188 -0
  8. pretensor/benchmark/l1/runner.py +245 -0
  9. pretensor/benchmark/l2/__init__.py +27 -0
  10. pretensor/benchmark/l2/gold.py +236 -0
  11. pretensor/benchmark/l2/metrics.py +146 -0
  12. pretensor/benchmark/l2/pipeline.py +124 -0
  13. pretensor/benchmark/l2/runner.py +530 -0
  14. pretensor/benchmark/l3/__init__.py +73 -0
  15. pretensor/benchmark/l3/agent.py +316 -0
  16. pretensor/benchmark/l3/db.py +188 -0
  17. pretensor/benchmark/l3/gold.py +85 -0
  18. pretensor/benchmark/l3/llm_client.py +395 -0
  19. pretensor/benchmark/l3/mcp_client.py +357 -0
  20. pretensor/benchmark/l3/pretensor_runner.py +456 -0
  21. pretensor/benchmark/l3/prompt.py +132 -0
  22. pretensor/benchmark/l3/runner.py +358 -0
  23. pretensor/benchmark/l3/sql_equivalence.py +176 -0
  24. pretensor/benchmark/release_gate.py +448 -0
  25. pretensor/benchmark/results.py +298 -0
  26. pretensor/benchmark/runner.py +109 -0
  27. pretensor/cli/__init__.py +1 -0
  28. pretensor/cli/commands/_source_runner.py +147 -0
  29. pretensor/cli/commands/analyze.py +201 -0
  30. pretensor/cli/commands/connections/__init__.py +7 -0
  31. pretensor/cli/commands/connections/add_remove.py +126 -0
  32. pretensor/cli/commands/connections/register.py +12 -0
  33. pretensor/cli/commands/export.py +131 -0
  34. pretensor/cli/commands/index.py +559 -0
  35. pretensor/cli/commands/list.py +76 -0
  36. pretensor/cli/commands/quickstart.py +207 -0
  37. pretensor/cli/commands/reindex.py +646 -0
  38. pretensor/cli/commands/semantic.py +190 -0
  39. pretensor/cli/commands/serve.py +144 -0
  40. pretensor/cli/commands/sync_grants.py +149 -0
  41. pretensor/cli/commands/validate.py +176 -0
  42. pretensor/cli/config_file.py +442 -0
  43. pretensor/cli/constants.py +10 -0
  44. pretensor/cli/dbt_enrichment.py +96 -0
  45. pretensor/cli/main.py +109 -0
  46. pretensor/cli/paths.py +43 -0
  47. pretensor/cli/plugin.py +52 -0
  48. pretensor/config.py +226 -0
  49. pretensor/connectors/__init__.py +29 -0
  50. pretensor/connectors/base.py +165 -0
  51. pretensor/connectors/bigquery.py +468 -0
  52. pretensor/connectors/inspect.py +321 -0
  53. pretensor/connectors/lineage_sqlglot.py +97 -0
  54. pretensor/connectors/models.py +130 -0
  55. pretensor/connectors/mysql.py +402 -0
  56. pretensor/connectors/pg_array_parse.py +53 -0
  57. pretensor/connectors/postgres.py +938 -0
  58. pretensor/connectors/registry.py +93 -0
  59. pretensor/connectors/snapshot.py +244 -0
  60. pretensor/connectors/snowflake.py +908 -0
  61. pretensor/core/__init__.py +1 -0
  62. pretensor/core/builder.py +307 -0
  63. pretensor/core/dsn_crypto.py +51 -0
  64. pretensor/core/graph_schema_manager.py +246 -0
  65. pretensor/core/graph_store.py +1226 -0
  66. pretensor/core/ids.py +101 -0
  67. pretensor/core/portable_export.py +276 -0
  68. pretensor/core/query_runner.py +67 -0
  69. pretensor/core/registry.py +209 -0
  70. pretensor/core/schema.py +473 -0
  71. pretensor/core/secure_io.py +93 -0
  72. pretensor/core/store.py +469 -0
  73. pretensor/enrichment/__init__.py +1 -0
  74. pretensor/enrichment/analyze/__init__.py +0 -0
  75. pretensor/enrichment/analyze/classify.py +49 -0
  76. pretensor/enrichment/analyze/extract_python.py +196 -0
  77. pretensor/enrichment/analyze/parse.py +141 -0
  78. pretensor/enrichment/analyze/pipeline.py +195 -0
  79. pretensor/enrichment/analyze/summary.py +38 -0
  80. pretensor/enrichment/analyze/walker.py +98 -0
  81. pretensor/enrichment/analyze/writers.py +214 -0
  82. pretensor/enrichment/dbt/__init__.py +30 -0
  83. pretensor/enrichment/dbt/lineage.py +100 -0
  84. pretensor/enrichment/dbt/manifest.py +300 -0
  85. pretensor/enrichment/dbt/metadata.py +263 -0
  86. pretensor/enrichment/dbt/pipeline.py +77 -0
  87. pretensor/enrichment/dbt/resolution.py +101 -0
  88. pretensor/enrichment/dbt/signals.py +305 -0
  89. pretensor/entities/__init__.py +27 -0
  90. pretensor/entities/builder.py +63 -0
  91. pretensor/entities/classifier.py +383 -0
  92. pretensor/entities/llm_extract.py +66 -0
  93. pretensor/errors.py +35 -0
  94. pretensor/graph_models/__init__.py +17 -0
  95. pretensor/graph_models/base.py +11 -0
  96. pretensor/graph_models/consumer.py +71 -0
  97. pretensor/graph_models/edge.py +35 -0
  98. pretensor/graph_models/entity.py +21 -0
  99. pretensor/graph_models/node.py +79 -0
  100. pretensor/graph_models/relationship.py +33 -0
  101. pretensor/integrations/__init__.py +42 -0
  102. pretensor/integrations/_base.py +138 -0
  103. pretensor/integrations/google_adk.py +49 -0
  104. pretensor/integrations/langchain.py +55 -0
  105. pretensor/integrations/llamaindex.py +53 -0
  106. pretensor/intelligence/__init__.py +33 -0
  107. pretensor/intelligence/cluster_labeler.py +425 -0
  108. pretensor/intelligence/clustering.py +168 -0
  109. pretensor/intelligence/combining.py +32 -0
  110. pretensor/intelligence/discovery.py +114 -0
  111. pretensor/intelligence/embeddings.py +317 -0
  112. pretensor/intelligence/graph_export.py +200 -0
  113. pretensor/intelligence/heuristic.py +544 -0
  114. pretensor/intelligence/join_paths/__init__.py +130 -0
  115. pretensor/intelligence/join_paths/on_demand.py +516 -0
  116. pretensor/intelligence/join_paths/storage.py +70 -0
  117. pretensor/intelligence/llm_infer.py +78 -0
  118. pretensor/intelligence/llm_runtime.py +62 -0
  119. pretensor/intelligence/metric_templates.py +193 -0
  120. pretensor/intelligence/pipeline.py +364 -0
  121. pretensor/intelligence/role_exemplars.py +263 -0
  122. pretensor/intelligence/schema_classification.py +360 -0
  123. pretensor/intelligence/scoring.py +76 -0
  124. pretensor/intelligence/semantic.py +240 -0
  125. pretensor/intelligence/shadow_alias.py +101 -0
  126. pretensor/intelligence/statistical.py +50 -0
  127. pretensor/intelligence/steps.py +191 -0
  128. pretensor/intelligence/steps_embedding.py +168 -0
  129. pretensor/introspection/__init__.py +6 -0
  130. pretensor/introspection/inspector.py +5 -0
  131. pretensor/introspection/models/__init__.py +0 -0
  132. pretensor/introspection/models/base.py +5 -0
  133. pretensor/introspection/models/config.py +237 -0
  134. pretensor/introspection/models/dsn.py +550 -0
  135. pretensor/introspection/models/plan.py +116 -0
  136. pretensor/introspection/models/schema.py +10 -0
  137. pretensor/introspection/models/semantic.py +121 -0
  138. pretensor/introspection/models/validation.py +116 -0
  139. pretensor/introspection/snapshot.py +46 -0
  140. pretensor/mcp/__init__.py +16 -0
  141. pretensor/mcp/config_json.py +24 -0
  142. pretensor/mcp/payload_types.py +274 -0
  143. pretensor/mcp/resources/__init__.py +17 -0
  144. pretensor/mcp/resources/markdown.py +314 -0
  145. pretensor/mcp/server.py +285 -0
  146. pretensor/mcp/service.py +49 -0
  147. pretensor/mcp/service_context.py +142 -0
  148. pretensor/mcp/service_registry.py +294 -0
  149. pretensor/mcp/store_cache.py +43 -0
  150. pretensor/mcp/tool_registry.py +136 -0
  151. pretensor/mcp/tools/__init__.py +1 -0
  152. pretensor/mcp/tools/_rank.py +244 -0
  153. pretensor/mcp/tools/_timed.py +26 -0
  154. pretensor/mcp/tools/compile_metric.py +144 -0
  155. pretensor/mcp/tools/consumers.py +161 -0
  156. pretensor/mcp/tools/context.py +1121 -0
  157. pretensor/mcp/tools/cypher.py +509 -0
  158. pretensor/mcp/tools/detect_changes.py +254 -0
  159. pretensor/mcp/tools/impact.py +271 -0
  160. pretensor/mcp/tools/list.py +131 -0
  161. pretensor/mcp/tools/schema.py +170 -0
  162. pretensor/mcp/tools/search.py +316 -0
  163. pretensor/mcp/tools/semantic_search.py +282 -0
  164. pretensor/mcp/tools/traverse.py +1027 -0
  165. pretensor/mcp/tools/validate_sql.py +150 -0
  166. pretensor/observability.py +203 -0
  167. pretensor/py.typed +0 -0
  168. pretensor/quickstart/README.md +29 -0
  169. pretensor/quickstart/__init__.py +6 -0
  170. pretensor/quickstart/docker-compose.yml +18 -0
  171. pretensor/quickstart/pagila_data.sql +63 -0
  172. pretensor/quickstart/pagila_ddl.sql +92 -0
  173. pretensor/search/__init__.py +6 -0
  174. pretensor/search/base.py +80 -0
  175. pretensor/search/index.py +435 -0
  176. pretensor/semantic/__init__.py +24 -0
  177. pretensor/semantic/base.py +123 -0
  178. pretensor/semantic/compiler.py +487 -0
  179. pretensor/semantic/yaml_layer.py +180 -0
  180. pretensor/skills/__init__.py +5 -0
  181. pretensor/skills/generator.py +235 -0
  182. pretensor/staleness/__init__.py +15 -0
  183. pretensor/staleness/graph_patcher.py +355 -0
  184. pretensor/staleness/impact_analyzer.py +162 -0
  185. pretensor/staleness/snapshot_store.py +38 -0
  186. pretensor/validation/__init__.py +9 -0
  187. pretensor/validation/query_validator.py +436 -0
  188. pretensor/visibility/__init__.py +23 -0
  189. pretensor/visibility/config.py +126 -0
  190. pretensor/visibility/filter.py +143 -0
  191. pretensor/visibility/kuzu_helpers.py +32 -0
  192. pretensor/visibility/runtime.py +36 -0
  193. pretensor/visibility/sync_grants.py +188 -0
  194. pretensor-0.1.0.dist-info/METADATA +251 -0
  195. pretensor-0.1.0.dist-info/RECORD +198 -0
  196. pretensor-0.1.0.dist-info/WHEEL +4 -0
  197. pretensor-0.1.0.dist-info/entry_points.txt +2 -0
  198. pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,395 @@
1
+ """LLM client adapters for the L3 NL-to-SQL agent.
2
+
3
+ The OSS core has no LLM dependency (``docs/contracts/architecture.md``
4
+ Invariant #6). This module talks to provider HTTP endpoints directly via
5
+ ``httpx`` (already a top-level dep), so ``import pretensor`` never pulls
6
+ an LLM SDK. Instantiation is lazy: callers build an :class:`AnthropicHttpClient`
7
+ or :class:`OpenAIHttpClient` inside the runner — module load does not call
8
+ ``os.environ`` or open any sockets.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import time
15
+ from dataclasses import dataclass
16
+ from typing import Any, Protocol, runtime_checkable
17
+
18
+ import httpx
19
+
20
+ from pretensor.benchmark.l3.agent import (
21
+ AgentMessage,
22
+ AgentStep,
23
+ AgentTool,
24
+ AgentToolCall,
25
+ )
26
+ from pretensor.errors import PretensorError
27
+
28
+ __all__ = [
29
+ "AnthropicHttpClient",
30
+ "LlmCallError",
31
+ "LlmClient",
32
+ "LlmResponse",
33
+ "OpenAIHttpClient",
34
+ ]
35
+
36
+
37
+ _DEFAULT_TIMEOUT_S = 60.0
38
+ _DEFAULT_MAX_TOKENS = 4096
39
+ _ANTHROPIC_API = "https://api.anthropic.com/v1/messages"
40
+ _ANTHROPIC_VERSION = "2023-06-01"
41
+ _OPENAI_API = "https://api.openai.com/v1/chat/completions"
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class LlmResponse:
46
+ """One LLM completion plus the runner's per-call telemetry.
47
+
48
+ ``text`` is the raw model output (no fence stripping — that's the
49
+ runner's job). ``prompt_tokens`` / ``completion_tokens`` are
50
+ optional; some providers omit them. ``latency_ms`` is wall-clock
51
+ time around the HTTP request, captured by the client itself so
52
+ the runner does not need to reach into provider-specific timing.
53
+ """
54
+
55
+ text: str
56
+ prompt_tokens: int | None
57
+ completion_tokens: int | None
58
+ latency_ms: int
59
+
60
+
61
+ class LlmCallError(PretensorError, RuntimeError):
62
+ """Raised on transport failure, non-2xx response, or unparseable body."""
63
+
64
+
65
+ @runtime_checkable
66
+ class LlmClient(Protocol):
67
+ """Sync NL-to-SQL completion surface.
68
+
69
+ A single ``complete`` call is one prompt → one completion. Sync because
70
+ L3 evaluates one question at a time and the LLM call is the dominant
71
+ latency — async would only buy concurrency the runner does not use.
72
+ """
73
+
74
+ def complete(
75
+ self,
76
+ *,
77
+ system: str,
78
+ user: str,
79
+ model: str,
80
+ temperature: float,
81
+ ) -> LlmResponse: ...
82
+
83
+
84
+ class AnthropicHttpClient:
85
+ """Default Anthropic Messages API client.
86
+
87
+ Reads ``ANTHROPIC_API_KEY`` from env at construction; raises
88
+ :class:`LlmCallError` if absent. Uses an internally-owned
89
+ :class:`httpx.Client` so the runner does not need to manage the
90
+ transport.
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ *,
96
+ api_key: str | None = None,
97
+ client: httpx.Client | None = None,
98
+ max_tokens: int = _DEFAULT_MAX_TOKENS,
99
+ ) -> None:
100
+ resolved_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
101
+ if not resolved_key:
102
+ raise LlmCallError(
103
+ "ANTHROPIC_API_KEY is not set; cannot run the Anthropic client."
104
+ )
105
+ self._api_key = resolved_key
106
+ self._client = client or httpx.Client(timeout=_DEFAULT_TIMEOUT_S)
107
+ self._max_tokens = max_tokens
108
+
109
+ def complete(
110
+ self,
111
+ *,
112
+ system: str,
113
+ user: str,
114
+ model: str,
115
+ temperature: float,
116
+ ) -> LlmResponse:
117
+ body: dict[str, Any] = {
118
+ "model": model,
119
+ "max_tokens": self._max_tokens,
120
+ "temperature": temperature,
121
+ "system": system,
122
+ "messages": [{"role": "user", "content": user}],
123
+ }
124
+ payload, latency_ms = self._post(body)
125
+ text = _extract_anthropic_text(payload)
126
+ usage = payload.get("usage") or {}
127
+ return LlmResponse(
128
+ text=text,
129
+ prompt_tokens=usage.get("input_tokens"),
130
+ completion_tokens=usage.get("output_tokens"),
131
+ latency_ms=latency_ms,
132
+ )
133
+
134
+ def agent_complete(
135
+ self,
136
+ *,
137
+ system: str,
138
+ messages: list[AgentMessage],
139
+ tools: list[AgentTool],
140
+ model: str,
141
+ temperature: float,
142
+ ) -> AgentStep:
143
+ """One tool-use step against the Anthropic Messages API.
144
+
145
+ Translates :class:`AgentMessage` history to Anthropic's
146
+ content-block wire format, sends the request with the supplied
147
+ ``tools`` array, and decodes the response into a provider-agnostic
148
+ :class:`AgentStep`. ``stop_reason="tool_use"`` becomes
149
+ ``tool_calls`` populated; ``stop_reason="end_turn"`` (or
150
+ ``"stop_sequence"``) becomes a final ``text``.
151
+ """
152
+ body: dict[str, Any] = {
153
+ "model": model,
154
+ "max_tokens": self._max_tokens,
155
+ "temperature": temperature,
156
+ "system": system,
157
+ "messages": [_anthropic_message(m) for m in messages],
158
+ "tools": [_anthropic_tool(t) for t in tools],
159
+ }
160
+ payload, latency_ms = self._post(body)
161
+ text, tool_calls = _decode_anthropic_step(payload)
162
+ usage = payload.get("usage") or {}
163
+ return AgentStep(
164
+ text=text,
165
+ tool_calls=tool_calls,
166
+ prompt_tokens=usage.get("input_tokens"),
167
+ completion_tokens=usage.get("output_tokens"),
168
+ latency_ms=latency_ms,
169
+ )
170
+
171
+ def _post(self, body: dict[str, Any]) -> tuple[dict[str, Any], int]:
172
+ """POST ``body`` to Anthropic, returning ``(payload, latency_ms)``.
173
+
174
+ Centralises the transport / error / timing path shared between
175
+ ``complete`` and ``agent_complete`` so the two stay byte-for-byte
176
+ consistent on retry semantics, header set, and timeout.
177
+ """
178
+ headers = {
179
+ "x-api-key": self._api_key,
180
+ "anthropic-version": _ANTHROPIC_VERSION,
181
+ "content-type": "application/json",
182
+ }
183
+ t0 = time.perf_counter()
184
+ try:
185
+ response = self._client.post(_ANTHROPIC_API, json=body, headers=headers)
186
+ except httpx.HTTPError as exc:
187
+ raise LlmCallError(f"Anthropic transport error: {exc}") from exc
188
+ latency_ms = int((time.perf_counter() - t0) * 1000)
189
+ if response.status_code >= 400:
190
+ raise LlmCallError(
191
+ f"Anthropic returned HTTP {response.status_code}: {response.text[:500]}"
192
+ )
193
+ try:
194
+ payload = response.json()
195
+ except ValueError as exc:
196
+ raise LlmCallError(f"Anthropic response was not JSON: {exc}") from exc
197
+ return payload, latency_ms
198
+
199
+
200
+ class OpenAIHttpClient:
201
+ """OpenAI chat-completions client.
202
+
203
+ Reads ``OPENAI_API_KEY`` from env at construction. Same shape as the
204
+ Anthropic client so the runner can swap providers behind the
205
+ :class:`LlmClient` Protocol.
206
+ """
207
+
208
+ def __init__(
209
+ self,
210
+ *,
211
+ api_key: str | None = None,
212
+ client: httpx.Client | None = None,
213
+ max_tokens: int = _DEFAULT_MAX_TOKENS,
214
+ ) -> None:
215
+ resolved_key = api_key or os.environ.get("OPENAI_API_KEY")
216
+ if not resolved_key:
217
+ raise LlmCallError(
218
+ "OPENAI_API_KEY is not set; cannot run the OpenAI client."
219
+ )
220
+ self._api_key = resolved_key
221
+ self._client = client or httpx.Client(timeout=_DEFAULT_TIMEOUT_S)
222
+ self._max_tokens = max_tokens
223
+
224
+ def complete(
225
+ self,
226
+ *,
227
+ system: str,
228
+ user: str,
229
+ model: str,
230
+ temperature: float,
231
+ ) -> LlmResponse:
232
+ body: dict[str, Any] = {
233
+ "model": model,
234
+ "max_tokens": self._max_tokens,
235
+ "temperature": temperature,
236
+ "messages": [
237
+ {"role": "system", "content": system},
238
+ {"role": "user", "content": user},
239
+ ],
240
+ }
241
+ headers = {
242
+ "authorization": f"Bearer {self._api_key}",
243
+ "content-type": "application/json",
244
+ }
245
+ t0 = time.perf_counter()
246
+ try:
247
+ response = self._client.post(_OPENAI_API, json=body, headers=headers)
248
+ except httpx.HTTPError as exc:
249
+ raise LlmCallError(f"OpenAI transport error: {exc}") from exc
250
+ latency_ms = int((time.perf_counter() - t0) * 1000)
251
+ if response.status_code >= 400:
252
+ raise LlmCallError(
253
+ f"OpenAI returned HTTP {response.status_code}: {response.text[:500]}"
254
+ )
255
+ try:
256
+ payload = response.json()
257
+ except ValueError as exc:
258
+ raise LlmCallError(f"OpenAI response was not JSON: {exc}") from exc
259
+ text = _extract_openai_text(payload)
260
+ usage = payload.get("usage") or {}
261
+ return LlmResponse(
262
+ text=text,
263
+ prompt_tokens=usage.get("prompt_tokens"),
264
+ completion_tokens=usage.get("completion_tokens"),
265
+ latency_ms=latency_ms,
266
+ )
267
+
268
+
269
+ def _extract_anthropic_text(payload: dict[str, Any]) -> str:
270
+ """Concatenate every ``text`` block from an Anthropic Messages response.
271
+
272
+ Tool-use blocks (which the L3 baseline does not request) are skipped.
273
+ """
274
+ content = payload.get("content")
275
+ if not isinstance(content, list):
276
+ raise LlmCallError("Anthropic response missing 'content' array.")
277
+ parts: list[str] = []
278
+ for block in content:
279
+ if isinstance(block, dict) and block.get("type") == "text":
280
+ text_part = block.get("text")
281
+ if isinstance(text_part, str):
282
+ parts.append(text_part)
283
+ if not parts:
284
+ raise LlmCallError("Anthropic response had no text blocks.")
285
+ return "".join(parts)
286
+
287
+
288
+ def _extract_openai_text(payload: dict[str, Any]) -> str:
289
+ choices = payload.get("choices")
290
+ if not isinstance(choices, list) or not choices:
291
+ raise LlmCallError("OpenAI response missing 'choices' array.")
292
+ first = choices[0]
293
+ if not isinstance(first, dict):
294
+ raise LlmCallError("OpenAI 'choices[0]' is not an object.")
295
+ message = first.get("message")
296
+ if not isinstance(message, dict):
297
+ raise LlmCallError("OpenAI 'choices[0].message' is missing.")
298
+ content = message.get("content")
299
+ if not isinstance(content, str):
300
+ raise LlmCallError("OpenAI 'choices[0].message.content' is not a string.")
301
+ return content
302
+
303
+
304
+ def _anthropic_tool(tool: AgentTool) -> dict[str, Any]:
305
+ """Render an :class:`AgentTool` as Anthropic's tool descriptor."""
306
+ return {
307
+ "name": tool.name,
308
+ "description": tool.description,
309
+ "input_schema": tool.input_schema,
310
+ }
311
+
312
+
313
+ def _anthropic_message(message: AgentMessage) -> dict[str, Any]:
314
+ """Render an :class:`AgentMessage` as one Anthropic ``messages[]`` entry.
315
+
316
+ A ``user`` turn with tool results becomes a content array of
317
+ ``tool_result`` blocks; an ``assistant`` turn merges any text +
318
+ ``tool_use`` blocks. Plain user prompts stay as a string so the wire
319
+ payload matches the format the simple ``complete`` path emits.
320
+ """
321
+ if message.role == "user":
322
+ if message.tool_results:
323
+ return {
324
+ "role": "user",
325
+ "content": [
326
+ {
327
+ "type": "tool_result",
328
+ "tool_use_id": r.id,
329
+ "content": r.content,
330
+ **({"is_error": True} if r.is_error else {}),
331
+ }
332
+ for r in message.tool_results
333
+ ],
334
+ }
335
+ return {"role": "user", "content": message.text or ""}
336
+
337
+ blocks: list[dict[str, Any]] = []
338
+ if message.text:
339
+ blocks.append({"type": "text", "text": message.text})
340
+ for call in message.tool_calls:
341
+ blocks.append(
342
+ {
343
+ "type": "tool_use",
344
+ "id": call.id,
345
+ "name": call.name,
346
+ "input": call.arguments,
347
+ }
348
+ )
349
+ return {"role": "assistant", "content": blocks}
350
+
351
+
352
+ def _decode_anthropic_step(
353
+ payload: dict[str, Any],
354
+ ) -> tuple[str | None, tuple[AgentToolCall, ...]]:
355
+ """Pull text and tool calls out of an Anthropic Messages response.
356
+
357
+ Returns ``(text, tool_calls)``. ``text`` is the concatenated text
358
+ blocks (may be empty when the model only emitted tool calls);
359
+ ``tool_calls`` is empty when the response is purely text.
360
+ """
361
+ content = payload.get("content")
362
+ if not isinstance(content, list):
363
+ raise LlmCallError("Anthropic response missing 'content' array.")
364
+ text_parts: list[str] = []
365
+ tool_calls: list[AgentToolCall] = []
366
+ for block in content:
367
+ if not isinstance(block, dict):
368
+ continue
369
+ block_type = block.get("type")
370
+ if block_type == "text":
371
+ text_part = block.get("text")
372
+ if isinstance(text_part, str):
373
+ text_parts.append(text_part)
374
+ elif block_type == "tool_use":
375
+ call_id = block.get("id")
376
+ name = block.get("name")
377
+ args = block.get("input")
378
+ if not isinstance(call_id, str) or not isinstance(name, str):
379
+ raise LlmCallError("Anthropic tool_use block missing 'id' or 'name'.")
380
+ # ``input`` is contractually a JSON object on Anthropic's API
381
+ # (no-arg tools still emit ``{}``). A missing or non-dict
382
+ # value is a provider-side regression — silently coercing
383
+ # it to ``{}`` would forward an empty-args tool call and
384
+ # mask the bug. Fail loud so callers can record the
385
+ # underlying provider response.
386
+ if not isinstance(args, dict):
387
+ raise LlmCallError(
388
+ f"Anthropic tool_use block 'input' for tool {name!r} "
389
+ f"is not a JSON object (got {type(args).__name__})."
390
+ )
391
+ tool_calls.append(
392
+ AgentToolCall(id=call_id, name=name, arguments=dict(args))
393
+ )
394
+ text = "".join(text_parts) if text_parts else None
395
+ return text, tuple(tool_calls)