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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- 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
|