fast-agent-mcp 0.2.41__py3-none-any.whl → 0.2.43__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.

Potentially problematic release.


This version of fast-agent-mcp might be problematic. Click here for more details.

@@ -0,0 +1,1787 @@
1
+ import json
2
+ import os
3
+ import re
4
+ from enum import Enum
5
+ from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Type, Union
6
+
7
+ from mcp.types import EmbeddedResource, ImageContent, TextContent
8
+ from rich.text import Text
9
+
10
+ from mcp_agent.core.exceptions import ProviderKeyError
11
+ from mcp_agent.core.request_params import RequestParams
12
+ from mcp_agent.event_progress import ProgressAction
13
+ from mcp_agent.llm.augmented_llm import AugmentedLLM
14
+ from mcp_agent.llm.provider_types import Provider
15
+ from mcp_agent.llm.usage_tracking import TurnUsage
16
+ from mcp_agent.logging.logger import get_logger
17
+ from mcp_agent.mcp.interfaces import ModelT
18
+ from mcp_agent.mcp.prompt_message_multipart import PromptMessageMultipart
19
+
20
+ if TYPE_CHECKING:
21
+ from mcp import ListToolsResult
22
+
23
+ try:
24
+ import boto3
25
+ from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError
26
+ except ImportError:
27
+ boto3 = None
28
+ BotoCoreError = Exception
29
+ ClientError = Exception
30
+ NoCredentialsError = Exception
31
+
32
+ try:
33
+ from anthropic.types import ToolParam
34
+ except ImportError:
35
+ ToolParam = None
36
+
37
+ from mcp.types import (
38
+ CallToolRequest,
39
+ CallToolRequestParams,
40
+ )
41
+
42
+ DEFAULT_BEDROCK_MODEL = "amazon.nova-lite-v1:0"
43
+
44
+ # Bedrock message format types
45
+ BedrockMessage = Dict[str, Any] # Bedrock message format
46
+ BedrockMessageParam = Dict[str, Any] # Bedrock message parameter format
47
+
48
+
49
+ class ToolSchemaType(Enum):
50
+ """Enum for different tool schema formats used by different model families."""
51
+
52
+ DEFAULT = "default" # Default toolSpec format used by most models (formerly Nova)
53
+ SYSTEM_PROMPT = "system_prompt" # System prompt-based tool calling format
54
+ ANTHROPIC = "anthropic" # Native Anthropic tool calling format
55
+
56
+
57
+ class BedrockAugmentedLLM(AugmentedLLM[BedrockMessageParam, BedrockMessage]):
58
+ """
59
+ AWS Bedrock implementation of AugmentedLLM using the Converse API.
60
+ Supports all Bedrock models including Nova, Claude, Meta, etc.
61
+ """
62
+
63
+ # Bedrock-specific parameter exclusions
64
+ BEDROCK_EXCLUDE_FIELDS = {
65
+ AugmentedLLM.PARAM_MESSAGES,
66
+ AugmentedLLM.PARAM_MODEL,
67
+ AugmentedLLM.PARAM_SYSTEM_PROMPT,
68
+ AugmentedLLM.PARAM_STOP_SEQUENCES,
69
+ AugmentedLLM.PARAM_MAX_TOKENS,
70
+ AugmentedLLM.PARAM_METADATA,
71
+ AugmentedLLM.PARAM_USE_HISTORY,
72
+ AugmentedLLM.PARAM_MAX_ITERATIONS,
73
+ AugmentedLLM.PARAM_PARALLEL_TOOL_CALLS,
74
+ AugmentedLLM.PARAM_TEMPLATE_VARS,
75
+ }
76
+
77
+ @classmethod
78
+ def matches_model_pattern(cls, model_name: str) -> bool:
79
+ """Check if a model name matches Bedrock model patterns."""
80
+ # Bedrock model patterns
81
+ bedrock_patterns = [
82
+ r"^amazon\.nova.*", # Amazon Nova models
83
+ r"^anthropic\.claude.*", # Anthropic Claude models
84
+ r"^meta\.llama.*", # Meta Llama models
85
+ r"^mistral\..*", # Mistral models
86
+ r"^cohere\..*", # Cohere models
87
+ r"^ai21\..*", # AI21 models
88
+ r"^stability\..*", # Stability AI models
89
+ ]
90
+
91
+ import re
92
+
93
+ return any(re.match(pattern, model_name) for pattern in bedrock_patterns)
94
+
95
+ def __init__(self, *args, **kwargs) -> None:
96
+ """Initialize the Bedrock LLM with AWS credentials and region."""
97
+ if boto3 is None:
98
+ raise ImportError(
99
+ "boto3 is required for Bedrock support. Install with: pip install boto3"
100
+ )
101
+
102
+ # Initialize logger
103
+ self.logger = get_logger(__name__)
104
+
105
+ # Extract AWS configuration from kwargs first
106
+ self.aws_region = kwargs.pop("region", None)
107
+ self.aws_profile = kwargs.pop("profile", None)
108
+
109
+ super().__init__(*args, provider=Provider.BEDROCK, **kwargs)
110
+
111
+ # Use config values if not provided in kwargs (after super().__init__)
112
+ if self.context.config and self.context.config.bedrock:
113
+ if not self.aws_region:
114
+ self.aws_region = self.context.config.bedrock.region
115
+ if not self.aws_profile:
116
+ self.aws_profile = self.context.config.bedrock.profile
117
+
118
+ # Final fallback to environment variables
119
+ if not self.aws_region:
120
+ # Support both AWS_REGION and AWS_DEFAULT_REGION
121
+ self.aws_region = os.environ.get("AWS_REGION") or os.environ.get(
122
+ "AWS_DEFAULT_REGION", "us-east-1"
123
+ )
124
+
125
+ if not self.aws_profile:
126
+ # Support AWS_PROFILE environment variable
127
+ self.aws_profile = os.environ.get("AWS_PROFILE")
128
+
129
+ # Initialize AWS clients
130
+ self._bedrock_client = None
131
+ self._bedrock_runtime_client = None
132
+
133
+ def _initialize_default_params(self, kwargs: dict) -> RequestParams:
134
+ """Initialize Bedrock-specific default parameters"""
135
+ # Get base defaults from parent (includes ModelDatabase lookup)
136
+ base_params = super()._initialize_default_params(kwargs)
137
+
138
+ # Override with Bedrock-specific settings
139
+ chosen_model = kwargs.get("model", DEFAULT_BEDROCK_MODEL)
140
+ base_params.model = chosen_model
141
+
142
+ return base_params
143
+
144
+ def _get_bedrock_client(self):
145
+ """Get or create Bedrock client."""
146
+ if self._bedrock_client is None:
147
+ try:
148
+ session = boto3.Session(profile_name=self.aws_profile)
149
+ self._bedrock_client = session.client("bedrock", region_name=self.aws_region)
150
+ except NoCredentialsError as e:
151
+ raise ProviderKeyError(
152
+ "AWS credentials not found",
153
+ "Please configure AWS credentials using AWS CLI, environment variables, or IAM roles.",
154
+ ) from e
155
+ return self._bedrock_client
156
+
157
+ def _get_bedrock_runtime_client(self):
158
+ """Get or create Bedrock Runtime client."""
159
+ if self._bedrock_runtime_client is None:
160
+ try:
161
+ session = boto3.Session(profile_name=self.aws_profile)
162
+ self._bedrock_runtime_client = session.client(
163
+ "bedrock-runtime", region_name=self.aws_region
164
+ )
165
+ except NoCredentialsError as e:
166
+ raise ProviderKeyError(
167
+ "AWS credentials not found",
168
+ "Please configure AWS credentials using AWS CLI, environment variables, or IAM roles.",
169
+ ) from e
170
+ return self._bedrock_runtime_client
171
+
172
+ def _get_tool_schema_type(self, model_id: str) -> ToolSchemaType:
173
+ """
174
+ Determine which tool schema format to use based on model family.
175
+
176
+ Args:
177
+ model_id: The model ID (e.g., "bedrock.meta.llama3-1-8b-instruct-v1:0")
178
+
179
+ Returns:
180
+ ToolSchemaType indicating which format to use
181
+ """
182
+ # Remove any "bedrock." prefix for pattern matching
183
+ clean_model = model_id.replace("bedrock.", "")
184
+
185
+ # Anthropic models use native Anthropic format
186
+ if re.search(r"anthropic\.claude", clean_model):
187
+ self.logger.debug(
188
+ f"Model {model_id} detected as Anthropic - using native Anthropic format"
189
+ )
190
+ return ToolSchemaType.ANTHROPIC
191
+
192
+ # Scout models use SYSTEM_PROMPT format
193
+ if re.search(r"meta\.llama4-scout", clean_model):
194
+ self.logger.debug(f"Model {model_id} detected as Scout - using SYSTEM_PROMPT format")
195
+ return ToolSchemaType.SYSTEM_PROMPT
196
+
197
+ # Other Llama 4 models use default toolConfig format
198
+ if re.search(r"meta\.llama4", clean_model):
199
+ self.logger.debug(
200
+ f"Model {model_id} detected as Llama 4 (non-Scout) - using default toolConfig format"
201
+ )
202
+ return ToolSchemaType.DEFAULT
203
+
204
+ # Llama 3.x models use system prompt format
205
+ if re.search(r"meta\.llama3", clean_model):
206
+ self.logger.debug(
207
+ f"Model {model_id} detected as Llama 3.x - using system prompt format"
208
+ )
209
+ return ToolSchemaType.SYSTEM_PROMPT
210
+
211
+ # Future: Add other model-specific formats here
212
+ # if re.search(r"mistral\.", clean_model):
213
+ # return ToolSchemaType.MISTRAL
214
+
215
+ # Default to default format for all other models
216
+ self.logger.debug(f"Model {model_id} using default tool format")
217
+ return ToolSchemaType.DEFAULT
218
+
219
+ def _supports_streaming_with_tools(self, model: str) -> bool:
220
+ """
221
+ Check if a model supports streaming with tools.
222
+
223
+ Some models (like AI21 Jamba) support tools but not in streaming mode.
224
+ This method uses regex patterns to identify such models.
225
+
226
+ Args:
227
+ model: The model name (e.g., "ai21.jamba-1-5-mini-v1:0")
228
+
229
+ Returns:
230
+ False if the model requires non-streaming for tools, True otherwise
231
+ """
232
+ # Remove any "bedrock." prefix for pattern matching
233
+ clean_model = model.replace("bedrock.", "")
234
+
235
+ # Models that don't support streaming with tools
236
+ non_streaming_patterns = [
237
+ r"ai21\.jamba", # All AI21 Jamba models
238
+ r"meta\.llama", # All Meta Llama models
239
+ r"mistral\.", # All Mistral models
240
+ r"amazon\.titan", # All Amazon Titan models
241
+ r"cohere\.command", # All Cohere Command models
242
+ r"anthropic\.claude-instant", # Anthropic Claude Instant models
243
+ r"anthropic\.claude-v2", # Anthropic Claude v2 models
244
+ r"deepseek\.", # All DeepSeek models
245
+ ]
246
+
247
+ for pattern in non_streaming_patterns:
248
+ if re.search(pattern, clean_model, re.IGNORECASE):
249
+ self.logger.debug(
250
+ f"Model {model} detected as non-streaming for tools (pattern: {pattern})"
251
+ )
252
+ return False
253
+
254
+ return True
255
+
256
+ def _supports_tool_use(self, model_id: str) -> bool:
257
+ """
258
+ Determine if a model supports tool use at all.
259
+ Some models don't support tools in any form.
260
+ Based on AWS Bedrock documentation: https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-supported-models-features.html
261
+ """
262
+ # Models that don't support tool use at all
263
+ no_tool_use_patterns = [
264
+ r"ai21\.jamba-instruct", # AI21 Jamba-Instruct (but not jamba 1.5)
265
+ r"ai21\..*jurassic", # AI21 Labs Jurassic-2 models
266
+ r"amazon\.titan", # All Amazon Titan models
267
+ r"anthropic\.claude-v2", # Anthropic Claude v2 models
268
+ r"anthropic\.claude-instant", # Anthropic Claude Instant models
269
+ r"cohere\.command(?!-r)", # Cohere Command (but not Command R/R+)
270
+ r"cohere\.command-light", # Cohere Command Light
271
+ r"deepseek\.", # All DeepSeek models
272
+ r"meta\.llama[23](?![-.])", # Meta Llama 2 and 3 (but not 3.1+, 3.2+, etc.)
273
+ r"meta\.llama3-1-8b", # Meta Llama 3.1 8b - doesn't support tool calls
274
+ r"meta\.llama3-2-[13]b", # Meta Llama 3.2 1b and 3b (but not 11b/90b)
275
+ r"meta\.llama3-2-11b", # Meta Llama 3.2 11b - doesn't support tool calls
276
+ r"mistral\..*-instruct", # Mistral AI Instruct (but not Mistral Large)
277
+ ]
278
+
279
+ for pattern in no_tool_use_patterns:
280
+ if re.search(pattern, model_id):
281
+ self.logger.info(f"Model {model_id} does not support tool use")
282
+ return False
283
+
284
+ return True
285
+
286
+ def _supports_system_messages(self, model: str) -> bool:
287
+ """
288
+ Check if a model supports system messages.
289
+
290
+ Some models (like Titan and Cohere embedding models) don't support system messages.
291
+ This method uses regex patterns to identify such models.
292
+
293
+ Args:
294
+ model: The model name (e.g., "amazon.titan-embed-text-v1")
295
+
296
+ Returns:
297
+ False if the model doesn't support system messages, True otherwise
298
+ """
299
+ # Remove any "bedrock." prefix for pattern matching
300
+ clean_model = model.replace("bedrock.", "")
301
+
302
+ # DEBUG: Print the model names for debugging
303
+ self.logger.info(
304
+ f"DEBUG: Checking system message support for model='{model}', clean_model='{clean_model}'"
305
+ )
306
+
307
+ # Models that don't support system messages (reverse logic as suggested)
308
+ no_system_message_patterns = [
309
+ r"amazon\.titan", # All Amazon Titan models
310
+ r"cohere\.command.*-text", # Cohere command text models (command-text-v14, command-light-text-v14)
311
+ r"mistral.*mixtral.*8x7b", # Mistral Mixtral 8x7b models
312
+ r"mistral.mistral-7b-instruct", # Mistral 7b instruct models
313
+ r"meta\.llama3-2-11b-instruct", # Specific Meta Llama3 model
314
+ ]
315
+
316
+ for pattern in no_system_message_patterns:
317
+ if re.search(pattern, clean_model, re.IGNORECASE):
318
+ self.logger.info(
319
+ f"DEBUG: Model {model} detected as NOT supporting system messages (pattern: {pattern})"
320
+ )
321
+ return False
322
+
323
+ self.logger.info(f"DEBUG: Model {model} detected as supporting system messages")
324
+ return True
325
+
326
+ def _convert_tools_nova_format(self, tools: "ListToolsResult") -> List[Dict[str, Any]]:
327
+ """Convert MCP tools to Nova-specific toolSpec format.
328
+
329
+ Note: Nova models have VERY strict JSON schema requirements:
330
+ - Top level schema must be of type Object
331
+ - ONLY three fields are supported: type, properties, required
332
+ - NO other fields like $schema, description, title, additionalProperties
333
+ - Properties can only have type and description
334
+ - Tools with no parameters should have empty properties object
335
+ """
336
+ bedrock_tools = []
337
+
338
+ # Create mapping from cleaned names to original names for tool execution
339
+ self.tool_name_mapping = {}
340
+
341
+ self.logger.debug(f"Converting {len(tools.tools)} MCP tools to Nova format")
342
+
343
+ for tool in tools.tools:
344
+ self.logger.debug(f"Converting MCP tool: {tool.name}")
345
+
346
+ # Extract and validate the input schema
347
+ input_schema = tool.inputSchema or {}
348
+
349
+ # Create Nova-compliant schema with ONLY the three allowed fields
350
+ # Always include type and properties (even if empty)
351
+ nova_schema: Dict[str, Any] = {"type": "object", "properties": {}}
352
+
353
+ # Properties - clean them strictly
354
+ properties: Dict[str, Any] = {}
355
+ if "properties" in input_schema and isinstance(input_schema["properties"], dict):
356
+ for prop_name, prop_def in input_schema["properties"].items():
357
+ # Only include type and description for each property
358
+ clean_prop: Dict[str, Any] = {}
359
+
360
+ if isinstance(prop_def, dict):
361
+ # Only include type (required) and description (optional)
362
+ clean_prop["type"] = prop_def.get("type", "string")
363
+ # Nova allows description in properties
364
+ if "description" in prop_def:
365
+ clean_prop["description"] = prop_def["description"]
366
+ else:
367
+ # Handle simple property definitions
368
+ clean_prop["type"] = "string"
369
+
370
+ properties[prop_name] = clean_prop
371
+
372
+ # Always set properties (even if empty for parameterless tools)
373
+ nova_schema["properties"] = properties
374
+
375
+ # Required fields - only add if present and not empty
376
+ if (
377
+ "required" in input_schema
378
+ and isinstance(input_schema["required"], list)
379
+ and input_schema["required"]
380
+ ):
381
+ nova_schema["required"] = input_schema["required"]
382
+
383
+ # IMPORTANT: Nova tool name compatibility fix
384
+ # Problem: Amazon Nova models fail with "Model produced invalid sequence as part of ToolUse"
385
+ # when tool names contain hyphens (e.g., "utils-get_current_date_information")
386
+ # Solution: Replace hyphens with underscores for Nova (e.g., "utils_get_current_date_information")
387
+ # Note: Underscores work fine, simple names work fine, but hyphens cause tool calling to fail
388
+ clean_name = tool.name.replace("-", "_")
389
+
390
+ # Store mapping from cleaned name back to original MCP name
391
+ # This is needed because:
392
+ # 1. Nova receives tools with cleaned names (utils_get_current_date_information)
393
+ # 2. Nova calls tools using cleaned names
394
+ # 3. But MCP server expects original names (utils-get_current_date_information)
395
+ # 4. So we map back: utils_get_current_date_information -> utils-get_current_date_information
396
+ self.tool_name_mapping[clean_name] = tool.name
397
+
398
+ bedrock_tool = {
399
+ "toolSpec": {
400
+ "name": clean_name,
401
+ "description": tool.description or f"Tool: {tool.name}",
402
+ "inputSchema": {"json": nova_schema},
403
+ }
404
+ }
405
+
406
+ bedrock_tools.append(bedrock_tool)
407
+
408
+ self.logger.debug(f"Converted {len(bedrock_tools)} tools for Nova format")
409
+ return bedrock_tools
410
+
411
+ def _convert_tools_system_prompt_format(self, tools: "ListToolsResult") -> str:
412
+ """Convert MCP tools to system prompt format.
413
+
414
+ Uses different formats based on the model:
415
+ - Scout models: Comprehensive system prompt format
416
+ - Other models: Minimal format
417
+ """
418
+ if not tools.tools:
419
+ return ""
420
+
421
+ # Create mapping from tool names to original names (no cleaning needed for Llama)
422
+ self.tool_name_mapping = {}
423
+
424
+ self.logger.debug(
425
+ f"Converting {len(tools.tools)} MCP tools to Llama native system prompt format"
426
+ )
427
+
428
+ # Check if this is a Scout model
429
+ model_id = self.default_request_params.model or DEFAULT_BEDROCK_MODEL
430
+ clean_model = model_id.replace("bedrock.", "")
431
+ is_scout = re.search(r"meta\.llama4-scout", clean_model)
432
+
433
+ if is_scout:
434
+ # Use comprehensive system prompt format for Scout models
435
+ prompt_parts = [
436
+ "You are a helpful assistant with access to the following functions. Use them if required:",
437
+ "",
438
+ ]
439
+
440
+ # Add each tool definition in JSON format
441
+ for tool in tools.tools:
442
+ self.logger.debug(f"Converting MCP tool: {tool.name}")
443
+
444
+ # Use original tool name (no hyphen replacement for Llama)
445
+ tool_name = tool.name
446
+
447
+ # Store mapping (identity mapping since no name cleaning)
448
+ self.tool_name_mapping[tool_name] = tool.name
449
+
450
+ # Create tool definition in the format Llama expects
451
+ tool_def = {
452
+ "type": "function",
453
+ "function": {
454
+ "name": tool_name,
455
+ "description": tool.description or f"Tool: {tool.name}",
456
+ "parameters": tool.inputSchema or {"type": "object", "properties": {}},
457
+ },
458
+ }
459
+
460
+ prompt_parts.append(json.dumps(tool_def))
461
+
462
+ # Add comprehensive response format instructions for Scout
463
+ prompt_parts.extend(
464
+ [
465
+ "",
466
+ "## Rules for Function Calling:",
467
+ "1. When you need to call a function, use the following format:",
468
+ " [function_name(arguments)]",
469
+ "2. You can call multiple functions in a single response if needed",
470
+ "3. Always provide the function results in your response to the user",
471
+ "4. If a function call fails, explain the error and try an alternative approach",
472
+ "5. Only call functions when necessary to answer the user's question",
473
+ "",
474
+ "## Response Rules:",
475
+ "- Always provide a complete answer to the user's question",
476
+ "- Include function results in your response",
477
+ "- Be helpful and informative",
478
+ "- If you cannot answer without calling a function, call the appropriate function first",
479
+ "",
480
+ "## Boundaries:",
481
+ "- Only call functions that are explicitly provided above",
482
+ "- Do not make up function names or parameters",
483
+ "- Follow the exact function signature provided",
484
+ "- Always validate your function calls before making them",
485
+ ]
486
+ )
487
+ else:
488
+ # Use minimal format for other Llama models
489
+ prompt_parts = [
490
+ "You have the following tools available to help answer the user's request. You can call one or more functions at a time. The functions are described here in JSON-schema format:",
491
+ "",
492
+ ]
493
+
494
+ # Add each tool definition in JSON format
495
+ for tool in tools.tools:
496
+ self.logger.debug(f"Converting MCP tool: {tool.name}")
497
+
498
+ # Use original tool name (no hyphen replacement for Llama)
499
+ tool_name = tool.name
500
+
501
+ # Store mapping (identity mapping since no name cleaning)
502
+ self.tool_name_mapping[tool_name] = tool.name
503
+
504
+ # Create tool definition in the format Llama expects
505
+ tool_def = {
506
+ "type": "function",
507
+ "function": {
508
+ "name": tool_name,
509
+ "description": tool.description or f"Tool: {tool.name}",
510
+ "parameters": tool.inputSchema or {"type": "object", "properties": {}},
511
+ },
512
+ }
513
+
514
+ prompt_parts.append(json.dumps(tool_def))
515
+
516
+ # Add the response format instructions based on community best practices
517
+ prompt_parts.extend(
518
+ [
519
+ "",
520
+ "To call one or more tools, provide the tool calls on a new line as a JSON-formatted array. Explain your steps in a neutral tone. Then, only call the tools you can for the first step, then end your turn. If you previously received an error, you can try to call the tool again. Give up after 3 errors.",
521
+ "",
522
+ "Conform precisely to the single-line format of this example:",
523
+ "Tool Call:",
524
+ '[{"name": "SampleTool", "arguments": {"foo": "bar"}},{"name": "SampleTool", "arguments": {"foo": "other"}}]',
525
+ ]
526
+ )
527
+
528
+ system_prompt = "\n".join(prompt_parts)
529
+ self.logger.debug(f"Generated Llama native system prompt: {system_prompt}")
530
+
531
+ return system_prompt
532
+
533
+ def _convert_tools_anthropic_format(self, tools: "ListToolsResult") -> List[Dict[str, Any]]:
534
+ """Convert MCP tools to Anthropic format wrapped in Bedrock toolSpec - preserves raw schema."""
535
+ # No tool name mapping needed for Anthropic (uses original names)
536
+ self.tool_name_mapping = {}
537
+
538
+ self.logger.debug(
539
+ f"Converting {len(tools.tools)} MCP tools to Anthropic format with toolSpec wrapper"
540
+ )
541
+
542
+ bedrock_tools = []
543
+ for tool in tools.tools:
544
+ self.logger.debug(f"Converting MCP tool: {tool.name}")
545
+
546
+ # Store identity mapping (no name cleaning for Anthropic)
547
+ self.tool_name_mapping[tool.name] = tool.name
548
+
549
+ # Use raw MCP schema (like native Anthropic provider) - no cleaning
550
+ input_schema = tool.inputSchema or {"type": "object", "properties": {}}
551
+
552
+ # Wrap in Bedrock toolSpec format but preserve raw Anthropic schema
553
+ bedrock_tool = {
554
+ "toolSpec": {
555
+ "name": tool.name, # Original name, no cleaning
556
+ "description": tool.description or f"Tool: {tool.name}",
557
+ "inputSchema": {
558
+ "json": input_schema # Raw MCP schema, not cleaned
559
+ },
560
+ }
561
+ }
562
+ bedrock_tools.append(bedrock_tool)
563
+
564
+ self.logger.debug(
565
+ f"Converted {len(bedrock_tools)} tools to Anthropic format with toolSpec wrapper"
566
+ )
567
+ return bedrock_tools
568
+
569
+ def _convert_mcp_tools_to_bedrock(
570
+ self, tools: "ListToolsResult"
571
+ ) -> Union[List[Dict[str, Any]], str]:
572
+ """Convert MCP tools to appropriate Bedrock format based on model type."""
573
+ model_id = self.default_request_params.model or DEFAULT_BEDROCK_MODEL
574
+ schema_type = self._get_tool_schema_type(model_id)
575
+
576
+ if schema_type == ToolSchemaType.SYSTEM_PROMPT:
577
+ system_prompt = self._convert_tools_system_prompt_format(tools)
578
+ # Store the system prompt for later use in system message
579
+ self._system_prompt_tools = system_prompt
580
+ return system_prompt
581
+ elif schema_type == ToolSchemaType.ANTHROPIC:
582
+ return self._convert_tools_anthropic_format(tools)
583
+ else:
584
+ return self._convert_tools_nova_format(tools)
585
+
586
+ def _add_tools_to_request(
587
+ self,
588
+ converse_args: Dict[str, Any],
589
+ available_tools: Union[List[Dict[str, Any]], str],
590
+ model_id: str,
591
+ ) -> None:
592
+ """Add tools to the request in the appropriate format based on model type."""
593
+ schema_type = self._get_tool_schema_type(model_id)
594
+
595
+ if schema_type == ToolSchemaType.SYSTEM_PROMPT:
596
+ # System prompt models expect tools in the system prompt, not as API parameters
597
+ # Tools are already handled in the system prompt generation
598
+ self.logger.debug("System prompt tools handled in system prompt")
599
+ elif schema_type == ToolSchemaType.ANTHROPIC:
600
+ # Anthropic models expect toolConfig with tools array (like native provider)
601
+ converse_args["toolConfig"] = {"tools": available_tools}
602
+ self.logger.debug(
603
+ f"Added {len(available_tools)} tools to Anthropic request in toolConfig format"
604
+ )
605
+ else:
606
+ # Nova models expect toolConfig with toolSpec format
607
+ converse_args["toolConfig"] = {"tools": available_tools}
608
+ self.logger.debug(
609
+ f"Added {len(available_tools)} tools to Nova request in toolConfig format"
610
+ )
611
+
612
+ def _parse_nova_tool_response(self, processed_response: Dict[str, Any]) -> List[Dict[str, Any]]:
613
+ """Parse Nova-format tool response (toolUse format)."""
614
+ tool_uses = [
615
+ content_item
616
+ for content_item in processed_response.get("content", [])
617
+ if "toolUse" in content_item
618
+ ]
619
+
620
+ parsed_tools = []
621
+ for tool_use_item in tool_uses:
622
+ tool_use = tool_use_item["toolUse"]
623
+ parsed_tools.append(
624
+ {
625
+ "type": "nova",
626
+ "name": tool_use["name"],
627
+ "arguments": tool_use["input"],
628
+ "id": tool_use["toolUseId"],
629
+ }
630
+ )
631
+
632
+ return parsed_tools
633
+
634
+ def _parse_system_prompt_tool_response(
635
+ self, processed_response: Dict[str, Any]
636
+ ) -> List[Dict[str, Any]]:
637
+ """Parse system prompt tool response format: function calls in text."""
638
+ # Extract text content from the response
639
+ text_content = ""
640
+ for content_item in processed_response.get("content", []):
641
+ if isinstance(content_item, dict) and "text" in content_item:
642
+ text_content += content_item["text"]
643
+
644
+ if not text_content:
645
+ return []
646
+
647
+ # Look for different tool call formats
648
+ tool_calls = []
649
+
650
+ # First try Scout format: [function_name(arguments)]
651
+ scout_pattern = r"\[([^(]+)\(([^)]*)\)\]"
652
+ scout_matches = re.findall(scout_pattern, text_content)
653
+ if scout_matches:
654
+ for i, (func_name, args_str) in enumerate(scout_matches):
655
+ func_name = func_name.strip()
656
+ args_str = args_str.strip()
657
+
658
+ # Parse arguments - could be empty, JSON object, or simple values
659
+ arguments = {}
660
+ if args_str:
661
+ try:
662
+ # Try to parse as JSON object first
663
+ if args_str.startswith("{") and args_str.endswith("}"):
664
+ arguments = json.loads(args_str)
665
+ else:
666
+ # For simple values, create a basic structure
667
+ arguments = {"value": args_str}
668
+ except json.JSONDecodeError:
669
+ # If JSON parsing fails, treat as string
670
+ arguments = {"value": args_str}
671
+
672
+ tool_calls.append(
673
+ {
674
+ "type": "system_prompt",
675
+ "name": func_name,
676
+ "arguments": arguments,
677
+ "id": f"system_prompt_{func_name}_{i}",
678
+ }
679
+ )
680
+
681
+ if tool_calls:
682
+ return tool_calls
683
+
684
+ # Second try: find the "Tool Call:" format
685
+ tool_call_match = re.search(r"Tool Call:\s*(\[.*?\])", text_content, re.DOTALL)
686
+ if tool_call_match:
687
+ json_str = tool_call_match.group(1)
688
+ try:
689
+ parsed_calls = json.loads(json_str)
690
+ if isinstance(parsed_calls, list):
691
+ for i, call in enumerate(parsed_calls):
692
+ if isinstance(call, dict) and "name" in call:
693
+ tool_calls.append(
694
+ {
695
+ "type": "system_prompt",
696
+ "name": call["name"],
697
+ "arguments": call.get("arguments", {}),
698
+ "id": f"system_prompt_{call['name']}_{i}",
699
+ }
700
+ )
701
+ return tool_calls
702
+ except json.JSONDecodeError as e:
703
+ self.logger.warning(f"Failed to parse Tool Call JSON array: {json_str} - {e}")
704
+
705
+ # Fallback: try to parse any JSON array in the text
706
+ array_match = re.search(r"\[.*?\]", text_content, re.DOTALL)
707
+ if array_match:
708
+ json_str = array_match.group(0)
709
+ try:
710
+ parsed_calls = json.loads(json_str)
711
+ if isinstance(parsed_calls, list):
712
+ for i, call in enumerate(parsed_calls):
713
+ if isinstance(call, dict) and "name" in call:
714
+ tool_calls.append(
715
+ {
716
+ "type": "system_prompt",
717
+ "name": call["name"],
718
+ "arguments": call.get("arguments", {}),
719
+ "id": f"system_prompt_{call['name']}_{i}",
720
+ }
721
+ )
722
+ return tool_calls
723
+ except json.JSONDecodeError as e:
724
+ self.logger.warning(f"Failed to parse JSON array: {json_str} - {e}")
725
+
726
+ # Fallback: try to parse as single JSON object (backward compatibility)
727
+ try:
728
+ json_match = re.search(r'\{[^}]*"name"[^}]*"arguments"[^}]*\}', text_content, re.DOTALL)
729
+ if json_match:
730
+ json_str = json_match.group(0)
731
+ function_call = json.loads(json_str)
732
+
733
+ if "name" in function_call:
734
+ return [
735
+ {
736
+ "type": "system_prompt",
737
+ "name": function_call["name"],
738
+ "arguments": function_call.get("arguments", {}),
739
+ "id": f"system_prompt_{function_call['name']}",
740
+ }
741
+ ]
742
+
743
+ except json.JSONDecodeError as e:
744
+ self.logger.warning(
745
+ f"Failed to parse system prompt tool response as JSON: {text_content} - {e}"
746
+ )
747
+
748
+ # Fallback to old custom tag format in case some models still use it
749
+ function_regex = r"<function=([^>]+)>(.*?)</function>"
750
+ match = re.search(function_regex, text_content)
751
+
752
+ if match:
753
+ function_name = match.group(1)
754
+ function_args_json = match.group(2)
755
+
756
+ try:
757
+ function_args = json.loads(function_args_json)
758
+ return [
759
+ {
760
+ "type": "system_prompt",
761
+ "name": function_name,
762
+ "arguments": function_args,
763
+ "id": f"system_prompt_{function_name}",
764
+ }
765
+ ]
766
+ except json.JSONDecodeError:
767
+ self.logger.warning(
768
+ f"Failed to parse fallback custom tag format: {function_args_json}"
769
+ )
770
+
771
+ return []
772
+
773
+ def _parse_anthropic_tool_response(
774
+ self, processed_response: Dict[str, Any]
775
+ ) -> List[Dict[str, Any]]:
776
+ """Parse Anthropic tool response format (same as native provider)."""
777
+ tool_uses = []
778
+
779
+ # Look for toolUse in content items (Bedrock format for Anthropic models)
780
+ for content_item in processed_response.get("content", []):
781
+ if "toolUse" in content_item:
782
+ tool_use = content_item["toolUse"]
783
+ tool_uses.append(
784
+ {
785
+ "type": "anthropic",
786
+ "name": tool_use["name"],
787
+ "arguments": tool_use["input"],
788
+ "id": tool_use["toolUseId"],
789
+ }
790
+ )
791
+
792
+ return tool_uses
793
+
794
+ def _parse_tool_response(
795
+ self, processed_response: Dict[str, Any], model_id: str
796
+ ) -> List[Dict[str, Any]]:
797
+ """Parse tool response based on model type."""
798
+ schema_type = self._get_tool_schema_type(model_id)
799
+
800
+ if schema_type == ToolSchemaType.SYSTEM_PROMPT:
801
+ return self._parse_system_prompt_tool_response(processed_response)
802
+ elif schema_type == ToolSchemaType.ANTHROPIC:
803
+ return self._parse_anthropic_tool_response(processed_response)
804
+ else:
805
+ return self._parse_nova_tool_response(processed_response)
806
+
807
+ def _convert_messages_to_bedrock(
808
+ self, messages: List[BedrockMessageParam]
809
+ ) -> List[Dict[str, Any]]:
810
+ """Convert message parameters to Bedrock format."""
811
+ bedrock_messages = []
812
+ for message in messages:
813
+ bedrock_message = {"role": message.get("role", "user"), "content": []}
814
+
815
+ content = message.get("content", [])
816
+
817
+ if isinstance(content, str):
818
+ bedrock_message["content"].append({"text": content})
819
+ elif isinstance(content, list):
820
+ for item in content:
821
+ item_type = item.get("type")
822
+ if item_type == "text":
823
+ bedrock_message["content"].append({"text": item.get("text", "")})
824
+ elif item_type == "tool_use":
825
+ bedrock_message["content"].append(
826
+ {
827
+ "toolUse": {
828
+ "toolUseId": item.get("id", ""),
829
+ "name": item.get("name", ""),
830
+ "input": item.get("input", {}),
831
+ }
832
+ }
833
+ )
834
+ elif item_type == "tool_result":
835
+ tool_use_id = item.get("tool_use_id")
836
+ raw_content = item.get("content", [])
837
+ status = item.get("status", "success")
838
+
839
+ bedrock_content_list = []
840
+ if raw_content:
841
+ for part in raw_content:
842
+ # FIX: The content parts are dicts, not TextContent objects.
843
+ if isinstance(part, dict) and "text" in part:
844
+ bedrock_content_list.append({"text": part.get("text", "")})
845
+
846
+ # Bedrock requires content for error statuses.
847
+ if not bedrock_content_list and status == "error":
848
+ bedrock_content_list.append({"text": "Tool call failed with an error."})
849
+
850
+ bedrock_message["content"].append(
851
+ {
852
+ "toolResult": {
853
+ "toolUseId": tool_use_id,
854
+ "content": bedrock_content_list,
855
+ "status": status,
856
+ }
857
+ }
858
+ )
859
+
860
+ # Only add the message if it has content
861
+ if bedrock_message["content"]:
862
+ bedrock_messages.append(bedrock_message)
863
+
864
+ return bedrock_messages
865
+
866
+ async def _process_stream(self, stream_response, model: str) -> BedrockMessage:
867
+ """Process streaming response from Bedrock."""
868
+ estimated_tokens = 0
869
+ response_content = []
870
+ tool_uses = []
871
+ stop_reason = None
872
+ usage = {"input_tokens": 0, "output_tokens": 0}
873
+
874
+ try:
875
+ for event in stream_response["stream"]:
876
+ if "messageStart" in event:
877
+ # Message started
878
+ continue
879
+ elif "contentBlockStart" in event:
880
+ # Content block started
881
+ content_block = event["contentBlockStart"]
882
+ if "start" in content_block and "toolUse" in content_block["start"]:
883
+ # Tool use block started
884
+ tool_use_start = content_block["start"]["toolUse"]
885
+ self.logger.debug(f"Tool use block started: {tool_use_start}")
886
+ tool_uses.append(
887
+ {
888
+ "toolUse": {
889
+ "toolUseId": tool_use_start.get("toolUseId"),
890
+ "name": tool_use_start.get("name"),
891
+ "input": tool_use_start.get("input", {}),
892
+ "_input_accumulator": "", # For accumulating streamed input
893
+ }
894
+ }
895
+ )
896
+ elif "contentBlockDelta" in event:
897
+ # Content delta received
898
+ delta = event["contentBlockDelta"]["delta"]
899
+ if "text" in delta:
900
+ text = delta["text"]
901
+ response_content.append(text)
902
+ # Update streaming progress
903
+ estimated_tokens = self._update_streaming_progress(
904
+ text, model, estimated_tokens
905
+ )
906
+ elif "toolUse" in delta:
907
+ # Tool use delta - handle tool call
908
+ tool_use = delta["toolUse"]
909
+ self.logger.debug(f"Tool use delta: {tool_use}")
910
+ if tool_use and tool_uses:
911
+ # Handle input accumulation for streaming tool arguments
912
+ if "input" in tool_use:
913
+ input_data = tool_use["input"]
914
+
915
+ # If input is a dict, merge it directly
916
+ if isinstance(input_data, dict):
917
+ tool_uses[-1]["toolUse"]["input"].update(input_data)
918
+ # If input is a string, accumulate it for later JSON parsing
919
+ elif isinstance(input_data, str):
920
+ tool_uses[-1]["toolUse"]["_input_accumulator"] += input_data
921
+ self.logger.debug(
922
+ f"Accumulated input: {tool_uses[-1]['toolUse']['_input_accumulator']}"
923
+ )
924
+ else:
925
+ self.logger.debug(
926
+ f"Tool use input is unexpected type: {type(input_data)}: {input_data}"
927
+ )
928
+ # Set the input directly if it's not a dict or string
929
+ tool_uses[-1]["toolUse"]["input"] = input_data
930
+ elif "contentBlockStop" in event:
931
+ # Content block stopped - finalize any accumulated tool input
932
+ if tool_uses:
933
+ for tool_use in tool_uses:
934
+ if "_input_accumulator" in tool_use["toolUse"]:
935
+ accumulated_input = tool_use["toolUse"]["_input_accumulator"]
936
+ if accumulated_input:
937
+ self.logger.debug(
938
+ f"Processing accumulated input: {accumulated_input}"
939
+ )
940
+ try:
941
+ # Try to parse the accumulated input as JSON
942
+ parsed_input = json.loads(accumulated_input)
943
+ if isinstance(parsed_input, dict):
944
+ tool_use["toolUse"]["input"].update(parsed_input)
945
+ else:
946
+ tool_use["toolUse"]["input"] = parsed_input
947
+ self.logger.debug(
948
+ f"Successfully parsed accumulated input: {parsed_input}"
949
+ )
950
+ except json.JSONDecodeError as e:
951
+ self.logger.warning(
952
+ f"Failed to parse accumulated input as JSON: {accumulated_input} - {e}"
953
+ )
954
+ # If it's not valid JSON, treat it as a string value
955
+ tool_use["toolUse"]["input"] = accumulated_input
956
+ # Clean up the accumulator
957
+ del tool_use["toolUse"]["_input_accumulator"]
958
+ continue
959
+ elif "messageStop" in event:
960
+ # Message stopped
961
+ if "stopReason" in event["messageStop"]:
962
+ stop_reason = event["messageStop"]["stopReason"]
963
+ elif "metadata" in event:
964
+ # Usage metadata
965
+ metadata = event["metadata"]
966
+ if "usage" in metadata:
967
+ usage = metadata["usage"]
968
+ actual_tokens = usage.get("outputTokens", 0)
969
+ if actual_tokens > 0:
970
+ # Emit final progress with actual token count
971
+ token_str = str(actual_tokens).rjust(5)
972
+ data = {
973
+ "progress_action": ProgressAction.STREAMING,
974
+ "model": model,
975
+ "agent_name": self.name,
976
+ "chat_turn": self.chat_turn(),
977
+ "details": token_str.strip(),
978
+ }
979
+ self.logger.info("Streaming progress", data=data)
980
+ except Exception as e:
981
+ self.logger.error(f"Error processing stream: {e}")
982
+ raise
983
+
984
+ # Construct the response message
985
+ full_text = "".join(response_content)
986
+ response = {
987
+ "content": [{"text": full_text}] if full_text else [],
988
+ "stop_reason": stop_reason or "end_turn",
989
+ "usage": {
990
+ "input_tokens": usage.get("inputTokens", 0),
991
+ "output_tokens": usage.get("outputTokens", 0),
992
+ },
993
+ "model": model,
994
+ "role": "assistant",
995
+ }
996
+
997
+ # Add tool uses if any
998
+ if tool_uses:
999
+ # Clean up any remaining accumulators before adding to response
1000
+ for tool_use in tool_uses:
1001
+ if "_input_accumulator" in tool_use["toolUse"]:
1002
+ accumulated_input = tool_use["toolUse"]["_input_accumulator"]
1003
+ if accumulated_input:
1004
+ self.logger.debug(
1005
+ f"Final processing of accumulated input: {accumulated_input}"
1006
+ )
1007
+ try:
1008
+ # Try to parse the accumulated input as JSON
1009
+ parsed_input = json.loads(accumulated_input)
1010
+ if isinstance(parsed_input, dict):
1011
+ tool_use["toolUse"]["input"].update(parsed_input)
1012
+ else:
1013
+ tool_use["toolUse"]["input"] = parsed_input
1014
+ self.logger.debug(
1015
+ f"Successfully parsed final accumulated input: {parsed_input}"
1016
+ )
1017
+ except json.JSONDecodeError as e:
1018
+ self.logger.warning(
1019
+ f"Failed to parse final accumulated input as JSON: {accumulated_input} - {e}"
1020
+ )
1021
+ # If it's not valid JSON, treat it as a string value
1022
+ tool_use["toolUse"]["input"] = accumulated_input
1023
+ # Clean up the accumulator
1024
+ del tool_use["toolUse"]["_input_accumulator"]
1025
+
1026
+ response["content"].extend(tool_uses)
1027
+
1028
+ return response
1029
+
1030
+ def _process_non_streaming_response(self, response, model: str) -> BedrockMessage:
1031
+ """Process non-streaming response from Bedrock."""
1032
+ self.logger.debug(f"Processing non-streaming response: {response}")
1033
+
1034
+ # Extract response content
1035
+ content = response.get("output", {}).get("message", {}).get("content", [])
1036
+ usage = response.get("usage", {})
1037
+ stop_reason = response.get("stopReason", "end_turn")
1038
+
1039
+ # Show progress for non-streaming (single update)
1040
+ if usage.get("outputTokens", 0) > 0:
1041
+ token_str = str(usage.get("outputTokens", 0)).rjust(5)
1042
+ data = {
1043
+ "progress_action": ProgressAction.STREAMING,
1044
+ "model": model,
1045
+ "agent_name": self.name,
1046
+ "chat_turn": self.chat_turn(),
1047
+ "details": token_str.strip(),
1048
+ }
1049
+ self.logger.info("Non-streaming progress", data=data)
1050
+
1051
+ # Convert to the same format as streaming response
1052
+ processed_response = {
1053
+ "content": content,
1054
+ "stop_reason": stop_reason,
1055
+ "usage": {
1056
+ "input_tokens": usage.get("inputTokens", 0),
1057
+ "output_tokens": usage.get("outputTokens", 0),
1058
+ },
1059
+ "model": model,
1060
+ "role": "assistant",
1061
+ }
1062
+
1063
+ return processed_response
1064
+
1065
+ async def _bedrock_completion(
1066
+ self,
1067
+ message_param: BedrockMessageParam,
1068
+ request_params: RequestParams | None = None,
1069
+ ) -> List[TextContent | ImageContent | EmbeddedResource]:
1070
+ """
1071
+ Process a query using Bedrock and available tools.
1072
+ """
1073
+ client = self._get_bedrock_runtime_client()
1074
+
1075
+ try:
1076
+ messages: List[BedrockMessageParam] = []
1077
+ params = self.get_request_params(request_params)
1078
+ except (ClientError, BotoCoreError) as e:
1079
+ error_msg = str(e)
1080
+ if "UnauthorizedOperation" in error_msg or "AccessDenied" in error_msg:
1081
+ raise ProviderKeyError(
1082
+ "AWS Bedrock access denied",
1083
+ "Please check your AWS credentials and IAM permissions for Bedrock.",
1084
+ ) from e
1085
+ else:
1086
+ raise ProviderKeyError(
1087
+ "AWS Bedrock error",
1088
+ f"Error accessing Bedrock: {error_msg}",
1089
+ ) from e
1090
+
1091
+ # Always include prompt messages, but only include conversation history
1092
+ # if use_history is True
1093
+ messages.extend(self.history.get(include_completion_history=params.use_history))
1094
+ messages.append(message_param)
1095
+
1096
+ # Get available tools - but only if model supports tool use
1097
+ available_tools = []
1098
+ tool_list = None
1099
+ model_to_check = self.default_request_params.model or DEFAULT_BEDROCK_MODEL
1100
+
1101
+ if self._supports_tool_use(model_to_check):
1102
+ try:
1103
+ tool_list = await self.aggregator.list_tools()
1104
+ self.logger.debug(f"Found {len(tool_list.tools)} MCP tools")
1105
+
1106
+ available_tools = self._convert_mcp_tools_to_bedrock(tool_list)
1107
+ self.logger.debug(
1108
+ f"Successfully converted {len(available_tools)} tools for Bedrock"
1109
+ )
1110
+
1111
+ except Exception as e:
1112
+ self.logger.error(f"Error fetching or converting MCP tools: {e}")
1113
+ import traceback
1114
+
1115
+ self.logger.debug(f"Traceback: {traceback.format_exc()}")
1116
+ available_tools = []
1117
+ tool_list = None
1118
+ else:
1119
+ self.logger.info(
1120
+ f"Model {model_to_check} does not support tool use - skipping tool preparation"
1121
+ )
1122
+
1123
+ responses: List[TextContent | ImageContent | EmbeddedResource] = []
1124
+ model = self.default_request_params.model
1125
+
1126
+ for i in range(params.max_iterations):
1127
+ self._log_chat_progress(self.chat_turn(), model=model)
1128
+
1129
+ # Process tools BEFORE message conversion for Llama native format
1130
+ model_to_check = model or DEFAULT_BEDROCK_MODEL
1131
+ schema_type = self._get_tool_schema_type(model_to_check)
1132
+
1133
+ # For Llama native format, we need to store tools before message conversion
1134
+ if schema_type == ToolSchemaType.SYSTEM_PROMPT and available_tools:
1135
+ has_tools = bool(available_tools) and (
1136
+ (isinstance(available_tools, list) and len(available_tools) > 0)
1137
+ or (isinstance(available_tools, str) and available_tools.strip())
1138
+ )
1139
+
1140
+ if has_tools:
1141
+ self._add_tools_to_request({}, available_tools, model_to_check)
1142
+ self.logger.debug("Pre-processed Llama native tools for message injection")
1143
+
1144
+ # Convert messages to Bedrock format
1145
+ bedrock_messages = self._convert_messages_to_bedrock(messages)
1146
+
1147
+ # Prepare Bedrock Converse API arguments
1148
+ converse_args = {
1149
+ "modelId": model,
1150
+ "messages": bedrock_messages,
1151
+ }
1152
+
1153
+ # Add system prompt if available and supported by the model
1154
+ system_text = self.instruction or params.systemPrompt
1155
+
1156
+ # For Llama native format, inject tools into system prompt
1157
+ if (
1158
+ schema_type == ToolSchemaType.SYSTEM_PROMPT
1159
+ and hasattr(self, "_system_prompt_tools")
1160
+ and self._system_prompt_tools
1161
+ ):
1162
+ # Combine system prompt with tools for Llama native format
1163
+ if system_text:
1164
+ system_text = f"{system_text}\n\n{self._system_prompt_tools}"
1165
+ else:
1166
+ system_text = self._system_prompt_tools
1167
+ self.logger.debug("Combined system prompt with system prompt tools")
1168
+ elif hasattr(self, "_system_prompt_tools") and self._system_prompt_tools:
1169
+ # For other formats, combine system prompt with tools
1170
+ if system_text:
1171
+ system_text = f"{system_text}\n\n{self._system_prompt_tools}"
1172
+ else:
1173
+ system_text = self._system_prompt_tools
1174
+ self.logger.debug("Combined system prompt with tools system prompt")
1175
+
1176
+ self.logger.info(
1177
+ f"DEBUG: BEFORE CHECK - model='{model_to_check}', has_system_text={bool(system_text)}"
1178
+ )
1179
+ self.logger.info(
1180
+ f"DEBUG: self.instruction='{self.instruction}', params.systemPrompt='{params.systemPrompt}'"
1181
+ )
1182
+
1183
+ supports_system = self._supports_system_messages(model_to_check)
1184
+ self.logger.info(f"DEBUG: supports_system={supports_system}")
1185
+
1186
+ if system_text and supports_system:
1187
+ converse_args["system"] = [{"text": system_text}]
1188
+ self.logger.info(f"DEBUG: Added system prompt to {model_to_check} request")
1189
+ elif system_text:
1190
+ # For models that don't support system messages, inject system prompt into the first user message
1191
+ self.logger.info(
1192
+ f"DEBUG: Injecting system prompt into first user message for {model_to_check} (doesn't support system messages)"
1193
+ )
1194
+ if bedrock_messages and bedrock_messages[0].get("role") == "user":
1195
+ first_message = bedrock_messages[0]
1196
+ if first_message.get("content") and len(first_message["content"]) > 0:
1197
+ # Prepend system instruction to the first user message
1198
+ original_text = first_message["content"][0].get("text", "")
1199
+ first_message["content"][0]["text"] = (
1200
+ f"System: {system_text}\n\nUser: {original_text}"
1201
+ )
1202
+ self.logger.info("DEBUG: Injected system prompt into first user message")
1203
+ else:
1204
+ self.logger.info(f"DEBUG: No system text provided for {model_to_check}")
1205
+
1206
+ # Add tools if available - format depends on model type (skip for Llama native as already processed)
1207
+ if schema_type != ToolSchemaType.SYSTEM_PROMPT:
1208
+ has_tools = bool(available_tools) and (
1209
+ (isinstance(available_tools, list) and len(available_tools) > 0)
1210
+ or (isinstance(available_tools, str) and available_tools.strip())
1211
+ )
1212
+
1213
+ if has_tools:
1214
+ self._add_tools_to_request(converse_args, available_tools, model_to_check)
1215
+ else:
1216
+ self.logger.debug(
1217
+ "No tools available - omitting tool configuration from request"
1218
+ )
1219
+
1220
+ # Add inference configuration
1221
+ inference_config = {}
1222
+ if params.maxTokens is not None:
1223
+ inference_config["maxTokens"] = params.maxTokens
1224
+ if params.stopSequences:
1225
+ inference_config["stopSequences"] = params.stopSequences
1226
+
1227
+ # Nova-specific recommended settings for tool calling
1228
+ if model and "nova" in model.lower():
1229
+ inference_config["topP"] = 1.0
1230
+ inference_config["temperature"] = 1.0
1231
+ # Add additionalModelRequestFields for topK
1232
+ converse_args["additionalModelRequestFields"] = {"inferenceConfig": {"topK": 1}}
1233
+
1234
+ if inference_config:
1235
+ converse_args["inferenceConfig"] = inference_config
1236
+
1237
+ self.logger.debug(f"Bedrock converse args: {converse_args}")
1238
+
1239
+ # Debug: Print the actual messages being sent to Bedrock for Llama models
1240
+ schema_type = self._get_tool_schema_type(model_to_check)
1241
+ if schema_type == ToolSchemaType.SYSTEM_PROMPT:
1242
+ self.logger.info("=== SYSTEM PROMPT DEBUG ===")
1243
+ self.logger.info("Messages being sent to Bedrock:")
1244
+ for i, msg in enumerate(converse_args.get("messages", [])):
1245
+ self.logger.info(f"Message {i} ({msg.get('role', 'unknown')}):")
1246
+ for j, content in enumerate(msg.get("content", [])):
1247
+ if "text" in content:
1248
+ self.logger.info(f" Content {j}: {content['text'][:500]}...")
1249
+ self.logger.info("=== END SYSTEM PROMPT DEBUG ===")
1250
+
1251
+ # Debug: Print the full tool config being sent
1252
+ if "toolConfig" in converse_args:
1253
+ self.logger.debug(
1254
+ f"Tool config being sent to Bedrock: {json.dumps(converse_args['toolConfig'], indent=2)}"
1255
+ )
1256
+
1257
+ try:
1258
+ # Choose streaming vs non-streaming based on model capabilities and tool presence
1259
+ # Logic: Only use non-streaming when BOTH conditions are true:
1260
+ # 1. Tools are available (available_tools is not empty)
1261
+ # 2. Model doesn't support streaming with tools
1262
+ # Otherwise, always prefer streaming for better UX
1263
+ has_tools = bool(available_tools) and (
1264
+ (isinstance(available_tools, list) and len(available_tools) > 0)
1265
+ or (isinstance(available_tools, str) and available_tools.strip())
1266
+ )
1267
+
1268
+ if has_tools and not self._supports_streaming_with_tools(
1269
+ model or DEFAULT_BEDROCK_MODEL
1270
+ ):
1271
+ # Use non-streaming API: model requires it for tool calls
1272
+ self.logger.debug(
1273
+ f"Using non-streaming API for {model} with tools (model limitation)"
1274
+ )
1275
+ response = client.converse(**converse_args)
1276
+ processed_response = self._process_non_streaming_response(
1277
+ response, model or DEFAULT_BEDROCK_MODEL
1278
+ )
1279
+ else:
1280
+ # Use streaming API: either no tools OR model supports streaming with tools
1281
+ streaming_reason = (
1282
+ "no tools present"
1283
+ if not has_tools
1284
+ else "model supports streaming with tools"
1285
+ )
1286
+ self.logger.debug(f"Using streaming API for {model} ({streaming_reason})")
1287
+ response = client.converse_stream(**converse_args)
1288
+ processed_response = await self._process_stream(
1289
+ response, model or DEFAULT_BEDROCK_MODEL
1290
+ )
1291
+ except (ClientError, BotoCoreError) as e:
1292
+ error_msg = str(e)
1293
+ self.logger.error(f"Bedrock API error: {error_msg}")
1294
+
1295
+ # Create error response
1296
+ processed_response = {
1297
+ "content": [{"text": f"Error during generation: {error_msg}"}],
1298
+ "stop_reason": "error",
1299
+ "usage": {"input_tokens": 0, "output_tokens": 0},
1300
+ "model": model,
1301
+ "role": "assistant",
1302
+ }
1303
+
1304
+ # Track usage
1305
+ if processed_response.get("usage"):
1306
+ try:
1307
+ usage = processed_response["usage"]
1308
+ turn_usage = TurnUsage(
1309
+ provider=Provider.BEDROCK.value,
1310
+ model=model,
1311
+ input_tokens=usage.get("input_tokens", 0),
1312
+ output_tokens=usage.get("output_tokens", 0),
1313
+ total_tokens=usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
1314
+ cache_creation_input_tokens=0,
1315
+ cache_read_input_tokens=0,
1316
+ raw_usage=usage,
1317
+ )
1318
+ self.usage_accumulator.add_turn(turn_usage)
1319
+ except Exception as e:
1320
+ self.logger.warning(f"Failed to track usage: {e}")
1321
+
1322
+ self.logger.debug(f"{model} response:", data=processed_response)
1323
+
1324
+ # Convert response to message param and add to messages
1325
+ response_message_param = self.convert_message_to_message_param(processed_response)
1326
+ messages.append(response_message_param)
1327
+
1328
+ # Extract text content for responses
1329
+ if processed_response.get("content"):
1330
+ for content_item in processed_response["content"]:
1331
+ if content_item.get("text"):
1332
+ responses.append(TextContent(type="text", text=content_item["text"]))
1333
+
1334
+ # Handle different stop reasons
1335
+ stop_reason = processed_response.get("stop_reason", "end_turn")
1336
+
1337
+ # For Llama native format, check for tool calls even if stop_reason is "end_turn"
1338
+ schema_type = self._get_tool_schema_type(model or DEFAULT_BEDROCK_MODEL)
1339
+ if schema_type == ToolSchemaType.SYSTEM_PROMPT and stop_reason == "end_turn":
1340
+ # Check if there's a tool call in the response
1341
+ parsed_tools = self._parse_tool_response(
1342
+ processed_response, model or DEFAULT_BEDROCK_MODEL
1343
+ )
1344
+ if parsed_tools:
1345
+ # Override stop_reason to handle as tool_use
1346
+ stop_reason = "tool_use"
1347
+ self.logger.debug(
1348
+ "Detected system prompt tool call, overriding stop_reason to 'tool_use'"
1349
+ )
1350
+
1351
+ if stop_reason == "end_turn":
1352
+ # Extract text for display
1353
+ message_text = ""
1354
+ for content_item in processed_response.get("content", []):
1355
+ if content_item.get("text"):
1356
+ message_text += content_item["text"]
1357
+
1358
+ await self.show_assistant_message(message_text)
1359
+ self.logger.debug(f"Iteration {i}: Stopping because stop_reason is 'end_turn'")
1360
+ break
1361
+ elif stop_reason == "stop_sequence":
1362
+ self.logger.debug(f"Iteration {i}: Stopping because stop_reason is 'stop_sequence'")
1363
+ break
1364
+ elif stop_reason == "max_tokens":
1365
+ self.logger.debug(f"Iteration {i}: Stopping because stop_reason is 'max_tokens'")
1366
+ if params.maxTokens is not None:
1367
+ message_text = Text(
1368
+ f"the assistant has reached the maximum token limit ({params.maxTokens})",
1369
+ style="dim green italic",
1370
+ )
1371
+ else:
1372
+ message_text = Text(
1373
+ "the assistant has reached the maximum token limit",
1374
+ style="dim green italic",
1375
+ )
1376
+ await self.show_assistant_message(message_text)
1377
+ break
1378
+ elif stop_reason in ["tool_use", "tool_calls"]:
1379
+ # Handle tool use/calls - format depends on model type
1380
+ message_text = ""
1381
+ for content_item in processed_response.get("content", []):
1382
+ if content_item.get("text"):
1383
+ message_text += content_item["text"]
1384
+
1385
+ # Parse tool calls using model-specific method
1386
+ self.logger.info(f"DEBUG: About to parse tool response: {processed_response}")
1387
+ parsed_tools = self._parse_tool_response(
1388
+ processed_response, model or DEFAULT_BEDROCK_MODEL
1389
+ )
1390
+ self.logger.info(f"DEBUG: Parsed tools: {parsed_tools}")
1391
+
1392
+ if parsed_tools:
1393
+ # We will comment out showing the assistant's intermediate message
1394
+ # to make the output less chatty, as requested by the user.
1395
+ # if not message_text:
1396
+ # message_text = Text(
1397
+ # "the assistant requested tool calls",
1398
+ # style="dim green italic",
1399
+ # )
1400
+ #
1401
+ # await self.show_assistant_message(message_text)
1402
+
1403
+ # Process tool calls and collect results
1404
+ tool_results_for_batch = []
1405
+ for tool_idx, parsed_tool in enumerate(parsed_tools):
1406
+ # The original name is needed to call the tool, which is in tool_name_mapping.
1407
+ tool_name_from_model = parsed_tool["name"]
1408
+ tool_name = self.tool_name_mapping.get(
1409
+ tool_name_from_model, tool_name_from_model
1410
+ )
1411
+
1412
+ tool_args = parsed_tool["arguments"]
1413
+ tool_use_id = parsed_tool["id"]
1414
+
1415
+ self.show_tool_call(tool_list.tools, tool_name, tool_args)
1416
+
1417
+ tool_call_request = CallToolRequest(
1418
+ method="tools/call",
1419
+ params=CallToolRequestParams(name=tool_name, arguments=tool_args),
1420
+ )
1421
+
1422
+ # Call the tool and get the result
1423
+ result = await self.call_tool(
1424
+ request=tool_call_request, tool_call_id=tool_use_id
1425
+ )
1426
+ # We will also comment out showing the raw tool result to reduce verbosity.
1427
+ # self.show_tool_result(result)
1428
+
1429
+ # Add each result to our collection
1430
+ tool_results_for_batch.append((tool_use_id, result, tool_name))
1431
+ responses.extend(result.content)
1432
+
1433
+ # After processing all tool calls for a turn, clear the intermediate
1434
+ # responses. This ensures that the final returned value only contains
1435
+ # the model's last message, not the reasoning or raw tool output.
1436
+ responses.clear()
1437
+
1438
+ # Now, create the message with tool results based on the model's schema type.
1439
+ schema_type = self._get_tool_schema_type(model or DEFAULT_BEDROCK_MODEL)
1440
+
1441
+ if schema_type == ToolSchemaType.SYSTEM_PROMPT:
1442
+ # For system prompt models (like Llama), format results as a simple text message.
1443
+ # The model expects to see the results in a human-readable format to continue.
1444
+ tool_result_parts = []
1445
+ for _, tool_result, tool_name in tool_results_for_batch:
1446
+ result_text = "".join(
1447
+ [
1448
+ part.text
1449
+ for part in tool_result.content
1450
+ if isinstance(part, TextContent)
1451
+ ]
1452
+ )
1453
+
1454
+ # Create a representation of the tool's output.
1455
+ # Using a JSON-like string is a robust way to present this.
1456
+ result_payload = {
1457
+ "tool_name": tool_name,
1458
+ "status": "error" if tool_result.isError else "success",
1459
+ "result": result_text,
1460
+ }
1461
+ tool_result_parts.append(json.dumps(result_payload))
1462
+
1463
+ if tool_result_parts:
1464
+ # Combine all tool results into a single text block.
1465
+ full_result_text = f"Tool Results:\n{', '.join(tool_result_parts)}"
1466
+ messages.append(
1467
+ {
1468
+ "role": "user",
1469
+ "content": [{"type": "text", "text": full_result_text}],
1470
+ }
1471
+ )
1472
+ else:
1473
+ # For native tool-using models (Anthropic, Nova), use the structured 'tool_result' format.
1474
+ tool_result_blocks = []
1475
+ for tool_id, tool_result, _ in tool_results_for_batch:
1476
+ # Convert tool result content into a list of content blocks
1477
+ # This mimics the native Anthropic provider's approach.
1478
+ result_content_blocks = []
1479
+ if tool_result.content:
1480
+ for part in tool_result.content:
1481
+ if isinstance(part, TextContent):
1482
+ result_content_blocks.append({"text": part.text})
1483
+ # Note: This can be extended to handle other content types like images
1484
+ # For now, we are focusing on making text-based tools work correctly.
1485
+
1486
+ # If there's no content, provide a default message.
1487
+ if not result_content_blocks:
1488
+ result_content_blocks.append(
1489
+ {"text": "[No content in tool result]"}
1490
+ )
1491
+
1492
+ # This is the format Bedrock expects for tool results in the Converse API
1493
+ tool_result_blocks.append(
1494
+ {
1495
+ "type": "tool_result",
1496
+ "tool_use_id": tool_id,
1497
+ "content": result_content_blocks,
1498
+ "status": "error" if tool_result.isError else "success",
1499
+ }
1500
+ )
1501
+
1502
+ if tool_result_blocks:
1503
+ # Append a single user message with all the tool results for this turn
1504
+ messages.append(
1505
+ {
1506
+ "role": "user",
1507
+ "content": tool_result_blocks,
1508
+ }
1509
+ )
1510
+
1511
+ continue
1512
+ else:
1513
+ # No tool uses but stop_reason was tool_use/tool_calls, treat as end_turn
1514
+ await self.show_assistant_message(message_text)
1515
+ break
1516
+ else:
1517
+ # Unknown stop reason, continue or break based on content
1518
+ message_text = ""
1519
+ for content_item in processed_response.get("content", []):
1520
+ if content_item.get("text"):
1521
+ message_text += content_item["text"]
1522
+
1523
+ if message_text:
1524
+ await self.show_assistant_message(message_text)
1525
+ break
1526
+
1527
+ # Update history
1528
+ if params.use_history:
1529
+ # Get current prompt messages
1530
+ prompt_messages = self.history.get(include_completion_history=False)
1531
+
1532
+ # Calculate new conversation messages (excluding prompts)
1533
+ new_messages = messages[len(prompt_messages) :]
1534
+
1535
+ # Remove system prompt from new messages if it was added
1536
+ if (self.instruction or params.systemPrompt) and new_messages:
1537
+ # System prompt is not added to messages list in Bedrock, so no need to remove it
1538
+ pass
1539
+
1540
+ self.history.set(new_messages)
1541
+
1542
+ # Strip leading whitespace from the *last* non-empty text block of the final response
1543
+ # to ensure the output is clean.
1544
+ if responses:
1545
+ for item in reversed(responses):
1546
+ if isinstance(item, TextContent) and item.text:
1547
+ item.text = item.text.lstrip()
1548
+ break
1549
+
1550
+ return responses
1551
+
1552
+ async def generate_messages(
1553
+ self,
1554
+ message_param: BedrockMessageParam,
1555
+ request_params: RequestParams | None = None,
1556
+ ) -> PromptMessageMultipart:
1557
+ """Generate messages using Bedrock."""
1558
+ responses = await self._bedrock_completion(message_param, request_params)
1559
+
1560
+ # Convert responses to PromptMessageMultipart
1561
+ content_list = []
1562
+ for response in responses:
1563
+ if isinstance(response, TextContent):
1564
+ content_list.append(response)
1565
+
1566
+ return PromptMessageMultipart(role="assistant", content=content_list)
1567
+
1568
+ async def _apply_prompt_provider_specific(
1569
+ self,
1570
+ multipart_messages: List[PromptMessageMultipart],
1571
+ request_params: RequestParams | None = None,
1572
+ is_template: bool = False,
1573
+ ) -> PromptMessageMultipart:
1574
+ """Apply Bedrock-specific prompt formatting."""
1575
+ if not multipart_messages:
1576
+ return PromptMessageMultipart(role="user", content=[])
1577
+
1578
+ # Check the last message role
1579
+ last_message = multipart_messages[-1]
1580
+
1581
+ # Add all previous messages to history (or all messages if last is from assistant)
1582
+ # if the last message is a "user" inference is required
1583
+ messages_to_add = (
1584
+ multipart_messages[:-1] if last_message.role == "user" else multipart_messages
1585
+ )
1586
+ converted = []
1587
+ for msg in messages_to_add:
1588
+ # Convert each message to Bedrock message parameter format
1589
+ bedrock_msg = {"role": msg.role, "content": []}
1590
+ for content_item in msg.content:
1591
+ if isinstance(content_item, TextContent):
1592
+ bedrock_msg["content"].append({"type": "text", "text": content_item.text})
1593
+ converted.append(bedrock_msg)
1594
+
1595
+ # Add messages to history
1596
+ self.history.extend(converted, is_prompt=is_template)
1597
+
1598
+ if last_message.role == "assistant":
1599
+ # For assistant messages: Return the last message (no completion needed)
1600
+ return last_message
1601
+
1602
+ # Convert the last user message to Bedrock message parameter format
1603
+ message_param = {"role": last_message.role, "content": []}
1604
+ for content_item in last_message.content:
1605
+ if isinstance(content_item, TextContent):
1606
+ message_param["content"].append({"type": "text", "text": content_item.text})
1607
+
1608
+ # Generate response
1609
+ return await self.generate_messages(message_param, request_params)
1610
+
1611
+ def _generate_simplified_schema(self, model: Type[ModelT]) -> str:
1612
+ """Generates a simplified, human-readable schema with inline enum constraints."""
1613
+
1614
+ def get_field_type_representation(field_type: Any) -> Any:
1615
+ """Get a string representation for a field type."""
1616
+ # Handle Optional types
1617
+ if hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
1618
+ non_none_types = [t for t in field_type.__args__ if t is not type(None)]
1619
+ if non_none_types:
1620
+ field_type = non_none_types[0]
1621
+
1622
+ # Handle basic types
1623
+ if field_type is str:
1624
+ return "string"
1625
+ elif field_type is int:
1626
+ return "integer"
1627
+ elif field_type is float:
1628
+ return "float"
1629
+ elif field_type is bool:
1630
+ return "boolean"
1631
+
1632
+ # Handle Enum types
1633
+ elif hasattr(field_type, "__bases__") and any(
1634
+ issubclass(base, Enum) for base in field_type.__bases__ if isinstance(base, type)
1635
+ ):
1636
+ enum_values = [f'"{e.value}"' for e in field_type]
1637
+ return f"string (must be one of: {', '.join(enum_values)})"
1638
+
1639
+ # Handle List types
1640
+ elif (
1641
+ hasattr(field_type, "__origin__")
1642
+ and hasattr(field_type, "__args__")
1643
+ and field_type.__origin__ is list
1644
+ ):
1645
+ item_type_repr = "any"
1646
+ if field_type.__args__:
1647
+ item_type_repr = get_field_type_representation(field_type.__args__[0])
1648
+ return [item_type_repr]
1649
+
1650
+ # Handle nested Pydantic models
1651
+ elif hasattr(field_type, "__bases__") and any(
1652
+ hasattr(base, "model_fields") for base in field_type.__bases__
1653
+ ):
1654
+ nested_schema = _generate_schema_dict(field_type)
1655
+ return nested_schema
1656
+
1657
+ # Default fallback
1658
+ else:
1659
+ return "any"
1660
+
1661
+ def _generate_schema_dict(model_class: Type) -> Dict[str, Any]:
1662
+ """Recursively generate the schema as a dictionary."""
1663
+ schema_dict = {}
1664
+ if hasattr(model_class, "model_fields"):
1665
+ for field_name, field_info in model_class.model_fields.items():
1666
+ schema_dict[field_name] = get_field_type_representation(field_info.annotation)
1667
+ return schema_dict
1668
+
1669
+ schema = _generate_schema_dict(model)
1670
+ return json.dumps(schema, indent=2)
1671
+
1672
+ async def _apply_prompt_provider_specific_structured(
1673
+ self,
1674
+ multipart_messages: List[PromptMessageMultipart],
1675
+ model: Type[ModelT],
1676
+ request_params: RequestParams | None = None,
1677
+ ) -> Tuple[ModelT | None, PromptMessageMultipart]:
1678
+ """Apply structured output for Bedrock using prompt engineering with a simplified schema."""
1679
+ request_params = self.get_request_params(request_params)
1680
+
1681
+ # Generate a simplified, human-readable schema
1682
+ simplified_schema = self._generate_simplified_schema(model)
1683
+
1684
+ # Build the new simplified prompt
1685
+ prompt_parts = [
1686
+ "You are a JSON generator. Respond with JSON that strictly follows the provided schema. Do not add any commentary or explanation.",
1687
+ "",
1688
+ "JSON Schema:",
1689
+ simplified_schema,
1690
+ "",
1691
+ "IMPORTANT RULES:",
1692
+ "- You MUST respond with only raw JSON data. No other text, commentary, or markdown is allowed.",
1693
+ "- All field names and enum values are case-sensitive and must match the schema exactly.",
1694
+ "- Do not add any extra fields to the JSON response. Only include the fields specified in the schema.",
1695
+ "- Valid JSON requires double quotes for all field names and string values. Other types (int, float, boolean, etc.) should not be quoted.",
1696
+ "",
1697
+ "Now, generate the valid JSON response for the following request:",
1698
+ ]
1699
+
1700
+ # Add the new prompt to the last user message
1701
+ multipart_messages[-1].add_text("\n".join(prompt_parts))
1702
+
1703
+ self.logger.info(f"DEBUG: Prompt messages: {multipart_messages[-1].content}")
1704
+
1705
+ result: PromptMessageMultipart = await self._apply_prompt_provider_specific(
1706
+ multipart_messages, request_params
1707
+ )
1708
+ return self._structured_from_multipart(result, model)
1709
+
1710
+ def _clean_json_response(self, text: str) -> str:
1711
+ """Clean up JSON response by removing text before first { and after last }."""
1712
+ if not text:
1713
+ return text
1714
+
1715
+ # Find the first { and last }
1716
+ first_brace = text.find("{")
1717
+ last_brace = text.rfind("}")
1718
+
1719
+ # If we found both braces, extract just the JSON part
1720
+ if first_brace != -1 and last_brace != -1 and first_brace < last_brace:
1721
+ return text[first_brace : last_brace + 1]
1722
+
1723
+ # Otherwise return the original text
1724
+ return text
1725
+
1726
+ def _structured_from_multipart(
1727
+ self, message: PromptMessageMultipart, model: Type[ModelT]
1728
+ ) -> Tuple[ModelT | None, PromptMessageMultipart]:
1729
+ """Override to apply JSON cleaning before parsing."""
1730
+ # Get the text from the multipart message
1731
+ text = message.all_text()
1732
+
1733
+ # Clean the JSON response to remove extra text
1734
+ cleaned_text = self._clean_json_response(text)
1735
+
1736
+ # If we cleaned the text, create a new multipart with the cleaned text
1737
+ if cleaned_text != text:
1738
+ from mcp.types import TextContent
1739
+
1740
+ cleaned_multipart = PromptMessageMultipart(
1741
+ role=message.role, content=[TextContent(type="text", text=cleaned_text)]
1742
+ )
1743
+ else:
1744
+ cleaned_multipart = message
1745
+
1746
+ # Use the parent class method with the cleaned multipart
1747
+ return super()._structured_from_multipart(cleaned_multipart, model)
1748
+
1749
+ @classmethod
1750
+ def convert_message_to_message_param(
1751
+ cls, message: BedrockMessage, **kwargs
1752
+ ) -> BedrockMessageParam:
1753
+ """Convert a Bedrock message to message parameter format."""
1754
+ message_param = {"role": message.get("role", "assistant"), "content": []}
1755
+
1756
+ for content_item in message.get("content", []):
1757
+ if isinstance(content_item, dict):
1758
+ if "text" in content_item:
1759
+ message_param["content"].append({"type": "text", "text": content_item["text"]})
1760
+ elif "toolUse" in content_item:
1761
+ tool_use = content_item["toolUse"]
1762
+ tool_input = tool_use.get("input", {})
1763
+
1764
+ # Ensure tool_input is a dictionary
1765
+ if not isinstance(tool_input, dict):
1766
+ if isinstance(tool_input, str):
1767
+ try:
1768
+ tool_input = json.loads(tool_input) if tool_input else {}
1769
+ except json.JSONDecodeError:
1770
+ tool_input = {}
1771
+ else:
1772
+ tool_input = {}
1773
+
1774
+ message_param["content"].append(
1775
+ {
1776
+ "type": "tool_use",
1777
+ "id": tool_use.get("toolUseId", ""),
1778
+ "name": tool_use.get("name", ""),
1779
+ "input": tool_input,
1780
+ }
1781
+ )
1782
+
1783
+ return message_param
1784
+
1785
+ def _api_key(self) -> str:
1786
+ """Bedrock doesn't use API keys, returns empty string."""
1787
+ return ""