Skip to content

Commit 8f0b241

Browse files
committed
Extract RealtimeAudioProtocol from the Nova session
Phase 2 of the AWS.Speech.MEAI stack. Pure refactor of AWS.Bedrock.MEAI with no behavior change, so the AWS.Speech.MEAI VoiceAgent (PR 4) can reuse the concurrency and marshalling invariants already proven by the Nova session instead of reimplementing them. Moves the provider-neutral, side-effect-free helpers into a new internal RealtimeAudioProtocol: - Tool-payload normalization (NormalizeToolPayload/NormalizeToolArguments and their helpers, plus the max-depth guard and WriteNormalizedValue). - MapRole. - The single-enumeration guard, exposed as TryBeginExclusiveEnumeration / EndExclusiveEnumeration that operate on a caller-owned ref field. The session keeps its _activeStreamingEnumeration field, so the reflection-based concurrency test is unaffected. Left in BedrockNovaRealtimeSession because they are Nova-protocol coupled: the outbound Channel typing and priority queue, ordered DisposeAsync, ParseUsage (Nova usageEvent keys), and SerializeToolResult (Nova toolResult JSON-object wrapping). SerializeToolResult now calls the shared normalizers. Grants AWS.Bedrock.MEAI internals to AWS.Speech.MEAI so the net8 pipeline can call RealtimeAudioProtocol; suppresses CS0436 in AWS.Speech.MEAI for the ExperimentalAttribute polyfill both assemblies define on down-level TFMs. RealtimeAudioProtocol is net8-only, matching the Nova session. All Bedrock/Nova tests pass unchanged (243 net8.0 incl. new helper tests, 173 net472); Speech tests unaffected.
1 parent 9dfd09e commit 8f0b241

6 files changed

Lines changed: 296 additions & 198 deletions

File tree

src/AWS.Bedrock.MEAI/AWS.Bedrock.MEAI.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646

4747
<ItemGroup>
4848
<InternalsVisibleTo Include="AWS.Bedrock.MEAI.UnitTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100db5f59f098d27276c7833875a6263a3cc74ab17ba9a9df0b52aedbe7252745db7274d5271fd79c1f08f668ecfa8eaab5626fa76adc811d3c8fc55859b0d09d3bc0a84eecd0ba891f2b8a2fc55141cdcc37c2053d53491e650a479967c3622762977900eddbf1252ed08a2413f00a28f3a0752a81203f03ccb7f684db373518b4" />
49+
<InternalsVisibleTo Include="AWS.Speech.MEAI, PublicKey=0024000004800000940000000602000000240000525341310004000001000100db5f59f098d27276c7833875a6263a3cc74ab17ba9a9df0b52aedbe7252745db7274d5271fd79c1f08f668ecfa8eaab5626fa76adc811d3c8fc55859b0d09d3bc0a84eecd0ba891f2b8a2fc55141cdcc37c2053d53491e650a479967c3622762977900eddbf1252ed08a2413f00a28f3a0752a81203f03ccb7f684db373518b4" />
4950
</ItemGroup>
5051

5152
<ItemGroup>

src/AWS.Bedrock.MEAI/BedrockNovaRealtimeSession.cs

Lines changed: 8 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ public sealed class BedrockNovaRealtimeSession : IRealtimeClientSession
5555
// bidirectional stream can't safely serve two concurrent readers.
5656
private int _activeStreamingEnumeration;
5757

58-
/// <summary>Maximum nesting depth for tool payloads to prevent stack overflow from malicious/malformed data.</summary>
59-
private const int MaxToolPayloadDepth = 64;
60-
6158
/// <summary>Initializes a new instance of the <see cref="BedrockNovaRealtimeSession"/> class.</summary>
6259
/// <param name="runtime">The Amazon Bedrock Runtime client.</param>
6360
/// <param name="modelId">The model ID to use.</param>
@@ -291,7 +288,7 @@ public async IAsyncEnumerable<RealtimeServerMessage> GetStreamingResponseAsync(
291288
throw new InvalidOperationException("Session is not connected. Call ConnectAsync first.");
292289
}
293290

294-
if (Interlocked.CompareExchange(ref _activeStreamingEnumeration, 1, 0) != 0)
291+
if (!RealtimeAudioProtocol.TryBeginExclusiveEnumeration(ref _activeStreamingEnumeration))
295292
{
296293
throw new InvalidOperationException(
297294
"Only one active streaming enumeration is allowed at a time. " +
@@ -419,7 +416,7 @@ public async IAsyncEnumerable<RealtimeServerMessage> GetStreamingResponseAsync(
419416
var item = new RealtimeConversationItem(
420417
new List<AIContent>(),
421418
id: currentContentId,
422-
role: MapRole(currentRole));
419+
role: RealtimeAudioProtocol.MapRole(currentRole));
423420

424421
yield return new ResponseOutputItemRealtimeServerMessage(RealtimeServerMessageType.ResponseOutputItemAdded)
425422
{
@@ -495,7 +492,7 @@ public async IAsyncEnumerable<RealtimeServerMessage> GetStreamingResponseAsync(
495492
var item = new RealtimeConversationItem(
496493
itemContents,
497494
id: currentContentId,
498-
role: MapRole(currentRole));
495+
role: RealtimeAudioProtocol.MapRole(currentRole));
499496

500497
yield return new ResponseOutputItemRealtimeServerMessage(RealtimeServerMessageType.ResponseOutputItemDone)
501498
{
@@ -610,7 +607,7 @@ public async IAsyncEnumerable<RealtimeServerMessage> GetStreamingResponseAsync(
610607
using var argDoc = JsonDocument.Parse(json);
611608
if (argDoc.RootElement.ValueKind == JsonValueKind.Object)
612609
{
613-
return ConvertJsonElementToToolPayload(argDoc.RootElement, 0)
610+
return RealtimeAudioProtocol.ConvertJsonElementToToolPayload(argDoc.RootElement, 0)
614611
as Dictionary<string, object?>;
615612
}
616613
}
@@ -659,7 +656,7 @@ public async IAsyncEnumerable<RealtimeServerMessage> GetStreamingResponseAsync(
659656
}
660657
finally
661658
{
662-
Volatile.Write(ref _activeStreamingEnumeration, 0);
659+
RealtimeAudioProtocol.EndExclusiveEnumeration(ref _activeStreamingEnumeration);
663660
}
664661
}
665662

@@ -1511,16 +1508,6 @@ private static string MapStopReason(string? stopReason) =>
15111508
_ => RealtimeResponseStatus.Completed
15121509
};
15131510

1514-
private static ChatRole? MapRole(string? role) =>
1515-
role?.ToUpperInvariant() switch
1516-
{
1517-
"USER" => ChatRole.User,
1518-
"ASSISTANT" => ChatRole.Assistant,
1519-
"SYSTEM" => ChatRole.System,
1520-
"TOOL" => ChatRole.Tool,
1521-
_ => null
1522-
};
1523-
15241511
private static UsageDetails? ParseUsage(JsonElement usageElement)
15251512
{
15261513
int totalInputTokens = 0;
@@ -1557,7 +1544,7 @@ private static string MapStopReason(string? stopReason) =>
15571544
private static string SerializeToolResult(object? result)
15581545
{
15591546
// Normalize first to handle JsonElement, byte[], nested dicts, etc.
1560-
result = NormalizeToolPayload(result);
1547+
result = RealtimeAudioProtocol.NormalizeToolPayload(result);
15611548

15621549
if (result is null)
15631550
{
@@ -1590,7 +1577,7 @@ private static string SerializeToolResult(object? result)
15901577
using var buffer = new MemoryStream();
15911578
using (var writer = new Utf8JsonWriter(buffer))
15921579
{
1593-
WriteNormalizedValue(dict, writer);
1580+
RealtimeAudioProtocol.WriteNormalizedValue(dict, writer);
15941581
}
15951582

15961583
return Encoding.UTF8.GetString(buffer.ToArray());
@@ -1602,183 +1589,13 @@ private static string SerializeToolResult(object? result)
16021589
{
16031590
writer.WriteStartObject();
16041591
writer.WritePropertyName("result");
1605-
WriteNormalizedValue(result, writer);
1592+
RealtimeAudioProtocol.WriteNormalizedValue(result, writer);
16061593
writer.WriteEndObject();
16071594
}
16081595

16091596
return Encoding.UTF8.GetString(wrapBuffer.ToArray());
16101597
}
16111598

1612-
/// <summary>
1613-
/// Writes a normalized value (produced by <see cref="NormalizeToolPayload"/>) to a <see cref="Utf8JsonWriter"/>.
1614-
/// Handles null, string, bool, numeric primitives, Dictionary, and List without reflection.
1615-
/// </summary>
1616-
private static void WriteNormalizedValue(object? value, Utf8JsonWriter writer)
1617-
{
1618-
switch (value)
1619-
{
1620-
case null:
1621-
writer.WriteNullValue();
1622-
break;
1623-
case string s:
1624-
writer.WriteStringValue(s);
1625-
break;
1626-
case bool b:
1627-
writer.WriteBooleanValue(b);
1628-
break;
1629-
case int i:
1630-
writer.WriteNumberValue(i);
1631-
break;
1632-
case long l:
1633-
writer.WriteNumberValue(l);
1634-
break;
1635-
case float f:
1636-
writer.WriteNumberValue(f);
1637-
break;
1638-
case double d:
1639-
writer.WriteNumberValue(d);
1640-
break;
1641-
case decimal m:
1642-
writer.WriteNumberValue(m);
1643-
break;
1644-
case Dictionary<string, object?> dict:
1645-
writer.WriteStartObject();
1646-
foreach (var kvp in dict)
1647-
{
1648-
writer.WritePropertyName(kvp.Key);
1649-
WriteNormalizedValue(kvp.Value, writer);
1650-
}
1651-
writer.WriteEndObject();
1652-
break;
1653-
case List<object?> list:
1654-
writer.WriteStartArray();
1655-
foreach (var item in list)
1656-
{
1657-
WriteNormalizedValue(item, writer);
1658-
}
1659-
writer.WriteEndArray();
1660-
break;
1661-
default:
1662-
writer.WriteStringValue(value.ToString());
1663-
break;
1664-
}
1665-
}
1666-
1667-
/// <summary>
1668-
/// Recursively normalizes a tool payload into a tree of primitives, dictionaries, and lists.
1669-
/// Handles JsonElement, byte[], nested dicts/lists, and enforces a maximum nesting depth.
1670-
/// </summary>
1671-
internal static object? NormalizeToolPayload(object? value, int depth = 0)
1672-
{
1673-
ValidateToolPayloadDepth(depth);
1674-
1675-
switch (value)
1676-
{
1677-
case null:
1678-
return null;
1679-
case byte[] bytes:
1680-
return Convert.ToBase64String(bytes);
1681-
case JsonElement element:
1682-
return ConvertJsonElementToToolPayload(element, depth + 1);
1683-
case JsonDocument document:
1684-
return ConvertJsonElementToToolPayload(document.RootElement, depth + 1);
1685-
case string:
1686-
case bool:
1687-
case int:
1688-
case long:
1689-
case float:
1690-
case double:
1691-
case decimal:
1692-
return value;
1693-
case IReadOnlyDictionary<string, object?> roDict:
1694-
return NormalizeToolArguments(roDict, depth + 1);
1695-
case IEnumerable<KeyValuePair<string, object?>> pairs:
1696-
return NormalizeToolArguments(
1697-
new Dictionary<string, object?>(pairs.Select(p => p), StringComparer.Ordinal), depth + 1);
1698-
case IDictionary dict:
1699-
var mapped = new Dictionary<string, object?>(StringComparer.Ordinal);
1700-
foreach (DictionaryEntry entry in dict)
1701-
{
1702-
string key = entry.Key.ToString()!;
1703-
mapped[key] = NormalizeToolPayload(entry.Value, depth + 1);
1704-
}
1705-
return mapped;
1706-
case IEnumerable<AIContent> aiContents:
1707-
return aiContents.Select(content => NormalizeToolPayload(content, depth + 1)).ToList();
1708-
case IEnumerable<object?> enumerable:
1709-
var list = new List<object?>();
1710-
foreach (var item in enumerable)
1711-
{
1712-
list.Add(NormalizeToolPayload(item, depth + 1));
1713-
}
1714-
return list;
1715-
default:
1716-
return value.ToString();
1717-
}
1718-
}
1719-
1720-
/// <summary>
1721-
/// Normalizes a dictionary of tool arguments, recursively normalizing each value.
1722-
/// </summary>
1723-
internal static Dictionary<string, object?> NormalizeToolArguments(IReadOnlyDictionary<string, object?> arguments, int depth = 0)
1724-
{
1725-
ValidateToolPayloadDepth(depth);
1726-
1727-
var normalized = new Dictionary<string, object?>(arguments.Count, StringComparer.Ordinal);
1728-
foreach (var pair in arguments)
1729-
{
1730-
normalized[pair.Key] = NormalizeToolPayload(pair.Value, depth + 1);
1731-
}
1732-
return normalized;
1733-
}
1734-
1735-
/// <summary>
1736-
/// Converts a <see cref="JsonElement"/> to a tree of primitives, dictionaries, and lists.
1737-
/// </summary>
1738-
private static object? ConvertJsonElementToToolPayload(JsonElement element, int depth)
1739-
{
1740-
ValidateToolPayloadDepth(depth);
1741-
1742-
switch (element.ValueKind)
1743-
{
1744-
case JsonValueKind.Object:
1745-
var dictionary = new Dictionary<string, object?>(StringComparer.Ordinal);
1746-
foreach (var property in element.EnumerateObject())
1747-
{
1748-
dictionary[property.Name] = ConvertJsonElementToToolPayload(property.Value, depth + 1);
1749-
}
1750-
return dictionary;
1751-
case JsonValueKind.Array:
1752-
var arrayList = new List<object?>();
1753-
foreach (var item in element.EnumerateArray())
1754-
{
1755-
arrayList.Add(ConvertJsonElementToToolPayload(item, depth + 1));
1756-
}
1757-
return arrayList;
1758-
case JsonValueKind.String:
1759-
return element.GetString();
1760-
case JsonValueKind.Number:
1761-
return element.TryGetInt64(out long l) ? l : element.GetDouble();
1762-
case JsonValueKind.True:
1763-
return true;
1764-
case JsonValueKind.False:
1765-
return false;
1766-
case JsonValueKind.Null:
1767-
case JsonValueKind.Undefined:
1768-
default:
1769-
return null;
1770-
}
1771-
}
1772-
1773-
private static void ValidateToolPayloadDepth(int depth)
1774-
{
1775-
if (depth > MaxToolPayloadDepth)
1776-
{
1777-
throw new InvalidOperationException(
1778-
$"Realtime tool payloads exceed the maximum supported nesting depth of {MaxToolPayloadDepth}.");
1779-
}
1780-
}
1781-
17821599
#endregion
17831600
}
17841601

0 commit comments

Comments
 (0)