Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
616117c
perf: Seal eligible classes to enable JIT devirtualization
Im5tu Mar 12, 2026
51f120f
perf: Zero-allocation response logging in AwsServiceClient.SendAsync
Im5tu Mar 12, 2026
7373dd0
perf: Add collection capacity hints to reduce resizing allocations
Im5tu Mar 12, 2026
1f213c6
refactor: Consolidate ProcessAwsErrorHeaders into base AwsServiceClient
Im5tu Mar 12, 2026
0755166
perf: Port pooled response reading to XML and SNS clients
Im5tu Mar 12, 2026
591461f
perf: Avoid byte[] allocation in ApiErrorReader
Im5tu Mar 12, 2026
11269ca
Fixed benchmark
Im5tu Mar 12, 2026
ad78275
perf: Add shared Throw helper class for DoesNotReturn throw extraction
Im5tu Mar 12, 2026
9576af1
perf: Extract LogScope indexer throws into Throw.IndexOutOfRange helper
Im5tu Mar 12, 2026
a009a21
perf: Use Throw.JsonException in Bedrock JSON converters
Im5tu Mar 12, 2026
51752d7
perf: Add [DoesNotReturn] to MissingAttributeException.Throw methods
Im5tu Mar 12, 2026
c6c4335
perf: Extract DynamoPaginationException throws into [DoesNotReturn] h…
Im5tu Mar 12, 2026
9b0c7e8
perf: Use Throw.InvalidOperation in AwsServiceClient.ReadResponseByte…
Im5tu Mar 12, 2026
393c948
perf: Replace RequestSigner private throw helpers with shared Throw c…
Im5tu Mar 12, 2026
2b56716
perf: Update generator templates to emit Throw helpers in generated code
Im5tu Mar 12, 2026
63448cf
fix: Make Throw class public for generated code and fix switch fall-t…
Im5tu Mar 12, 2026
03d9e14
test: Update generator test assertion to match Throw helper pattern
Im5tu Mar 12, 2026
8d1fbcd
perf: Guard BeginScope calls with log-level check in AwsServiceClient
Im5tu Mar 12, 2026
366d74d
perf: Cache MediaTypeHeaderValue in XmlAwsServiceClient and SnsServic…
Im5tu Mar 12, 2026
882da08
perf: Cache common HTTP status code strings in AwsServiceClient
Im5tu Mar 12, 2026
5c91952
refactor: Extract AwsServiceClient log scope key names to constants
Im5tu Mar 12, 2026
2844f0d
refactor: Extract ResponseHeaders header name constants
Im5tu Mar 12, 2026
1f264b4
refactor: Extract RequestSigner header name constants
Im5tu Mar 12, 2026
d614c87
perf: Guard amzHeaders Dictionary and BeginScope with log-level check
Im5tu Mar 12, 2026
eba58c2
perf: Replace FirstOrDefault with manual enumeration in ResponseHeaders
Im5tu Mar 12, 2026
a1796ba
perf: Use span-based TrimStart in IsJsonSerialized and IsXmlSerialized
Im5tu Mar 12, 2026
234fc25
perf: Remove ResponseHeaders class and use NonValidated header enumer…
Im5tu Mar 13, 2026
9c13b92
perf: Adopt LoggerMessage source generator for service clients and co…
Im5tu Mar 13, 2026
13c3348
perf: Replace LINQ with manual loops to reduce allocations
Im5tu Mar 13, 2026
9e62f14
perf: Add specialized DynamoRecord response readers with property nam…
Im5tu Mar 13, 2026
7a162a3
perf: Suppress boxing warning for error-path Dictionary in DynamoServ…
Im5tu Mar 13, 2026
c7549ad
Minor updates
Im5tu Mar 17, 2026
a355ef7
Fixed unwrapping
Im5tu Mar 17, 2026
0ad0379
Fixed property name cache ctor
Im5tu Mar 17, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public DynamoConversationStore(IDynamoClient dynamoClient, DynamoConversationSto
var conversationId = Guid.CreateVersion7().ToString("N");
var now = DateTimeOffset.UtcNow;

var item = new Dictionary<string, AttributeValue>
var item = new Dictionary<string, AttributeValue>(8)
{
[_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId),
[_configuration.SortKeyName] = _configuration.ConversationSkValue,
Expand Down Expand Up @@ -111,7 +111,7 @@ public DynamoConversationStore(IDynamoClient dynamoClient, DynamoConversationSto
var request = new GetItemRequest
{
TableName = _configuration.TableName,
Key = new Dictionary<string, AttributeValue>
Key = new Dictionary<string, AttributeValue>(2)
{
[_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId),
[_configuration.SortKeyName] = _configuration.ConversationSkValue
Expand Down Expand Up @@ -145,7 +145,7 @@ public async Task<ErrorOr<ConversationWithMessages>> GetConversationWithMessages
{
TableName = _configuration.TableName,
KeyConditionExpression = $"{_configuration.PartitionKeyName} = :pk AND begins_with({_configuration.SortKeyName}, :skPrefix)",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
ExpressionAttributeValues = new Dictionary<string, AttributeValue>(2)
{
[":pk"] = _configuration.ConversationPkFormat(conversationId),
[":skPrefix"] = _configuration.MessageSkPrefix
Expand Down Expand Up @@ -212,18 +212,18 @@ public Task<ErrorOr<IReadOnlyList<ConversationMessage>>> AddMessagesAsync(
IEnumerable<(ConversationRole Role, Message Message, TokenUsage? TokenUsage)> messages,
CancellationToken ct)
{
return AddMessagesInternalAsync(
conversationId,
messages.Select(m => (m.Role, m.Message, m.TokenUsage, (IReadOnlyDictionary<string, IReadOnlyList<string>>?)null)),
ct);
var expanded = new List<(ConversationRole Role, Message Message, TokenUsage? TokenUsage, IReadOnlyDictionary<string, IReadOnlyList<string>>? ExtractedTags)>();
foreach (var m in messages)
expanded.Add((m.Role, m.Message, m.TokenUsage, null));
return AddMessagesInternalAsync(conversationId, expanded, ct);
}

private async Task<ErrorOr<IReadOnlyList<ConversationMessage>>> AddMessagesInternalAsync(
string conversationId,
IEnumerable<(ConversationRole Role, Message Message, TokenUsage? TokenUsage, IReadOnlyDictionary<string, IReadOnlyList<string>>? ExtractedTags)> messages,
CancellationToken ct)
{
var messageList = messages.ToList();
var messageList = new List<(ConversationRole Role, Message Message, TokenUsage? TokenUsage, IReadOnlyDictionary<string, IReadOnlyList<string>>? ExtractedTags)>(messages);
if (messageList.Count == 0)
return Error.Validation(ConversationErrorCodes.MessagesEmpty, "At least one message must be provided");

Expand Down Expand Up @@ -255,7 +255,7 @@ private async Task<ErrorOr<IReadOnlyList<ConversationMessage>>> AddMessagesInter
var sequenceNumber = startSequence + i;
var messageId = Guid.NewGuid().ToString("N");

var messageItem = new Dictionary<string, AttributeValue>
var messageItem = new Dictionary<string, AttributeValue>(12)
{
[_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId),
[_configuration.SortKeyName] = _configuration.MessageSkFormat(conversationId, sequenceNumber),
Expand Down Expand Up @@ -292,7 +292,7 @@ private async Task<ErrorOr<IReadOnlyList<ConversationMessage>>> AddMessagesInter
// Add extracted tags if present
if (extractedTags is not null && extractedTags.Count > 0)
{
var tagsMap = new Dictionary<string, AttributeValue>();
var tagsMap = new Dictionary<string, AttributeValue>(extractedTags.Count);
foreach (var kvp in extractedTags)
{
var tagValues = new List<AttributeValue>();
Expand Down Expand Up @@ -335,13 +335,13 @@ private async Task<ErrorOr<IReadOnlyList<ConversationMessage>>> AddMessagesInter
}

// Build update expression for conversation
var updateParts = new List<string>
var updateParts = new List<string>(5)
{
$"{MessageCountAttribute} = {MessageCountAttribute} + :msgCount",
$"{UpdatedAtAttribute} = :updatedAt"
};

var updateExpressionValues = new Dictionary<string, AttributeValue>
var updateExpressionValues = new Dictionary<string, AttributeValue>(6)
{
[":msgCount"] = AttributeValue.Number(messageList.Count.ToString()),
[":updatedAt"] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString())
Expand All @@ -363,7 +363,7 @@ private async Task<ErrorOr<IReadOnlyList<ConversationMessage>>> AddMessagesInter
Update = new TransactUpdateItem
{
TableName = _configuration.TableName,
Key = new Dictionary<string, AttributeValue>
Key = new Dictionary<string, AttributeValue>(2)
{
[_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId),
[_configuration.SortKeyName] = _configuration.ConversationSkValue
Expand Down Expand Up @@ -393,12 +393,12 @@ private async Task<ErrorOr<IReadOnlyList<ConversationMessage>>> AddMessagesInter
{
var now = DateTimeOffset.UtcNow;

var updateParts = new List<string> { $"{UpdatedAtAttribute} = :updatedAt" };
var expressionAttributeValues = new Dictionary<string, AttributeValue>
var updateParts = new List<string>(4) { $"{UpdatedAtAttribute} = :updatedAt" };
var expressionAttributeValues = new Dictionary<string, AttributeValue>(4)
{
[":updatedAt"] = AttributeValue.Number(now.ToUnixTimeSeconds().ToString())
};
var expressionAttributeNames = new Dictionary<string, string>();
var expressionAttributeNames = new Dictionary<string, string>(1);

if (metadata.Title is not null)
{
Expand All @@ -422,7 +422,7 @@ private async Task<ErrorOr<IReadOnlyList<ConversationMessage>>> AddMessagesInter
{
updateParts.Add($"#customData = :customData");
expressionAttributeNames["#customData"] = CustomDataAttribute;
var customDataMap = new Dictionary<string, AttributeValue>();
var customDataMap = new Dictionary<string, AttributeValue>(metadata.CustomData.Count);
foreach (var kvp in metadata.CustomData)
{
customDataMap[kvp.Key] = kvp.Value;
Expand All @@ -433,7 +433,7 @@ private async Task<ErrorOr<IReadOnlyList<ConversationMessage>>> AddMessagesInter
var updateRequest = new UpdateItemRequest
{
TableName = _configuration.TableName,
Key = new Dictionary<string, AttributeValue>
Key = new Dictionary<string, AttributeValue>(2)
{
[_configuration.PartitionKeyName] = _configuration.ConversationPkFormat(conversationId),
[_configuration.SortKeyName] = _configuration.ConversationSkValue
Expand Down Expand Up @@ -469,7 +469,7 @@ public async Task<ErrorOr<Deleted>> DeleteConversationAsync(string conversationI
{
TableName = _configuration.TableName,
KeyConditionExpression = $"{_configuration.PartitionKeyName} = :pk",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
ExpressionAttributeValues = new Dictionary<string, AttributeValue>(1)
{
[":pk"] = pk
}
Expand All @@ -486,7 +486,7 @@ public async Task<ErrorOr<Deleted>> DeleteConversationAsync(string conversationI

foreach (var item in queryResult.Value.Items)
{
allItems.Add(new Dictionary<string, AttributeValue>
allItems.Add(new Dictionary<string, AttributeValue>(2)
{
[_configuration.PartitionKeyName] = item[_configuration.PartitionKeyName]!.Value,
[_configuration.SortKeyName] = item[_configuration.SortKeyName]!.Value
Expand All @@ -503,15 +503,19 @@ public async Task<ErrorOr<Deleted>> DeleteConversationAsync(string conversationI
const int batchSize = 25;
for (var i = 0; i < allItems.Count; i += batchSize)
{
var batch = allItems.Skip(i).Take(batchSize).ToList();
var transactItems = batch.Select(key => new TransactWriteItem
var end = Math.Min(i + batchSize, allItems.Count);
var transactItems = new List<TransactWriteItem>(end - i);
for (var j = i; j < end; j++)
{
Delete = new TransactDeleteItem
transactItems.Add(new TransactWriteItem
{
TableName = _configuration.TableName,
Key = key
}
}).ToList();
Delete = new TransactDeleteItem
{
TableName = _configuration.TableName,
Key = allItems[j]
}
});
}

var transactRequest = new TransactWriteRequest
{
Expand Down Expand Up @@ -539,7 +543,7 @@ private static void SerializeMetadata(ConversationMetadata metadata, Dictionary<

if (metadata.CustomData.Count > 0)
{
var customDataMap = new Dictionary<string, AttributeValue>();
var customDataMap = new Dictionary<string, AttributeValue>(metadata.CustomData.Count);
foreach (var kvp in metadata.CustomData)
{
customDataMap[kvp.Key] = kvp.Value;
Expand Down Expand Up @@ -607,7 +611,7 @@ private static void SerializeMetadata(ConversationMetadata metadata, Dictionary<

if (record.TryGetStringSet(TagsAttribute, out var tags))
{
metadata.Tags = tags.ToList();
metadata.Tags = new List<string>(tags);
hasMetadata = true;
}

Expand Down Expand Up @@ -665,7 +669,7 @@ private ErrorOr<ConversationMessage> DeserializeMessage(DynamoRecord record)
IReadOnlyDictionary<string, IReadOnlyList<string>>? extractedTags = null;
if (record.TryGetMap(ExtractedTagsAttribute, out var tagsMap) && tagsMap is not null)
{
var tags = new Dictionary<string, IReadOnlyList<string>>();
var tags = new Dictionary<string, IReadOnlyList<string>>(tagsMap.Count);
foreach (var kvp in tagsMap)
{
if (kvp.Value.L is not null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace Goa.Clients.Bedrock.Conversation.Dynamo;
/// <summary>
/// Configuration for the DynamoDB-backed conversation store.
/// </summary>
public class DynamoConversationStoreConfiguration
public sealed class DynamoConversationStoreConfiguration
{
/// <summary>
/// The name of the DynamoDB table to store conversations.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public static ErrorOr<AttributeValue> Serialize(ContentBlock block)
{
if (block.Text is not null)
{
return AttributeValue.FromMap(new Dictionary<string, AttributeValue>
return AttributeValue.FromMap(new Dictionary<string, AttributeValue>(2)
{
[TypeAttribute] = TextType,
["text"] = block.Text
Expand All @@ -39,7 +39,7 @@ public static ErrorOr<AttributeValue> Serialize(ContentBlock block)
if (validation.IsError)
return validation.Errors;

var imageMap = new Dictionary<string, AttributeValue>
var imageMap = new Dictionary<string, AttributeValue>(4)
{
[TypeAttribute] = ImageType,
["format"] = block.Image.Format
Expand All @@ -61,7 +61,7 @@ public static ErrorOr<AttributeValue> Serialize(ContentBlock block)
if (validation.IsError)
return validation.Errors;

var docMap = new Dictionary<string, AttributeValue>
var docMap = new Dictionary<string, AttributeValue>(5)
{
[TypeAttribute] = DocumentType,
["format"] = block.Document.Format,
Expand All @@ -80,7 +80,7 @@ public static ErrorOr<AttributeValue> Serialize(ContentBlock block)

if (block.ToolUse is not null)
{
var toolUseMap = new Dictionary<string, AttributeValue>
var toolUseMap = new Dictionary<string, AttributeValue>(4)
{
[TypeAttribute] = ToolUseType,
["toolUseId"] = block.ToolUse.ToolUseId,
Expand All @@ -102,7 +102,7 @@ public static ErrorOr<AttributeValue> Serialize(ContentBlock block)
contentList.Add(serialized.Value);
}

var toolResultMap = new Dictionary<string, AttributeValue>
var toolResultMap = new Dictionary<string, AttributeValue>(4)
{
[TypeAttribute] = ToolResultType,
["toolUseId"] = block.ToolResult.ToolUseId,
Expand Down
70 changes: 46 additions & 24 deletions src/Clients/Goa.Clients.Bedrock.Conversation/Chat/ChatSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public async Task<ErrorOr<ChatResponse>> SendAsync(IEnumerable<ContentBlock> con
var userMessage = new Message
{
Role = ConversationRole.User,
Content = content.ToList()
Content = new List<ContentBlock>(content)
};

// Clean message content (extract XML tags)
Expand Down Expand Up @@ -163,24 +163,32 @@ public async Task<ErrorOr<ChatResponse>> SendAsync(IEnumerable<ContentBlock> con
}

// Execute tools
var toolUseBlocks = assistantMessage.Content
.Where(c => c.ToolUse != null)
.Select(c => c.ToolUse!)
.ToList();

var toolResults = new List<ContentBlock>();
foreach (var toolUse in toolUseBlocks)
foreach (var block in assistantMessage.Content)
{
var toolUse = block.ToolUse;
if (toolUse is null) continue;

var toolResult = await _toolAdapter.ExecuteToolAsync(toolUse, ct).ConfigureAwait(false);
toolResults.Add(new ContentBlock { ToolResult = toolResult });

string resultText = string.Empty;
foreach (var resultContent in toolResult.Content)
{
if (resultContent.Text is not null)
{
resultText = resultContent.Text;
break;
}
}

// Track tool execution
toolExecutions.Add(new ToolExecution
{
ToolName = toolUse.Name,
ToolUseId = toolUse.ToolUseId,
Input = JsonDocument.Parse(toolUse.Input.GetRawText()),
Result = toolResult.Content.FirstOrDefault()?.Text ?? string.Empty,
Result = resultText,
Success = toolResult.Status == "success"
});
}
Expand Down Expand Up @@ -218,10 +226,15 @@ public async Task<ErrorOr<ChatResponse>> SendAsync(IEnumerable<ContentBlock> con
var (cleanedFinalMessage, finalExtractedTags) = CleanMessageContent(finalMessage);

// Extract cleaned text content
var cleanedText = cleanedFinalMessage.Content
.Where(c => c.Text != null)
.Select(c => c.Text!)
.FirstOrDefault() ?? string.Empty;
var cleanedText = string.Empty;
foreach (var block in cleanedFinalMessage.Content)
{
if (block.Text is not null)
{
cleanedText = block.Text;
break;
}
}

// Detect thinking-only response: tags were extracted but no real text remains.
// Note: CleanMessageContent preserves original content when all text blocks are
Expand Down Expand Up @@ -305,13 +318,17 @@ public async Task<ErrorOr<ChatResponse>> SendAsync(IEnumerable<ContentBlock> con
/// <inheritdoc />
public Task<ErrorOr<IReadOnlyList<ChatMessage>>> GetHistoryAsync(CancellationToken ct = default)
{
var history = _messages.Select((m, index) => new ChatMessage
var history = new List<ChatMessage>(_messages.Count);
foreach (var m in _messages)
{
Role = m.Role,
Content = m.Content,
Timestamp = DateTimeOffset.UtcNow, // In-memory messages don't have timestamps
TokenUsage = null
}).ToList();
history.Add(new ChatMessage
{
Role = m.Role,
Content = m.Content,
Timestamp = DateTimeOffset.UtcNow, // In-memory messages don't have timestamps
TokenUsage = null
});
}

return Task.FromResult<ErrorOr<IReadOnlyList<ChatMessage>>>(history);
}
Expand Down Expand Up @@ -416,14 +433,16 @@ private static (Message CleanedMessage, IReadOnlyDictionary<string, IReadOnlyLis
}
}

var result = allTags.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<string>)kvp.Value);
var result = new Dictionary<string, IReadOnlyList<string>>(allTags.Count);
foreach (var kvp in allTags)
{
result[kvp.Key] = kvp.Value;
}

if (cleanedContent.Count == 0 && message.Content.Count > 0)
{
// Tag extraction emptied all content - preserve original to avoid empty messages
return (new Message { Role = message.Role, Content = message.Content.ToList() }, result);
return (new Message { Role = message.Role, Content = new List<ContentBlock>(message.Content) }, result);
}

return (new Message { Role = message.Role, Content = cleanedContent }, result);
Expand Down Expand Up @@ -452,7 +471,10 @@ private ConverseRequest BuildConverseRequest(IReadOnlyList<Tool>? bedrockTools)

if (_options.StopSequences is { Count: > 0 })
{
builder.WithStopSequences(_options.StopSequences.ToArray());
if (_options.StopSequences is string[] arr)
builder.WithStopSequences(arr);
else
builder.WithStopSequences(_options.StopSequences.ToArray());
}

if (_options.ServiceTier.HasValue)
Expand All @@ -471,7 +493,7 @@ private ConverseRequest BuildConverseRequest(IReadOnlyList<Tool>? bedrockTools)
// Add tools if any are registered
if (bedrockTools is { Count: > 0 })
{
request.ToolConfig = new ToolConfiguration { Tools = bedrockTools.ToList() };
request.ToolConfig = new ToolConfiguration { Tools = new List<Tool>(bedrockTools) };
}

if (_options.OutputConfig is not null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Goa.Clients.Bedrock.Conversation.Entities;
/// <summary>
/// Represents a conversation with a Bedrock model.
/// </summary>
public class Conversation
public sealed class Conversation
{
/// <summary>
/// The unique identifier for the conversation.
Expand Down
Loading
Loading