Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/frameworks.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ framework is restricted to `Strands` or `VercelAI`; other values are rejected. S
| **OpenAIAgents** | OpenAI only |
| **VercelAI** | Bedrock, Anthropic, OpenAI, Gemini |

## Runtime Input Validation

Validate agent invocation payloads before passing them to a framework. Template entrypoints validate plain prompts as
strings; preserve that validation when extending a generated application, and pass only prompt text to the agent.

## Framework Selection Guide

### Strands Agents
Expand Down
5 changes: 4 additions & 1 deletion e2e-tests/fixtures/import/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ def get_or_create_agent():
async def invoke(payload, context):
log.info("Invoking Agent.....")
agent = get_or_create_agent()
stream = agent.stream_async(payload.get("prompt"))
prompt = payload.get("prompt")
if not isinstance(prompt, str):
raise ValueError("prompt must be a string")
stream = agent.stream_async(prompt)
async for event in stream:
if "data" in event and isinstance(event["data"], str):
yield event["data"]
Expand Down
5 changes: 5 additions & 0 deletions src/assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ Run your agent locally:
agentcore dev
```

### Validate Invocation Input

Validate runtime invocation payloads before forwarding them to an agent framework. Keep user prompts typed as strings
and pass only prompt text to the agent.

### Deployment

Deploy to AWS:
Expand Down
118 changes: 109 additions & 9 deletions src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3335,6 +3335,11 @@ file defines a Starlette ASGI app with the AutoGen framework running within.

\`model/load.py\` instantiates your chosen model provider.

## Input Validation

Validate invocation input before forwarding it to the agent framework. Keep plain prompts typed as strings and pass
only prompt text to the agent.

## Environment Variables

| Variable | Required | Description |
Expand Down Expand Up @@ -3538,6 +3543,8 @@ async def invoke(payload, context):

# Process the user prompt
prompt = payload.get("prompt", "What can you help me with?")
if not isinstance(prompt, str):
raise ValueError("prompt must be a string")
session_id = getattr(context, "session_id", "default-session")

# Reuse the per-session agent (preserves conversation history)
Expand Down Expand Up @@ -3791,6 +3798,11 @@ file defines a Starlette ASGI app with the Google ADK framework running within.

\`model/load.py\` instantiates your chosen model provider (Gemini).

## Input Validation

Validate invocation input before forwarding it to the agent framework. Keep plain prompts typed as strings and pass
only prompt text to the agent.

## Environment Variables

| Variable | Required | Description |
Expand Down Expand Up @@ -4058,6 +4070,8 @@ async def invoke(payload, context):

# Process the user prompt
prompt = payload.get("prompt", "What can you help me with?")
if not isinstance(prompt, str):
raise ValueError("prompt must be a string")
session_id = getattr(context, "session_id", "default_session")
user_id = payload.get("user_id", "default_user")

Expand Down Expand Up @@ -4251,6 +4265,11 @@ file defines a Starlette ASGI app with the LangChain/LangGraph framework running

\`model/load.py\` instantiates your chosen model provider.

## Input Validation

Validate invocation input before forwarding it to the agent framework. Keep plain prompts typed as strings and pass
only prompt text to the agent.

## Environment Variables

| Variable | Required | Description |
Expand Down Expand Up @@ -4510,6 +4529,8 @@ async def invoke(payload, context):

# Process the user prompt
prompt = payload.get("prompt", "What can you help me with?")
if not isinstance(prompt, str):
raise ValueError("prompt must be a string")
session_id = getattr(context, "session_id", "default-session")
touch_thread(session_id)
log.info(f"Agent input: {prompt}")
Expand All @@ -4529,6 +4550,8 @@ async def invoke(payload, context):

# Process the user prompt
prompt = payload.get("prompt", "What can you help me with?")
if not isinstance(prompt, str):
raise ValueError("prompt must be a string")
session_id = getattr(context, "session_id", "default-session")
touch_thread(session_id)
log.info(f"Agent input: {prompt}")
Expand Down Expand Up @@ -4829,6 +4852,11 @@ file defines a Starlette ASGI app with the OpenAI Agents SDK framework running w

\`model/load.py\` instantiates your chosen model provider (OpenAI).

## Input Validation

Validate invocation input before forwarding it to the agent framework. Keep plain prompts typed as strings and pass
only prompt text to the agent.

## Environment Variables

| Variable | Required | Description |
Expand Down Expand Up @@ -5089,6 +5117,8 @@ async def invoke(payload, context):

# Process the user prompt
prompt = payload.get("prompt", "What can you help me with?")
if not isinstance(prompt, str):
raise ValueError("prompt must be a string")
session_id = getattr(context, "session_id", "default-session")
session = get_session(session_id)

Expand Down Expand Up @@ -5274,6 +5304,12 @@ file defines a Starlette ASGI app with the chosen Agent framework SDK running wi

\`model/load.py\` instantiates your chosen model provider.

## Input Validation

Validate invocation input before forwarding it to Strands. Keep plain prompts typed as strings. If the app accepts a
caller-supplied message history, retain \`strip_trailing_tool_use()\`, which normalizes the history tail before
invoking the agent.

## Environment Variables

| Variable | Required | Description |
Expand Down Expand Up @@ -5830,17 +5866,53 @@ get_or_create_agent = agent_factory()
{{/if}}


def strip_trailing_tool_use(messages: Any) -> list[dict]:
"""Strip toolUse blocks from the tail until the last message has none."""
if not isinstance(messages, list):
raise ValueError("messages must be a list")

messages = list(messages)
while messages:
last = messages[-1]
if not isinstance(last, dict):
raise ValueError("each message must be an object")
original_content = last.get("content", [])
if not isinstance(original_content, list) or not all(isinstance(block, dict) for block in original_content):
raise ValueError("each message content value must be a list of content blocks")

content = [block for block in original_content if "toolUse" not in block]
if len(content) == len(original_content):
break
if content:
messages[-1] = {**last, "content": content}
break
messages.pop()

return messages


def _extract_prompt(payload: dict):
"""Accept harness-style messages[], tool_results[], or plain prompt string payloads."""
"""Accept validated harness messages, tool results, or a plain prompt string."""
if not isinstance(payload, dict):
raise ValueError("payload must be a JSON object")
if "messages" in payload:
return payload["messages"]
return strip_trailing_tool_use(payload["messages"])
if "tool_results" in payload:
tool_results = payload["tool_results"]
if not isinstance(tool_results, list) or not all(
isinstance(tool_result, dict) and isinstance(tool_result.get("toolUseId"), str)
for tool_result in tool_results
):
raise ValueError("tool_results must contain objects with a toolUseId string")
return [{"role": "user", "content": [{"toolResult": {
"toolUseId": tr["toolUseId"],
"status": tr.get("status", "success"),
"content": tr.get("content", []),
}} for tr in payload["tool_results"]]}]
return payload.get("prompt", "")
}} for tr in tool_results]}]
prompt = payload.get("prompt", "")
if not isinstance(prompt, str):
raise ValueError("prompt must be a string")
return prompt


def _has_inline_function_call(messages) -> bool:
Expand Down Expand Up @@ -7308,6 +7380,11 @@ Run your agent locally:
agentcore dev
\`\`\`

### Validate Invocation Input

Validate runtime invocation payloads before forwarding them to an agent framework. Keep user prompts typed as strings
and pass only prompt text to the agent.

### Deployment

Deploy to AWS:
Expand Down Expand Up @@ -7402,6 +7479,8 @@ Tags defined in \`agentcore.json\` flow through to deployed CloudFormation resou
\`agentcore validate\` to check.
4. **Resource Removal:** Use \`agentcore remove\` to remove resources. Run \`agentcore deploy\` after removal to tear down
deployed infrastructure.
5. **Invocation Input:** Validate runtime payloads and require text prompts to be strings. If a Strands app accepts a
caller-supplied message history, normalize the history tail with \`strip_trailing_tool_use()\` before invocation.

## Directory Structure

Expand Down Expand Up @@ -7615,6 +7694,11 @@ defines an HTTP server that streams tokens from your chosen Agent framework SDK.

\`model/load.ts\` instantiates your chosen model provider.

## Input Validation

The generated Zod request schema keeps plain prompts typed as strings before forwarding them to Strands. Retain this
validation when extending the request shape, and pass only prompt text to the agent.

## Environment Variables

| Variable | Required | Description |
Expand Down Expand Up @@ -7703,6 +7787,10 @@ const SYSTEM_PROMPT = \`
You are a helpful assistant. Use tools when appropriate.
\`;

const requestSchema = z.object({
prompt: z.string().default(''),
});

{{#if hasMemory}}
const agentCache = new Map<string, Agent>();

Expand Down Expand Up @@ -7756,7 +7844,8 @@ async function getOrCreateAgent(sessionId: string): Promise<Agent> {

const app = new BedrockAgentCoreApp({
invocationHandler: {
async *process(payload: any, context: any) {
requestSchema,
async *process(payload, context) {
{{#if hasMemory}}
const sessionId = context?.sessionId ?? 'default-session';
const actorId = getActorId(payload, context);
Expand All @@ -7768,7 +7857,7 @@ const app = new BedrockAgentCoreApp({

{{#if hasMemory}}
try {
for await (const event of agent.stream(payload.prompt ?? '')) {
for await (const event of agent.stream(payload.prompt)) {
if (
event.type === 'modelStreamUpdateEvent' &&
event.event?.type === 'modelContentBlockDeltaEvent' &&
Expand All @@ -7792,7 +7881,7 @@ const app = new BedrockAgentCoreApp({
// e.g. Anthropic). Restoring on error keeps the session reusable.
const snapshot = agent.takeSnapshot({ include: ['messages'] });
try {
for await (const event of agent.stream(payload.prompt ?? '')) {
for await (const event of agent.stream(payload.prompt)) {
if (
event.type === 'modelStreamUpdateEvent' &&
event.event?.type === 'modelContentBlockDeltaEvent' &&
Expand Down Expand Up @@ -8072,6 +8161,11 @@ defines an HTTP app that streams tokens using the Vercel AI SDK's \`streamText\`

\`model/load.ts\` instantiates your chosen model provider.

## Input Validation

The generated Zod request schema keeps plain prompts typed as strings before forwarding them to the agent framework.
Retain this validation when extending the request shape, and pass only prompt text to the agent.

## Environment Variables

| Variable | Required | Description |
Expand Down Expand Up @@ -8126,10 +8220,15 @@ Thumbs.db
exports[`Assets Directory Snapshots > TypeScript assets > typescript/typescript/http/vercelai/base/main.ts should match snapshot 1`] = `
"import { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime';
import { streamText, type ModelMessage } from 'ai';
import { z } from 'zod';
import { loadModel } from './model/load.js';

const SYSTEM_PROMPT = \`You are a helpful assistant.\`;

const requestSchema = z.object({
prompt: z.string().default(''),
});

const HISTORY_LIMIT = 128;

// Keeps one message history per sessionId so each session remembers its own
Expand Down Expand Up @@ -8158,10 +8257,11 @@ function getHistory(sessionId: string): ModelMessage[] {

const app = new BedrockAgentCoreApp({
invocationHandler: {
async *process(payload: any, context: any) {
requestSchema,
async *process(payload, context) {
const sessionId = context?.sessionId ?? 'default-session';
const history = getHistory(sessionId);
const userMessage: ModelMessage = { role: 'user', content: payload.prompt ?? '' };
const userMessage: ModelMessage = { role: 'user', content: payload.prompt };

const model = await loadModel();
const result = streamText({
Expand Down
44 changes: 44 additions & 0 deletions src/assets/__tests__/input-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';

const ASSETS_DIR = resolve(__dirname, '..');

const PYTHON_HTTP_ENTRYPOINTS = [
'python/http/autogen/base/main.py',
'python/http/googleadk/base/main.py',
'python/http/langchain_langgraph/base/main.py',
'python/http/openaiagents/base/main.py',
'python/http/strands/base/main.py',
];

const TYPESCRIPT_HTTP_ENTRYPOINTS = [
'typescript/http/strands/base/main.ts',
'typescript/http/vercelai/base/main.ts',
];

describe('HTTP agent template input validation', () => {
it.each(PYTHON_HTTP_ENTRYPOINTS)('%s rejects non-string prompts', templatePath => {
const template = readFileSync(resolve(ASSETS_DIR, templatePath), 'utf8');

expect(template).toContain('if not isinstance(prompt, str):');
expect(template).toContain('raise ValueError("prompt must be a string")');
});

it.each(TYPESCRIPT_HTTP_ENTRYPOINTS)('%s validates prompts with Zod', templatePath => {
const template = readFileSync(resolve(ASSETS_DIR, templatePath), 'utf8');

expect(template).toContain("prompt: z.string().default('')");
expect(template).toContain('requestSchema,');
});

it('strips toolUse blocks from the Python Strands message-history tail', () => {
const template = readFileSync(resolve(ASSETS_DIR, 'python/http/strands/base/main.py'), 'utf8');

expect(template).toContain('def strip_trailing_tool_use(messages: Any) -> list[dict]:');
expect(template).toContain('while messages:');
expect(template).toContain('content = [block for block in original_content if "toolUse" not in block]');
expect(template).toContain('messages.pop()');
expect(template).toContain('return strip_trailing_tool_use(payload["messages"])');
});
});
2 changes: 2 additions & 0 deletions src/assets/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Tags defined in `agentcore.json` flow through to deployed CloudFormation resourc
`agentcore validate` to check.
4. **Resource Removal:** Use `agentcore remove` to remove resources. Run `agentcore deploy` after removal to tear down
deployed infrastructure.
5. **Invocation Input:** Validate runtime payloads and require text prompts to be strings. If a Strands app accepts a
caller-supplied message history, normalize the history tail with `strip_trailing_tool_use()` before invocation.

## Directory Structure

Expand Down
5 changes: 5 additions & 0 deletions src/assets/python/http/autogen/base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ file defines a Starlette ASGI app with the AutoGen framework running within.

`model/load.py` instantiates your chosen model provider.

## Input Validation

Validate invocation input before forwarding it to the agent framework. Keep plain prompts typed as strings and pass
only prompt text to the agent.

## Environment Variables

| Variable | Required | Description |
Expand Down
2 changes: 2 additions & 0 deletions src/assets/python/http/autogen/base/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ async def invoke(payload, context):

# Process the user prompt
prompt = payload.get("prompt", "What can you help me with?")
if not isinstance(prompt, str):
raise ValueError("prompt must be a string")
session_id = getattr(context, "session_id", "default-session")

# Reuse the per-session agent (preserves conversation history)
Expand Down
Loading
Loading