monkeybot 2.1.1__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 (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,473 @@
1
+ """Shared provider utilities (cost estimation, Anthropic message shaping)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from typing import Any, Literal
9
+
10
+ from monkeybot.core.llm.provider import (
11
+ Done,
12
+ Message,
13
+ ProviderEvent,
14
+ TextDelta,
15
+ ThinkingDelta,
16
+ ToolCall,
17
+ ToolInputDelta,
18
+ UsageEvent,
19
+ )
20
+ from monkeybot.core.logging_utils import kv
21
+ from monkeybot.core.types.content_blocks import (
22
+ ContentBlock,
23
+ File,
24
+ Image,
25
+ RedactedThinking,
26
+ Text,
27
+ Thinking,
28
+ ToolRequest,
29
+ ToolResponse,
30
+ )
31
+ from monkeybot.providers.pricing import estimate_cost
32
+
33
+ _log = logging.getLogger(__name__)
34
+
35
+
36
+ def safe_parse_tool_args(
37
+ raw: str,
38
+ *,
39
+ call_id: str,
40
+ tool_name: str,
41
+ provider: str,
42
+ ) -> tuple[dict[str, object], str | None]:
43
+ """Parse streamed tool arguments; return ``({}, error)`` when JSON is invalid."""
44
+ text = (raw or "").strip()
45
+ if not text:
46
+ return {}, None
47
+ try:
48
+ parsed = json.loads(text)
49
+ except json.JSONDecodeError as exc:
50
+ action = "malformed_tool_args"
51
+ error = f"malformed tool args JSON: {exc}"
52
+ else:
53
+ if isinstance(parsed, dict):
54
+ return parsed, None
55
+ action = "non_object_tool_args"
56
+ error = f"tool args must be a JSON object, got {type(parsed).__name__}"
57
+ _log.warning(
58
+ "stream_parse_repair %s",
59
+ kv(
60
+ action=action,
61
+ call_id=call_id,
62
+ tool_name=tool_name,
63
+ provider=provider,
64
+ ),
65
+ )
66
+ return {}, error
67
+
68
+
69
+ def anthropic_tool_defs(tools: Sequence[Any]) -> list[dict[str, Any]]:
70
+ return [
71
+ {"name": t.name, "description": t.description, "input_schema": t.input_schema}
72
+ for t in tools
73
+ ]
74
+
75
+
76
+ def split_leading_system(messages: Sequence[Message]) -> tuple[str, list[Message]]:
77
+ msgs = list(messages)
78
+ if not msgs or msgs[0].role != "system":
79
+ return "", msgs
80
+ system = "\n\n".join(b.text for b in msgs[0].content if isinstance(b, Text))
81
+ return system, msgs[1:]
82
+
83
+
84
+ async def count_anthropic_input_tokens(
85
+ client: Any,
86
+ *,
87
+ anthropic_module: Any,
88
+ model: str,
89
+ system: str,
90
+ messages: Sequence[dict[str, Any]],
91
+ tools: Sequence[dict[str, Any]] | None,
92
+ ) -> int:
93
+ resp = await client.messages.count_tokens(
94
+ model=model,
95
+ system=system or anthropic_module.NOT_GIVEN,
96
+ messages=messages,
97
+ tools=tools if tools else anthropic_module.NOT_GIVEN,
98
+ )
99
+ return int(resp.input_tokens)
100
+
101
+
102
+ async def iter_anthropic_sdk_stream(
103
+ client: Any,
104
+ stream_kwargs: dict[str, Any],
105
+ *,
106
+ provider: str,
107
+ error_message: str,
108
+ n_messages: int | None = None,
109
+ n_tools: int | None = None,
110
+ ) -> AsyncIterator[ProviderEvent]:
111
+ tool_input_buf = ""
112
+ tool_id = ""
113
+ tool_name = ""
114
+ input_tokens = 0
115
+ output_tokens = 0
116
+ cache_read = 0
117
+ cache_creation = 0
118
+ stop_reason: str | None = None
119
+
120
+ try:
121
+ async with client.messages.stream(**stream_kwargs) as stream:
122
+ async for event in stream:
123
+ match event.type:
124
+ case "content_block_start":
125
+ if event.content_block.type == "tool_use":
126
+ tool_id = event.content_block.id
127
+ tool_name = event.content_block.name
128
+ tool_input_buf = ""
129
+ case "content_block_delta":
130
+ if event.delta.type == "text_delta":
131
+ yield TextDelta(text=event.delta.text)
132
+ elif event.delta.type == "thinking_delta":
133
+ thought = getattr(event.delta, "thinking", None) or ""
134
+ if thought:
135
+ yield ThinkingDelta(text=thought)
136
+ elif event.delta.type == "signature_delta":
137
+ sig = getattr(event.delta, "signature", None) or ""
138
+ if sig:
139
+ yield ThinkingDelta(text="", signature=sig)
140
+ elif event.delta.type == "input_json_delta":
141
+ partial = event.delta.partial_json
142
+ tool_input_buf += partial
143
+ if tool_id and partial:
144
+ yield ToolInputDelta(
145
+ call_id=tool_id,
146
+ name=tool_name,
147
+ delta=partial,
148
+ )
149
+ case "content_block_stop":
150
+ if tool_id:
151
+ args, parse_error = safe_parse_tool_args(
152
+ tool_input_buf,
153
+ call_id=tool_id,
154
+ tool_name=tool_name,
155
+ provider=provider,
156
+ )
157
+ yield ToolCall(
158
+ call_id=tool_id,
159
+ name=tool_name,
160
+ args=args,
161
+ parse_error=parse_error,
162
+ )
163
+ tool_id = tool_name = tool_input_buf = ""
164
+ case "message_delta":
165
+ delta = getattr(event, "delta", None)
166
+ sr = getattr(delta, "stop_reason", None) if delta is not None else None
167
+ if sr:
168
+ stop_reason = str(sr)
169
+ if hasattr(event, "usage"):
170
+ output_tokens = int(getattr(event.usage, "output_tokens", 0) or 0)
171
+ read = int(
172
+ getattr(event.usage, "cache_read_input_tokens", 0) or 0
173
+ )
174
+ created = int(
175
+ getattr(event.usage, "cache_creation_input_tokens", 0) or 0
176
+ )
177
+ if read:
178
+ cache_read = read
179
+ if created:
180
+ cache_creation = created
181
+ case "message_start":
182
+ if hasattr(event, "message") and event.message.usage:
183
+ usage = event.message.usage
184
+ input_tokens = int(getattr(usage, "input_tokens", 0) or 0)
185
+ cache_read = int(
186
+ getattr(usage, "cache_read_input_tokens", 0) or 0
187
+ )
188
+ cache_creation = int(
189
+ getattr(usage, "cache_creation_input_tokens", 0) or 0
190
+ )
191
+ except Exception:
192
+ _log.warning(
193
+ error_message,
194
+ kv(
195
+ provider=provider,
196
+ model=stream_kwargs.get("model"),
197
+ n_messages=n_messages,
198
+ n_tools=n_tools,
199
+ ),
200
+ exc_info=True,
201
+ )
202
+ raise
203
+
204
+ yield UsageEvent(
205
+ input_tokens=input_tokens,
206
+ output_tokens=output_tokens,
207
+ cache_read_tokens=cache_read,
208
+ cache_creation_tokens=cache_creation,
209
+ cached_tokens=cache_read + cache_creation,
210
+ )
211
+ yield Done(truncated=stop_reason == "max_tokens")
212
+
213
+
214
+ def _anthropic_tool_result_content(result: list[ContentBlock]) -> list[dict[str, Any]]:
215
+ """Map ``ToolResponse.result`` blocks to Anthropic ``tool_result`` content list."""
216
+ out: list[dict[str, Any]] = []
217
+ for block in result:
218
+ if isinstance(block, Text):
219
+ out.append({"type": "text", "text": block.text})
220
+ elif isinstance(block, Image):
221
+ out.append(
222
+ {
223
+ "type": "image",
224
+ "source": {
225
+ "type": "base64",
226
+ "media_type": block.mime_type,
227
+ "data": block.data,
228
+ },
229
+ }
230
+ )
231
+ elif isinstance(block, File):
232
+ out.append(
233
+ {
234
+ "type": "document",
235
+ "source": {
236
+ "type": "base64",
237
+ "media_type": block.mime_type,
238
+ "data": block.data,
239
+ },
240
+ }
241
+ )
242
+ else:
243
+ raise ValueError(
244
+ f"unsupported ToolResponse block for Anthropic: {type(block).__name__}"
245
+ )
246
+ return out
247
+
248
+
249
+ def _anthropic_user_block(block: ContentBlock) -> dict[str, Any]:
250
+ if isinstance(block, Text):
251
+ return {"type": "text", "text": block.text}
252
+ if isinstance(block, Image):
253
+ return {
254
+ "type": "image",
255
+ "source": {
256
+ "type": "base64",
257
+ "media_type": block.mime_type,
258
+ "data": block.data,
259
+ },
260
+ }
261
+ if isinstance(block, File):
262
+ return {
263
+ "type": "document",
264
+ "source": {
265
+ "type": "base64",
266
+ "media_type": block.mime_type,
267
+ "data": block.data,
268
+ },
269
+ }
270
+ if isinstance(block, ToolResponse):
271
+ return {
272
+ "type": "tool_result",
273
+ "tool_use_id": block.id,
274
+ "content": _anthropic_tool_result_content(block.result),
275
+ }
276
+ raise ValueError(f"unsupported user content block for Anthropic: {type(block).__name__}")
277
+
278
+
279
+ def _anthropic_assistant_block(block: ContentBlock) -> dict[str, Any]:
280
+ if isinstance(block, Text):
281
+ return {"type": "text", "text": block.text}
282
+ if isinstance(block, ToolRequest):
283
+ return {
284
+ "type": "tool_use",
285
+ "id": block.id,
286
+ "name": block.name,
287
+ "input": dict(block.args),
288
+ }
289
+ if isinstance(block, Thinking):
290
+ return {
291
+ "type": "thinking",
292
+ "thinking": block.thinking,
293
+ "signature": block.signature,
294
+ }
295
+ if isinstance(block, RedactedThinking):
296
+ return {"type": "redacted_thinking", "data": block.data}
297
+ raise ValueError(
298
+ f"unsupported assistant content block for Anthropic: {type(block).__name__}"
299
+ )
300
+
301
+
302
+ # Must match volatile section headers emitted by ``compose_system_prompt``
303
+ # (``core.prompts.prompt``), ``_append_extra_system_text`` (``core.runtime.loop``),
304
+ # and ``_format_system_context_update`` (``core.context.epoch``).
305
+ #
306
+ # These modules cannot be imported here directly: ``core.context`` transitively
307
+ # imports ``core.config.settings``, which imports concrete provider classes
308
+ # from this package, creating a circular import. Each owning module exposes
309
+ # its heading as a module-level ``*_HEADING`` constant (not just an inline
310
+ # literal) specifically so this list — and any other out-of-package consumer —
311
+ # can be kept in sync by grepping for ``_HEADING = "\n\n## `` rather than by
312
+ # re-deriving the split point from prose. A dedicated regression test
313
+ # (``tests/providers/test_anthropic_cache.py::test_volatile_markers_match_heading_constants``)
314
+ # asserts these literals stay byte-identical to the owning constants.
315
+ _VOLATILE_SYSTEM_MARKERS = (
316
+ "\n\n## Memory index\n",
317
+ "\n\n## Memory\n",
318
+ "\n\n## Skills\n",
319
+ "\n\n## Current request\n",
320
+ "\n\n## Runtime notes\n",
321
+ "\n\n## System context update\n",
322
+ )
323
+
324
+
325
+ def split_system_prompt_for_cache(system: str) -> tuple[str, str]:
326
+ """Split composed system text into stable (cacheable) prefix and volatile tail."""
327
+ split_at = len(system)
328
+ for marker in _VOLATILE_SYSTEM_MARKERS:
329
+ idx = system.find(marker)
330
+ if idx != -1:
331
+ split_at = min(split_at, idx)
332
+ if split_at >= len(system):
333
+ return system, ""
334
+ return system[:split_at], system[split_at:]
335
+
336
+
337
+ def build_cached_system_blocks(
338
+ system: str,
339
+ *,
340
+ cache_retention: Literal["none", "short", "long"] = "short",
341
+ ) -> list[dict[str, Any]]:
342
+ """Return Anthropic system blocks with cache_control only on the stable prefix.
343
+
344
+ Volatile tail sections (memory, skills, current request) are sent in a second
345
+ uncached block so explicit caching hits across curation turns.
346
+
347
+ When ``cache_retention`` is ``none``, no ``cache_control`` markers are applied.
348
+
349
+ Args:
350
+ system: Non-empty system prompt text. Callers MUST guard empty strings
351
+ and pass anthropic.NOT_GIVEN instead (see provider stream methods).
352
+ cache_retention: ``none`` disables markers; ``short``/``long`` enable
353
+ ephemeral ``cache_control`` (Anthropic does not distinguish short/long
354
+ on the block itself; session affinity is a separate hint).
355
+
356
+ Returns:
357
+ One or two text blocks; the stable prefix carries ``cache_control: ephemeral``
358
+ unless retention is ``none``.
359
+ """
360
+ stable, volatile = split_system_prompt_for_cache(system)
361
+ if cache_retention == "none":
362
+ if not volatile.strip():
363
+ return [{"type": "text", "text": system}]
364
+ blocks: list[dict[str, Any]] = [{"type": "text", "text": stable}]
365
+ if volatile:
366
+ blocks.append({"type": "text", "text": volatile})
367
+ return blocks
368
+ if not volatile.strip():
369
+ return [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}]
370
+ blocks = [
371
+ {"type": "text", "text": stable, "cache_control": {"type": "ephemeral"}},
372
+ ]
373
+ if volatile:
374
+ blocks.append({"type": "text", "text": volatile})
375
+ return blocks
376
+
377
+
378
+ def mark_last_tool_cached(
379
+ tools: list[dict[str, Any]],
380
+ *,
381
+ cache_retention: Literal["none", "short", "long"] = "short",
382
+ ) -> list[dict[str, Any]]:
383
+ """Return a copy of ``tools`` with ``cache_control: ephemeral`` on the LAST tool.
384
+
385
+ Marks the final tool dict so Anthropic caches the entire tools-array prefix.
386
+ No-ops when ``tools`` is empty or ``cache_retention`` is ``none``.
387
+
388
+ Args:
389
+ tools: Anthropic tool dicts (output of a provider ``_convert_tools``).
390
+ cache_retention: When ``none``, returns tools unchanged (no markers).
391
+
392
+ Returns:
393
+ A new list; only the last element gains a ``cache_control`` key. Input
394
+ list and its dicts are not mutated (shallow-copy the last dict).
395
+ """
396
+ if not tools or cache_retention == "none":
397
+ return tools
398
+ marked_last = {**tools[-1], "cache_control": {"type": "ephemeral"}}
399
+ return [*tools[:-1], marked_last]
400
+
401
+
402
+ def build_anthropic_messages(messages: Sequence[Message]) -> list[dict[str, Any]]:
403
+ """Convert harness messages to Anthropic ``messages`` API shape (no string parsing)."""
404
+ out: list[dict[str, Any]] = []
405
+ for m in messages:
406
+ role = getattr(m, "role", None)
407
+ if role == "tool":
408
+ raise ValueError('disallowed role "tool"; use user messages with ToolResponse blocks')
409
+ if role not in ("user", "assistant"):
410
+ raise ValueError(f"unsupported role for Anthropic messages: {role!r}")
411
+
412
+ blocks = list(m.content)
413
+
414
+ if role == "user":
415
+ if len(blocks) == 1 and isinstance(blocks[0], Text):
416
+ out.append({"role": "user", "content": blocks[0].text})
417
+ continue
418
+ out.append(
419
+ {
420
+ "role": "user",
421
+ "content": [_anthropic_user_block(b) for b in blocks],
422
+ }
423
+ )
424
+ continue
425
+
426
+ # assistant
427
+ if len(blocks) == 1 and isinstance(blocks[0], Text):
428
+ out.append({"role": "assistant", "content": blocks[0].text})
429
+ continue
430
+ out.append(
431
+ {
432
+ "role": "assistant",
433
+ "content": [_anthropic_assistant_block(b) for b in blocks],
434
+ }
435
+ )
436
+ return out
437
+
438
+
439
+ def estimate_anthropic_input_tokens(
440
+ *,
441
+ system: str,
442
+ messages: Sequence[dict[str, Any]],
443
+ tools: Sequence[dict[str, Any]] | None,
444
+ ) -> int:
445
+ """Rough prompt token estimate when provider ``count_tokens`` is unavailable (e.g. Bedrock)."""
446
+ parts: list[str] = [system]
447
+ for msg in messages:
448
+ content = msg.get("content")
449
+ if isinstance(content, str):
450
+ parts.append(content)
451
+ elif isinstance(content, list):
452
+ parts.append(json.dumps(content, ensure_ascii=False))
453
+ else:
454
+ parts.append(str(content))
455
+ if tools:
456
+ parts.append(json.dumps(list(tools), ensure_ascii=False))
457
+ char_count = sum(len(p) for p in parts)
458
+ return max(1, char_count // 4)
459
+
460
+
461
+ __all__ = [
462
+ "anthropic_tool_defs",
463
+ "build_anthropic_messages",
464
+ "build_cached_system_blocks",
465
+ "count_anthropic_input_tokens",
466
+ "estimate_anthropic_input_tokens",
467
+ "estimate_cost",
468
+ "iter_anthropic_sdk_stream",
469
+ "mark_last_tool_cached",
470
+ "safe_parse_tool_args",
471
+ "split_leading_system",
472
+ "split_system_prompt_for_cache",
473
+ ]
@@ -0,0 +1,145 @@
1
+ """Anthropic Claude on AWS Bedrock (``AsyncAnthropicBedrock``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from typing import Any
9
+
10
+ from monkeybot.core.llm.provider import Message, ProviderCallHints, ProviderEvent
11
+ from monkeybot.core.logging_utils import kv
12
+ from monkeybot.core.types.types_tools import ToolDef
13
+ from monkeybot.providers._utils import (
14
+ anthropic_tool_defs,
15
+ build_anthropic_messages,
16
+ build_cached_system_blocks,
17
+ count_anthropic_input_tokens,
18
+ estimate_anthropic_input_tokens,
19
+ iter_anthropic_sdk_stream,
20
+ mark_last_tool_cached,
21
+ split_leading_system,
22
+ )
23
+ from monkeybot.providers.sampling import resolve_model_sampling
24
+
25
+ _log = logging.getLogger(__name__)
26
+
27
+
28
+ class BedrockClaudeProvider:
29
+ """Claude on AWS Bedrock via the Anthropic Bedrock client."""
30
+
31
+ @property
32
+ def name(self) -> str:
33
+ return "bedrock"
34
+
35
+ @property
36
+ def supports_streaming(self) -> bool:
37
+ return True
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ aws_region: str | None = None,
43
+ temperature: float | None = None,
44
+ max_tokens: int | None = None,
45
+ ) -> None:
46
+ self._aws_region = (
47
+ (aws_region or "").strip()
48
+ or os.environ.get("AWS_REGION", "").strip()
49
+ or os.environ.get("AWS_DEFAULT_REGION", "").strip()
50
+ or "us-east-1"
51
+ )
52
+ sampling = resolve_model_sampling(temperature=temperature, max_tokens=max_tokens)
53
+ self._temperature = sampling.temperature
54
+ self._max_tokens = sampling.max_tokens
55
+
56
+ def _client(self) -> Any:
57
+ from anthropic import AsyncAnthropicBedrock # noqa: PLC0415
58
+
59
+ return AsyncAnthropicBedrock(aws_region=self._aws_region)
60
+
61
+ async def count_input_tokens(
62
+ self,
63
+ messages: Sequence[Message],
64
+ tools: Sequence[ToolDef],
65
+ *,
66
+ model: str,
67
+ thinking_budget: int | None = None,
68
+ hints: ProviderCallHints | None = None,
69
+ ) -> int:
70
+ del thinking_budget, hints
71
+ import anthropic # noqa: PLC0415
72
+
73
+ system, msgs = split_leading_system(messages)
74
+ converted_messages = build_anthropic_messages(msgs)
75
+ converted_tools = anthropic_tool_defs(tools) if tools else None
76
+ client = self._client()
77
+ try:
78
+ return await count_anthropic_input_tokens(
79
+ client,
80
+ anthropic_module=anthropic,
81
+ model=model,
82
+ system=system,
83
+ messages=converted_messages,
84
+ tools=converted_tools,
85
+ )
86
+ except Exception as exc:
87
+ msg = str(exc).lower()
88
+ if "token counting" in msg or "not supported" in msg or "bedrock" in msg:
89
+ _log.warning(
90
+ "Bedrock count_tokens unavailable, using estimate %s",
91
+ kv(provider="bedrock", model=model),
92
+ exc_info=True,
93
+ )
94
+ return estimate_anthropic_input_tokens(
95
+ system=system,
96
+ messages=converted_messages,
97
+ tools=converted_tools,
98
+ )
99
+ raise
100
+
101
+ async def stream(
102
+ self,
103
+ messages: Sequence[Message],
104
+ tools: Sequence[ToolDef],
105
+ *,
106
+ model: str,
107
+ thinking_budget: int | None = None,
108
+ hints: ProviderCallHints | None = None,
109
+ ) -> AsyncIterator[ProviderEvent]:
110
+ del thinking_budget
111
+ import anthropic # noqa: PLC0415
112
+
113
+ retention = hints.cache_retention if hints is not None else "short"
114
+ system, msgs = split_leading_system(messages)
115
+ converted_messages = build_anthropic_messages(msgs)
116
+ converted = anthropic_tool_defs(tools) if tools else None
117
+ system_param: Any = (
118
+ build_cached_system_blocks(system, cache_retention=retention)
119
+ if system
120
+ else anthropic.NOT_GIVEN
121
+ )
122
+ tools_param: Any = (
123
+ mark_last_tool_cached(converted, cache_retention=retention)
124
+ if converted
125
+ else anthropic.NOT_GIVEN
126
+ )
127
+
128
+ client = self._client()
129
+ stream_kwargs: dict[str, Any] = {
130
+ "model": model,
131
+ "system": system_param,
132
+ "messages": converted_messages,
133
+ "tools": tools_param,
134
+ "max_tokens": self._max_tokens,
135
+ "temperature": self._temperature,
136
+ }
137
+ async for event in iter_anthropic_sdk_stream(
138
+ client,
139
+ stream_kwargs,
140
+ provider="bedrock",
141
+ error_message="Bedrock Claude stream error: %s",
142
+ n_messages=len(messages),
143
+ n_tools=len(tools),
144
+ ):
145
+ yield event