@cdklabs/cdk-appmod-catalog-blueprints 1.16.1 → 1.17.0
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.
- package/.jsii +960 -143
- package/lib/document-processing/adapter/queued-s3-adapter.js +1 -1
- package/lib/document-processing/agentic-document-processing.js +1 -1
- package/lib/document-processing/base-document-processing.js +1 -1
- package/lib/document-processing/bedrock-document-processing.d.ts +1 -0
- package/lib/document-processing/bedrock-document-processing.js +10 -6
- package/lib/document-processing/default-document-processing-config.js +1 -1
- package/lib/document-processing/index.d.ts +2 -0
- package/lib/document-processing/index.js +3 -1
- package/lib/document-processing/localstack-agentic-document-processing.d.ts +20 -0
- package/lib/document-processing/localstack-agentic-document-processing.js +66 -0
- package/lib/document-processing/localstack-bedrock-document-processing.d.ts +16 -0
- package/lib/document-processing/localstack-bedrock-document-processing.js +38 -0
- package/lib/document-processing/resources/cleanup/handler.py +16 -1
- package/lib/document-processing/resources/default-localstack-invoke/index.py +184 -0
- package/lib/document-processing/resources/default-localstack-invoke/provider_runtime.py +251 -0
- package/lib/document-processing/resources/default-localstack-invoke/requirements.txt +5 -0
- package/lib/document-processing/resources/default-sqs-consumer/index.py +17 -2
- package/lib/document-processing/resources/pdf-chunking/handler.py +16 -1
- package/lib/document-processing/tests/localstack-agentic-document-processing.test.d.ts +1 -0
- package/lib/document-processing/tests/localstack-agentic-document-processing.test.js +78 -0
- package/lib/document-processing/tests/localstack-document-processing.test.d.ts +1 -0
- package/lib/document-processing/tests/localstack-document-processing.test.js +116 -0
- package/lib/framework/agents/base-agent.js +1 -1
- package/lib/framework/agents/batch-agent.d.ts +1 -0
- package/lib/framework/agents/batch-agent.js +7 -4
- package/lib/framework/agents/default-agent-config.js +1 -1
- package/lib/framework/agents/index.d.ts +1 -0
- package/lib/framework/agents/index.js +2 -1
- package/lib/framework/agents/interactive-agent.js +10 -10
- package/lib/framework/agents/knowledge-base/base-knowledge-base.js +1 -1
- package/lib/framework/agents/knowledge-base/bedrock-knowledge-base.js +1 -1
- package/lib/framework/agents/localstack-batch-agent.d.ts +15 -0
- package/lib/framework/agents/localstack-batch-agent.js +33 -0
- package/lib/framework/agents/resources/default-ollama-agent/batch.py +396 -0
- package/lib/framework/agents/resources/default-ollama-agent/models.py +7 -0
- package/lib/framework/agents/resources/default-ollama-agent/requirements.txt +9 -0
- package/lib/framework/agents/resources/default-ollama-agent/runtime_support.py +237 -0
- package/lib/framework/agents/resources/default-ollama-agent/utils.py +77 -0
- package/lib/framework/bedrock/bedrock.d.ts +9 -0
- package/lib/framework/bedrock/bedrock.js +20 -10
- package/lib/framework/custom-resource/default-runtimes.js +1 -1
- package/lib/framework/foundation/access-log.js +1 -1
- package/lib/framework/foundation/eventbridge-broker.js +1 -1
- package/lib/framework/foundation/network.js +1 -1
- package/lib/framework/index.d.ts +1 -0
- package/lib/framework/index.js +2 -1
- package/lib/framework/localstack/index.d.ts +1 -0
- package/lib/framework/localstack/index.js +18 -0
- package/lib/framework/localstack/localstack-config.d.ts +79 -0
- package/lib/framework/localstack/localstack-config.js +49 -0
- package/lib/framework/tests/localstack-batch-agent.test.d.ts +1 -0
- package/lib/framework/tests/localstack-batch-agent.test.js +67 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/utilities/data-loader.js +1 -1
- package/lib/utilities/lambda-iam-utils.js +1 -1
- package/lib/utilities/observability/cloudfront-distribution-observability-property-injector.js +1 -1
- package/lib/utilities/observability/cloudwatch-transaction-search.js +1 -1
- package/lib/utilities/observability/default-observability-config.js +1 -1
- package/lib/utilities/observability/lambda-observability-property-injector.js +1 -1
- package/lib/utilities/observability/log-group-data-protection-utils.js +1 -1
- package/lib/utilities/observability/powertools-config.js +1 -1
- package/lib/utilities/observability/state-machine-observability-property-injector.js +1 -1
- package/lib/webapp/frontend-construct.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Batch agent Lambda handler for processing documents with AI agents.
|
|
3
|
+
|
|
4
|
+
This module provides the Lambda handler for batch processing of documents
|
|
5
|
+
using Amazon Bedrock agents with tool integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from aws_lambda_powertools import Logger, Metrics, Tracer
|
|
15
|
+
from aws_lambda_powertools.metrics import MetricUnit
|
|
16
|
+
from strands import Agent
|
|
17
|
+
from strands_tools import file_read
|
|
18
|
+
|
|
19
|
+
import runtime_support
|
|
20
|
+
from utils import (
|
|
21
|
+
convert_tools_config_into_model,
|
|
22
|
+
create_boto3_client,
|
|
23
|
+
download_and_load_system_prompt,
|
|
24
|
+
download_tools,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ContentType(str, Enum):
|
|
29
|
+
"""
|
|
30
|
+
Document content type enumeration.
|
|
31
|
+
|
|
32
|
+
Defines how document content is provided to the agent.
|
|
33
|
+
"""
|
|
34
|
+
FILE = "file" # Document stored in S3 or file system
|
|
35
|
+
DATA = "data" # Document content provided inline
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ContentLocation(str, Enum):
|
|
39
|
+
"""
|
|
40
|
+
Document storage location enumeration.
|
|
41
|
+
|
|
42
|
+
Defines where file-based documents are stored.
|
|
43
|
+
"""
|
|
44
|
+
S3 = "s3" # Document stored in Amazon S3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InvokeType(str, Enum):
|
|
48
|
+
"""
|
|
49
|
+
Agent invocation type enumeration.
|
|
50
|
+
|
|
51
|
+
Defines the processing mode for the agent.
|
|
52
|
+
"""
|
|
53
|
+
BATCH = "batch" # Batch processing (one document at a time)
|
|
54
|
+
INTERACTIVE = "interactive" # Interactive conversation mode (future)
|
|
55
|
+
ATTACH_DIRECTLY = "attach-directly" # Direct invocation (e.g., RAG, API endpoints)
|
|
56
|
+
CLASSIFICATION = "classification" # Document classification step
|
|
57
|
+
PROCESSING = "processing" # Document processing step
|
|
58
|
+
AGGREGATION = "aggregation" # Document aggregation step
|
|
59
|
+
|
|
60
|
+
# Initialize AWS Lambda Powertools
|
|
61
|
+
logger = Logger()
|
|
62
|
+
metrics = Metrics()
|
|
63
|
+
tracer = Tracer()
|
|
64
|
+
|
|
65
|
+
# Initialize AWS clients
|
|
66
|
+
s3_client = create_boto3_client('s3', 'AWS_ENDPOINT_URL_S3')
|
|
67
|
+
|
|
68
|
+
# Load configuration at module initialization
|
|
69
|
+
TOOLS_CONFIG = convert_tools_config_into_model(os.getenv('TOOLS_CONFIG', '{}'))
|
|
70
|
+
AGENT_TOOLS = download_tools(TOOLS_CONFIG)
|
|
71
|
+
SYSTEM_PROMPT = download_and_load_system_prompt(
|
|
72
|
+
os.environ['SYSTEM_PROMPT_S3_BUCKET_NAME'],
|
|
73
|
+
os.environ['SYSTEM_PROMPT_S3_KEY']
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def extract_json_from_text(text: str) -> Optional[Dict[str, Any]]:
|
|
78
|
+
"""
|
|
79
|
+
Extract JSON object from text containing JSON in code blocks or raw format.
|
|
80
|
+
|
|
81
|
+
Attempts to extract JSON in the following order:
|
|
82
|
+
1. JSON within ```json code blocks
|
|
83
|
+
2. Raw JSON objects in the text
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
text: Text potentially containing JSON data
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Parsed JSON dictionary if found, None otherwise
|
|
90
|
+
"""
|
|
91
|
+
return runtime_support.extract_json_from_text(text, logger)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def download_document_from_s3(bucket: str, key: str) -> str:
|
|
95
|
+
"""
|
|
96
|
+
Download a document from S3 to local /tmp directory.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
bucket: S3 bucket name
|
|
100
|
+
key: S3 object key
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Local file path where document was downloaded
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ClientError: If S3 download fails
|
|
107
|
+
"""
|
|
108
|
+
filename = key.split('/')[-1]
|
|
109
|
+
local_path = f"/tmp/{filename}"
|
|
110
|
+
|
|
111
|
+
logger.info("Downloading document from S3", extra={
|
|
112
|
+
"bucket": bucket,
|
|
113
|
+
"key": key,
|
|
114
|
+
"local_path": local_path
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
s3_client.download_file(bucket, key, local_path)
|
|
118
|
+
return local_path
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_agent_prompt(base_prompt: str, event: Dict[str, Any], invoke_type: InvokeType) -> str:
|
|
122
|
+
"""
|
|
123
|
+
Build the complete agent prompt from base prompt and event data.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
base_prompt: Base prompt template
|
|
127
|
+
event: Lambda event containing document and classification data
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Complete prompt with document information and classification
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
ValueError: If content_type is invalid or required fields are missing
|
|
134
|
+
"""
|
|
135
|
+
prompt = base_prompt or "Analyze the attached document and verify the information using the provided tools."
|
|
136
|
+
|
|
137
|
+
# Replace classification placeholder if present
|
|
138
|
+
if 'classificationResult' in event:
|
|
139
|
+
classification = event['classificationResult']['documentClassification']
|
|
140
|
+
prompt = prompt.replace("[ACTUAL_CLASSIFICATION]", classification)
|
|
141
|
+
logger.info("Added classification to prompt", extra={"classification": classification})
|
|
142
|
+
|
|
143
|
+
# Validate and process content based on type
|
|
144
|
+
content_type_str = event.get('contentType', '')
|
|
145
|
+
content = event.get('content', {})
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
content_type = ContentType(content_type_str)
|
|
149
|
+
except ValueError:
|
|
150
|
+
logger.error("Invalid content type", extra={
|
|
151
|
+
"content_type": content_type_str,
|
|
152
|
+
"valid_types": [ct.value for ct in ContentType]
|
|
153
|
+
})
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"Invalid content_type '{content_type_str}'. "
|
|
156
|
+
f"Must be one of: {', '.join(ct.value for ct in ContentType)}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Process based on content type
|
|
160
|
+
if content_type == ContentType.FILE:
|
|
161
|
+
location = content.get('location', '')
|
|
162
|
+
|
|
163
|
+
# Validate location
|
|
164
|
+
try:
|
|
165
|
+
location_enum = ContentLocation(location)
|
|
166
|
+
except ValueError:
|
|
167
|
+
logger.error("Invalid content location", extra={
|
|
168
|
+
"location": location,
|
|
169
|
+
"valid_locations": [loc.value for loc in ContentLocation]
|
|
170
|
+
})
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"Invalid content location '{location}'. "
|
|
173
|
+
f"Must be one of: {', '.join(loc.value for loc in ContentLocation)}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if location_enum == ContentLocation.S3:
|
|
177
|
+
if 'bucket' not in content or 'key' not in content:
|
|
178
|
+
raise ValueError("S3 content must include 'bucket' and 'key' fields")
|
|
179
|
+
|
|
180
|
+
local_path = download_document_from_s3(content['bucket'], content['key'])
|
|
181
|
+
prompt += f" Attached document is located in {local_path}"
|
|
182
|
+
logger.info("Added file location to prompt", extra={"path": local_path})
|
|
183
|
+
|
|
184
|
+
elif content_type == ContentType.DATA:
|
|
185
|
+
if 'data' not in content:
|
|
186
|
+
raise ValueError("Data content must include 'data' field")
|
|
187
|
+
|
|
188
|
+
if invoke_type == InvokeType.BATCH:
|
|
189
|
+
prompt += f" Attached document content are as follows: {content['data']}"
|
|
190
|
+
elif invoke_type == InvokeType.ATTACH_DIRECTLY:
|
|
191
|
+
prompt += f" {content['data']}"
|
|
192
|
+
logger.info("Added inline data to prompt")
|
|
193
|
+
|
|
194
|
+
return prompt
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@metrics.log_metrics
|
|
198
|
+
@tracer.capture_lambda_handler
|
|
199
|
+
def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
200
|
+
"""
|
|
201
|
+
Lambda handler for batch agent processing.
|
|
202
|
+
|
|
203
|
+
Processes documents using an AI agent with tool integration.
|
|
204
|
+
Supports both file-based (S3) and inline data inputs.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
event: Lambda event containing:
|
|
208
|
+
- contentType: 'file' or 'data' (see ContentType enum)
|
|
209
|
+
- content: Document location or inline data
|
|
210
|
+
- classificationResult: Optional document classification
|
|
211
|
+
context: Lambda context object
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Dictionary with 'result' key containing agent response
|
|
215
|
+
(parsed as JSON if EXPECT_JSON is enabled)
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
KeyError: If required environment variables are missing
|
|
219
|
+
ValueError: If event contains invalid content_type or missing required fields
|
|
220
|
+
Exception: If agent invocation fails
|
|
221
|
+
"""
|
|
222
|
+
# Load configuration
|
|
223
|
+
runtime_support.configure_provider_runtime()
|
|
224
|
+
model_id = os.getenv("MODEL_ID")
|
|
225
|
+
model = runtime_support.build_agent_model(model_id)
|
|
226
|
+
base_prompt = os.getenv("PROMPT")
|
|
227
|
+
expect_json = bool(os.getenv("EXPECT_JSON", ""))
|
|
228
|
+
output_contract = runtime_support.resolve_output_contract()
|
|
229
|
+
invoke_type_str = os.environ.get("INVOKE_TYPE", InvokeType.BATCH.value)
|
|
230
|
+
|
|
231
|
+
# Validate invoke type
|
|
232
|
+
try:
|
|
233
|
+
invoke_type = InvokeType(invoke_type_str)
|
|
234
|
+
except ValueError:
|
|
235
|
+
logger.warning("Invalid invoke type, using default", extra={
|
|
236
|
+
"invoke_type": invoke_type_str,
|
|
237
|
+
"default": InvokeType.BATCH.value
|
|
238
|
+
})
|
|
239
|
+
invoke_type = InvokeType.BATCH
|
|
240
|
+
|
|
241
|
+
# Add observability metadata
|
|
242
|
+
tracer.put_annotation(key="invoke_type", value=invoke_type.value)
|
|
243
|
+
tracer.put_annotation(key="expect_json", value=expect_json)
|
|
244
|
+
metrics.add_dimension(name="invoke_type", value=invoke_type.value)
|
|
245
|
+
metrics.add_dimension(name="output_contract", value=output_contract)
|
|
246
|
+
|
|
247
|
+
logger.info("Processing batch agent request", extra={
|
|
248
|
+
"model_id": model_id,
|
|
249
|
+
"expect_json": expect_json,
|
|
250
|
+
"output_contract": output_contract,
|
|
251
|
+
"invoke_type": invoke_type.value,
|
|
252
|
+
"content_type": event.get('contentType')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
# Build complete prompt (validates content_type and structure)
|
|
257
|
+
prompt = build_agent_prompt(base_prompt, event, invoke_type)
|
|
258
|
+
if runtime_support.debug_agent_payload_enabled():
|
|
259
|
+
logger.info(
|
|
260
|
+
"Agent payload debug",
|
|
261
|
+
extra={
|
|
262
|
+
"system_prompt_preview": runtime_support.truncate_for_debug(SYSTEM_PROMPT),
|
|
263
|
+
"prompt_preview": runtime_support.truncate_for_debug(prompt),
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Initialize and invoke agent
|
|
268
|
+
agent = Agent(
|
|
269
|
+
model=model,
|
|
270
|
+
tools=AGENT_TOOLS + [file_read],
|
|
271
|
+
system_prompt=SYSTEM_PROMPT
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
logger.info("Invoking agent", extra={"prompt_length": len(prompt)})
|
|
275
|
+
response = agent(prompt)
|
|
276
|
+
|
|
277
|
+
message_payload = runtime_support.extract_response_message(response)
|
|
278
|
+
if runtime_support.debug_agent_payload_enabled():
|
|
279
|
+
logger.info(
|
|
280
|
+
"Agent response payload debug",
|
|
281
|
+
extra={
|
|
282
|
+
"message_preview": runtime_support.truncate_for_debug(json.dumps(message_payload)),
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
if runtime_support.debug_agent_tool_flow_enabled():
|
|
286
|
+
logger.info(
|
|
287
|
+
"Agent tool flow summary",
|
|
288
|
+
extra=runtime_support.summarize_tool_usage_from_message(message_payload),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Extract response text
|
|
292
|
+
response_text = runtime_support.extract_response_text(response)
|
|
293
|
+
logger.info("Agent invocation successful", extra={
|
|
294
|
+
"response_length": len(response_text)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
# Record success metric
|
|
298
|
+
metrics.add_metric(
|
|
299
|
+
name="SuccessfulAgentInvocation",
|
|
300
|
+
unit=MetricUnit.Count,
|
|
301
|
+
value=1
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Parse JSON if requested
|
|
305
|
+
if expect_json:
|
|
306
|
+
result = extract_json_from_text(response_text)
|
|
307
|
+
if result is None or runtime_support.is_empty_assistant_payload(result):
|
|
308
|
+
logger.warning(
|
|
309
|
+
"Expected JSON but final response was empty or non-JSON; requesting final JSON retry",
|
|
310
|
+
extra={
|
|
311
|
+
"initial_response_length": len(response_text),
|
|
312
|
+
"initial_response_preview": response_text[:500],
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
retry_response = agent(runtime_support.build_final_json_retry_prompt())
|
|
316
|
+
retry_text = runtime_support.extract_response_text(retry_response)
|
|
317
|
+
retry_message_payload = runtime_support.extract_response_message(retry_response)
|
|
318
|
+
if runtime_support.debug_agent_payload_enabled():
|
|
319
|
+
logger.info(
|
|
320
|
+
"Agent retry payload debug",
|
|
321
|
+
extra={
|
|
322
|
+
"retry_message_preview": runtime_support.truncate_for_debug(json.dumps(retry_message_payload)),
|
|
323
|
+
},
|
|
324
|
+
)
|
|
325
|
+
if runtime_support.debug_agent_tool_flow_enabled():
|
|
326
|
+
logger.info(
|
|
327
|
+
"Agent retry tool flow summary",
|
|
328
|
+
extra=runtime_support.summarize_tool_usage_from_message(retry_message_payload),
|
|
329
|
+
)
|
|
330
|
+
retry_result = extract_json_from_text(retry_text)
|
|
331
|
+
|
|
332
|
+
if retry_result is None or runtime_support.is_empty_assistant_payload(retry_result):
|
|
333
|
+
logger.warning(
|
|
334
|
+
"Retry did not return valid JSON",
|
|
335
|
+
extra={
|
|
336
|
+
"retry_response_length": len(retry_text),
|
|
337
|
+
"retry_response_preview": retry_text[:500],
|
|
338
|
+
},
|
|
339
|
+
)
|
|
340
|
+
result = {
|
|
341
|
+
"raw_response": retry_text,
|
|
342
|
+
"initial_response": response_text,
|
|
343
|
+
}
|
|
344
|
+
else:
|
|
345
|
+
result = retry_result
|
|
346
|
+
|
|
347
|
+
if (
|
|
348
|
+
runtime_support.is_fraud_output_contract_enabled()
|
|
349
|
+
and isinstance(result, dict)
|
|
350
|
+
and not runtime_support.is_complete_fraud_result(result)
|
|
351
|
+
):
|
|
352
|
+
logger.warning(
|
|
353
|
+
"Result JSON missing required fraud schema fields; forcing a required-tools retry",
|
|
354
|
+
extra={
|
|
355
|
+
"result_preview": runtime_support.truncate_for_debug(json.dumps(result)),
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
schema_retry_response = agent(runtime_support.build_required_tools_retry_prompt())
|
|
359
|
+
schema_retry_text = runtime_support.extract_response_text(schema_retry_response)
|
|
360
|
+
schema_retry_result = extract_json_from_text(schema_retry_text)
|
|
361
|
+
|
|
362
|
+
if schema_retry_result is None or not runtime_support.is_complete_fraud_result(schema_retry_result):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
"Agent response did not include required fraud schema after required-tools retry",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
result = schema_retry_result
|
|
368
|
+
else:
|
|
369
|
+
result = response_text
|
|
370
|
+
|
|
371
|
+
return {"result": result}
|
|
372
|
+
|
|
373
|
+
except ValueError as e:
|
|
374
|
+
# Validation errors (invalid content_type, missing fields, etc.)
|
|
375
|
+
logger.error("Validation error", extra={
|
|
376
|
+
"error": str(e),
|
|
377
|
+
"error_type": "ValidationError"
|
|
378
|
+
})
|
|
379
|
+
metrics.add_metric(
|
|
380
|
+
name="ValidationError",
|
|
381
|
+
unit=MetricUnit.Count,
|
|
382
|
+
value=1
|
|
383
|
+
)
|
|
384
|
+
raise
|
|
385
|
+
|
|
386
|
+
except Exception as e:
|
|
387
|
+
logger.error("Agent invocation failed", extra={
|
|
388
|
+
"error": str(e),
|
|
389
|
+
"error_type": type(e).__name__
|
|
390
|
+
})
|
|
391
|
+
metrics.add_metric(
|
|
392
|
+
name="FailedAgentInvocation",
|
|
393
|
+
unit=MetricUnit.Count,
|
|
394
|
+
value=1
|
|
395
|
+
)
|
|
396
|
+
raise
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
MODEL_PROVIDER_BEDROCK = 'bedrock'
|
|
7
|
+
MODEL_PROVIDER_OLLAMA = 'ollama'
|
|
8
|
+
OUTPUT_CONTRACT_NONE = 'none'
|
|
9
|
+
OUTPUT_CONTRACT_FRAUD_V1 = 'fraud_v1'
|
|
10
|
+
MAX_DEBUG_PAYLOAD_CHARS = int(os.getenv('DEBUG_AGENT_MAX_CHARS', '4000'))
|
|
11
|
+
REQUIRED_FRAUD_RESULT_KEYS = {'risk_score', 'risk_level', 'findings', 'indicators', 'recommended_actions'}
|
|
12
|
+
REQUIRED_FRAUD_FINDINGS_KEYS = {'metadata_analysis', 'pattern_matches', 'anomalies', 'database_checks'}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_truthy_env(var_name: str) -> bool:
|
|
16
|
+
return os.getenv(var_name, '').strip().lower() in {'1', 'true', 'yes', 'on'}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def debug_agent_payload_enabled() -> bool:
|
|
20
|
+
return _is_truthy_env('DEBUG_AGENT_PAYLOAD')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def debug_agent_tool_flow_enabled() -> bool:
|
|
24
|
+
return _is_truthy_env('DEBUG_AGENT_TOOL_FLOW')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def truncate_for_debug(text: str, max_chars: int = MAX_DEBUG_PAYLOAD_CHARS) -> str:
|
|
28
|
+
if len(text) <= max_chars:
|
|
29
|
+
return text
|
|
30
|
+
return f'{text[:max_chars]}...[truncated]'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_model_provider() -> str:
|
|
34
|
+
provider = os.getenv('MODEL_PROVIDER', MODEL_PROVIDER_BEDROCK).strip().lower()
|
|
35
|
+
if provider in {MODEL_PROVIDER_BEDROCK, MODEL_PROVIDER_OLLAMA}:
|
|
36
|
+
return provider
|
|
37
|
+
return MODEL_PROVIDER_BEDROCK
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_output_contract() -> str:
|
|
41
|
+
output_contract = os.getenv('AGENT_OUTPUT_CONTRACT', OUTPUT_CONTRACT_NONE).strip().lower()
|
|
42
|
+
if output_contract == OUTPUT_CONTRACT_FRAUD_V1:
|
|
43
|
+
return OUTPUT_CONTRACT_FRAUD_V1
|
|
44
|
+
return OUTPUT_CONTRACT_NONE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_fraud_output_contract_enabled() -> bool:
|
|
48
|
+
return resolve_output_contract() == OUTPUT_CONTRACT_FRAUD_V1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_ollama_model_name(model_id: Optional[str]) -> str:
|
|
52
|
+
raw_model_id = model_id or ''
|
|
53
|
+
explicit_ollama_model = os.getenv('OLLAMA_MODEL_ID')
|
|
54
|
+
if explicit_ollama_model:
|
|
55
|
+
return explicit_ollama_model
|
|
56
|
+
|
|
57
|
+
if raw_model_id.startswith('ollama/'):
|
|
58
|
+
return raw_model_id.split('ollama/', 1)[1]
|
|
59
|
+
if raw_model_id.startswith('ollama.'):
|
|
60
|
+
return raw_model_id.split('ollama.', 1)[1]
|
|
61
|
+
return raw_model_id
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_agent_model(model_id: Optional[str]) -> Any:
|
|
65
|
+
resolved_provider = resolve_model_provider()
|
|
66
|
+
resolved_model_id = model_id or ''
|
|
67
|
+
|
|
68
|
+
if resolved_provider != MODEL_PROVIDER_OLLAMA:
|
|
69
|
+
return resolved_model_id
|
|
70
|
+
|
|
71
|
+
from strands.models import OllamaModel
|
|
72
|
+
|
|
73
|
+
ollama_host = os.getenv('OLLAMA_BASE_URL') or os.getenv('OLLAMA_HOST')
|
|
74
|
+
ollama_model_name = resolve_ollama_model_name(resolved_model_id)
|
|
75
|
+
if not ollama_model_name:
|
|
76
|
+
raise ValueError('OLLAMA model name is empty. Set MODEL_ID or OLLAMA_MODEL_ID.')
|
|
77
|
+
|
|
78
|
+
return OllamaModel(
|
|
79
|
+
host=ollama_host,
|
|
80
|
+
model_id=ollama_model_name,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def configure_provider_runtime() -> None:
|
|
85
|
+
provider = resolve_model_provider()
|
|
86
|
+
if provider != MODEL_PROVIDER_OLLAMA:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
ollama_base_url = os.getenv('OLLAMA_BASE_URL')
|
|
90
|
+
if ollama_base_url and not os.getenv('OLLAMA_HOST'):
|
|
91
|
+
os.environ['OLLAMA_HOST'] = ollama_base_url
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def extract_json_from_text(text: str, logger) -> Optional[Dict[str, Any]]:
|
|
95
|
+
json_block_pattern = re.compile(r'```json\s*(.*?)\s*```', re.DOTALL | re.IGNORECASE)
|
|
96
|
+
decoder = json.JSONDecoder()
|
|
97
|
+
# Track each JSON candidate with its absolute end index in the full response text.
|
|
98
|
+
# This ensures selection prefers the latest object in the output, not the longest substring.
|
|
99
|
+
candidates: list[tuple[Dict[str, Any], int]] = []
|
|
100
|
+
for json_block_match in json_block_pattern.finditer(text):
|
|
101
|
+
block_text = json_block_match.group(1).strip()
|
|
102
|
+
try:
|
|
103
|
+
parsed = json.loads(block_text)
|
|
104
|
+
if isinstance(parsed, dict):
|
|
105
|
+
candidates.append((parsed, json_block_match.end()))
|
|
106
|
+
except json.JSONDecodeError as error:
|
|
107
|
+
logger.warning('Failed to parse JSON from code block', extra={'error': str(error)})
|
|
108
|
+
|
|
109
|
+
for start_idx, char in enumerate(text):
|
|
110
|
+
if char != '{':
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
parsed, end_idx = decoder.raw_decode(text[start_idx:])
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
if not isinstance(parsed, dict):
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
absolute_end_idx = start_idx + end_idx
|
|
121
|
+
candidates.append((parsed, absolute_end_idx))
|
|
122
|
+
|
|
123
|
+
if candidates:
|
|
124
|
+
if not is_fraud_output_contract_enabled():
|
|
125
|
+
return max(candidates, key=lambda candidate: candidate[1])[0]
|
|
126
|
+
|
|
127
|
+
schema_candidates = [
|
|
128
|
+
candidate for candidate in candidates if REQUIRED_FRAUD_RESULT_KEYS.issubset(set(candidate[0].keys()))
|
|
129
|
+
]
|
|
130
|
+
if schema_candidates:
|
|
131
|
+
return max(schema_candidates, key=lambda candidate: candidate[1])[0]
|
|
132
|
+
|
|
133
|
+
return max(candidates, key=lambda candidate: candidate[1])[0]
|
|
134
|
+
|
|
135
|
+
logger.info('No valid JSON found in text')
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def extract_response_text(response: Any) -> str:
|
|
140
|
+
message = getattr(response, 'message', None)
|
|
141
|
+
if not isinstance(message, dict):
|
|
142
|
+
return str(response)
|
|
143
|
+
|
|
144
|
+
content_blocks = message.get('content')
|
|
145
|
+
if not isinstance(content_blocks, list):
|
|
146
|
+
return json.dumps(message)
|
|
147
|
+
|
|
148
|
+
text_parts = []
|
|
149
|
+
for block in content_blocks:
|
|
150
|
+
if isinstance(block, dict) and isinstance(block.get('text'), str):
|
|
151
|
+
text_parts.append(block['text'])
|
|
152
|
+
|
|
153
|
+
if text_parts:
|
|
154
|
+
return '\n'.join(text_parts)
|
|
155
|
+
|
|
156
|
+
return json.dumps(message)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def extract_response_message(response: Any) -> Dict[str, Any]:
|
|
160
|
+
message = getattr(response, 'message', None)
|
|
161
|
+
if isinstance(message, dict):
|
|
162
|
+
return message
|
|
163
|
+
return {'raw': str(response)}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def summarize_tool_usage_from_message(message: Dict[str, Any]) -> Dict[str, Any]:
|
|
167
|
+
content_blocks = message.get('content', [])
|
|
168
|
+
if not isinstance(content_blocks, list):
|
|
169
|
+
return {'block_count': 0, 'tool_use_count': 0, 'tool_result_count': 0, 'tools': []}
|
|
170
|
+
|
|
171
|
+
tool_names = []
|
|
172
|
+
tool_result_count = 0
|
|
173
|
+
for block in content_blocks:
|
|
174
|
+
if not isinstance(block, dict):
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
tool_use = block.get('toolUse') or block.get('tool_use')
|
|
178
|
+
if isinstance(tool_use, dict):
|
|
179
|
+
name = tool_use.get('name')
|
|
180
|
+
if isinstance(name, str):
|
|
181
|
+
tool_names.append(name)
|
|
182
|
+
|
|
183
|
+
if block.get('toolResult') is not None or block.get('tool_result') is not None:
|
|
184
|
+
tool_result_count += 1
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
'block_count': len(content_blocks),
|
|
188
|
+
'tool_use_count': len(tool_names),
|
|
189
|
+
'tool_result_count': tool_result_count,
|
|
190
|
+
'tools': tool_names,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def is_empty_assistant_payload(payload: Any) -> bool:
|
|
195
|
+
return (
|
|
196
|
+
isinstance(payload, dict)
|
|
197
|
+
and payload.get('role') == 'assistant'
|
|
198
|
+
and isinstance(payload.get('content'), list)
|
|
199
|
+
and len(payload.get('content', [])) == 0
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def build_final_json_retry_prompt() -> str:
|
|
204
|
+
if is_fraud_output_contract_enabled():
|
|
205
|
+
return (
|
|
206
|
+
'Provide the final answer now as a single valid JSON object only. '
|
|
207
|
+
'Do not call any tools. '
|
|
208
|
+
'Use exactly these keys: risk_score, risk_level, findings, indicators, recommended_actions.'
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
'Provide the final answer now as a single valid JSON object only. '
|
|
213
|
+
'Do not call any tools.'
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def is_complete_fraud_result(result: Dict[str, Any]) -> bool:
|
|
218
|
+
if not isinstance(result, dict):
|
|
219
|
+
return False
|
|
220
|
+
if not REQUIRED_FRAUD_RESULT_KEYS.issubset(result.keys()):
|
|
221
|
+
return False
|
|
222
|
+
findings = result.get('findings')
|
|
223
|
+
if not isinstance(findings, dict):
|
|
224
|
+
return False
|
|
225
|
+
return REQUIRED_FRAUD_FINDINGS_KEYS.issubset(findings.keys())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def build_required_tools_retry_prompt() -> str:
|
|
229
|
+
return (
|
|
230
|
+
'Your previous response was incomplete. '
|
|
231
|
+
'You MUST run the full fraud tool sequence in this exact order: '
|
|
232
|
+
'1) extract_document_fields, 2) analyze_metadata, 3) detect_anomalies, '
|
|
233
|
+
'4) match_patterns, 5) lookup_vendor. '
|
|
234
|
+
'After all tools complete, return ONLY valid JSON with keys: '
|
|
235
|
+
'risk_score, risk_level, findings, indicators, recommended_actions. '
|
|
236
|
+
'The findings object MUST include: metadata_analysis, pattern_matches, anomalies, database_checks.'
|
|
237
|
+
)
|