From cd17ab368f847a5fe3dec3ffafdd9566c21565ce Mon Sep 17 00:00:00 2001 From: Doug Perkes Date: Fri, 17 Jul 2026 09:22:10 -0500 Subject: [PATCH] New serverless pattern - sam-dotnet-responsestreaming-bedrock-agent --- .../.gitignore | 16 ++ .../README.md | 214 ++++++++++++++++++ .../events/plan-trip.json | 31 +++ ...otnet-responsestreaming-bedrock-agent.json | 64 ++++++ .../src/Models.cs | 17 ++ .../src/Program.cs | 54 +++++ .../src/RequestParser.cs | 31 +++ .../src/ResponseFormatter.cs | 55 +++++ .../src/ToolDefinitions.cs | 54 +++++ .../src/TripPlannerAgent.cs | 152 +++++++++++++ .../src/TripPlannerStreaming.csproj | 19 ++ .../template.yaml | 92 ++++++++ 12 files changed, 799 insertions(+) create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/.gitignore create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/README.md create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/events/plan-trip.json create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/sam-dotnet-responsestreaming-bedrock-agent.json create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/src/Models.cs create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/src/Program.cs create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/src/RequestParser.cs create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/src/ResponseFormatter.cs create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/src/ToolDefinitions.cs create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/src/TripPlannerAgent.cs create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/src/TripPlannerStreaming.csproj create mode 100644 sam-dotnet-responsestreaming-bedrock-agent/template.yaml diff --git a/sam-dotnet-responsestreaming-bedrock-agent/.gitignore b/sam-dotnet-responsestreaming-bedrock-agent/.gitignore new file mode 100644 index 000000000..eb9697107 --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/.gitignore @@ -0,0 +1,16 @@ +## .NET +bin/ +obj/ +*.user +*.suo +*.userosscache +*.sln.docstates + +## SAM +.aws-sam/ +samconfig.toml + +## IDE +.idea/ +.vs/ +*.swp diff --git a/sam-dotnet-responsestreaming-bedrock-agent/README.md b/sam-dotnet-responsestreaming-bedrock-agent/README.md new file mode 100644 index 000000000..26e19d5ee --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/README.md @@ -0,0 +1,214 @@ +# AWS Lambda Response Streaming with Bedrock Agent — AI Trip Planner (.NET) + +This sample demonstrates an **AI agent** built with Amazon Bedrock (Claude Sonnet) **tool use** combined with **Lambda Response Streaming through API Gateway**. The agent uses a tool-calling loop to build a day-by-day trip itinerary, streaming each day's plan to the client as it's generated. + +## Architecture + +``` +┌──────────────┐ ┌──────────────────┐ ┌────────────────────┐ ┌─────────────┐ +│ Client │ │ API Gateway │ │ Lambda Function │ │ Amazon │ +│ (curl) │──GET─▶│ REST API │──────▶│ (Agent Loop) │◀─────▶│ Bedrock │ +│ │◀─stream│ ResponseTransfer │◀stream│ │ │ (Claude) │ +│ │ │ Mode: STREAM │ │ Day 1... Day 2... │ │ │ +└──────────────┘ └──────────────────┘ └────────────────────┘ └─────────────┘ + │ + Tool Use Loop: + 1. Model calls add_day_plan tool + 2. Lambda formats & streams day to client + 3. Lambda returns tool result to model + 4. Repeat until all days planned +``` + +## What It Demonstrates + +- **Agentic tool use** — The model calls an `add_day_plan` tool for each day, driving a structured multi-turn loop. The Lambda orchestrates the conversation and decides when the agent is done. +- **Streaming structured output** — Each day's itinerary is streamed to the client as soon as the model generates it, giving progressive feedback during a multi-step agent workflow. +- **API Gateway response streaming** — Uses `ResponseTransferMode: STREAM` so the client sees each day appear incrementally over the connection. +- **No external dependencies** — The agent relies only on Claude Sonnet's knowledge. No databases, APIs, or third-party services needed. +- **Practical AI pattern** — Represents a real-world agentic pattern where a model drives a structured workflow through tool calling. + +## Project Structure + +``` +├── template.yaml # SAM template (API Gateway + Lambda + Bedrock IAM) +├── events/ +│ └── plan-trip.json # Sample API Gateway proxy event +└── src/ + ├── Program.cs # Agent loop: Bedrock tool use → stream formatted output + └── TripPlannerStreaming.csproj +``` + +--- + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) +- AWS account with credentials configured (`aws configure`) +- **Amazon Bedrock model access** — Enable access to Claude Sonnet in the [Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess) + +--- + +## Build & Deploy + +### Build + +```bash +sam build +``` + +### Deploy + +```bash +sam deploy --guided +``` + +Follow the prompts to configure stack name, region, and confirm IAM role creation. On subsequent deploys: + +```bash +sam deploy +``` + +--- + +## Testing + +### 1. Get the API endpoint + +```bash +API_URL=$(aws cloudformation describe-stacks \ + --stack-name sam-dotnet-responsestreaming-bedrock-agent \ + --query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \ + --output text) +``` + +### 2. Plan a trip with defaults (Tokyo, 3 days) + +```bash +curl --no-buffer "$API_URL" +``` + +You'll see each day's plan stream in progressively: + +``` +✈️ Trip Planner — Tokyo, Japan (3 days) + Interests: culture, food, and nature +════════════════════════════════════════════════════════════════ + +📅 Day 1: Traditional Tokyo — Temples & Tsukiji + 🌅 Morning: Start at Senso-ji temple in Asakusa... + ☀️ Afternoon: Explore the Tsukiji Outer Market... + 🌙 Evening: Head to Shinjuku for yakitori... + 💡 Tips: Get a Suica card at the airport... + +📅 Day 2: Modern Tokyo — Shibuya & Harajuku + ... + +📅 Day 3: Nature & Serenity — Day Trip to Nikko + ... + +════════════════════════════════════════════════════════════════ + +Overall trip tips: ... + +✅ Happy travels! +``` + +### 3. Custom trips + +```bash +# Barcelona for 5 days, food & architecture +curl --no-buffer "$API_URL?destination=Barcelona,+Spain&days=5&interests=architecture,+tapas,+and+beach" + +# Iceland adventure +curl --no-buffer "$API_URL?destination=Iceland&days=7&interests=northern+lights,+glaciers,+and+hot+springs" + +# Quick weekend in Paris +curl --no-buffer "$API_URL?destination=Paris,+France&days=2&interests=art,+pastries,+and+wine" +``` + +### Parameters + +| Parameter | Default | Range | Description | +|---------------|----------------------|--------|----------------------------------------| +| `destination` | Tokyo, Japan | — | Where to travel | +| `days` | 3 | 1–14 | Number of days for the trip | +| `interests` | culture, food, and nature | — | Traveler's interests (comma-separated) | + +--- + +## How the Agent Loop Works + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Agent Loop (Lambda) │ +│ │ +│ 1. Send user request + tool definition to Bedrock │ +│ 2. Model responds with add_day_plan tool call │ +│ 3. Lambda formats the day plan and streams it to client │ +│ 4. Lambda returns tool result ("Day N added") to model │ +│ 5. Model calls tool again for next day (or ends turn) │ +│ 6. Repeat until model provides final summary (no tool call) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +```csharp +while (continueLoop) +{ + var response = await bedrockClient.ConverseAsync(converseRequest); + + // If model used the add_day_plan tool, format and stream it + foreach (var toolUse in toolUseBlocks) + { + // Extract structured day plan from tool input + var title = input.GetProperty("title").GetString(); + // ... format and write to response stream ... + await writer.FlushAsync(); // Stream to client immediately + + // Return result so model continues to next day + toolResults.Add(new ContentBlock { ToolResult = ... }); + } + + // If no tool use, model is done — stream the summary + if (response.StopReason == StopReason.EndTurn) + continueLoop = false; +} +``` + +Key points: +- **Tool use drives structure** — Instead of free-form text, the model outputs structured day plans via the `add_day_plan` tool, ensuring consistent formatting. +- **Progressive streaming** — Each day appears as soon as the model generates it. The client doesn't wait for the entire itinerary. +- **Multi-turn conversation** — The agent loop sends tool results back to the model, maintaining conversation context across turns. +- **Graceful termination** — When the model stops calling tools and provides text directly, the loop ends and streams the summary. + +--- + +## Cleanup + +```bash +sam delete +``` + +--- + +## Useful Commands + +| Command | Description | +|---------|-------------| +| `sam build` | Build the Lambda function | +| `sam deploy --guided` | Deploy with interactive prompts | +| `sam deploy` | Deploy with saved config | +| `sam logs --tail` | Tail CloudWatch logs | +| `sam delete` | Tear down the stack | +| `dotnet build src/` | Build locally without SAM | + +--- + +## References + +- [Building responsive APIs with API Gateway response streaming](https://aws.amazon.com/blogs/compute/building-responsive-apis-with-amazon-api-gateway-response-streaming/) +- [Bedrock Converse API with tool use](https://docs.aws.amazon.com/bedrock/latest/userguide/tool-use.html) +- [Lambda Response Streaming (.NET SDK PR)](https://github.com/aws/aws-lambda-dotnet/pull/2288) +- [Claude Sonnet on Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-5.html) +- [AWS SAM Developer Guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/) diff --git a/sam-dotnet-responsestreaming-bedrock-agent/events/plan-trip.json b/sam-dotnet-responsestreaming-bedrock-agent/events/plan-trip.json new file mode 100644 index 000000000..35fedb28c --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/events/plan-trip.json @@ -0,0 +1,31 @@ +{ + "resource": "/plan", + "path": "/plan", + "httpMethod": "GET", + "headers": { + "Accept": "*/*", + "Host": "abc123.execute-api.us-east-1.amazonaws.com" + }, + "queryStringParameters": { + "destination": "Barcelona, Spain", + "days": "4", + "interests": "architecture, tapas, and beach" + }, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "resourceId": "abc123", + "resourcePath": "/plan", + "httpMethod": "GET", + "requestId": "test-request-id", + "accountId": "123456789012", + "stage": "prod", + "identity": { + "sourceIp": "127.0.0.1", + "userAgent": "curl/8.0" + }, + "apiId": "abc123" + }, + "body": null, + "isBase64Encoded": false +} diff --git a/sam-dotnet-responsestreaming-bedrock-agent/sam-dotnet-responsestreaming-bedrock-agent.json b/sam-dotnet-responsestreaming-bedrock-agent/sam-dotnet-responsestreaming-bedrock-agent.json new file mode 100644 index 000000000..422ea033c --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/sam-dotnet-responsestreaming-bedrock-agent.json @@ -0,0 +1,64 @@ +{ + "title": "Lambda Response Streaming with Bedrock Agent Tool Use (.NET)", + "description": "An AI trip planner agent using Claude Sonnet tool calling that streams a day-by-day itinerary progressively through API Gateway response streaming.", + "language": ".NET", + "level": "300", + "framework": "SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern deploys a Lambda function that runs an agentic tool-use loop with Amazon Bedrock (Claude Sonnet). The model calls an add_day_plan tool for each day of a trip itinerary, and the Lambda streams each day's plan to the client as it's generated.", + "The agent loop continues until all days are planned, giving the user progressive feedback as structured output appears. API Gateway response streaming ensures the client sees each day incrementally over the connection.", + "This demonstrates a practical AI agent pattern where a model drives a structured workflow through tool calling, with no external databases or APIs required." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sam-dotnet-responsestreaming-bedrock-agent", + "templateURL": "serverless-patterns/sam-dotnet-responsestreaming-bedrock-agent", + "projectFolder": "sam-dotnet-responsestreaming-bedrock-agent", + "templateFile": "template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "Lambda Response Streaming Documentation", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/response-streaming.html" + }, + { + "text": "Amazon Bedrock Tool Use (Function Calling)", + "link": "https://docs.aws.amazon.com/bedrock/latest/userguide/tool-use.html" + }, + { + "text": "Amazon Bedrock Converse API", + "link": "https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-call.html" + } + ] + }, + "deploy": { + "text": [ + "sam build", + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: sam delete." + ] + }, + "authors": [ + { + "name": "Doug Perkes", + "image": "", + "bio": "Senior Solutions Architect at AWS, focused on .NET and serverless.", + "linkedin": "dougperkes", + "twitter": "" + } + ] +} diff --git a/sam-dotnet-responsestreaming-bedrock-agent/src/Models.cs b/sam-dotnet-responsestreaming-bedrock-agent/src/Models.cs new file mode 100644 index 000000000..f482b8319 --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/src/Models.cs @@ -0,0 +1,17 @@ +namespace TripPlannerStreaming; + +/// +/// Parsed trip planning request parameters. +/// +public record TripRequest(string Destination, int Days, string Interests) +{ + public const string DefaultDestination = "Tokyo, Japan"; + public const int DefaultDays = 3; + public const string DefaultInterests = "culture, food, and nature"; + public const int MaxDays = 14; +} + +/// +/// A single day's plan extracted from the agent's tool call. +/// +public record DayPlan(int DayNumber, string Title, string Morning, string Afternoon, string Evening, string Tips); diff --git a/sam-dotnet-responsestreaming-bedrock-agent/src/Program.cs b/sam-dotnet-responsestreaming-bedrock-agent/src/Program.cs new file mode 100644 index 000000000..f0e6cc944 --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/src/Program.cs @@ -0,0 +1,54 @@ +using System.Net; +using Amazon.BedrockRuntime; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Core; +using Amazon.Lambda.Core.ResponseStreaming; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; +using TripPlannerStreaming; + +#pragma warning disable CA2252 // Opt in to preview features (response streaming) + +var bedrockClient = new AmazonBedrockRuntimeClient(); +var agent = new TripPlannerAgent(bedrockClient); + +var handler = async (APIGatewayProxyRequest request, ILambdaContext context) => +{ + var trip = RequestParser.Parse(request); + + var prelude = new HttpResponseStreamPrelude + { + StatusCode = HttpStatusCode.OK, + Headers = + { + { "Content-Type", "text/plain; charset=utf-8" }, + { "Cache-Control", "no-cache" }, + { "X-Content-Type-Options", "nosniff" } + } + }; + + using var responseStream = LambdaResponseStreamFactory.CreateHttpStream(prelude); + using var writer = new StreamWriter(responseStream) { AutoFlush = false }; + var formatter = new ResponseFormatter(writer); + + await formatter.WriteHeaderAsync(trip); + + try + { + await agent.RunAsync( + trip, + onDayPlan: formatter.WriteDayPlanAsync, + onSummary: formatter.WriteSummaryAsync); + + await formatter.WriteFooterAsync(); + } + catch (Exception ex) + { + context.Logger.LogError($"Error invoking Bedrock: {ex.Message}"); + await formatter.WriteErrorAsync(ex.Message); + } +}; + +await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); diff --git a/sam-dotnet-responsestreaming-bedrock-agent/src/RequestParser.cs b/sam-dotnet-responsestreaming-bedrock-agent/src/RequestParser.cs new file mode 100644 index 000000000..9c12e491c --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/src/RequestParser.cs @@ -0,0 +1,31 @@ +using Amazon.Lambda.APIGatewayEvents; + +namespace TripPlannerStreaming; + +/// +/// Parses trip planning parameters from an API Gateway request. +/// +public static class RequestParser +{ + public static TripRequest Parse(APIGatewayProxyRequest request) + { + var destination = TripRequest.DefaultDestination; + var days = TripRequest.DefaultDays; + var interests = TripRequest.DefaultInterests; + + if (request.QueryStringParameters is null) + return new TripRequest(destination, days, interests); + + if (request.QueryStringParameters.TryGetValue("destination", out var d) && !string.IsNullOrWhiteSpace(d)) + destination = d; + + if (request.QueryStringParameters.TryGetValue("days", out var n) && + int.TryParse(n, out var parsedDays) && parsedDays >= 1 && parsedDays <= TripRequest.MaxDays) + days = parsedDays; + + if (request.QueryStringParameters.TryGetValue("interests", out var i) && !string.IsNullOrWhiteSpace(i)) + interests = i; + + return new TripRequest(destination, days, interests); + } +} diff --git a/sam-dotnet-responsestreaming-bedrock-agent/src/ResponseFormatter.cs b/sam-dotnet-responsestreaming-bedrock-agent/src/ResponseFormatter.cs new file mode 100644 index 000000000..a37b4d08b --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/src/ResponseFormatter.cs @@ -0,0 +1,55 @@ +namespace TripPlannerStreaming; + +/// +/// Formats and streams trip planner output to a StreamWriter. +/// +public class ResponseFormatter +{ + private readonly StreamWriter _writer; + + public ResponseFormatter(StreamWriter writer) + { + _writer = writer; + } + + public async Task WriteHeaderAsync(TripRequest trip) + { + await _writer.WriteLineAsync($"✈️ Trip Planner — {trip.Destination} ({trip.Days} days)"); + await _writer.WriteLineAsync($" Interests: {trip.Interests}"); + await _writer.WriteLineAsync(new string('═', 60)); + await _writer.WriteLineAsync(); + await _writer.FlushAsync(); + } + + public async Task WriteDayPlanAsync(DayPlan day) + { + await _writer.WriteLineAsync($"📅 Day {day.DayNumber}: {day.Title}"); + await _writer.WriteLineAsync($" 🌅 Morning: {day.Morning}"); + await _writer.WriteLineAsync($" ☀️ Afternoon: {day.Afternoon}"); + await _writer.WriteLineAsync($" 🌙 Evening: {day.Evening}"); + await _writer.WriteLineAsync($" 💡 Tips: {day.Tips}"); + await _writer.WriteLineAsync(); + await _writer.FlushAsync(); + } + + public async Task WriteSummaryAsync(string summary) + { + await _writer.WriteLineAsync(new string('═', 60)); + await _writer.WriteLineAsync(); + await _writer.WriteLineAsync(summary); + await _writer.FlushAsync(); + } + + public async Task WriteFooterAsync() + { + await _writer.WriteLineAsync(); + await _writer.WriteLineAsync("✅ Happy travels!"); + await _writer.FlushAsync(); + } + + public async Task WriteErrorAsync(string message) + { + await _writer.WriteLineAsync($"\n\n[Error generating trip plan: {message}]"); + await _writer.FlushAsync(); + } +} diff --git a/sam-dotnet-responsestreaming-bedrock-agent/src/ToolDefinitions.cs b/sam-dotnet-responsestreaming-bedrock-agent/src/ToolDefinitions.cs new file mode 100644 index 000000000..ec57bb04d --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/src/ToolDefinitions.cs @@ -0,0 +1,54 @@ +using Amazon.BedrockRuntime.Model; +using Document = Amazon.Runtime.Documents.Document; + +namespace TripPlannerStreaming; + +/// +/// Defines the tools available to the trip planner agent. +/// +public static class ToolDefinitions +{ + public const string AddDayPlanToolName = "add_day_plan"; + + public static Tool AddDayPlanTool { get; } = new() + { + ToolSpec = new ToolSpecification + { + Name = AddDayPlanToolName, + Description = "Add a single day's plan to the trip itinerary. Call this once for each day of the trip.", + InputSchema = new ToolInputSchema + { + Json = Document.FromObject(new + { + type = "object", + properties = new + { + day_number = new { type = "integer", description = "The day number (1-indexed)" }, + title = new { type = "string", description = "A short title for the day (e.g., 'Historic Old Town & River Cruise')" }, + morning = new { type = "string", description = "Morning activities and recommendations (2-3 sentences)" }, + afternoon = new { type = "string", description = "Afternoon activities and recommendations (2-3 sentences)" }, + evening = new { type = "string", description = "Evening activities, dinner recommendations (2-3 sentences)" }, + tips = new { type = "string", description = "Practical tips for this day (transport, tickets, what to pack)" } + }, + required = new[] { "day_number", "title", "morning", "afternoon", "evening", "tips" } + }) + } + } + }; + + /// + /// Extracts a DayPlan from the tool use input Document. + /// + public static DayPlan ParseDayPlan(ToolUseBlock toolUse) + { + var input = toolUse.Input.AsDictionary(); + return new DayPlan( + DayNumber: input["day_number"].AsInt(), + Title: input["title"].AsString(), + Morning: input["morning"].AsString(), + Afternoon: input["afternoon"].AsString(), + Evening: input["evening"].AsString(), + Tips: input["tips"].AsString() + ); + } +} diff --git a/sam-dotnet-responsestreaming-bedrock-agent/src/TripPlannerAgent.cs b/sam-dotnet-responsestreaming-bedrock-agent/src/TripPlannerAgent.cs new file mode 100644 index 000000000..67201c372 --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/src/TripPlannerAgent.cs @@ -0,0 +1,152 @@ +using Amazon.BedrockRuntime; +using Amazon.BedrockRuntime.Model; + +namespace TripPlannerStreaming; + +/// +/// Orchestrates the multi-turn conversation with Bedrock to produce a trip itinerary. +/// Calls the provided callback each time a day plan is generated, enabling streaming. +/// +public class TripPlannerAgent +{ + private const string ModelId = "us.anthropic.claude-sonnet-5"; + private const int MaxTokens = 4096; + private const int MaxTurns = 20; // Safety limit to prevent infinite loops + + private readonly IAmazonBedrockRuntime _bedrockClient; + + public TripPlannerAgent(IAmazonBedrockRuntime bedrockClient) + { + _bedrockClient = bedrockClient; + } + + /// + /// Runs the agent loop. Invokes onDayPlan for each day generated and onSummary for the final text. + /// + public async Task RunAsync(TripRequest trip, Func onDayPlan, Func onSummary) + { + var systemPrompt = BuildSystemPrompt(trip); + var messages = new List + { + new() + { + Role = ConversationRole.User, + Content = [new ContentBlock { Text = BuildUserMessage(trip) }] + } + }; + + for (var turn = 0; turn < MaxTurns; turn++) + { + var response = await CallModelAsync(systemPrompt, messages); + var assistantContent = response.Output.Message.Content; + + messages.Add(new Message + { + Role = ConversationRole.Assistant, + Content = assistantContent + }); + + var toolUseBlocks = assistantContent + .Where(c => c.ToolUse is not null) + .Select(c => c.ToolUse) + .ToList(); + + if (toolUseBlocks.Count > 0) + { + var toolResults = await ProcessToolCallsAsync(toolUseBlocks, onDayPlan); + messages.Add(new Message + { + Role = ConversationRole.User, + Content = toolResults + }); + } + else + { + // No tool calls — emit any final text as the summary + await EmitSummaryAsync(assistantContent, onSummary); + break; + } + + if (response.StopReason == StopReason.End_turn) + { + await EmitSummaryAsync(assistantContent, onSummary); + break; + } + } + } + + private async Task CallModelAsync(string systemPrompt, List messages) + { + var request = new ConverseRequest + { + ModelId = ModelId, + System = [new SystemContentBlock { Text = systemPrompt }], + Messages = messages, + ToolConfig = new ToolConfiguration + { + Tools = [ToolDefinitions.AddDayPlanTool] + }, + InferenceConfig = new InferenceConfiguration + { + MaxTokens = MaxTokens + } + }; + + return await _bedrockClient.ConverseAsync(request); + } + + private static async Task> ProcessToolCallsAsync( + List toolUseBlocks, Func onDayPlan) + { + var toolResults = new List(); + + foreach (var toolUse in toolUseBlocks) + { + if (toolUse.Name == ToolDefinitions.AddDayPlanToolName) + { + var dayPlan = ToolDefinitions.ParseDayPlan(toolUse); + await onDayPlan(dayPlan); + + toolResults.Add(new ContentBlock + { + ToolResult = new ToolResultBlock + { + ToolUseId = toolUse.ToolUseId, + Content = [new ToolResultContentBlock + { + Text = $"Day {dayPlan.DayNumber} added to itinerary successfully." + }] + } + }); + } + } + + return toolResults; + } + + private static async Task EmitSummaryAsync(List content, Func onSummary) + { + var textBlocks = content + .Where(c => c.Text is not null) + .Select(c => c.Text) + .ToList(); + + foreach (var text in textBlocks) + { + await onSummary(text!); + } + } + + private static string BuildSystemPrompt(TripRequest trip) => $""" + You are an expert travel planner. Create a detailed {trip.Days}-day itinerary for {trip.Destination}. + The traveler is interested in: {trip.Interests}. + + You MUST use the add_day_plan tool exactly {trip.Days} times, once for each day. + Plan the days in order from day 1 to day {trip.Days}. + Make each day distinct and well-paced. Include specific place names and local recommendations. + After adding all days, provide a brief closing summary with overall trip tips. + """; + + private static string BuildUserMessage(TripRequest trip) => + $"Plan my {trip.Days}-day trip to {trip.Destination}. I'm interested in {trip.Interests}."; +} diff --git a/sam-dotnet-responsestreaming-bedrock-agent/src/TripPlannerStreaming.csproj b/sam-dotnet-responsestreaming-bedrock-agent/src/TripPlannerStreaming.csproj new file mode 100644 index 000000000..52fcd71aa --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/src/TripPlannerStreaming.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + Exe + TripPlannerStreaming + Lambda + true + true + + + + + + + + + diff --git a/sam-dotnet-responsestreaming-bedrock-agent/template.yaml b/sam-dotnet-responsestreaming-bedrock-agent/template.yaml new file mode 100644 index 000000000..49a40b0c7 --- /dev/null +++ b/sam-dotnet-responsestreaming-bedrock-agent/template.yaml @@ -0,0 +1,92 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + AI Trip Planner agent using Amazon Bedrock (Claude Sonnet) with Lambda Response Streaming + through API Gateway. The agent uses tool calling to build a day-by-day itinerary, + streaming each day's plan to the client as it's generated. + +Globals: + Function: + Timeout: 120 + MemorySize: 512 + Runtime: dotnet10 + Architectures: + - x86_64 + +Resources: + # ───────────────────────────────────────────────────────────────────── + # REST API with response streaming + # ───────────────────────────────────────────────────────────────────── + TripApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: trip-planner-streaming-api + Description: REST API with response streaming for AI trip planner + + TripResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref TripApi + ParentId: !GetAtt TripApi.RootResourceId + PathPart: plan + + TripMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref TripApi + ResourceId: !Ref TripResource + HttpMethod: GET + AuthorizationType: NONE + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + ResponseTransferMode: STREAM + TimeoutInMillis: 120000 + Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2021-11-15/functions/${TripPlannerFunction.Arn}/response-streaming-invocations + + ApiDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: TripMethod + Properties: + RestApiId: !Ref TripApi + + ApiStage: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: !Ref TripApi + DeploymentId: !Ref ApiDeployment + StageName: prod + + # ───────────────────────────────────────────────────────────────────── + # Lambda Function — AI trip planner agent with tool use + # ───────────────────────────────────────────────────────────────────── + TripPlannerFunction: + Type: AWS::Serverless::Function + Properties: + Handler: TripPlannerStreaming + CodeUri: ./src/ + Description: AI trip planner agent that streams day-by-day itineraries via Bedrock tool use + Policies: + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModelWithResponseStream + - bedrock:InvokeModel + Resource: "*" + + # Permission for API Gateway to invoke the function via response streaming + TripPlannerFunctionPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !GetAtt TripPlannerFunction.Arn + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${TripApi}/* + +Outputs: + ApiEndpoint: + Description: API Gateway endpoint URL for the trip planner + Value: !Sub "https://${TripApi}.execute-api.${AWS::Region}.amazonaws.com/prod/plan" + TripPlannerFunctionArn: + Description: Trip planner Lambda function ARN + Value: !GetAtt TripPlannerFunction.Arn