From 3c51804b5ef80f9797d66c8bad56d06a001450cc Mon Sep 17 00:00:00 2001 From: John Vajda Date: Tue, 22 Jul 2025 08:27:15 -0600 Subject: [PATCH 01/10] feat: add support for agent tags --- .../UnitTests/ClientTests/AgentClientTests.cs | 233 ++++++++++++++++++ Deepgram/Models/Agent/v2/WebSocket/Agent.cs | 7 + 2 files changed, 240 insertions(+) diff --git a/Deepgram.Tests/UnitTests/ClientTests/AgentClientTests.cs b/Deepgram.Tests/UnitTests/ClientTests/AgentClientTests.cs index d788527..f0c0efa 100644 --- a/Deepgram.Tests/UnitTests/ClientTests/AgentClientTests.cs +++ b/Deepgram.Tests/UnitTests/ClientTests/AgentClientTests.cs @@ -389,4 +389,237 @@ public void Agent_Should_Not_Have_MipOptOut_Property() } #endregion + + #region Tags Tests + + [Test] + public void Agent_Tags_Should_Have_Default_Value_Null() + { + // Arrange & Act + var agent = new Agent(); + + // Assert + using (new AssertionScope()) + { + agent.Tags.Should().BeNull(); + } + } + + [Test] + public void Agent_Tags_Should_Be_Settable() + { + // Arrange & Act + var agent = new Agent + { + Tags = new List { "test", "demo", "agent" } + }; + + // Assert + using (new AssertionScope()) + { + agent.Tags.Should().NotBeNull(); + agent.Tags.Should().HaveCount(3); + agent.Tags.Should().Contain("test"); + agent.Tags.Should().Contain("demo"); + agent.Tags.Should().Contain("agent"); + } + } + + [Test] + public void Agent_Tags_Should_Serialize_To_Json_Array() + { + // Arrange + var agent = new Agent + { + Tags = new List { "production", "voice-bot", "customer-service" } + }; + + // Act + var result = agent.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("tags"); + result.Should().Contain("["); + result.Should().Contain("]"); + result.Should().Contain("production"); + result.Should().Contain("voice-bot"); + result.Should().Contain("customer-service"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + var tagsArray = parsed.RootElement.GetProperty("tags"); + tagsArray.ValueKind.Should().Be(JsonValueKind.Array); + tagsArray.GetArrayLength().Should().Be(3); + + var tagsList = new List(); + foreach (var tag in tagsArray.EnumerateArray()) + { + tagsList.Add(tag.GetString()!); + } + tagsList.Should().Contain("production"); + tagsList.Should().Contain("voice-bot"); + tagsList.Should().Contain("customer-service"); + } + } + + [Test] + public void Agent_Tags_Empty_List_Should_Serialize_As_Empty_Array() + { + // Arrange + var agent = new Agent + { + Tags = new List() + }; + + // Act + var result = agent.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("tags"); + result.Should().Contain("[]"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + var tagsArray = parsed.RootElement.GetProperty("tags"); + tagsArray.ValueKind.Should().Be(JsonValueKind.Array); + tagsArray.GetArrayLength().Should().Be(0); + } + } + + [Test] + public void Agent_Tags_Null_Should_Not_Serialize() + { + // Arrange + var agent = new Agent + { + Tags = null + }; + + // Act + var result = agent.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().NotContain("tags"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.TryGetProperty("tags", out _).Should().BeFalse(); + } + } + + [Test] + public void Agent_With_Tags_Should_Serialize_With_Other_Properties() + { + // Arrange + var agent = new Agent + { + Language = "en", + Greeting = "Hello, I'm your agent", + Tags = new List { "test-tag", "integration" }, + MipOptOut = true + }; + + // Act + var result = agent.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("language"); + result.Should().Contain("greeting"); + result.Should().Contain("tags"); + result.Should().Contain("mip_opt_out"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("language").GetString().Should().Be("en"); + parsed.RootElement.GetProperty("greeting").GetString().Should().Be("Hello, I'm your agent"); + parsed.RootElement.GetProperty("mip_opt_out").GetBoolean().Should().BeTrue(); + + var tagsArray = parsed.RootElement.GetProperty("tags"); + tagsArray.ValueKind.Should().Be(JsonValueKind.Array); + tagsArray.GetArrayLength().Should().Be(2); + } + } + + [Test] + public void Agent_Tags_Should_Support_Special_Characters() + { + // Arrange + var agent = new Agent + { + Tags = new List { "test-with-dashes", "test_with_underscores", "test with spaces", "test.with.dots" } + }; + + // Act + var result = agent.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + var tagsArray = parsed.RootElement.GetProperty("tags"); + tagsArray.ValueKind.Should().Be(JsonValueKind.Array); + tagsArray.GetArrayLength().Should().Be(4); + + var tagsList = new List(); + foreach (var tag in tagsArray.EnumerateArray()) + { + tagsList.Add(tag.GetString()!); + } + tagsList.Should().Contain("test-with-dashes"); + tagsList.Should().Contain("test_with_underscores"); + tagsList.Should().Contain("test with spaces"); + tagsList.Should().Contain("test.with.dots"); + } + } + + [Test] + public void Agent_Tags_Schema_Should_Match_API_Specification() + { + // Arrange - Test various scenarios as per API specification + var agentWithTags = new Agent { Tags = new List { "search-filter", "analytics", "production" } }; + var agentWithoutTags = new Agent { Tags = null }; + var agentWithEmptyTags = new Agent { Tags = new List() }; + + // Act + var withTagsResult = agentWithTags.ToString(); + var withoutTagsResult = agentWithoutTags.ToString(); + var emptyTagsResult = agentWithEmptyTags.ToString(); + + // Assert + using (new AssertionScope()) + { + // With tags should serialize array + var withTagsParsed = JsonDocument.Parse(withTagsResult); + var tagsArray = withTagsParsed.RootElement.GetProperty("tags"); + tagsArray.ValueKind.Should().Be(JsonValueKind.Array); + tagsArray.GetArrayLength().Should().Be(3); + + // Without tags should not include tags property + var withoutTagsParsed = JsonDocument.Parse(withoutTagsResult); + withoutTagsParsed.RootElement.TryGetProperty("tags", out _).Should().BeFalse(); + + // Empty tags should serialize as empty array + var emptyTagsParsed = JsonDocument.Parse(emptyTagsResult); + var emptyTagsArray = emptyTagsParsed.RootElement.GetProperty("tags"); + emptyTagsArray.ValueKind.Should().Be(JsonValueKind.Array); + emptyTagsArray.GetArrayLength().Should().Be(0); + } + } + + #endregion } \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/Agent.cs b/Deepgram/Models/Agent/v2/WebSocket/Agent.cs index 0004ee1..47a0b71 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/Agent.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/Agent.cs @@ -25,6 +25,13 @@ public record Agent [JsonPropertyName("speak")] public Speak Speak { get; set; } = new Speak(); + /// + /// Tags to associate with the request. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("tags")] + public List? Tags { get; set; } + /// /// The message to speak at the start of the connection. /// From fb74f821fc23108f2ebe15341fff14ebab152940 Mon Sep 17 00:00:00 2001 From: John Vajda Date: Fri, 1 Aug 2025 16:19:20 -0600 Subject: [PATCH 02/10] moves agent tags to settings --- .../UnitTests/ClientTests/AgentClientTests.cs | 104 +++++++++++------- Deepgram/Models/Agent/v2/WebSocket/Agent.cs | 7 -- .../Models/Agent/v2/WebSocket/Settings.cs | 7 ++ 3 files changed, 70 insertions(+), 48 deletions(-) diff --git a/Deepgram.Tests/UnitTests/ClientTests/AgentClientTests.cs b/Deepgram.Tests/UnitTests/ClientTests/AgentClientTests.cs index f0c0efa..0e2d1de 100644 --- a/Deepgram.Tests/UnitTests/ClientTests/AgentClientTests.cs +++ b/Deepgram.Tests/UnitTests/ClientTests/AgentClientTests.cs @@ -393,23 +393,23 @@ public void Agent_Should_Not_Have_MipOptOut_Property() #region Tags Tests [Test] - public void Agent_Tags_Should_Have_Default_Value_Null() + public void SettingsSchema_Tags_Should_Have_Default_Value_Null() { // Arrange & Act - var agent = new Agent(); + var settings = new SettingsSchema(); // Assert using (new AssertionScope()) { - agent.Tags.Should().BeNull(); + settings.Tags.Should().BeNull(); } } [Test] - public void Agent_Tags_Should_Be_Settable() + public void SettingsSchema_Tags_Should_Be_Settable() { // Arrange & Act - var agent = new Agent + var settings = new SettingsSchema { Tags = new List { "test", "demo", "agent" } }; @@ -417,25 +417,25 @@ public void Agent_Tags_Should_Be_Settable() // Assert using (new AssertionScope()) { - agent.Tags.Should().NotBeNull(); - agent.Tags.Should().HaveCount(3); - agent.Tags.Should().Contain("test"); - agent.Tags.Should().Contain("demo"); - agent.Tags.Should().Contain("agent"); + settings.Tags.Should().NotBeNull(); + settings.Tags.Should().HaveCount(3); + settings.Tags.Should().Contain("test"); + settings.Tags.Should().Contain("demo"); + settings.Tags.Should().Contain("agent"); } } [Test] - public void Agent_Tags_Should_Serialize_To_Json_Array() + public void SettingsSchema_Tags_Should_Serialize_To_Json_Array() { // Arrange - var agent = new Agent + var settings = new SettingsSchema { Tags = new List { "production", "voice-bot", "customer-service" } }; // Act - var result = agent.ToString(); + var result = settings.ToString(); // Assert using (new AssertionScope()) @@ -466,16 +466,16 @@ public void Agent_Tags_Should_Serialize_To_Json_Array() } [Test] - public void Agent_Tags_Empty_List_Should_Serialize_As_Empty_Array() + public void SettingsSchema_Tags_Empty_List_Should_Serialize_As_Empty_Array() { // Arrange - var agent = new Agent + var settings = new SettingsSchema { Tags = new List() }; // Act - var result = agent.ToString(); + var result = settings.ToString(); // Assert using (new AssertionScope()) @@ -493,16 +493,16 @@ public void Agent_Tags_Empty_List_Should_Serialize_As_Empty_Array() } [Test] - public void Agent_Tags_Null_Should_Not_Serialize() + public void SettingsSchema_Tags_Null_Should_Not_Serialize() { // Arrange - var agent = new Agent + var settings = new SettingsSchema { Tags = null }; // Act - var result = agent.ToString(); + var result = settings.ToString(); // Assert using (new AssertionScope()) @@ -517,33 +517,30 @@ public void Agent_Tags_Null_Should_Not_Serialize() } [Test] - public void Agent_With_Tags_Should_Serialize_With_Other_Properties() + public void SettingsSchema_With_Tags_Should_Serialize_With_Other_Properties() { // Arrange - var agent = new Agent + var settings = new SettingsSchema { - Language = "en", - Greeting = "Hello, I'm your agent", - Tags = new List { "test-tag", "integration" }, - MipOptOut = true + Experimental = true, + MipOptOut = true, + Tags = new List { "test-tag", "integration" } }; // Act - var result = agent.ToString(); + var result = settings.ToString(); // Assert using (new AssertionScope()) { result.Should().NotBeNull(); - result.Should().Contain("language"); - result.Should().Contain("greeting"); - result.Should().Contain("tags"); + result.Should().Contain("experimental"); result.Should().Contain("mip_opt_out"); + result.Should().Contain("tags"); // Verify it's valid JSON by parsing it var parsed = JsonDocument.Parse(result); - parsed.RootElement.GetProperty("language").GetString().Should().Be("en"); - parsed.RootElement.GetProperty("greeting").GetString().Should().Be("Hello, I'm your agent"); + parsed.RootElement.GetProperty("experimental").GetBoolean().Should().BeTrue(); parsed.RootElement.GetProperty("mip_opt_out").GetBoolean().Should().BeTrue(); var tagsArray = parsed.RootElement.GetProperty("tags"); @@ -553,16 +550,16 @@ public void Agent_With_Tags_Should_Serialize_With_Other_Properties() } [Test] - public void Agent_Tags_Should_Support_Special_Characters() + public void SettingsSchema_Tags_Should_Support_Special_Characters() { // Arrange - var agent = new Agent + var settings = new SettingsSchema { Tags = new List { "test-with-dashes", "test_with_underscores", "test with spaces", "test.with.dots" } }; // Act - var result = agent.ToString(); + var result = settings.ToString(); // Assert using (new AssertionScope()) @@ -588,17 +585,17 @@ public void Agent_Tags_Should_Support_Special_Characters() } [Test] - public void Agent_Tags_Schema_Should_Match_API_Specification() + public void SettingsSchema_Tags_Schema_Should_Match_API_Specification() { // Arrange - Test various scenarios as per API specification - var agentWithTags = new Agent { Tags = new List { "search-filter", "analytics", "production" } }; - var agentWithoutTags = new Agent { Tags = null }; - var agentWithEmptyTags = new Agent { Tags = new List() }; + var settingsWithTags = new SettingsSchema { Tags = new List { "search-filter", "analytics", "production" } }; + var settingsWithoutTags = new SettingsSchema { Tags = null }; + var settingsWithEmptyTags = new SettingsSchema { Tags = new List() }; // Act - var withTagsResult = agentWithTags.ToString(); - var withoutTagsResult = agentWithoutTags.ToString(); - var emptyTagsResult = agentWithEmptyTags.ToString(); + var withTagsResult = settingsWithTags.ToString(); + var withoutTagsResult = settingsWithoutTags.ToString(); + var emptyTagsResult = settingsWithEmptyTags.ToString(); // Assert using (new AssertionScope()) @@ -621,5 +618,30 @@ public void Agent_Tags_Schema_Should_Match_API_Specification() } } + [Test] + public void Agent_Should_Not_Have_Tags_Property() + { + // Arrange + var agent = new Agent + { + Language = "en", + Greeting = "Hello, I'm your agent" + }; + + // Act + var result = agent.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().NotContain("tags"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.TryGetProperty("tags", out _).Should().BeFalse(); + } + } + #endregion } \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/Agent.cs b/Deepgram/Models/Agent/v2/WebSocket/Agent.cs index 47a0b71..0004ee1 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/Agent.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/Agent.cs @@ -25,13 +25,6 @@ public record Agent [JsonPropertyName("speak")] public Speak Speak { get; set; } = new Speak(); - /// - /// Tags to associate with the request. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("tags")] - public List? Tags { get; set; } - /// /// The message to speak at the start of the connection. /// diff --git a/Deepgram/Models/Agent/v2/WebSocket/Settings.cs b/Deepgram/Models/Agent/v2/WebSocket/Settings.cs index cbb8449..8c33cdc 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/Settings.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/Settings.cs @@ -20,6 +20,13 @@ public class SettingsSchema [JsonPropertyName("experimental")] public bool? Experimental { get; set; } + /// + /// Tags to associate with the request. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("tags")] + public List? Tags { get; set; } + /// /// To opt out of Deepgram Model Improvement Program /// From 67733ab06393979000946aa1702692be1a4ced62 Mon Sep 17 00:00:00 2001 From: John Vajda Date: Wed, 23 Jul 2025 18:37:54 -0600 Subject: [PATCH 03/10] feat: add support for agent context history --- .../ClientTests/AgentHistoryTests.cs | 535 ++++++++++++++++++ Deepgram/Clients/Agent/v2/Websocket/Client.cs | 102 ++++ Deepgram/Models/Agent/v2/WebSocket/Agent.cs | 7 + .../Models/Agent/v2/WebSocket/AgentType.cs | 2 + Deepgram/Models/Agent/v2/WebSocket/Context.cs | 28 + Deepgram/Models/Agent/v2/WebSocket/Flags.cs | 28 + .../v2/WebSocket/HistoryConversationText.cs | 42 ++ .../v2/WebSocket/HistoryFunctionCalls.cs | 81 +++ .../Models/Agent/v2/WebSocket/Settings.cs | 5 + 9 files changed, 830 insertions(+) create mode 100644 Deepgram.Tests/UnitTests/ClientTests/AgentHistoryTests.cs create mode 100644 Deepgram/Models/Agent/v2/WebSocket/Context.cs create mode 100644 Deepgram/Models/Agent/v2/WebSocket/Flags.cs create mode 100644 Deepgram/Models/Agent/v2/WebSocket/HistoryConversationText.cs create mode 100644 Deepgram/Models/Agent/v2/WebSocket/HistoryFunctionCalls.cs diff --git a/Deepgram.Tests/UnitTests/ClientTests/AgentHistoryTests.cs b/Deepgram.Tests/UnitTests/ClientTests/AgentHistoryTests.cs new file mode 100644 index 0000000..e2119a6 --- /dev/null +++ b/Deepgram.Tests/UnitTests/ClientTests/AgentHistoryTests.cs @@ -0,0 +1,535 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using Bogus; +using FluentAssertions; +using FluentAssertions.Execution; +using NSubstitute; +using System.Text.Json; +using Deepgram.Models.Authenticate.v1; +using Deepgram.Models.Agent.v2.WebSocket; +using Deepgram.Clients.Agent.v2.WebSocket; + +namespace Deepgram.Tests.UnitTests.ClientTests; + +public class AgentHistoryTests +{ + DeepgramWsClientOptions _options; + string _apiKey; + + [SetUp] + public void Setup() + { + _apiKey = new Faker().Random.Guid().ToString(); + _options = new DeepgramWsClientOptions(_apiKey) + { + OnPrem = true, + }; + } + + #region SendHistoryConversationText Tests + + [Test] + public async Task SendHistoryConversationText_With_String_Parameters_Should_Send_Message() + { + // Input and Output + var role = "user"; + var content = "What's the weather like today?"; + var agentClient = Substitute.For(_apiKey, _options); + + // Mock the SendMessageImmediately method + agentClient.When(x => x.SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any())) + .DoNotCallBase(); + + // Act + await agentClient.SendHistoryConversationText(role, content); + + // Assert + await agentClient.Received(1).SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SendHistoryConversationText_With_Schema_Should_Send_Message() + { + // Input and Output + var schema = new HistoryConversationText + { + Role = "assistant", + Content = "Based on the current data, it's sunny with a temperature of 72°F." + }; + var agentClient = Substitute.For(_apiKey, _options); + + // Mock the SendMessageImmediately method + agentClient.When(x => x.SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any())) + .DoNotCallBase(); + + // Act + await agentClient.SendHistoryConversationText(schema); + + // Assert + await agentClient.Received(1).SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SendHistoryConversationText_With_Null_Role_Should_Throw_ArgumentException() + { + // Input and Output + string? role = null; + var content = "Test content"; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryConversationText(role!, content)) + .Should().ThrowAsync() + .WithMessage("Role cannot be null or empty*"); + exception.And.ParamName.Should().Be("role"); + } + + [Test] + public async Task SendHistoryConversationText_With_Empty_Role_Should_Throw_ArgumentException() + { + // Input and Output + var role = ""; + var content = "Test content"; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryConversationText(role, content)) + .Should().ThrowAsync() + .WithMessage("Role cannot be null or empty*"); + exception.And.ParamName.Should().Be("role"); + } + + [Test] + public async Task SendHistoryConversationText_With_Null_Content_Should_Throw_ArgumentException() + { + // Input and Output + var role = "user"; + string? content = null; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryConversationText(role, content!)) + .Should().ThrowAsync() + .WithMessage("Content cannot be null or empty*"); + exception.And.ParamName.Should().Be("content"); + } + + [Test] + public async Task SendHistoryConversationText_With_Null_Schema_Should_Throw_ArgumentNullException() + { + // Input and Output + HistoryConversationText? schema = null; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryConversationText(schema!)) + .Should().ThrowAsync(); + exception.And.ParamName.Should().Be("historyConversationText"); + } + + #endregion + + #region SendHistoryFunctionCalls Tests + + [Test] + public async Task SendHistoryFunctionCalls_With_List_Should_Send_Message() + { + // Input and Output + var functionCalls = new List + { + new HistoryFunctionCall + { + Id = "fc_12345678-90ab-cdef-1234-567890abcdef", + Name = "check_order_status", + ClientSide = true, + Arguments = "{\"order_id\": \"ORD-123456\"}", + Response = "Order #123456 status: Shipped - Expected delivery date: 2024-03-15" + } + }; + var agentClient = Substitute.For(_apiKey, _options); + + // Mock the SendMessageImmediately method + agentClient.When(x => x.SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any())) + .DoNotCallBase(); + + // Act + await agentClient.SendHistoryFunctionCalls(functionCalls); + + // Assert + await agentClient.Received(1).SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SendHistoryFunctionCalls_With_Schema_Should_Send_Message() + { + // Input and Output + var schema = new HistoryFunctionCalls + { + FunctionCalls = new List + { + new HistoryFunctionCall + { + Id = "fc_12345678-90ab-cdef-1234-567890abcdef", + Name = "get_weather", + ClientSide = false, + Arguments = "{\"location\": \"New York\"}", + Response = "Temperature: 22°C, Conditions: Sunny" + } + } + }; + var agentClient = Substitute.For(_apiKey, _options); + + // Mock the SendMessageImmediately method + agentClient.When(x => x.SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any())) + .DoNotCallBase(); + + // Act + await agentClient.SendHistoryFunctionCalls(schema); + + // Assert + await agentClient.Received(1).SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SendHistoryFunctionCalls_With_Null_List_Should_Throw_ArgumentException() + { + // Input and Output + List? functionCalls = null; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryFunctionCalls(functionCalls!)) + .Should().ThrowAsync() + .WithMessage("FunctionCalls cannot be null or empty*"); + exception.And.ParamName.Should().Be("functionCalls"); + } + + [Test] + public async Task SendHistoryFunctionCalls_With_Empty_List_Should_Throw_ArgumentException() + { + // Input and Output + var functionCalls = new List(); + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryFunctionCalls(functionCalls)) + .Should().ThrowAsync() + .WithMessage("FunctionCalls cannot be null or empty*"); + exception.And.ParamName.Should().Be("functionCalls"); + } + + [Test] + public async Task SendHistoryFunctionCalls_With_Null_Schema_Should_Throw_ArgumentNullException() + { + // Input and Output + HistoryFunctionCalls? schema = null; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryFunctionCalls(schema!)) + .Should().ThrowAsync(); + exception.And.ParamName.Should().Be("historyFunctionCalls"); + } + + #endregion + + #region Model Serialization Tests + + [Test] + public void HistoryConversationText_Should_Have_Correct_Type() + { + // Input and Output + var schema = new HistoryConversationText + { + Role = "user", + Content = "Test message" + }; + + // Assert + using (new AssertionScope()) + { + schema.Type.Should().Be("History"); + schema.Role.Should().Be("user"); + schema.Content.Should().Be("Test message"); + } + } + + [Test] + public void HistoryConversationText_ToString_Should_Return_Valid_Json() + { + // Input and Output + var schema = new HistoryConversationText + { + Role = "assistant", + Content = "Based on the current data, it's sunny with a temperature of 72°F (22°C)." + }; + + // Act + var result = schema.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("History"); + result.Should().Contain("assistant"); + result.Should().Contain("sunny with a temperature"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("type").GetString().Should().Be("History"); + parsed.RootElement.GetProperty("role").GetString().Should().Be("assistant"); + parsed.RootElement.GetProperty("content").GetString().Should().Contain("sunny"); + } + } + + [Test] + public void HistoryFunctionCalls_Should_Have_Correct_Type() + { + // Input and Output + var schema = new HistoryFunctionCalls + { + FunctionCalls = new List + { + new HistoryFunctionCall + { + Id = "test-id", + Name = "test_function", + ClientSide = true, + Arguments = "{}", + Response = "success" + } + } + }; + + // Assert + using (new AssertionScope()) + { + schema.Type.Should().Be("History"); + schema.FunctionCalls.Should().HaveCount(1); + schema.FunctionCalls![0].Name.Should().Be("test_function"); + } + } + + [Test] + public void HistoryFunctionCalls_ToString_Should_Return_Valid_Json() + { + // Input and Output + var schema = new HistoryFunctionCalls + { + FunctionCalls = new List + { + new HistoryFunctionCall + { + Id = "fc_12345678-90ab-cdef-1234-567890abcdef", + Name = "check_order_status", + ClientSide = true, + Arguments = "simple_argument_value", + Response = "Order #123456 status: Shipped" + } + } + }; + + // Act + var result = schema.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("History"); + result.Should().Contain("check_order_status"); + result.Should().Contain("fc_12345678-90ab-cdef-1234-567890abcdef"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("type").GetString().Should().Be("History"); + parsed.RootElement.GetProperty("function_calls").GetArrayLength().Should().Be(1); + var functionCall = parsed.RootElement.GetProperty("function_calls")[0]; + functionCall.GetProperty("name").GetString().Should().Be("check_order_status"); + functionCall.GetProperty("client_side").GetBoolean().Should().BeTrue(); + } + } + + [Test] + public void HistoryFunctionCall_Should_Serialize_ClientSide_As_Snake_Case() + { + // Input and Output + var functionCall = new HistoryFunctionCall + { + Id = "test-id", + Name = "test_function", + ClientSide = false, + Arguments = "{}", + Response = "success" + }; + + // Act + var result = functionCall.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("client_side"); + result.Should().Contain("false"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("client_side").GetBoolean().Should().BeFalse(); + } + } + + [Test] + public void Flags_Should_Have_Default_History_True() + { + // Arrange & Act + var flags = new Flags(); + + // Assert + using (new AssertionScope()) + { + flags.History.Should().BeTrue(); + } + } + + [Test] + public void Flags_Should_Be_Settable() + { + // Arrange & Act + var flags = new Flags + { + History = false + }; + + // Assert + using (new AssertionScope()) + { + flags.History.Should().BeFalse(); + } + } + + [Test] + public void Flags_ToString_Should_Return_Valid_Json() + { + // Input and Output + var flags = new Flags + { + History = false + }; + + // Act + var result = flags.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("history"); + result.Should().Contain("false"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("history").GetBoolean().Should().BeFalse(); + } + } + + [Test] + public void Context_Should_Allow_Null_Messages() + { + // Arrange & Act + var context = new Context(); + + // Assert + using (new AssertionScope()) + { + context.Messages.Should().BeNull(); + } + } + + [Test] + public void Context_Should_Be_Settable() + { + // Arrange & Act + var context = new Context + { + Messages = new List + { + new HistoryConversationText { Role = "user", Content = "Hello" } + } + }; + + // Assert + using (new AssertionScope()) + { + context.Messages.Should().HaveCount(1); + } + } + + [Test] + public void Agent_Context_Should_Be_Settable() + { + // Arrange & Act + var agent = new Agent + { + Context = new Context + { + Messages = new List + { + new HistoryConversationText { Role = "user", Content = "Test" } + } + } + }; + + // Assert + using (new AssertionScope()) + { + agent.Context.Should().NotBeNull(); + agent.Context!.Messages.Should().HaveCount(1); + } + } + + [Test] + public void SettingsSchema_Flags_Should_Have_Default_Value() + { + // Arrange & Act + var settings = new SettingsSchema(); + + // Assert + using (new AssertionScope()) + { + settings.Flags.Should().NotBeNull(); + settings.Flags!.History.Should().BeTrue(); + } + } + + [Test] + public void SettingsSchema_With_History_Disabled_Should_Serialize_Correctly() + { + // Arrange + var settings = new SettingsSchema + { + Flags = new Flags { History = false } + }; + + // Act + var result = settings.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("flags"); + result.Should().Contain("history"); + result.Should().Contain("false"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("flags").GetProperty("history").GetBoolean().Should().BeFalse(); + } + } + + #endregion +} \ No newline at end of file diff --git a/Deepgram/Clients/Agent/v2/Websocket/Client.cs b/Deepgram/Clients/Agent/v2/Websocket/Client.cs index 0118155..768ed66 100644 --- a/Deepgram/Clients/Agent/v2/Websocket/Client.cs +++ b/Deepgram/Clients/Agent/v2/Websocket/Client.cs @@ -542,6 +542,108 @@ await _clientWebSocket.SendAsync(new ArraySegment(new byte[1] { 0 }), WebS byte[] data = Encoding.ASCII.GetBytes(message.ToString()); await SendMessageImmediately(data); } + + /// + /// Sends a history conversation text message to the agent + /// + /// The role (user or assistant) who spoke the statement + /// The actual statement that was spoken + public async Task SendHistoryConversationText(string role, string content) + { + if (string.IsNullOrWhiteSpace(role)) + { + Log.Warning("SendHistoryConversationText", "Role cannot be null or empty"); + throw new ArgumentException("Role cannot be null or empty", nameof(role)); + } + + if (string.IsNullOrWhiteSpace(content)) + { + Log.Warning("SendHistoryConversationText", "Content cannot be null or empty"); + throw new ArgumentException("Content cannot be null or empty", nameof(content)); + } + + var historyMessage = new HistoryConversationText + { + Role = role, + Content = content + }; + + await SendHistoryConversationText(historyMessage); + } + + /// + /// Sends a history conversation text message to the agent using a schema object + /// + /// The history conversation text schema containing the message details + public async Task SendHistoryConversationText(HistoryConversationText historyConversationText) + { + if (historyConversationText == null) + { + Log.Warning("SendHistoryConversationText", "HistoryConversationText cannot be null"); + throw new ArgumentNullException(nameof(historyConversationText)); + } + + if (string.IsNullOrWhiteSpace(historyConversationText.Role)) + { + Log.Warning("SendHistoryConversationText", "Role cannot be null or empty"); + throw new ArgumentException("Role cannot be null or empty", nameof(historyConversationText.Role)); + } + + if (string.IsNullOrWhiteSpace(historyConversationText.Content)) + { + Log.Warning("SendHistoryConversationText", "Content cannot be null or empty"); + throw new ArgumentException("Content cannot be null or empty", nameof(historyConversationText.Content)); + } + + Log.Debug("SendHistoryConversationText", $"Sending History Conversation Text: {historyConversationText.Role} - {historyConversationText.Content}"); + + byte[] data = Encoding.UTF8.GetBytes(historyConversationText.ToString()); + await SendMessageImmediately(data); + } + + /// + /// Sends a history function calls message to the agent + /// + /// List of function call objects to send as history + public async Task SendHistoryFunctionCalls(List functionCalls) + { + if (functionCalls == null || functionCalls.Count == 0) + { + Log.Warning("SendHistoryFunctionCalls", "FunctionCalls cannot be null or empty"); + throw new ArgumentException("FunctionCalls cannot be null or empty", nameof(functionCalls)); + } + + var historyMessage = new HistoryFunctionCalls + { + FunctionCalls = functionCalls + }; + + await SendHistoryFunctionCalls(historyMessage); + } + + /// + /// Sends a history function calls message to the agent using a schema object + /// + /// The history function calls schema containing the function call details + public async Task SendHistoryFunctionCalls(HistoryFunctionCalls historyFunctionCalls) + { + if (historyFunctionCalls == null) + { + Log.Warning("SendHistoryFunctionCalls", "HistoryFunctionCalls cannot be null"); + throw new ArgumentNullException(nameof(historyFunctionCalls)); + } + + if (historyFunctionCalls.FunctionCalls == null || historyFunctionCalls.FunctionCalls.Count == 0) + { + Log.Warning("SendHistoryFunctionCalls", "FunctionCalls cannot be null or empty"); + throw new ArgumentException("FunctionCalls cannot be null or empty", nameof(historyFunctionCalls.FunctionCalls)); + } + + Log.Debug("SendHistoryFunctionCalls", $"Sending History Function Calls: {historyFunctionCalls.FunctionCalls.Count} calls"); + + byte[] data = Encoding.UTF8.GetBytes(historyFunctionCalls.ToString()); + await SendMessageImmediately(data); + } #endregion internal async Task ProcessKeepAlive() diff --git a/Deepgram/Models/Agent/v2/WebSocket/Agent.cs b/Deepgram/Models/Agent/v2/WebSocket/Agent.cs index 0004ee1..8355b4b 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/Agent.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/Agent.cs @@ -13,6 +13,13 @@ public record Agent [JsonPropertyName("language")] public string? Language { get; set; } = "en"; + /// + /// Conversation context including the history of messages and function calls + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("context")] + public Context? Context { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("listen")] public Listen Listen { get; set; } = new Listen(); diff --git a/Deepgram/Models/Agent/v2/WebSocket/AgentType.cs b/Deepgram/Models/Agent/v2/WebSocket/AgentType.cs index b415b38..ae6b2f9 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/AgentType.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/AgentType.cs @@ -24,6 +24,7 @@ public enum AgentType SettingsApplied, PromptUpdated, SpeakUpdated, + History, } public static class AgentClientTypes @@ -37,4 +38,5 @@ public static class AgentClientTypes public const string FunctionCallResponse = "FunctionCallResponse"; public const string KeepAlive = "KeepAlive"; public const string Close = "Close"; + public const string History = "History"; } diff --git a/Deepgram/Models/Agent/v2/WebSocket/Context.cs b/Deepgram/Models/Agent/v2/WebSocket/Context.cs new file mode 100644 index 0000000..435a3eb --- /dev/null +++ b/Deepgram/Models/Agent/v2/WebSocket/Context.cs @@ -0,0 +1,28 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + +namespace Deepgram.Models.Agent.v2.WebSocket; + +public record Context +{ + /// + /// Conversation history as a list of messages and function calls + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("messages")] + public List? Messages { get; set; } + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/Flags.cs b/Deepgram/Models/Agent/v2/WebSocket/Flags.cs new file mode 100644 index 0000000..f70bf10 --- /dev/null +++ b/Deepgram/Models/Agent/v2/WebSocket/Flags.cs @@ -0,0 +1,28 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + +namespace Deepgram.Models.Agent.v2.WebSocket; + +public record Flags +{ + /// + /// Enable or disable history message reporting + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("history")] + public bool? History { get; set; } = true; + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/HistoryConversationText.cs b/Deepgram/Models/Agent/v2/WebSocket/HistoryConversationText.cs new file mode 100644 index 0000000..77021f5 --- /dev/null +++ b/Deepgram/Models/Agent/v2/WebSocket/HistoryConversationText.cs @@ -0,0 +1,42 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + +namespace Deepgram.Models.Agent.v2.WebSocket; + +public record HistoryConversationText +{ + /// + /// Message type identifier for conversation text + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("type")] + public string? Type { get; set; } = "History"; + + /// + /// Identifies who spoke the statement + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// The actual statement that was spoken + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/HistoryFunctionCalls.cs b/Deepgram/Models/Agent/v2/WebSocket/HistoryFunctionCalls.cs new file mode 100644 index 0000000..0f9c597 --- /dev/null +++ b/Deepgram/Models/Agent/v2/WebSocket/HistoryFunctionCalls.cs @@ -0,0 +1,81 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + +namespace Deepgram.Models.Agent.v2.WebSocket; + +public record HistoryFunctionCalls +{ + /// + /// Message type identifier for function calls + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("type")] + public string? Type { get; set; } = "History"; + + /// + /// List of function call objects + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("function_calls")] + public List? FunctionCalls { get; set; } + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} + +public record HistoryFunctionCall +{ + /// + /// Unique identifier for the function call + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Name of the function called + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Indicates if the call was client-side or server-side + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("client_side")] + public bool? ClientSide { get; set; } + + /// + /// Arguments passed to the function + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("arguments")] + public string? Arguments { get; set; } + + /// + /// Response received from the function + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("response")] + public string? Response { get; set; } + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/Settings.cs b/Deepgram/Models/Agent/v2/WebSocket/Settings.cs index cbb8449..280dce3 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/Settings.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/Settings.cs @@ -26,6 +26,11 @@ public class SettingsSchema [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("mip_opt_out")] public bool? MipOptOut { get; set; } = false; + /// Agent flags configuration + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("flags")] + public Flags? Flags { get; set; } = new Flags(); [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("audio")] From 1c265a78db491e763981556276d41762044ff7da Mon Sep 17 00:00:00 2001 From: John Vajda Date: Sat, 2 Aug 2025 11:17:33 -0600 Subject: [PATCH 04/10] fixes agent example + test tags --- examples/agent/websocket/simple/Program.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/agent/websocket/simple/Program.cs b/examples/agent/websocket/simple/Program.cs index 35fe317..ef1e675 100644 --- a/examples/agent/websocket/simple/Program.cs +++ b/examples/agent/websocket/simple/Program.cs @@ -217,6 +217,11 @@ await agentClient.Subscribe(new EventHandler((sender, e) => settingsConfiguration.Agent.Listen.Provider.Type = "deepgram"; settingsConfiguration.Agent.Listen.Provider.Model = "nova-3"; settingsConfiguration.Agent.Listen.Provider.Keyterms = new List { "Deepgram" }; + settingsConfiguration.Agent.Speak.Provider.Type = "deepgram"; + settingsConfiguration.Agent.Speak.Provider.Model = "aura-2-thalia-en"; + + // Add tags to test the new tagging capabilities + settingsConfiguration.Tags = new List { "dotnet-example","live-agent-test" }; // To avoid issues with empty objects, Voice and Endpoint are instantiated as null. Construct them as needed. // settingsConfiguration.Agent.Speak.Provider.Voice = new CartesiaVoice(); From 0aaeadb4f10c062648bfbd5b839621a63df8d59d Mon Sep 17 00:00:00 2001 From: John Vajda Date: Sat, 2 Aug 2025 11:45:27 -0600 Subject: [PATCH 05/10] fixes agent example + test tags --- examples/agent/websocket/simple/Program.cs | 270 ++++++++++++++------- 1 file changed, 179 insertions(+), 91 deletions(-) diff --git a/examples/agent/websocket/simple/Program.cs b/examples/agent/websocket/simple/Program.cs index ef1e675..949f249 100644 --- a/examples/agent/websocket/simple/Program.cs +++ b/examples/agent/websocket/simple/Program.cs @@ -7,6 +7,7 @@ using Deepgram.Models.Authenticate.v1; using Deepgram.Models.Agent.v2.WebSocket; using System.Collections.Generic; +using System.Runtime.InteropServices; using PortAudioSharp; namespace SampleApp @@ -55,107 +56,38 @@ static async Task Main(string[] args) DeepgramWsClientOptions options = new DeepgramWsClientOptions(null, null, true); var agentClient = ClientFactory.CreateAgentWebSocketClient(apiKey: "", options: options); - // current time - var lastAudioTime = DateTime.Now; - var audioFileCount = 0; + // Initialize conversation + Console.WriteLine("šŸŽ¤ Ready for conversation! Speak into your microphone..."); // Subscribe to the EventResponseReceived event await agentClient.Subscribe(new EventHandler((sender, e) => { Console.WriteLine($"----> {e.Type} received"); })); - await agentClient.Subscribe(new EventHandler((sender, e) => + await agentClient.Subscribe(new EventHandler((sender, e) => { Console.WriteLine($"----> {e.Type} received"); - // if the last audio response is more than 5 seconds ago, add a wav header - if (DateTime.Now.Subtract(lastAudioTime).TotalSeconds > 7) + if (e.Stream != null && e.Stream.Length > 0) { - audioFileCount = audioFileCount + 1; // increment the audio file count + var audioData = e.Stream.ToArray(); + Console.WriteLine($"šŸ”Š Queueing {audioData.Length} bytes of agent speech for playback"); - // delete the file if it exists - if (File.Exists($"output_{audioFileCount}.wav")) - { - File.Delete($"output_{audioFileCount}.wav"); - } - - using (BinaryWriter writer = new BinaryWriter(File.Open($"output_{audioFileCount}.wav", FileMode.Append))) - { - Console.WriteLine("Adding WAV header to output.wav"); - byte[] wavHeader = new byte[44]; - int sampleRate = 48000; - short bitsPerSample = 16; - short channels = 1; - int byteRate = sampleRate * channels * (bitsPerSample / 8); - short blockAlign = (short)(channels * (bitsPerSample / 8)); - - wavHeader[0] = 0x52; // R - wavHeader[1] = 0x49; // I - wavHeader[2] = 0x46; // F - wavHeader[3] = 0x46; // F - wavHeader[4] = 0x00; // Placeholder for file size (will be updated later) - wavHeader[5] = 0x00; // Placeholder for file size (will be updated later) - wavHeader[6] = 0x00; // Placeholder for file size (will be updated later) - wavHeader[7] = 0x00; // Placeholder for file size (will be updated later) - wavHeader[8] = 0x57; // W - wavHeader[9] = 0x41; // A - wavHeader[10] = 0x56; // V - wavHeader[11] = 0x45; // E - wavHeader[12] = 0x66; // f - wavHeader[13] = 0x6D; // m - wavHeader[14] = 0x74; // t - wavHeader[15] = 0x20; // Space - wavHeader[16] = 0x10; // Subchunk1Size (16 for PCM) - wavHeader[17] = 0x00; // Subchunk1Size - wavHeader[18] = 0x00; // Subchunk1Size - wavHeader[19] = 0x00; // Subchunk1Size - wavHeader[20] = 0x01; // AudioFormat (1 for PCM) - wavHeader[21] = 0x00; // AudioFormat - wavHeader[22] = (byte)channels; // NumChannels - wavHeader[23] = 0x00; // NumChannels - wavHeader[24] = (byte)(sampleRate & 0xFF); // SampleRate - wavHeader[25] = (byte)((sampleRate >> 8) & 0xFF); // SampleRate - wavHeader[26] = (byte)((sampleRate >> 16) & 0xFF); // SampleRate - wavHeader[27] = (byte)((sampleRate >> 24) & 0xFF); // SampleRate - wavHeader[28] = (byte)(byteRate & 0xFF); // ByteRate - wavHeader[29] = (byte)((byteRate >> 8) & 0xFF); // ByteRate - wavHeader[30] = (byte)((byteRate >> 16) & 0xFF); // ByteRate - wavHeader[31] = (byte)((byteRate >> 24) & 0xFF); // ByteRate - wavHeader[32] = (byte)blockAlign; // BlockAlign - wavHeader[33] = 0x00; // BlockAlign - wavHeader[34] = (byte)bitsPerSample; // BitsPerSample - wavHeader[35] = 0x00; // BitsPerSample - wavHeader[36] = 0x64; // d - wavHeader[37] = 0x61; // a - wavHeader[38] = 0x74; // t - wavHeader[39] = 0x61; // a - wavHeader[40] = 0x00; // Placeholder for data chunk size (will be updated later) - wavHeader[41] = 0x00; // Placeholder for data chunk size (will be updated later) - wavHeader[42] = 0x00; // Placeholder for data chunk size (will be updated later) - wavHeader[43] = 0x00; // Placeholder for data chunk size (will be updated later) - - writer.Write(wavHeader); - } + // Play audio through speakers + PlayAudioThroughSpeakers(audioData); } - - if (e.Stream != null) + else { - using (BinaryWriter writer = new BinaryWriter(File.Open($"output_{audioFileCount}.wav", FileMode.Append))) - { - writer.Write(e.Stream.ToArray()); - } + Console.WriteLine($"āš ļø Received empty audio stream"); } - - // record the last audio time - lastAudioTime = DateTime.Now; })); - await agentClient.Subscribe(new EventHandler((sender, e) => + await agentClient.Subscribe(new EventHandler((sender, e) => { - Console.WriteLine($"----> {e} received"); + Console.WriteLine($"----> {e} received - Agent finished speaking šŸŽ¤"); })); await agentClient.Subscribe(new EventHandler((sender, e) => { - Console.WriteLine($"----> {e} received"); + Console.WriteLine($"----> {e} received - Agent is speaking šŸ—£ļø"); })); await agentClient.Subscribe(new EventHandler((sender, e) => { @@ -171,7 +103,7 @@ await agentClient.Subscribe(new EventHandler((sende })); await agentClient.Subscribe(new EventHandler((sender, e) => { - Console.WriteLine($"----> {e} received"); + Console.WriteLine($"----> {e} received - User is speaking šŸ‘¤"); })); await agentClient.Subscribe(new EventHandler((sender, e) => { @@ -210,10 +142,15 @@ await agentClient.Subscribe(new EventHandler((sender, e) => var settingsConfiguration = new SettingsSchema(); settingsConfiguration.Agent.Think.Provider.Type = "open_ai"; settingsConfiguration.Agent.Think.Provider.Model = "gpt-4o-mini"; - settingsConfiguration.Audio.Output.SampleRate = 16000; - settingsConfiguration.Audio.Output.Container = "wav"; - settingsConfiguration.Audio.Input.SampleRate = 44100; - settingsConfiguration.Agent.Greeting = "Hello, how can I help you today?"; + + // Configure audio settings - keep your input format, fix output + settingsConfiguration.Audio.Input.Encoding = "linear16"; + settingsConfiguration.Audio.Input.SampleRate = 24000; + settingsConfiguration.Audio.Output.Encoding = "linear16"; // Use linear16 for output too + settingsConfiguration.Audio.Output.SampleRate = 24000; + settingsConfiguration.Audio.Output.Container = "none"; + + settingsConfiguration.Agent.Greeting = "Hello! How can I help you today?"; settingsConfiguration.Agent.Listen.Provider.Type = "deepgram"; settingsConfiguration.Agent.Listen.Provider.Model = "nova-3"; settingsConfiguration.Agent.Listen.Provider.Keyterms = new List { "Deepgram" }; @@ -236,18 +173,42 @@ await agentClient.Subscribe(new EventHandler((sender, e) => return; } - // Microphone streaming + // Microphone streaming with debugging Console.WriteLine("Starting microphone..."); Microphone microphone = null; - try + int audioDataCounter = 0; + + try { - microphone = new Microphone(agentClient.SendBinary); + // Create microphone with proper sample rate and debugging + microphone = new Microphone( + push_callback: (audioData, length) => + { + audioDataCounter++; + Console.WriteLine($"[MIC] Captured audio chunk #{audioDataCounter}: {length} bytes"); + + // Create array with actual length + byte[] actualData = new byte[length]; + Array.Copy(audioData, actualData, length); + + // Send to agent + agentClient.SendBinary(actualData); + }, + rate: 24000, // Match the agent's expected input rate (24kHz) + chunkSize: 8192, // Standard chunk size + channels: 1, // Mono + device_index: PortAudio.DefaultInputDevice, + format: SampleFormat.Int16 + ); + microphone.Start(); - Console.WriteLine("Microphone started successfully. Waiting for audio input..."); + Console.WriteLine("Microphone started successfully. Speak into your microphone now!"); + Console.WriteLine("You should see '[MIC] Captured audio chunk' messages when speaking..."); } catch (Exception ex) { Console.WriteLine($"Error starting microphone: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); return; } @@ -271,6 +232,133 @@ await agentClient.Subscribe(new EventHandler((sender, e) => { Console.WriteLine($"Exception: {ex.Message}"); } + } + + // Audio playback queue and position tracking + private static Queue audioQueue = new Queue(); + private static byte[]? currentAudioBuffer = null; + private static int audioPosition = 0; + private static readonly object audioLock = new object(); + + /// + /// Plays audio data through the system's default output device (speakers) + /// + /// PCM audio data to play + static void PlayAudioThroughSpeakers(byte[] audioData) + { + try + { + lock (audioLock) + { + // Add to queue for playback + audioQueue.Enqueue(audioData); + } + + // Start playback stream if not already running + StartAudioPlayback(); + } + catch (Exception ex) + { + Console.WriteLine($"āŒ Error queuing audio: {ex.Message}"); + } + } + + private static PortAudioSharp.Stream? _outputStream = null; + + private static void StartAudioPlayback() + { + if (_outputStream != null) + return; // Already playing + + try + { + // Get default output device + int outputDevice = PortAudio.DefaultOutputDevice; + if (outputDevice == PortAudio.NoDevice) + { + Console.WriteLine("āš ļø No default output device found for audio playback"); + return; + } + + var deviceInfo = PortAudio.GetDeviceInfo(outputDevice); + Console.WriteLine($"šŸ”Š Playing through: {deviceInfo.name}"); + + // Set up output stream parameters + var outputParams = new PortAudioSharp.StreamParameters + { + device = outputDevice, + channelCount = 1, // mono + sampleFormat = PortAudioSharp.SampleFormat.Int16, + suggestedLatency = deviceInfo.defaultLowOutputLatency, + hostApiSpecificStreamInfo = IntPtr.Zero + }; + + // Create and start the output stream + _outputStream = new PortAudioSharp.Stream( + inParams: null, + outParams: outputParams, + sampleRate: 24000, // Match agent output (24kHz) + framesPerBuffer: 512, + streamFlags: PortAudioSharp.StreamFlags.ClipOff, + callback: OutputCallback, + userData: IntPtr.Zero + ); + + _outputStream.Start(); + } + catch (Exception ex) + { + Console.WriteLine($"āŒ Error starting audio playback: {ex.Message}"); + _outputStream = null; + } + } + + private static PortAudioSharp.StreamCallbackResult OutputCallback(nint input, nint output, uint frameCount, ref PortAudioSharp.StreamCallbackTimeInfo timeInfo, PortAudioSharp.StreamCallbackFlags statusFlags, nint userDataPtr) + { + lock (audioLock) + { + int bytesToWrite = (int)(frameCount * sizeof(Int16)); // 16-bit samples + byte[] outputBuffer = new byte[bytesToWrite]; + + int bytesWritten = 0; + while (bytesWritten < bytesToWrite) + { + // Get next buffer if current one is exhausted + if (currentAudioBuffer == null || audioPosition >= currentAudioBuffer.Length) + { + if (audioQueue.Count > 0) + { + currentAudioBuffer = audioQueue.Dequeue(); + audioPosition = 0; + Console.WriteLine($"šŸ”Š Playing new audio buffer: {currentAudioBuffer.Length} bytes (Queue: {audioQueue.Count} remaining)"); + } + else + { + // No more audio, fill with silence but KEEP stream running for next audio + for (int i = bytesWritten; i < bytesToWrite; i++) + outputBuffer[i] = 0; + + Marshal.Copy(outputBuffer, 0, output, bytesToWrite); + // DON'T stop the stream - keep it running for next conversation + return PortAudioSharp.StreamCallbackResult.Continue; + } + } + + // Copy data from current buffer + int remainingInBuffer = currentAudioBuffer.Length - audioPosition; + int remainingToWrite = bytesToWrite - bytesWritten; + int bytesToCopy = Math.Min(remainingInBuffer, remainingToWrite); + + Array.Copy(currentAudioBuffer, audioPosition, outputBuffer, bytesWritten, bytesToCopy); + audioPosition += bytesToCopy; + bytesWritten += bytesToCopy; + } + + // Copy to output + Marshal.Copy(outputBuffer, 0, output, bytesToWrite); + } + + return PortAudioSharp.StreamCallbackResult.Continue; } } } From 35556e8cb5dc2f42abaeeede95667bb6985c9709 Mon Sep 17 00:00:00 2001 From: John Vajda Date: Mon, 4 Aug 2025 16:16:22 -0600 Subject: [PATCH 06/10] code rabbit feedback --- examples/agent/websocket/simple/Program.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/agent/websocket/simple/Program.cs b/examples/agent/websocket/simple/Program.cs index 949f249..c78f354 100644 --- a/examples/agent/websocket/simple/Program.cs +++ b/examples/agent/websocket/simple/Program.cs @@ -224,6 +224,14 @@ await agentClient.Subscribe(new EventHandler((sender, e) => // Stop the connection await agentClient.Stop(); + // Stop and dispose PortAudio output stream + if (_outputStream != null) + { + _outputStream.Stop(); + _outputStream.Dispose(); + _outputStream = null; + } + // Terminate Libraries Deepgram.Microphone.Library.Terminate(); Deepgram.Library.Terminate(); From 87b3bb9179412789876c372a9ce6f7041747c8e8 Mon Sep 17 00:00:00 2001 From: John Vajda Date: Sat, 9 Aug 2025 05:23:19 -0600 Subject: [PATCH 07/10] chore: adds dx code owners (#387) --- .github/CODEOWNERS | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..67b5e5f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# Global code owners - these users will be requested for review on all API SPEC PRs +# DX TEAM Members +* @lukeocodes + +# Future Reference: you can also specify owners for specific paths if needed: +# /src/ @username1 @username2 \ No newline at end of file From 2bf4dc1ba4249c932bb54d86266c3276495da27b Mon Sep 17 00:00:00 2001 From: John Vajda Date: Wed, 23 Jul 2025 18:37:54 -0600 Subject: [PATCH 08/10] feat: add support for agent context history --- .../ClientTests/AgentHistoryTests.cs | 535 ++++++++++++++++++ Deepgram/Clients/Agent/v2/Websocket/Client.cs | 102 ++++ Deepgram/Models/Agent/v2/WebSocket/Agent.cs | 7 + .../Models/Agent/v2/WebSocket/AgentType.cs | 2 + Deepgram/Models/Agent/v2/WebSocket/Context.cs | 28 + Deepgram/Models/Agent/v2/WebSocket/Flags.cs | 28 + .../v2/WebSocket/HistoryConversationText.cs | 42 ++ .../v2/WebSocket/HistoryFunctionCalls.cs | 81 +++ .../Models/Agent/v2/WebSocket/Settings.cs | 5 + 9 files changed, 830 insertions(+) create mode 100644 Deepgram.Tests/UnitTests/ClientTests/AgentHistoryTests.cs create mode 100644 Deepgram/Models/Agent/v2/WebSocket/Context.cs create mode 100644 Deepgram/Models/Agent/v2/WebSocket/Flags.cs create mode 100644 Deepgram/Models/Agent/v2/WebSocket/HistoryConversationText.cs create mode 100644 Deepgram/Models/Agent/v2/WebSocket/HistoryFunctionCalls.cs diff --git a/Deepgram.Tests/UnitTests/ClientTests/AgentHistoryTests.cs b/Deepgram.Tests/UnitTests/ClientTests/AgentHistoryTests.cs new file mode 100644 index 0000000..e2119a6 --- /dev/null +++ b/Deepgram.Tests/UnitTests/ClientTests/AgentHistoryTests.cs @@ -0,0 +1,535 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using Bogus; +using FluentAssertions; +using FluentAssertions.Execution; +using NSubstitute; +using System.Text.Json; +using Deepgram.Models.Authenticate.v1; +using Deepgram.Models.Agent.v2.WebSocket; +using Deepgram.Clients.Agent.v2.WebSocket; + +namespace Deepgram.Tests.UnitTests.ClientTests; + +public class AgentHistoryTests +{ + DeepgramWsClientOptions _options; + string _apiKey; + + [SetUp] + public void Setup() + { + _apiKey = new Faker().Random.Guid().ToString(); + _options = new DeepgramWsClientOptions(_apiKey) + { + OnPrem = true, + }; + } + + #region SendHistoryConversationText Tests + + [Test] + public async Task SendHistoryConversationText_With_String_Parameters_Should_Send_Message() + { + // Input and Output + var role = "user"; + var content = "What's the weather like today?"; + var agentClient = Substitute.For(_apiKey, _options); + + // Mock the SendMessageImmediately method + agentClient.When(x => x.SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any())) + .DoNotCallBase(); + + // Act + await agentClient.SendHistoryConversationText(role, content); + + // Assert + await agentClient.Received(1).SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SendHistoryConversationText_With_Schema_Should_Send_Message() + { + // Input and Output + var schema = new HistoryConversationText + { + Role = "assistant", + Content = "Based on the current data, it's sunny with a temperature of 72°F." + }; + var agentClient = Substitute.For(_apiKey, _options); + + // Mock the SendMessageImmediately method + agentClient.When(x => x.SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any())) + .DoNotCallBase(); + + // Act + await agentClient.SendHistoryConversationText(schema); + + // Assert + await agentClient.Received(1).SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SendHistoryConversationText_With_Null_Role_Should_Throw_ArgumentException() + { + // Input and Output + string? role = null; + var content = "Test content"; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryConversationText(role!, content)) + .Should().ThrowAsync() + .WithMessage("Role cannot be null or empty*"); + exception.And.ParamName.Should().Be("role"); + } + + [Test] + public async Task SendHistoryConversationText_With_Empty_Role_Should_Throw_ArgumentException() + { + // Input and Output + var role = ""; + var content = "Test content"; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryConversationText(role, content)) + .Should().ThrowAsync() + .WithMessage("Role cannot be null or empty*"); + exception.And.ParamName.Should().Be("role"); + } + + [Test] + public async Task SendHistoryConversationText_With_Null_Content_Should_Throw_ArgumentException() + { + // Input and Output + var role = "user"; + string? content = null; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryConversationText(role, content!)) + .Should().ThrowAsync() + .WithMessage("Content cannot be null or empty*"); + exception.And.ParamName.Should().Be("content"); + } + + [Test] + public async Task SendHistoryConversationText_With_Null_Schema_Should_Throw_ArgumentNullException() + { + // Input and Output + HistoryConversationText? schema = null; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryConversationText(schema!)) + .Should().ThrowAsync(); + exception.And.ParamName.Should().Be("historyConversationText"); + } + + #endregion + + #region SendHistoryFunctionCalls Tests + + [Test] + public async Task SendHistoryFunctionCalls_With_List_Should_Send_Message() + { + // Input and Output + var functionCalls = new List + { + new HistoryFunctionCall + { + Id = "fc_12345678-90ab-cdef-1234-567890abcdef", + Name = "check_order_status", + ClientSide = true, + Arguments = "{\"order_id\": \"ORD-123456\"}", + Response = "Order #123456 status: Shipped - Expected delivery date: 2024-03-15" + } + }; + var agentClient = Substitute.For(_apiKey, _options); + + // Mock the SendMessageImmediately method + agentClient.When(x => x.SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any())) + .DoNotCallBase(); + + // Act + await agentClient.SendHistoryFunctionCalls(functionCalls); + + // Assert + await agentClient.Received(1).SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SendHistoryFunctionCalls_With_Schema_Should_Send_Message() + { + // Input and Output + var schema = new HistoryFunctionCalls + { + FunctionCalls = new List + { + new HistoryFunctionCall + { + Id = "fc_12345678-90ab-cdef-1234-567890abcdef", + Name = "get_weather", + ClientSide = false, + Arguments = "{\"location\": \"New York\"}", + Response = "Temperature: 22°C, Conditions: Sunny" + } + } + }; + var agentClient = Substitute.For(_apiKey, _options); + + // Mock the SendMessageImmediately method + agentClient.When(x => x.SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any())) + .DoNotCallBase(); + + // Act + await agentClient.SendHistoryFunctionCalls(schema); + + // Assert + await agentClient.Received(1).SendMessageImmediately(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SendHistoryFunctionCalls_With_Null_List_Should_Throw_ArgumentException() + { + // Input and Output + List? functionCalls = null; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryFunctionCalls(functionCalls!)) + .Should().ThrowAsync() + .WithMessage("FunctionCalls cannot be null or empty*"); + exception.And.ParamName.Should().Be("functionCalls"); + } + + [Test] + public async Task SendHistoryFunctionCalls_With_Empty_List_Should_Throw_ArgumentException() + { + // Input and Output + var functionCalls = new List(); + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryFunctionCalls(functionCalls)) + .Should().ThrowAsync() + .WithMessage("FunctionCalls cannot be null or empty*"); + exception.And.ParamName.Should().Be("functionCalls"); + } + + [Test] + public async Task SendHistoryFunctionCalls_With_Null_Schema_Should_Throw_ArgumentNullException() + { + // Input and Output + HistoryFunctionCalls? schema = null; + var agentClient = new Client(_apiKey, _options); + + // Act & Assert + var exception = await agentClient.Invoking(y => y.SendHistoryFunctionCalls(schema!)) + .Should().ThrowAsync(); + exception.And.ParamName.Should().Be("historyFunctionCalls"); + } + + #endregion + + #region Model Serialization Tests + + [Test] + public void HistoryConversationText_Should_Have_Correct_Type() + { + // Input and Output + var schema = new HistoryConversationText + { + Role = "user", + Content = "Test message" + }; + + // Assert + using (new AssertionScope()) + { + schema.Type.Should().Be("History"); + schema.Role.Should().Be("user"); + schema.Content.Should().Be("Test message"); + } + } + + [Test] + public void HistoryConversationText_ToString_Should_Return_Valid_Json() + { + // Input and Output + var schema = new HistoryConversationText + { + Role = "assistant", + Content = "Based on the current data, it's sunny with a temperature of 72°F (22°C)." + }; + + // Act + var result = schema.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("History"); + result.Should().Contain("assistant"); + result.Should().Contain("sunny with a temperature"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("type").GetString().Should().Be("History"); + parsed.RootElement.GetProperty("role").GetString().Should().Be("assistant"); + parsed.RootElement.GetProperty("content").GetString().Should().Contain("sunny"); + } + } + + [Test] + public void HistoryFunctionCalls_Should_Have_Correct_Type() + { + // Input and Output + var schema = new HistoryFunctionCalls + { + FunctionCalls = new List + { + new HistoryFunctionCall + { + Id = "test-id", + Name = "test_function", + ClientSide = true, + Arguments = "{}", + Response = "success" + } + } + }; + + // Assert + using (new AssertionScope()) + { + schema.Type.Should().Be("History"); + schema.FunctionCalls.Should().HaveCount(1); + schema.FunctionCalls![0].Name.Should().Be("test_function"); + } + } + + [Test] + public void HistoryFunctionCalls_ToString_Should_Return_Valid_Json() + { + // Input and Output + var schema = new HistoryFunctionCalls + { + FunctionCalls = new List + { + new HistoryFunctionCall + { + Id = "fc_12345678-90ab-cdef-1234-567890abcdef", + Name = "check_order_status", + ClientSide = true, + Arguments = "simple_argument_value", + Response = "Order #123456 status: Shipped" + } + } + }; + + // Act + var result = schema.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("History"); + result.Should().Contain("check_order_status"); + result.Should().Contain("fc_12345678-90ab-cdef-1234-567890abcdef"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("type").GetString().Should().Be("History"); + parsed.RootElement.GetProperty("function_calls").GetArrayLength().Should().Be(1); + var functionCall = parsed.RootElement.GetProperty("function_calls")[0]; + functionCall.GetProperty("name").GetString().Should().Be("check_order_status"); + functionCall.GetProperty("client_side").GetBoolean().Should().BeTrue(); + } + } + + [Test] + public void HistoryFunctionCall_Should_Serialize_ClientSide_As_Snake_Case() + { + // Input and Output + var functionCall = new HistoryFunctionCall + { + Id = "test-id", + Name = "test_function", + ClientSide = false, + Arguments = "{}", + Response = "success" + }; + + // Act + var result = functionCall.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("client_side"); + result.Should().Contain("false"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("client_side").GetBoolean().Should().BeFalse(); + } + } + + [Test] + public void Flags_Should_Have_Default_History_True() + { + // Arrange & Act + var flags = new Flags(); + + // Assert + using (new AssertionScope()) + { + flags.History.Should().BeTrue(); + } + } + + [Test] + public void Flags_Should_Be_Settable() + { + // Arrange & Act + var flags = new Flags + { + History = false + }; + + // Assert + using (new AssertionScope()) + { + flags.History.Should().BeFalse(); + } + } + + [Test] + public void Flags_ToString_Should_Return_Valid_Json() + { + // Input and Output + var flags = new Flags + { + History = false + }; + + // Act + var result = flags.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("history"); + result.Should().Contain("false"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("history").GetBoolean().Should().BeFalse(); + } + } + + [Test] + public void Context_Should_Allow_Null_Messages() + { + // Arrange & Act + var context = new Context(); + + // Assert + using (new AssertionScope()) + { + context.Messages.Should().BeNull(); + } + } + + [Test] + public void Context_Should_Be_Settable() + { + // Arrange & Act + var context = new Context + { + Messages = new List + { + new HistoryConversationText { Role = "user", Content = "Hello" } + } + }; + + // Assert + using (new AssertionScope()) + { + context.Messages.Should().HaveCount(1); + } + } + + [Test] + public void Agent_Context_Should_Be_Settable() + { + // Arrange & Act + var agent = new Agent + { + Context = new Context + { + Messages = new List + { + new HistoryConversationText { Role = "user", Content = "Test" } + } + } + }; + + // Assert + using (new AssertionScope()) + { + agent.Context.Should().NotBeNull(); + agent.Context!.Messages.Should().HaveCount(1); + } + } + + [Test] + public void SettingsSchema_Flags_Should_Have_Default_Value() + { + // Arrange & Act + var settings = new SettingsSchema(); + + // Assert + using (new AssertionScope()) + { + settings.Flags.Should().NotBeNull(); + settings.Flags!.History.Should().BeTrue(); + } + } + + [Test] + public void SettingsSchema_With_History_Disabled_Should_Serialize_Correctly() + { + // Arrange + var settings = new SettingsSchema + { + Flags = new Flags { History = false } + }; + + // Act + var result = settings.ToString(); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result.Should().Contain("flags"); + result.Should().Contain("history"); + result.Should().Contain("false"); + + // Verify it's valid JSON by parsing it + var parsed = JsonDocument.Parse(result); + parsed.RootElement.GetProperty("flags").GetProperty("history").GetBoolean().Should().BeFalse(); + } + } + + #endregion +} \ No newline at end of file diff --git a/Deepgram/Clients/Agent/v2/Websocket/Client.cs b/Deepgram/Clients/Agent/v2/Websocket/Client.cs index 0118155..768ed66 100644 --- a/Deepgram/Clients/Agent/v2/Websocket/Client.cs +++ b/Deepgram/Clients/Agent/v2/Websocket/Client.cs @@ -542,6 +542,108 @@ await _clientWebSocket.SendAsync(new ArraySegment(new byte[1] { 0 }), WebS byte[] data = Encoding.ASCII.GetBytes(message.ToString()); await SendMessageImmediately(data); } + + /// + /// Sends a history conversation text message to the agent + /// + /// The role (user or assistant) who spoke the statement + /// The actual statement that was spoken + public async Task SendHistoryConversationText(string role, string content) + { + if (string.IsNullOrWhiteSpace(role)) + { + Log.Warning("SendHistoryConversationText", "Role cannot be null or empty"); + throw new ArgumentException("Role cannot be null or empty", nameof(role)); + } + + if (string.IsNullOrWhiteSpace(content)) + { + Log.Warning("SendHistoryConversationText", "Content cannot be null or empty"); + throw new ArgumentException("Content cannot be null or empty", nameof(content)); + } + + var historyMessage = new HistoryConversationText + { + Role = role, + Content = content + }; + + await SendHistoryConversationText(historyMessage); + } + + /// + /// Sends a history conversation text message to the agent using a schema object + /// + /// The history conversation text schema containing the message details + public async Task SendHistoryConversationText(HistoryConversationText historyConversationText) + { + if (historyConversationText == null) + { + Log.Warning("SendHistoryConversationText", "HistoryConversationText cannot be null"); + throw new ArgumentNullException(nameof(historyConversationText)); + } + + if (string.IsNullOrWhiteSpace(historyConversationText.Role)) + { + Log.Warning("SendHistoryConversationText", "Role cannot be null or empty"); + throw new ArgumentException("Role cannot be null or empty", nameof(historyConversationText.Role)); + } + + if (string.IsNullOrWhiteSpace(historyConversationText.Content)) + { + Log.Warning("SendHistoryConversationText", "Content cannot be null or empty"); + throw new ArgumentException("Content cannot be null or empty", nameof(historyConversationText.Content)); + } + + Log.Debug("SendHistoryConversationText", $"Sending History Conversation Text: {historyConversationText.Role} - {historyConversationText.Content}"); + + byte[] data = Encoding.UTF8.GetBytes(historyConversationText.ToString()); + await SendMessageImmediately(data); + } + + /// + /// Sends a history function calls message to the agent + /// + /// List of function call objects to send as history + public async Task SendHistoryFunctionCalls(List functionCalls) + { + if (functionCalls == null || functionCalls.Count == 0) + { + Log.Warning("SendHistoryFunctionCalls", "FunctionCalls cannot be null or empty"); + throw new ArgumentException("FunctionCalls cannot be null or empty", nameof(functionCalls)); + } + + var historyMessage = new HistoryFunctionCalls + { + FunctionCalls = functionCalls + }; + + await SendHistoryFunctionCalls(historyMessage); + } + + /// + /// Sends a history function calls message to the agent using a schema object + /// + /// The history function calls schema containing the function call details + public async Task SendHistoryFunctionCalls(HistoryFunctionCalls historyFunctionCalls) + { + if (historyFunctionCalls == null) + { + Log.Warning("SendHistoryFunctionCalls", "HistoryFunctionCalls cannot be null"); + throw new ArgumentNullException(nameof(historyFunctionCalls)); + } + + if (historyFunctionCalls.FunctionCalls == null || historyFunctionCalls.FunctionCalls.Count == 0) + { + Log.Warning("SendHistoryFunctionCalls", "FunctionCalls cannot be null or empty"); + throw new ArgumentException("FunctionCalls cannot be null or empty", nameof(historyFunctionCalls.FunctionCalls)); + } + + Log.Debug("SendHistoryFunctionCalls", $"Sending History Function Calls: {historyFunctionCalls.FunctionCalls.Count} calls"); + + byte[] data = Encoding.UTF8.GetBytes(historyFunctionCalls.ToString()); + await SendMessageImmediately(data); + } #endregion internal async Task ProcessKeepAlive() diff --git a/Deepgram/Models/Agent/v2/WebSocket/Agent.cs b/Deepgram/Models/Agent/v2/WebSocket/Agent.cs index 0004ee1..8355b4b 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/Agent.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/Agent.cs @@ -13,6 +13,13 @@ public record Agent [JsonPropertyName("language")] public string? Language { get; set; } = "en"; + /// + /// Conversation context including the history of messages and function calls + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("context")] + public Context? Context { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("listen")] public Listen Listen { get; set; } = new Listen(); diff --git a/Deepgram/Models/Agent/v2/WebSocket/AgentType.cs b/Deepgram/Models/Agent/v2/WebSocket/AgentType.cs index b415b38..ae6b2f9 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/AgentType.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/AgentType.cs @@ -24,6 +24,7 @@ public enum AgentType SettingsApplied, PromptUpdated, SpeakUpdated, + History, } public static class AgentClientTypes @@ -37,4 +38,5 @@ public static class AgentClientTypes public const string FunctionCallResponse = "FunctionCallResponse"; public const string KeepAlive = "KeepAlive"; public const string Close = "Close"; + public const string History = "History"; } diff --git a/Deepgram/Models/Agent/v2/WebSocket/Context.cs b/Deepgram/Models/Agent/v2/WebSocket/Context.cs new file mode 100644 index 0000000..435a3eb --- /dev/null +++ b/Deepgram/Models/Agent/v2/WebSocket/Context.cs @@ -0,0 +1,28 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + +namespace Deepgram.Models.Agent.v2.WebSocket; + +public record Context +{ + /// + /// Conversation history as a list of messages and function calls + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("messages")] + public List? Messages { get; set; } + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/Flags.cs b/Deepgram/Models/Agent/v2/WebSocket/Flags.cs new file mode 100644 index 0000000..f70bf10 --- /dev/null +++ b/Deepgram/Models/Agent/v2/WebSocket/Flags.cs @@ -0,0 +1,28 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + +namespace Deepgram.Models.Agent.v2.WebSocket; + +public record Flags +{ + /// + /// Enable or disable history message reporting + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("history")] + public bool? History { get; set; } = true; + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/HistoryConversationText.cs b/Deepgram/Models/Agent/v2/WebSocket/HistoryConversationText.cs new file mode 100644 index 0000000..77021f5 --- /dev/null +++ b/Deepgram/Models/Agent/v2/WebSocket/HistoryConversationText.cs @@ -0,0 +1,42 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + +namespace Deepgram.Models.Agent.v2.WebSocket; + +public record HistoryConversationText +{ + /// + /// Message type identifier for conversation text + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("type")] + public string? Type { get; set; } = "History"; + + /// + /// Identifies who spoke the statement + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// The actual statement that was spoken + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/HistoryFunctionCalls.cs b/Deepgram/Models/Agent/v2/WebSocket/HistoryFunctionCalls.cs new file mode 100644 index 0000000..0f9c597 --- /dev/null +++ b/Deepgram/Models/Agent/v2/WebSocket/HistoryFunctionCalls.cs @@ -0,0 +1,81 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + +namespace Deepgram.Models.Agent.v2.WebSocket; + +public record HistoryFunctionCalls +{ + /// + /// Message type identifier for function calls + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("type")] + public string? Type { get; set; } = "History"; + + /// + /// List of function call objects + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("function_calls")] + public List? FunctionCalls { get; set; } + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} + +public record HistoryFunctionCall +{ + /// + /// Unique identifier for the function call + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Name of the function called + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Indicates if the call was client-side or server-side + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("client_side")] + public bool? ClientSide { get; set; } + + /// + /// Arguments passed to the function + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("arguments")] + public string? Arguments { get; set; } + + /// + /// Response received from the function + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("response")] + public string? Response { get; set; } + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} \ No newline at end of file diff --git a/Deepgram/Models/Agent/v2/WebSocket/Settings.cs b/Deepgram/Models/Agent/v2/WebSocket/Settings.cs index 8c33cdc..b25e195 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/Settings.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/Settings.cs @@ -33,6 +33,11 @@ public class SettingsSchema [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("mip_opt_out")] public bool? MipOptOut { get; set; } = false; + /// Agent flags configuration + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("flags")] + public Flags? Flags { get; set; } = new Flags(); [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("audio")] From 28cd9d4a1517e6bcd5b2fc14f9e2e59a2f0b58a5 Mon Sep 17 00:00:00 2001 From: John Vajda Date: Tue, 12 Aug 2025 16:12:00 -0600 Subject: [PATCH 09/10] feat: example for context history + final changes --- Deepgram/Clients/Agent/v2/Websocket/Client.cs | 61 ++ .../Interfaces/v2/IAgentWebSocketClient.cs | 37 + .../WebSocket/FunctionCallRequestResponse.cs | 12 +- .../WebSocket/FunctionCallResponseSchema.cs | 17 +- .../Agent/v2/WebSocket/HistoryResponse.cs | 50 ++ .../context-history/ContextHistory.csproj | 19 + .../websocket/context-history/Program.cs | 665 ++++++++++++++++++ 7 files changed, 856 insertions(+), 5 deletions(-) create mode 100644 Deepgram/Models/Agent/v2/WebSocket/HistoryResponse.cs create mode 100644 examples/agent/websocket/context-history/ContextHistory.csproj create mode 100644 examples/agent/websocket/context-history/Program.cs diff --git a/Deepgram/Clients/Agent/v2/Websocket/Client.cs b/Deepgram/Clients/Agent/v2/Websocket/Client.cs index 768ed66..4510a46 100644 --- a/Deepgram/Clients/Agent/v2/Websocket/Client.cs +++ b/Deepgram/Clients/Agent/v2/Websocket/Client.cs @@ -45,6 +45,7 @@ public Client(string? apiKey = null, IDeepgramClientOptions? options = null) : b private event EventHandler? _injectionRefusedReceived; private event EventHandler? _promptUpdatedReceived; private event EventHandler? _speakUpdatedReceived; + private event EventHandler? _historyReceived; #endregion /// @@ -383,6 +384,24 @@ public async Task Subscribe(EventHandler eventHandle return true; } + /// + /// Subscribe to a History event from the Deepgram API + /// + /// True if successful + public async Task Subscribe(EventHandler eventHandler) + { + await _mutexSubscribe.WaitAsync(); + try + { + _historyReceived += (sender, e) => eventHandler(sender, e); + } + finally + { + _mutexSubscribe.Release(); + } + return true; + } + /// /// Subscribe to an Close event from the Deepgram API /// @@ -644,6 +663,30 @@ public async Task SendHistoryFunctionCalls(HistoryFunctionCalls historyFunctionC byte[] data = Encoding.UTF8.GetBytes(historyFunctionCalls.ToString()); await SendMessageImmediately(data); } + + /// + /// Sends a function call response back to the agent + /// + /// The function call response schema + public async Task SendFunctionCallResponse(FunctionCallResponseSchema functionCallResponse) + { + if (functionCallResponse == null) + { + Log.Warning("SendFunctionCallResponse", "FunctionCallResponse cannot be null"); + throw new ArgumentNullException(nameof(functionCallResponse)); + } + + if (string.IsNullOrWhiteSpace(functionCallResponse.Id)) + { + Log.Warning("SendFunctionCallResponse", "Id cannot be null or empty"); + throw new ArgumentException("Id cannot be null or empty", nameof(functionCallResponse.Id)); + } + + Log.Debug("SendFunctionCallResponse", $"Sending Function Call Response: {functionCallResponse.Id}"); + + byte[] data = Encoding.UTF8.GetBytes(functionCallResponse.ToString()); + await SendMessageImmediately(data); + } #endregion internal async Task ProcessKeepAlive() @@ -951,6 +994,24 @@ internal override void ProcessTextMessage(WebSocketReceiveResult result, MemoryS Log.Debug("ProcessTextMessage", $"Invoking SpeakUpdatedResponse. event: {speakUpdatedResponse}"); InvokeParallel(_speakUpdatedReceived, speakUpdatedResponse); break; + case AgentType.History: + var historyResponse = data.Deserialize(); + if (_historyReceived == null) + { + Log.Debug("ProcessTextMessage", "_historyReceived has no listeners"); + Log.Verbose("ProcessTextMessage", "LEAVE"); + return; + } + if (historyResponse == null) + { + Log.Warning("ProcessTextMessage", "HistoryResponse is invalid"); + Log.Verbose("ProcessTextMessage", "LEAVE"); + return; + } + + Log.Debug("ProcessTextMessage", $"Invoking HistoryResponse. event: {historyResponse}"); + InvokeParallel(_historyReceived, historyResponse); + break; default: Log.Debug("ProcessTextMessage", "Calling base.ProcessTextMessage..."); base.ProcessTextMessage(result, ms); diff --git a/Deepgram/Clients/Interfaces/v2/IAgentWebSocketClient.cs b/Deepgram/Clients/Interfaces/v2/IAgentWebSocketClient.cs index ac576f7..e95a645 100644 --- a/Deepgram/Clients/Interfaces/v2/IAgentWebSocketClient.cs +++ b/Deepgram/Clients/Interfaces/v2/IAgentWebSocketClient.cs @@ -123,6 +123,12 @@ public Task Subscribe(EventHandler eventHand /// /// True if successful public Task Subscribe(EventHandler eventHandler); + + /// + /// Subscribe to a History event from the Deepgram API + /// + /// True if successful + public Task Subscribe(EventHandler eventHandler); #endregion #region Send Functions @@ -177,6 +183,37 @@ public Task Subscribe(EventHandler eventHand /// The number of bytes from the data to send. Use `Constants.UseArrayLengthForSend` to send the entire array. /// /// Provide a cancel token to be used for the send function or use the internal one public Task SendMessageImmediately(byte[] data, int length = Constants.UseArrayLengthForSend, CancellationTokenSource? _cancellationToken = null); + + /// + /// Sends a history conversation text message to the agent + /// + /// The role (user or assistant) who spoke the statement + /// The actual statement that was spoken + public Task SendHistoryConversationText(string role, string content); + + /// + /// Sends a history conversation text message to the agent using a schema object + /// + /// The history conversation text schema containing the message details + public Task SendHistoryConversationText(HistoryConversationText historyConversationText); + + /// + /// Sends a history function calls message to the agent + /// + /// List of function call objects to send as history + public Task SendHistoryFunctionCalls(List functionCalls); + + /// + /// Sends a history function calls message to the agent using a schema object + /// + /// The history function calls schema containing the function call details + public Task SendHistoryFunctionCalls(HistoryFunctionCalls historyFunctionCalls); + + /// + /// Sends a function call response back to the agent + /// + /// The function call response schema + public Task SendFunctionCallResponse(FunctionCallResponseSchema functionCallResponse); #endregion #region Helpers diff --git a/Deepgram/Models/Agent/v2/WebSocket/FunctionCallRequestResponse.cs b/Deepgram/Models/Agent/v2/WebSocket/FunctionCallRequestResponse.cs index 0e67a4b..5717f6e 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/FunctionCallRequestResponse.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/FunctionCallRequestResponse.cs @@ -2,6 +2,11 @@ // Use of this source code is governed by a MIT license that can be found in the LICENSE file. // SPDX-License-Identifier: MIT +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + namespace Deepgram.Models.Agent.v2.WebSocket; public record FunctionCallRequestResponse @@ -14,7 +19,12 @@ public record FunctionCallRequestResponse [JsonConverter(typeof(JsonStringEnumConverter))] public AgentType? Type { get; } = AgentType.FunctionCallRequest; - // TODO: this needs to be defined + /// + /// List of function calls in this request + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("functions")] + public List? Functions { get; set; } /// /// Override ToString method to serialize the object diff --git a/Deepgram/Models/Agent/v2/WebSocket/FunctionCallResponseSchema.cs b/Deepgram/Models/Agent/v2/WebSocket/FunctionCallResponseSchema.cs index 5b5b66e..87f5741 100644 --- a/Deepgram/Models/Agent/v2/WebSocket/FunctionCallResponseSchema.cs +++ b/Deepgram/Models/Agent/v2/WebSocket/FunctionCallResponseSchema.cs @@ -2,6 +2,11 @@ // Use of this source code is governed by a MIT license that can be found in the LICENSE file. // SPDX-License-Identifier: MIT +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + namespace Deepgram.Models.Agent.v2.WebSocket; public class FunctionCallResponseSchema @@ -14,12 +19,16 @@ public class FunctionCallResponseSchema public string? Type { get; } = AgentClientTypes.FunctionCallResponse; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("function_call_id")] - public string? FunctionCallId { get; set; } + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("name")] + public string? Name { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("output")] - public string? Output { get; set; } + [JsonPropertyName("content")] + public string? Content { get; set; } /// /// Override ToString method to serialize the object diff --git a/Deepgram/Models/Agent/v2/WebSocket/HistoryResponse.cs b/Deepgram/Models/Agent/v2/WebSocket/HistoryResponse.cs new file mode 100644 index 0000000..6caa815 --- /dev/null +++ b/Deepgram/Models/Agent/v2/WebSocket/HistoryResponse.cs @@ -0,0 +1,50 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Deepgram.Utilities; + +namespace Deepgram.Models.Agent.v2.WebSocket; + +public record HistoryResponse +{ + /// + /// History event type. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("type")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public AgentType? Type { get; } = AgentType.History; + + /// + /// Identifies who spoke the statement (for conversation history) + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// The actual statement that was spoken (for conversation history) + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// List of function call objects (for function call history) + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("function_calls")] + public List? FunctionCalls { get; set; } + + /// + /// Override ToString method to serialize the object + /// + public override string ToString() + { + return Regex.Unescape(JsonSerializer.Serialize(this, JsonSerializeOptions.DefaultOptions)); + } +} diff --git a/examples/agent/websocket/context-history/ContextHistory.csproj b/examples/agent/websocket/context-history/ContextHistory.csproj new file mode 100644 index 0000000..511ee63 --- /dev/null +++ b/examples/agent/websocket/context-history/ContextHistory.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/examples/agent/websocket/context-history/Program.cs b/examples/agent/websocket/context-history/Program.cs new file mode 100644 index 0000000..0e6adec --- /dev/null +++ b/examples/agent/websocket/context-history/Program.cs @@ -0,0 +1,665 @@ +// Copyright 2024 Deepgram .NET SDK contributors. All Rights Reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. +// SPDX-License-Identifier: MIT + +using Deepgram.Logger; +using Deepgram.Microphone; +using Deepgram.Models.Authenticate.v1; +using Deepgram.Models.Agent.v2.WebSocket; +using Deepgram.Clients.Interfaces.v2; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text.Json; +using PortAudioSharp; + +namespace SampleApp +{ + class Program + { + // Mock weather data for demo purposes (similar to Python version) + private static readonly Dictionary WeatherData = new() + { + ["new york"] = new() { Temperature = 72, Condition = "sunny", Humidity = 45 }, + ["london"] = new() { Temperature = 64, Condition = "cloudy", Humidity = 80 }, // 18°C to °F + ["tokyo"] = new() { Temperature = 77, Condition = "rainy", Humidity = 90 }, // 25°C to °F + ["paris"] = new() { Temperature = 68, Condition = "partly cloudy", Humidity = 60 }, // 20°C to °F + ["sydney"] = new() { Temperature = 82, Condition = "sunny", Humidity = 50 }, // 28°C to °F + }; + + public class WeatherInfo + { + public int Temperature { get; set; } + public string Condition { get; set; } = ""; + public int Humidity { get; set; } + } + + public class WeatherResponse + { + public string Location { get; set; } = ""; + public int Temperature { get; set; } + public string Unit { get; set; } = ""; + public string Condition { get; set; } = ""; + public int Humidity { get; set; } + public string Description { get; set; } = ""; + } + static async Task Main(string[] args) + { + try + { + // Initialize Library with clean logging (Information level for cleaner output) + Deepgram.Library.Initialize(LogLevel.Information); + // For debugging, you can use LogLevel.Debug or LogLevel.Verbose + //Deepgram.Library.Initialize(LogLevel.Debug); // More detailed logs + //Deepgram.Library.Initialize(LogLevel.Verbose); // Very chatty logging + + // Initialize the microphone library + Console.WriteLine("Initializing microphone library..."); + try + { + Deepgram.Microphone.Library.Initialize(); + Console.WriteLine("Microphone library initialized successfully."); + + // Get default input device + int defaultDevice = PortAudio.DefaultInputDevice; + if (defaultDevice == PortAudio.NoDevice) + { + Console.WriteLine("Error: No default input device found."); + return; + } + + var deviceInfo = PortAudio.GetDeviceInfo(defaultDevice); + Console.WriteLine($"Using default input device: {deviceInfo.name}"); + Console.WriteLine($"Sample rate: {deviceInfo.defaultSampleRate}"); + Console.WriteLine($"Channels: {deviceInfo.maxInputChannels}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error initializing microphone library: {ex.Message}"); + return; + } + + Console.WriteLine("\n🌟 Context History Demo - Weather Assistant with Function Calling"); + Console.WriteLine("šŸ“š This demo showcases conversation history and function calling features"); + Console.WriteLine("Press any key to stop and exit...\n"); + + // Set "DEEPGRAM_API_KEY" environment variable to your Deepgram API Key + DeepgramWsClientOptions options = new DeepgramWsClientOptions(null, null, true); + var agentClient = ClientFactory.CreateAgentWebSocketClient(apiKey: "", options: options); + + // Initialize conversation + Console.WriteLine("šŸŽ¤ Ready for conversation! Ask me about weather in any location..."); + + // Create conversation history for context + var conversationHistory = new List + { + new HistoryConversationText + { + Role = "user", + Content = "Hi, I'm looking for weather information. Can you help me with that?" + }, + new HistoryConversationText + { + Role = "assistant", + Content = "Hello! Absolutely, I'd be happy to help you with weather information. I have access to current weather data and can check conditions for any location you're interested in. Just let me know which city or location you'd like to know about!" + } + }; + + // Example function call history (optional - showing previous weather check) + var functionCallHistory = new HistoryFunctionCalls + { + FunctionCalls = new List + { + new HistoryFunctionCall + { + Id = "fc_weather_demo_123", + Name = "get_weather", + ClientSide = true, + Arguments = "{\"location\": \"San Francisco\", \"unit\": \"fahrenheit\"}", + Response = "The weather in San Francisco is currently 68°F with partly cloudy skies. It's a pleasant day with light winds from the west at 8 mph." + } + } + }; + + // Subscribe to the EventResponseReceived event + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸ”— Connection opened - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + // Audio received - process silently for cleaner output + if (e.Stream != null && e.Stream.Length > 0) + { + var audioData = e.Stream.ToArray(); + // Play audio through speakers without logging each chunk + PlayAudioThroughSpeakers(audioData); + } + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸŽ¤ Agent finished speaking - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸ—£ļø Agent is speaking - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸ’­ Agent is thinking - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸ’¬ Conversation: [{e.Role}] {e.Content}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸ”§ Function call request received - {e.Type}"); + + if (e.Functions != null && e.Functions.Count > 0) + { + foreach (var functionCall in e.Functions) + { + Console.WriteLine($" Function: {functionCall.Name}"); + Console.WriteLine($" ID: {functionCall.Id}"); + Console.WriteLine($" Arguments: {functionCall.Arguments}"); + Console.WriteLine($" Client Side: {functionCall.ClientSide}"); + + // Handle the weather function call + if (functionCall.Name == "get_weather") + { + _ = Task.Run(() => HandleWeatherFunctionCall(agentClient, functionCall)); + } + } + } + else + { + Console.WriteLine(" No functions in request"); + } + })); + + // Subscribe to History events + await agentClient.Subscribe(new EventHandler((sender, e) => + { + if (e.FunctionCalls != null && e.FunctionCalls.Count > 0) + { + // This is function call history + Console.WriteLine($"šŸ“š Function Call History received:"); + foreach (var functionCall in e.FunctionCalls) + { + Console.WriteLine($" šŸ“ž Function: {functionCall.Name}"); + Console.WriteLine($" Arguments: {functionCall.Arguments}"); + Console.WriteLine($" Response: {functionCall.Response}"); + Console.WriteLine($" Client-side: {functionCall.ClientSide}"); + } + } + else if (!string.IsNullOrEmpty(e.Role) && !string.IsNullOrEmpty(e.Content)) + { + // This is conversation history + Console.WriteLine($"šŸ“š Conversation History: [{e.Role}] {e.Content}"); + } + else + { + Console.WriteLine($"šŸ“š History event received: {e}"); + } + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸ‘¤ User is speaking - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸ‘‹ Welcome - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸ”š Connection closed - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"āš™ļø Settings applied - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"🚫 Injection refused - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸ“ Prompt updated - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"šŸŽµ Speak updated - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"ā“ Unhandled event - {e.Type}"); + })); + + await agentClient.Subscribe(new EventHandler((sender, e) => + { + Console.WriteLine($"āŒ Error received - {e.Type}. Error: {e.Message}"); + })); + + // Configure agent settings with context history and function calling + var settingsConfiguration = new SettingsSchema(); + + // Enable history feature for conversation context + settingsConfiguration.Flags = new Flags { History = true }; + + // Agent tags for analytics + settingsConfiguration.Tags = new List { "history-example", "function-calling", "weather-demo" }; + + // Audio configuration + settingsConfiguration.Audio.Input.Encoding = "linear16"; + settingsConfiguration.Audio.Input.SampleRate = 16000; + settingsConfiguration.Audio.Output.Encoding = "linear16"; + settingsConfiguration.Audio.Output.SampleRate = 24000; + settingsConfiguration.Audio.Output.Container = "none"; + + // Agent configuration with context + settingsConfiguration.Agent.Language = "en"; + + // Provide conversation context/history + settingsConfiguration.Agent.Context = new Context + { + Messages = conversationHistory + }; + + settingsConfiguration.Agent.Listen.Provider.Type = "deepgram"; + settingsConfiguration.Agent.Listen.Provider.Model = "nova-2"; + + settingsConfiguration.Agent.Speak.Provider.Type = "deepgram"; + settingsConfiguration.Agent.Speak.Provider.Model = "aura-asteria-en"; + + // Configure the thinking/LLM provider with function calling + settingsConfiguration.Agent.Think.Provider.Type = "open_ai"; + settingsConfiguration.Agent.Think.Provider.Model = "gpt-4o-mini"; + + // Define available functions using OpenAPI-like schema + settingsConfiguration.Agent.Think.Functions = new List + { + new Function + { + Name = "get_weather", + Description = "Get the current weather conditions for a specific location", + Parameters = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["location"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The city or location to get weather for (e.g., 'New York', 'London', 'Tokyo')" + }, + ["unit"] = new Dictionary + { + ["type"] = "string", + ["enum"] = new[] { "fahrenheit", "celsius" }, + ["description"] = "Temperature unit preference", + ["default"] = "fahrenheit" + } + }, + ["required"] = new[] { "location" } + } + } + }; + + settingsConfiguration.Agent.Think.Prompt = "You are a helpful weather assistant with access to current weather data. Use the get_weather function to provide accurate, up-to-date weather information when users ask about weather conditions. Always be conversational and provide context about the weather conditions."; + + settingsConfiguration.Agent.Greeting = "Hello! I'm your weather assistant with access to current weather data. What would you like to know?"; + + bool bConnected = await agentClient.Connect(settingsConfiguration); + if (!bConnected) + { + Console.WriteLine("āŒ Failed to connect to Deepgram WebSocket server."); + return; + } + + Console.WriteLine("āœ… Connected! Function calling configured and history enabled."); + Console.WriteLine("šŸ“š Initial context provided in agent configuration - ready for conversation!"); + + // Microphone streaming with debugging + Console.WriteLine("šŸŽ¤ Starting microphone..."); + Microphone microphone = null; + int audioDataCounter = 0; + + try + { + // Create microphone with proper sample rate and debugging + microphone = new Microphone( + push_callback: (audioData, length) => + { + audioDataCounter++; + if (audioDataCounter % 100 == 0) // Log every 100th chunk to reduce noise + { + Console.WriteLine($"[MIC] Captured audio chunk #{audioDataCounter}: {length} bytes"); + } + + // Create array with actual length + byte[] actualData = new byte[length]; + Array.Copy(audioData, actualData, length); + + // Send to agent + agentClient.SendBinary(actualData); + }, + rate: 16000, // Match the agent's expected input rate (16kHz) + chunkSize: 8192, // Standard chunk size + channels: 1, // Mono + device_index: PortAudio.DefaultInputDevice, + format: SampleFormat.Int16 + ); + + microphone.Start(); + Console.WriteLine("šŸŽ¤ Microphone started successfully. Try asking: 'What's the weather like in New York?'"); + Console.WriteLine("šŸ”§ Function calling is enabled - I can fetch real weather data!"); + } + catch (Exception ex) + { + Console.WriteLine($"āŒ Error starting microphone: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + return; + } + + // Wait for the user to press a key + Console.ReadKey(); + + // Stop the microphone + if (microphone != null) + { + microphone.Stop(); + } + + // Stop the connection + await agentClient.Stop(); + + // Stop and dispose PortAudio output stream + if (_outputStream != null) + { + _outputStream.Stop(); + _outputStream.Dispose(); + _outputStream = null; + } + + // Terminate Libraries + Deepgram.Microphone.Library.Terminate(); + Deepgram.Library.Terminate(); + } + catch (Exception ex) + { + Console.WriteLine($"āŒ Exception: {ex.Message}"); + } + } + + /// + /// Handle weather function call requests with mock weather data + /// + private static async Task HandleWeatherFunctionCall(IAgentWebSocketClient agentClient, HistoryFunctionCall functionCall) + { + try + { + Console.WriteLine("šŸŒ¤ļø Processing weather function call..."); + + // Parse the arguments + var argumentsJson = functionCall.Arguments ?? "{}"; + var arguments = JsonSerializer.Deserialize>(argumentsJson); + + string location = arguments.ContainsKey("location") ? arguments["location"].ToString() ?? "Unknown" : "Unknown"; + string unit = arguments.ContainsKey("unit") ? arguments["unit"].ToString() ?? "fahrenheit" : "fahrenheit"; + + Console.WriteLine($"šŸŒ Getting weather for: {location} (in {unit})"); + + // Get weather data (mock) + var weatherData = GetWeather(location, unit); + + Console.WriteLine($"ā˜€ļø Weather response: {weatherData.Description}"); + + // Send the function call response back to the agent + var functionCallResponse = new FunctionCallResponseSchema + { + Id = functionCall.Id, + Name = functionCall.Name, + Content = weatherData.Description // Just send the description text, not full JSON + }; + + Console.WriteLine($"šŸ“¤ Sending function response: {functionCallResponse.ToString()}"); + await agentClient.SendFunctionCallResponse(functionCallResponse); + Console.WriteLine("āœ… Function call response sent!"); + } + catch (Exception ex) + { + Console.WriteLine($"āŒ Error handling weather function call: {ex.Message}"); + + // Send error response + var errorResponse = new FunctionCallResponseSchema + { + Id = functionCall.Id, + Name = functionCall.Name, + Content = $"Error retrieving weather data: {ex.Message}" // Just plain text error + }; + Console.WriteLine($"šŸ“¤ Sending error response: {errorResponse.ToString()}"); + await agentClient.SendFunctionCallResponse(errorResponse); + } + } + + /// + /// Get weather data from mock data or generate random data (similar to Python version) + /// + private static WeatherResponse GetWeather(string location, string unit = "fahrenheit") + { + var locationKey = location.ToLower(); + WeatherInfo weather; + + if (WeatherData.ContainsKey(locationKey)) + { + weather = WeatherData[locationKey]; + } + else + { + // Return random weather for unknown locations + var random = new Random(); + var conditions = new[] { "sunny", "cloudy", "rainy", "partly cloudy", "windy" }; + weather = new WeatherInfo + { + Temperature = random.Next(50, 95), // Random Fahrenheit temperature + Condition = conditions[random.Next(conditions.Length)], + Humidity = random.Next(30, 90) + }; + } + + // Convert temperature if needed + int finalTemp = weather.Temperature; + if (unit.ToLower() == "celsius") + { + // Convert from Fahrenheit to Celsius + finalTemp = (int)((weather.Temperature - 32) * 5.0 / 9.0); + } + + var response = new WeatherResponse + { + Location = location, + Temperature = finalTemp, + Unit = unit, + Condition = weather.Condition, + Humidity = weather.Humidity, + Description = $"The weather in {location} is {weather.Condition} with a temperature of {finalTemp}°{(unit.ToLower() == "fahrenheit" ? "F" : "C")} and {weather.Humidity}% humidity." + }; + + return response; + } + + /// + /// Generate mock weather data (replace with real weather API) + /// + private static string GenerateMockWeatherResponse(string location, string unit) + { + var random = new Random(); + var conditions = new[] { "sunny", "partly cloudy", "cloudy", "light rain", "clear" }; + var condition = conditions[random.Next(conditions.Length)]; + + // Generate temperature based on unit + int temperature; + string unitSymbol; + + if (unit.ToLower() == "celsius") + { + temperature = random.Next(-5, 35); // -5 to 35°C + unitSymbol = "°C"; + } + else + { + temperature = random.Next(20, 95); // 20 to 95°F + unitSymbol = "°F"; + } + + var windSpeed = random.Next(0, 15); + var directions = new[] { "north", "south", "east", "west", "northeast", "northwest", "southeast", "southwest" }; + var windDirection = directions[random.Next(directions.Length)]; + + return $"The weather in {location} is currently {temperature}{unitSymbol} with {condition} skies. " + + $"Wind is coming from the {windDirection} at {windSpeed} mph. " + + $"It's a {(temperature > (unit.ToLower() == "celsius" ? 20 : 70) ? "warm" : "cool")} day!"; + } + + // Audio playback queue and position tracking + private static Queue audioQueue = new Queue(); + private static byte[]? currentAudioBuffer = null; + private static int audioPosition = 0; + private static readonly object audioLock = new object(); + + /// + /// Plays audio data through the system's default output device (speakers) + /// + /// PCM audio data to play + static void PlayAudioThroughSpeakers(byte[] audioData) + { + try + { + lock (audioLock) + { + // Add to queue for playback + audioQueue.Enqueue(audioData); + } + + // Start playback stream if not already running + StartAudioPlayback(); + } + catch (Exception ex) + { + Console.WriteLine($"āŒ Error queuing audio: {ex.Message}"); + } + } + + private static PortAudioSharp.Stream? _outputStream = null; + + private static void StartAudioPlayback() + { + if (_outputStream != null) + return; // Already playing + + try + { + // Get default output device + int outputDevice = PortAudio.DefaultOutputDevice; + if (outputDevice == PortAudio.NoDevice) + { + Console.WriteLine("āš ļø No default output device found for audio playback"); + return; + } + + var deviceInfo = PortAudio.GetDeviceInfo(outputDevice); + Console.WriteLine($"šŸ”Š Playing through: {deviceInfo.name}"); + + // Set up output stream parameters + var outputParams = new PortAudioSharp.StreamParameters + { + device = outputDevice, + channelCount = 1, // mono + sampleFormat = PortAudioSharp.SampleFormat.Int16, + suggestedLatency = deviceInfo.defaultLowOutputLatency, + hostApiSpecificStreamInfo = IntPtr.Zero + }; + + // Create and start the output stream + _outputStream = new PortAudioSharp.Stream( + inParams: null, + outParams: outputParams, + sampleRate: 24000, // Match agent output (24kHz) + framesPerBuffer: 512, + streamFlags: PortAudioSharp.StreamFlags.ClipOff, + callback: OutputCallback, + userData: IntPtr.Zero + ); + + _outputStream.Start(); + } + catch (Exception ex) + { + Console.WriteLine($"āŒ Error starting audio playback: {ex.Message}"); + _outputStream = null; + } + } + + private static PortAudioSharp.StreamCallbackResult OutputCallback(nint input, nint output, uint frameCount, ref PortAudioSharp.StreamCallbackTimeInfo timeInfo, PortAudioSharp.StreamCallbackFlags statusFlags, nint userDataPtr) + { + lock (audioLock) + { + int bytesToWrite = (int)(frameCount * sizeof(Int16)); // 16-bit samples + byte[] outputBuffer = new byte[bytesToWrite]; + + int bytesWritten = 0; + while (bytesWritten < bytesToWrite) + { + // Get next buffer if current one is exhausted + if (currentAudioBuffer == null || audioPosition >= currentAudioBuffer.Length) + { + if (audioQueue.Count > 0) + { + currentAudioBuffer = audioQueue.Dequeue(); + audioPosition = 0; + // Audio buffer logging removed for cleaner output + } + else + { + // No more audio, fill with silence but KEEP stream running for next audio + for (int i = bytesWritten; i < bytesToWrite; i++) + outputBuffer[i] = 0; + + Marshal.Copy(outputBuffer, 0, output, bytesToWrite); + // DON'T stop the stream - keep it running for next conversation + return PortAudioSharp.StreamCallbackResult.Continue; + } + } + + // Copy data from current buffer + int remainingInBuffer = currentAudioBuffer.Length - audioPosition; + int remainingToWrite = bytesToWrite - bytesWritten; + int bytesToCopy = Math.Min(remainingInBuffer, remainingToWrite); + + Array.Copy(currentAudioBuffer, audioPosition, outputBuffer, bytesWritten, bytesToCopy); + audioPosition += bytesToCopy; + bytesWritten += bytesToCopy; + } + + // Copy to output + Marshal.Copy(outputBuffer, 0, output, bytesToWrite); + } + + return PortAudioSharp.StreamCallbackResult.Continue; + } + } +} From 3b497342ff0451a28f4cd49e4e7b325e7372a7bb Mon Sep 17 00:00:00 2001 From: John Vajda Date: Tue, 12 Aug 2025 17:22:27 -0600 Subject: [PATCH 10/10] code rabbit review --- .../websocket/context-history/Program.cs | 9 ++++--- examples/agent/websocket/simple/Program.cs | 25 ++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/examples/agent/websocket/context-history/Program.cs b/examples/agent/websocket/context-history/Program.cs index 0e6adec..5511e01 100644 --- a/examples/agent/websocket/context-history/Program.cs +++ b/examples/agent/websocket/context-history/Program.cs @@ -104,8 +104,9 @@ static async Task Main(string[] args) } }; - // Example function call history (optional - showing previous weather check) - var functionCallHistory = new HistoryFunctionCalls + // Example function call history structure (for reference - not sent in this demo) + // This shows developers what function call history looks like when implementing real apps + var functionCallHistoryExample = new HistoryFunctionCalls { FunctionCalls = new List { @@ -119,6 +120,8 @@ static async Task Main(string[] args) } } }; + // Note: In real applications, you would send this via: + // await agentClient.SendHistoryFunctionCalls(functionCallHistoryExample); // Subscribe to the EventResponseReceived event await agentClient.Subscribe(new EventHandler((sender, e) => @@ -273,7 +276,7 @@ await agentClient.Subscribe(new EventHandler((sender, e) => // Agent configuration with context settingsConfiguration.Agent.Language = "en"; - // Provide conversation context/history + // Provide conversation context/history settingsConfiguration.Agent.Context = new Context { Messages = conversationHistory diff --git a/examples/agent/websocket/simple/Program.cs b/examples/agent/websocket/simple/Program.cs index c78f354..38689a1 100644 --- a/examples/agent/websocket/simple/Program.cs +++ b/examples/agent/websocket/simple/Program.cs @@ -248,6 +248,9 @@ await agentClient.Subscribe(new EventHandler((sender, e) => private static int audioPosition = 0; private static readonly object audioLock = new object(); + // Preallocated buffer for OutputCallback to avoid per-callback allocations + private static readonly byte[] PreallocatedOutputBuffer = new byte[8192]; // Max buffer size + /// /// Plays audio data through the system's default output device (speakers) /// @@ -326,7 +329,6 @@ private static PortAudioSharp.StreamCallbackResult OutputCallback(nint input, ni lock (audioLock) { int bytesToWrite = (int)(frameCount * sizeof(Int16)); // 16-bit samples - byte[] outputBuffer = new byte[bytesToWrite]; int bytesWritten = 0; while (bytesWritten < bytesToWrite) @@ -338,32 +340,31 @@ private static PortAudioSharp.StreamCallbackResult OutputCallback(nint input, ni { currentAudioBuffer = audioQueue.Dequeue(); audioPosition = 0; - Console.WriteLine($"šŸ”Š Playing new audio buffer: {currentAudioBuffer.Length} bytes (Queue: {audioQueue.Count} remaining)"); + // Removed Console.WriteLine to avoid blocking real-time thread } else { - // No more audio, fill with silence but KEEP stream running for next audio - for (int i = bytesWritten; i < bytesToWrite; i++) - outputBuffer[i] = 0; + // No more audio, fill remaining output with silence + int remainingBytes = bytesToWrite - bytesWritten; + + // Clear the preallocated buffer for silence + Array.Clear(PreallocatedOutputBuffer, 0, remainingBytes); + Marshal.Copy(PreallocatedOutputBuffer, 0, output + bytesWritten, remainingBytes); - Marshal.Copy(outputBuffer, 0, output, bytesToWrite); - // DON'T stop the stream - keep it running for next conversation return PortAudioSharp.StreamCallbackResult.Continue; } } - // Copy data from current buffer + // Copy data directly from current buffer to output int remainingInBuffer = currentAudioBuffer.Length - audioPosition; int remainingToWrite = bytesToWrite - bytesWritten; int bytesToCopy = Math.Min(remainingInBuffer, remainingToWrite); - Array.Copy(currentAudioBuffer, audioPosition, outputBuffer, bytesWritten, bytesToCopy); + // Direct memory copy to output buffer + Marshal.Copy(currentAudioBuffer, audioPosition, output + bytesWritten, bytesToCopy); audioPosition += bytesToCopy; bytesWritten += bytesToCopy; } - - // Copy to output - Marshal.Copy(outputBuffer, 0, output, bytesToWrite); } return PortAudioSharp.StreamCallbackResult.Continue;