Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,16 @@ Model ID to environment variable mapping:
| `*claude-opus-4-6*` | `CLAUDE_OPUS_PROFILE_ARN` |
| `*nova-premier*` | `NOVA_PREMIER_PROFILE_ARN` |

### Qwen3 VL

BADGERS supports the multimodal Bedrock model
`qwen.qwen3-vl-235b-a22b` for the agent and specialists. Qwen uses direct
regional invocation and the Bedrock Converse API. Do not configure Claude
thinking fields for Qwen. See the
[deployment model configuration](deployment/DEPLOYMENT_README.md#qwen3-vl-support)
for agent and specialist examples and verify that the model is available in your
deployment Region.

### ➕ Adding a New Specialist

**Option 1: Use the Wizard (Recommended)**
Expand Down
5 changes: 4 additions & 1 deletion deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ step_infra() {
"$(_sn DynamoDB)"
"$(_sn IAM)"
"$(_sn ECR)"
"$(_sn InferenceProfiles)"
"$(_sn Memory)"
"$(_sn Vpc)"
)
if [ "${BADGERS_SKIP_INFERENCE_PROFILES:-}" != "1" ] \
&& [[ "${BADGERS_MODEL_ID:-}" != qwen.* ]]; then
stacks+=("$(_sn InferenceProfiles)")
fi
if [ "${BADGERS_SKIP_XRAY}" != "1" ]; then
stacks+=("$(_sn XRay)")
fi
Expand Down
53 changes: 53 additions & 0 deletions deployment/DEPLOYMENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,59 @@ Each specialist has a manifest file in `s3_files/manifests/` that configures its
> [!NOTE]
> Extended thinking is only supported on Claude models. When enabled, thinking content is saved to S3 alongside results: `{session_id}/{specialist_name}/{image}_thinking_{timestamp}.txt`

### Qwen3 VL support

`qwen.qwen3-vl-235b-a22b` is supported for both the orchestrating agent and
image-aware specialists through the Amazon Bedrock Converse API. Confirm that the
model is available in the deployment Region before selecting it.

To use Qwen3 VL for the orchestrating agent, update
`s3_files/agent_config/agent_model_config.json` before deployment (or update the
same object in the configuration bucket):

```json
{
"model_id": "qwen.qwen3-vl-235b-a22b",
"temperature": 0.1,
"max_tokens": 8000,
"fallback_models": []
}
```

To use it for a specialist, select the same model ID in that specialist's
manifest:

```json
"model_selections": {
"primary": {
"model_id": "qwen.qwen3-vl-235b-a22b"
},
"fallback_list": []
}
```

Qwen does not use the Claude `thinking`, `extended_thinking`,
`adaptive_thinking`, or `budget_tokens` fields. Omit those fields from Qwen
configurations. The deployment grants direct regional invocation permission for
this model. When `BADGERS_MODEL_ID` starts with `qwen.`, the application inference
profile stack is omitted automatically because it is not required and its
profile-backed models are not available in every Region. Set
`BADGERS_SKIP_INFERENCE_PROFILES=1` to request the same direct-invocation-only
deployment explicitly.

For a deployment-time override of the agent and image-enhancer models, export
the model IDs before running the deployment command:

```bash
export BADGERS_MODEL_ID=qwen.qwen3-vl-235b-a22b
export BADGERS_VISION_MODEL_ID=qwen.qwen3-vl-235b-a22b
./deploy.sh
```

These variables configure the orchestrating agent and the image enhancer. Each
specialist still follows its own manifest, allowing Qwen adoption per specialist
without changing unrelated model selections.

Simple format (no extended thinking) is still supported for backward compatibility:
```json
"model_selections": {
Expand Down
33 changes: 23 additions & 10 deletions deployment/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,15 +167,26 @@ def _sn(name: str) -> str:
description="ECR repository for AgentCore Runtime agent container",
)

# Inference Profiles for cost tracking
inference_profiles_stack = InferenceProfilesStack(
app,
_sn("InferenceProfiles"),
deployment_id=deployment_id,
deployment_tags=deployment_tags,
env=env,
description="Application Inference Profiles for cost tracking and usage monitoring",
# Application inference profiles are only required by profile-backed models. Qwen is
# invoked directly in the deployment Region, and some Regions do not offer the
# cross-Region profiles configured by this stack.
model_id = os.environ.get("BADGERS_MODEL_ID", "").strip().lower()
SKIP_INFERENCE_PROFILES = (
os.environ.get("BADGERS_SKIP_INFERENCE_PROFILES", "").strip() == "1"
or model_id.startswith("qwen.")
)
inference_profiles_stack = None
if SKIP_INFERENCE_PROFILES:
print("Application inference profiles omitted for direct model invocation")
else:
inference_profiles_stack = InferenceProfilesStack(
app,
_sn("InferenceProfiles"),
deployment_id=deployment_id,
deployment_tags=deployment_tags,
env=env,
description="Application Inference Profiles for cost tracking and usage monitoring",
)

# Load deployment config for selective specialist deployment
enabled_specialists = None
Expand Down Expand Up @@ -212,7 +223,8 @@ def _sn(name: str) -> str:
description="Lambda specialists for BADGERS",
)
lambda_stack.add_dependency(ecr_stack)
lambda_stack.add_dependency(inference_profiles_stack)
if inference_profiles_stack is not None:
lambda_stack.add_dependency(inference_profiles_stack)
lambda_stack.add_dependency(dynamodb_stack)

# X-Ray Transaction Search (account-level prerequisite for AgentCore tracing).
Expand Down Expand Up @@ -291,7 +303,8 @@ def _sn(name: str) -> str:
runtime_websocket_stack.add_dependency(gateway_stack)
runtime_websocket_stack.add_dependency(cognito_stack)
runtime_websocket_stack.add_dependency(memory_stack)
runtime_websocket_stack.add_dependency(inference_profiles_stack)
if inference_profiles_stack is not None:
runtime_websocket_stack.add_dependency(inference_profiles_stack)
if xray_stack is not None:
runtime_websocket_stack.add_dependency(xray_stack)
runtime_websocket_stack.add_dependency(dynamodb_stack)
Expand Down
116 changes: 107 additions & 9 deletions deployment/badgers-foundation/foundation/bedrock_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Handles Bedrock client creation, invocation, and error handling for different specialist types.
"""

import base64
import json
import logging
import time
Expand Down Expand Up @@ -81,7 +82,7 @@ def get_model_family(model_id: str) -> str:
model_id: The Bedrock model ID

Returns:
'claude' or 'nova'
'claude', 'nova', or 'qwen'

Raises:
BedrockError: If model family cannot be determined
Expand All @@ -92,6 +93,8 @@ def get_model_family(model_id: str) -> str:
return "claude"
elif "nova" in model_lower or "amazon.nova" in model_lower:
return "nova"
elif "qwen" in model_lower:
return "qwen"
else:
raise BedrockError(f"Unknown model family for model ID: {model_id}")

Expand Down Expand Up @@ -365,6 +368,20 @@ def _invoke_single_model(
adaptive_thinking,
)

if model_family == "qwen":
if extended_thinking or adaptive_thinking:
self.logger.warning(
"Extended/adaptive thinking is not supported for Qwen models; ignoring"
)
normalized = self._invoke_qwen_with_converse(
client,
invoke_model_id,
payload,
max_retries=max_retries,
)
normalized["model_id"] = model_id
return normalized

# Convert payload to model-specific format
model_payload = self._convert_payload_for_model(
payload,
Expand Down Expand Up @@ -396,6 +413,7 @@ def _invoke_single_model(

# Normalize response to common format
normalized = self._normalize_response(response_body, model_family)
normalized["model_id"] = model_id

self.logger.info("Model invocation successful")
return normalized
Expand All @@ -405,6 +423,76 @@ def _invoke_single_model(
except Exception as e:
raise BedrockError(f"Model invocation failed: {e}") from e

def _invoke_qwen_with_converse(
self,
client,
model_id: str,
payload: Dict[str, Any],
max_retries: int = 3,
) -> Dict[str, Any]:
"""Invoke a Qwen model with the Bedrock Converse API."""
messages: list[Dict[str, Any]] = []
for message in payload.get("messages", []):
content = message.get("content", [])
if isinstance(content, str):
content = [{"type": "text", "text": content}]

converted_content: list[Dict[str, Any]] = []
for item in content:
if item.get("type") == "text" or "text" in item:
converted_content.append({"text": item.get("text", "")})
elif item.get("type") == "image":
source = item.get("source", {})
image_format = (
source.get("media_type", "image/png")
.split("/", 1)[-1]
.lower()
)
if image_format == "jpg":
image_format = "jpeg"
image_data = source.get("data", "")
if isinstance(image_data, str):
image_data = base64.b64decode(image_data)
converted_content.append(
{
"image": {
"format": image_format,
"source": {"bytes": image_data},
}
}
)

messages.append(
{
"role": message.get("role", "user"),
"content": converted_content,
}
)

converse_args: Dict[str, Any] = {
"modelId": model_id,
"messages": messages,
"inferenceConfig": {
"maxTokens": min(int(payload.get("max_tokens", 8000)), 8000),
"temperature": float(payload.get("temperature", 0.1)),
},
}
system_prompt = payload.get("system")
if system_prompt:
converse_args["system"] = (
[{"text": system_prompt}]
if isinstance(system_prompt, str)
else system_prompt
)

time.sleep(self.throttle_delay)
response = self.handle_throttling(
client.converse,
max_retries=max_retries,
**converse_args,
)
return self._normalize_response(response, "qwen")

def _convert_payload_for_model(
self,
payload: Dict[str, Any],
Expand All @@ -419,7 +507,7 @@ def _convert_payload_for_model(

Args:
payload: Base payload with system, messages, max_tokens, temperature
model_family: 'claude' or 'nova'
model_family: 'claude', 'nova', or 'qwen'
extended_thinking: Whether to enable extended thinking (Claude only)
budget_tokens: Optional budget tokens for extended thinking
adaptive_thinking: Whether to enable adaptive thinking (Claude only)
Expand All @@ -442,6 +530,8 @@ def _convert_payload_for_model(
"Extended/adaptive thinking not supported for Nova models, ignoring"
)
return self._convert_to_nova_payload(payload)
elif model_family == "qwen":
return payload
else:
raise BedrockError(f"Unknown model family: {model_family}")

Expand Down Expand Up @@ -576,7 +666,7 @@ def _normalize_response(

Args:
response_body: Raw response from model
model_family: 'claude' or 'nova'
model_family: 'claude', 'nova', or 'qwen'

Returns:
Normalized response with 'content' key and optional 'thinking' key
Expand All @@ -600,22 +690,28 @@ def _normalize_response(

return result

elif model_family == "nova":
# Convert Nova response to Claude format
elif model_family in {"nova", "qwen"}:
# Convert Bedrock Converse/Nova response to Claude-compatible content.
try:
nova_content = response_body["output"]["message"]["content"]
# Convert Nova content format to Claude format
claude_content = []
for item in nova_content:
if "text" in item:
claude_content.append({"type": "text", "text": item["text"]})

if not claude_content:
raise BedrockError("Empty response from Nova model")
raise BedrockError(f"Empty response from {model_family} model")

return {"content": claude_content}
result = {"content": claude_content}
if "usage" in response_body:
result["usage"] = response_body["usage"]
if "stopReason" in response_body:
result["stop_reason"] = response_body["stopReason"]
return result
except KeyError as e:
raise BedrockError(f"Invalid Nova response structure: {e}") from e
raise BedrockError(
f"Invalid {model_family} response structure: {e}"
) from e

else:
raise BedrockError(f"Unknown model family: {model_family}")
Expand Down Expand Up @@ -749,6 +845,8 @@ def validate_model_id(self, model_id: str) -> bool:
"amazon.nova-",
"us.amazon.nova-",
"global.amazon.nova-",
# Qwen
"qwen.",
# Other supported models
"amazon.titan-",
"ai21.j2-",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@
AWS_REGION = os.environ.get("AWS_REGION", "us-west-2")
# Use application inference profile ARN for cost tracking and cross-region routing
# Falls back to system inference profile ID if not set
VISION_MODEL = os.environ.get(
"CLAUDE_OPUS_46_PROFILE_ARN", "us.anthropic.claude-opus-4-6-v1"
VISION_MODEL = (
os.environ.get("VISION_MODEL")
or os.environ.get("CLAUDE_OPUS_46_PROFILE_ARN")
or "us.anthropic.claude-opus-4-6-v1"
)
MAX_ITERATIONS = int(os.environ.get("MAX_ITERATIONS", "2"))
MAX_IMAGE_DIMENSION = int(os.environ.get("MAX_IMAGE_DIMENSION", "4000"))
Expand Down
19 changes: 12 additions & 7 deletions deployment/runtime/agent/main-websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,13 +504,18 @@ async def stream_agent_events(
RUNTIME SESSION ID: {runtime_session_id}
Include session_id: "{runtime_session_id}" in ALL tool calls."""

model = BedrockModel(
model_id=model_config.get("model_id", DEFAULT_MODEL_CONFIG["model_id"]),
region_name=os.environ.get("AWS_REGION", "us-west-2"),
temperature=model_config.get("temperature", 1.0),
max_tokens=model_config.get("max_tokens", 8000),
additional_request_fields={"thinking": model_config.get("thinking", {})},
)
model_kwargs: dict[str, Any] = {
"model_id": os.environ.get("BADGERS_MODEL_ID")
or model_config.get("model_id", DEFAULT_MODEL_CONFIG["model_id"]),
"region_name": os.environ.get("AWS_REGION", "us-west-2"),
"temperature": model_config.get("temperature", 1.0),
"max_tokens": model_config.get("max_tokens", 8000),
}
thinking = model_config.get("thinking")
if thinking:
model_kwargs["additional_request_fields"] = {"thinking": thinking}

model = BedrockModel(**model_kwargs)

mcp_client = MCPClient(lambda: create_mcp_transport(gateway_url, access_token))

Expand Down
2 changes: 1 addition & 1 deletion deployment/s3_files/S3_FILES_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Contains configuration for the orchestrating agent that coordinates PDF analysis

| File | Purpose |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `agent_model_config.json` | Model selection (Claude Sonnet 4.5), temperature, max tokens, and thinking budget configuration |
| `agent_model_config.json` | Agent model selection, temperature, max tokens, and optional thinking configuration |
| `agent_operating_environment_config.json` | ⚠️ **Critical** — Operating environment context injected into all prompts (agent + specialists). See below. |

### ⚠️ Operating Environment Configuration
Expand Down
Loading