From 4ea39b14b708babf2c7348b51765dbeedac657a8 Mon Sep 17 00:00:00 2001 From: Devis Lucato Date: Mon, 29 Jul 2024 15:52:49 -0700 Subject: [PATCH] Add .NET Connector and .NET Agent example (#4) Add .NET connector, taking care of all the details to connect an agent to Semantic Workbench. Add agent example, showing how to leverage the connector. The .NET agent implements a basic "echo" behavior, repeating what the user says in a conversation, and supports a simple `/say` command showing how to leverage commands. --- .gitignore | 4 + dotnet/.editorconfig | 474 +++++++++++++++ dotnet/SemanticWorkbench.sln | 22 + dotnet/SemanticWorkbench.sln.DotSettings | 2 + dotnet/WorkbenchConnector/AgentBase.cs | 398 +++++++++++++ dotnet/WorkbenchConnector/Constants.cs | 67 +++ dotnet/WorkbenchConnector/IAgentConfig.cs | 9 + .../Models/ChatHistoryExt.cs | 22 + dotnet/WorkbenchConnector/Models/Command.cs | 26 + .../WorkbenchConnector/Models/Conversation.cs | 123 ++++ .../Models/ConversationEvent.cs | 30 + dotnet/WorkbenchConnector/Models/DebugInfo.cs | 12 + dotnet/WorkbenchConnector/Models/Insight.cs | 38 ++ dotnet/WorkbenchConnector/Models/Message.cs | 66 +++ .../Models/MessageMetadata.cs | 16 + .../WorkbenchConnector/Models/Participant.cs | 24 + dotnet/WorkbenchConnector/Models/Sender.cs | 15 + .../WorkbenchConnector/Storage/AgentInfo.cs | 21 + .../Storage/AgentServiceStorage.cs | 151 +++++ .../Storage/IAgentServiceStorage.cs | 20 + dotnet/WorkbenchConnector/Webservice.cs | 558 ++++++++++++++++++ dotnet/WorkbenchConnector/WorkbenchConfig.cs | 34 ++ .../WorkbenchConnector/WorkbenchConnector.cs | 431 ++++++++++++++ .../WorkbenchConnector.csproj | 19 + examples/dotnet-example01/.editorconfig | 474 +++++++++++++++ .../dotnet-example01/AgentExample01.csproj | 35 ++ examples/dotnet-example01/MyAgent.cs | 122 ++++ examples/dotnet-example01/MyAgentConfig.cs | 70 +++ .../dotnet-example01/MyWorkbenchConnector.cs | 49 ++ examples/dotnet-example01/Program.cs | 44 ++ examples/dotnet-example01/README.md | 44 ++ examples/dotnet-example01/appsettings.json | 60 ++ 32 files changed, 3480 insertions(+) create mode 100644 dotnet/.editorconfig create mode 100644 dotnet/SemanticWorkbench.sln create mode 100644 dotnet/SemanticWorkbench.sln.DotSettings create mode 100644 dotnet/WorkbenchConnector/AgentBase.cs create mode 100644 dotnet/WorkbenchConnector/Constants.cs create mode 100644 dotnet/WorkbenchConnector/IAgentConfig.cs create mode 100644 dotnet/WorkbenchConnector/Models/ChatHistoryExt.cs create mode 100644 dotnet/WorkbenchConnector/Models/Command.cs create mode 100644 dotnet/WorkbenchConnector/Models/Conversation.cs create mode 100644 dotnet/WorkbenchConnector/Models/ConversationEvent.cs create mode 100644 dotnet/WorkbenchConnector/Models/DebugInfo.cs create mode 100644 dotnet/WorkbenchConnector/Models/Insight.cs create mode 100644 dotnet/WorkbenchConnector/Models/Message.cs create mode 100644 dotnet/WorkbenchConnector/Models/MessageMetadata.cs create mode 100644 dotnet/WorkbenchConnector/Models/Participant.cs create mode 100644 dotnet/WorkbenchConnector/Models/Sender.cs create mode 100644 dotnet/WorkbenchConnector/Storage/AgentInfo.cs create mode 100644 dotnet/WorkbenchConnector/Storage/AgentServiceStorage.cs create mode 100644 dotnet/WorkbenchConnector/Storage/IAgentServiceStorage.cs create mode 100644 dotnet/WorkbenchConnector/Webservice.cs create mode 100644 dotnet/WorkbenchConnector/WorkbenchConfig.cs create mode 100644 dotnet/WorkbenchConnector/WorkbenchConnector.cs create mode 100644 dotnet/WorkbenchConnector/WorkbenchConnector.csproj create mode 100644 examples/dotnet-example01/.editorconfig create mode 100644 examples/dotnet-example01/AgentExample01.csproj create mode 100644 examples/dotnet-example01/MyAgent.cs create mode 100644 examples/dotnet-example01/MyAgentConfig.cs create mode 100644 examples/dotnet-example01/MyWorkbenchConnector.cs create mode 100644 examples/dotnet-example01/Program.cs create mode 100644 examples/dotnet-example01/README.md create mode 100644 examples/dotnet-example01/appsettings.json diff --git a/.gitignore b/.gitignore index c8f068cb..735c9d88 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ *.local *__local__* .data/ +.idea/ +appsettings.*.json # Dependencies and build cache node_modules @@ -13,6 +15,8 @@ poetry.lock __pycache__ .pytest_cache .cache +bin/ +obj/ # Logs *.log diff --git a/dotnet/.editorconfig b/dotnet/.editorconfig new file mode 100644 index 00000000..5bf7c515 --- /dev/null +++ b/dotnet/.editorconfig @@ -0,0 +1,474 @@ +# To learn more about .editorconfig see https://aka.ms/editorconfigdocs +############################### +# Core EditorConfig Options # +############################### +root = true +# All files +[*] +indent_style = space +end_of_line = lf + +# XML project files +[*.{vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# XML config files +[*.{targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# XML config files +[*.csproj] +indent_size = 4 + +# XML config files +[*.props] +indent_size = 4 + +[Directory.Packages.props] +indent_size = 2 + +# YAML config files +[*.{yml,yaml}] +tab_width = 2 +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +# JSON config files +[*.json] +tab_width = 2 +indent_size = 2 +insert_final_newline = false +trim_trailing_whitespace = true + +# Typescript files +[*.{ts,tsx}] +insert_final_newline = true +trim_trailing_whitespace = true +tab_width = 4 +indent_size = 4 +file_header_template = Copyright (c) Microsoft. All rights reserved. + +# Stylesheet files +[*.{css,scss,sass,less}] +insert_final_newline = true +trim_trailing_whitespace = true +tab_width = 4 +indent_size = 4 + +# Code files +[*.{cs,csx,vb,vbx}] +tab_width = 4 +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8-bom +file_header_template = Copyright (c) Microsoft. All rights reserved. + +############################### +# .NET Coding Conventions # +############################### +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true +# this. preferences +dotnet_style_qualification_for_field = true:error +dotnet_style_qualification_for_property = true:error +dotnet_style_qualification_for_method = true:error +dotnet_style_qualification_for_event = true:error +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:error +dotnet_style_readonly_field = true:suggestion +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:silent +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +# Code quality rules +dotnet_code_quality_unused_parameters = all:suggestion + +[*.cs] + +# TODO: enable this but stop "dotnet format" from applying incorrect fixes and introducing a lot of unwanted changes. +dotnet_analyzer_diagnostic.severity = none + +# Note: these settings cause "dotnet format" to fix the code. You should review each change if you uses "dotnet format". +dotnet_diagnostic.RCS1007.severity = warning # Add braces +dotnet_diagnostic.RCS1036.severity = warning # Remove unnecessary blank line. +dotnet_diagnostic.RCS1037.severity = warning # Remove trailing white-space. +dotnet_diagnostic.RCS1097.severity = warning # Remove redundant 'ToString' call. +dotnet_diagnostic.RCS1138.severity = warning # Add summary to documentation comment. +dotnet_diagnostic.RCS1139.severity = warning # Add summary element to documentation comment. +dotnet_diagnostic.RCS1168.severity = warning # Parameter name 'foo' differs from base name 'bar'. +dotnet_diagnostic.RCS1175.severity = warning # Unused 'this' parameter 'operation'. +dotnet_diagnostic.RCS1192.severity = warning # Unnecessary usage of verbatim string literal. +dotnet_diagnostic.RCS1194.severity = warning # Implement exception constructors. +dotnet_diagnostic.RCS1211.severity = warning # Remove unnecessary else clause. +dotnet_diagnostic.RCS1214.severity = warning # Unnecessary interpolated string. +dotnet_diagnostic.RCS1225.severity = warning # Make class sealed. +dotnet_diagnostic.RCS1232.severity = warning # Order elements in documentation comment. + +# Diagnostics elevated as warnings +dotnet_diagnostic.CA1000.severity = warning # Do not declare static members on generic types +dotnet_diagnostic.CA1031.severity = warning # Do not catch general exception types +dotnet_diagnostic.CA1050.severity = warning # Declare types in namespaces +dotnet_diagnostic.CA1063.severity = warning # Implement IDisposable correctly +dotnet_diagnostic.CA1064.severity = warning # Exceptions should be public +dotnet_diagnostic.CA1303.severity = warning # Do not pass literals as localized parameters +dotnet_diagnostic.CA1416.severity = warning # Validate platform compatibility +dotnet_diagnostic.CA1508.severity = warning # Avoid dead conditional code +dotnet_diagnostic.CA1852.severity = warning # Sealed classes +dotnet_diagnostic.CA1859.severity = warning # Use concrete types when possible for improved performance +dotnet_diagnostic.CA1860.severity = warning # Prefer comparing 'Count' to 0 rather than using 'Any()', both for clarity and for performance +dotnet_diagnostic.CA2000.severity = warning # Call System.IDisposable.Dispose on object before all references to it are out of scope +dotnet_diagnostic.CA2007.severity = error # Do not directly await a Task +dotnet_diagnostic.CA2201.severity = warning # Exception type System.Exception is not sufficiently specific +dotnet_diagnostic.CA2225.severity = warning # Operator overloads have named alternates + +dotnet_diagnostic.IDE0001.severity = warning # Simplify name +dotnet_diagnostic.IDE0005.severity = warning # Remove unnecessary using directives +dotnet_diagnostic.IDE1006.severity = warning # Code style errors, e.g. dotnet_naming_rule rules violations +dotnet_diagnostic.IDE0009.severity = warning # Add this or Me qualification +dotnet_diagnostic.IDE0011.severity = warning # Add braces +dotnet_diagnostic.IDE0018.severity = warning # Inline variable declaration +dotnet_diagnostic.IDE0032.severity = warning # Use auto-implemented property +dotnet_diagnostic.IDE0034.severity = warning # Simplify 'default' expression +dotnet_diagnostic.IDE0035.severity = warning # Remove unreachable code +dotnet_diagnostic.IDE0040.severity = warning # Add accessibility modifiers +dotnet_diagnostic.IDE0049.severity = warning # Use language keywords instead of framework type names for type references +dotnet_diagnostic.IDE0050.severity = warning # Convert anonymous type to tuple +dotnet_diagnostic.IDE0051.severity = warning # Remove unused private member +dotnet_diagnostic.IDE0055.severity = warning # Formatting rule +dotnet_diagnostic.IDE0060.severity = warning # Remove unused parameter +dotnet_diagnostic.IDE0070.severity = warning # Use 'System.HashCode.Combine' +dotnet_diagnostic.IDE0071.severity = warning # Simplify interpolation +dotnet_diagnostic.IDE0073.severity = warning # Require file header +dotnet_diagnostic.IDE0082.severity = warning # Convert typeof to nameof +dotnet_diagnostic.IDE0090.severity = warning # Simplify new expression +dotnet_diagnostic.IDE0130.severity = warning # Namespace does not match folder structure +dotnet_diagnostic.IDE0161.severity = warning # Use file-scoped namespace + +dotnet_diagnostic.RCS1032.severity = warning # Remove redundant parentheses. +dotnet_diagnostic.RCS1118.severity = warning # Mark local variable as const. +dotnet_diagnostic.RCS1141.severity = warning # Add 'param' element to documentation comment. +dotnet_diagnostic.RCS1197.severity = warning # Optimize StringBuilder.AppendLine call. +dotnet_diagnostic.RCS1205.severity = warning # Order named arguments according to the order of parameters. +dotnet_diagnostic.RCS1229.severity = warning # Use async/await when necessary. + +dotnet_diagnostic.VSTHRD111.severity = error # Use .ConfigureAwait(bool) + +# Suppressed diagnostics + +# Commented out because `dotnet format` change can be disruptive. +# dotnet_diagnostic.RCS1085.severity = warning # Use auto-implemented property. + +# Commented out because `dotnet format` removes the xmldoc element, while we should add the missing documentation instead. +# dotnet_diagnostic.RCS1228.severity = warning # Unused element in documentation comment. + +dotnet_diagnostic.CA1002.severity = none # Change 'List' in '...' to use 'Collection' ... +dotnet_diagnostic.CA1032.severity = none # We're using RCS1194 which seems to cover more ctors +dotnet_diagnostic.CA1034.severity = none # Do not nest type. Alternatively, change its accessibility so that it is not externally visible +dotnet_diagnostic.CA1054.severity = none # URI parameters should not be strings +dotnet_diagnostic.CA1062.severity = none # Disable null check, C# already does it for us +dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member +dotnet_diagnostic.CA1805.severity = none # Member is explicitly initialized to its default value +dotnet_diagnostic.CA1822.severity = none # Member does not access instance data and can be marked as static +dotnet_diagnostic.CA1848.severity = none # For improved performance, use the LoggerMessage delegates +dotnet_diagnostic.CA2227.severity = none # Change to be read-only by removing the property setter +dotnet_diagnostic.CA2253.severity = none # Named placeholders in the logging message template should not be comprised of only numeric characters +dotnet_diagnostic.RCS1021.severity = none # Use expression-bodied lambda. +dotnet_diagnostic.RCS1061.severity = none # Merge 'if' with nested 'if'. +dotnet_diagnostic.RCS1069.severity = none # Remove unnecessary case label. +dotnet_diagnostic.RCS1074.severity = none # Remove redundant constructor. +dotnet_diagnostic.RCS1077.severity = none # Optimize LINQ method call. +dotnet_diagnostic.RCS1124.severity = none # Inline local variable. +dotnet_diagnostic.RCS1129.severity = none # Remove redundant field initialization. +dotnet_diagnostic.RCS1140.severity = none # Add exception to documentation comment. +dotnet_diagnostic.RCS1142.severity = none # Add 'typeparam' element to documentation comment. +dotnet_diagnostic.RCS1146.severity = none # Use conditional access. +dotnet_diagnostic.RCS1151.severity = none # Remove redundant cast. +dotnet_diagnostic.RCS1158.severity = none # Static member in generic type should use a type parameter. +dotnet_diagnostic.RCS1161.severity = none # Enum should declare explicit value +dotnet_diagnostic.RCS1163.severity = none # Unused parameter 'foo'. +dotnet_diagnostic.RCS1170.severity = none # Use read-only auto-implemented property. +dotnet_diagnostic.RCS1173.severity = none # Use coalesce expression instead of 'if'. +dotnet_diagnostic.RCS1181.severity = none # Convert comment to documentation comment. +dotnet_diagnostic.RCS1186.severity = none # Use Regex instance instead of static method. +dotnet_diagnostic.RCS1188.severity = none # Remove redundant auto-property initialization. +dotnet_diagnostic.RCS1189.severity = none # Add region name to #endregion. +dotnet_diagnostic.RCS1201.severity = none # Use method chaining. +dotnet_diagnostic.RCS1212.severity = none # Remove redundant assignment. +dotnet_diagnostic.RCS1217.severity = none # Convert interpolated string to concatenation. +dotnet_diagnostic.RCS1222.severity = none # Merge preprocessor directives. +dotnet_diagnostic.RCS1226.severity = none # Add paragraph to documentation comment. +dotnet_diagnostic.RCS1234.severity = none # Enum duplicate value +dotnet_diagnostic.RCS1238.severity = none # Avoid nested ?: operators. +dotnet_diagnostic.RCS1241.severity = none # Implement IComparable when implementing IComparable. +dotnet_diagnostic.IDE0001.severity = none # Simplify name +dotnet_diagnostic.IDE0002.severity = none # Simplify member access +dotnet_diagnostic.IDE0004.severity = none # Remove unnecessary cast +dotnet_diagnostic.IDE0035.severity = none # Remove unreachable code +dotnet_diagnostic.IDE0051.severity = none # Remove unused private member +dotnet_diagnostic.IDE0052.severity = none # Remove unread private member +dotnet_diagnostic.IDE0058.severity = none # Remove unused expression value +dotnet_diagnostic.IDE0059.severity = none # Unnecessary assignment of a value +dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter +dotnet_diagnostic.IDE0080.severity = none # Remove unnecessary suppression operator +dotnet_diagnostic.IDE0100.severity = none # Remove unnecessary equality operator +dotnet_diagnostic.IDE0110.severity = none # Remove unnecessary discards +dotnet_diagnostic.IDE0032.severity = none # Use auto property +dotnet_diagnostic.IDE0160.severity = none # Use block-scoped namespace +dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave +dotnet_diagnostic.xUnit1004.severity = none # Test methods should not be skipped. Remove the Skip property to start running the test again. + +dotnet_diagnostic.SKEXP0003.severity = none # XYZ is for evaluation purposes only +dotnet_diagnostic.SKEXP0010.severity = none +dotnet_diagnostic.SKEXP0010.severity = none +dotnet_diagnostic.SKEXP0011.severity = none +dotnet_diagnostic.SKEXP0052.severity = none +dotnet_diagnostic.SKEXP0101.severity = none + +dotnet_diagnostic.KMEXP00.severity = none # XYZ is for evaluation purposes only +dotnet_diagnostic.KMEXP01.severity = none +dotnet_diagnostic.KMEXP02.severity = none +dotnet_diagnostic.KMEXP03.severity = none + +############################### +# C# Coding Conventions # +############################### + +# var preferences +csharp_style_var_for_built_in_types = false:none +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = false:none +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +# Modifier preferences +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:suggestion +# Expression-level preferences +csharp_prefer_braces = true:error +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:error +csharp_style_inlined_variable_declaration = true:suggestion + +############################### +# C# Formatting Rules # +############################### + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = false # Does not work with resharper, forcing code to be on long lines instead of wrapping +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +# Indentation preferences +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true +csharp_using_directive_placement = outside_namespace:warning +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent + +############################### +# Global Naming Conventions # +############################### + +# Styles + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +dotnet_naming_style.static_underscored.capitalization = camel_case +dotnet_naming_style.static_underscored.required_prefix = s_ + +dotnet_naming_style.underscored.capitalization = camel_case +dotnet_naming_style.underscored.required_prefix = _ + +dotnet_naming_style.uppercase_with_underscore_separator.capitalization = all_upper +dotnet_naming_style.uppercase_with_underscore_separator.word_separator = _ + +dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_suffix = Async +dotnet_naming_style.end_in_async.capitalization = pascal_case +dotnet_naming_style.end_in_async.word_separator = + +# Symbols + +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_constant.applicable_kinds = local +dotnet_naming_symbols.local_constant.applicable_accessibilities = * +dotnet_naming_symbols.local_constant.required_modifiers = const + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_symbols.any_async_methods.applicable_kinds = method +dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * +dotnet_naming_symbols.any_async_methods.required_modifiers = async + +# Rules + +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = error + +dotnet_naming_rule.local_constant_should_be_pascal_case.symbols = local_constant +dotnet_naming_rule.local_constant_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.local_constant_should_be_pascal_case.severity = error + +dotnet_naming_rule.private_constant_fields.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields.style = pascal_case_style +dotnet_naming_rule.private_constant_fields.severity = error + +dotnet_naming_rule.private_static_fields_underscored.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_underscored.style = static_underscored +dotnet_naming_rule.private_static_fields_underscored.severity = error + +dotnet_naming_rule.private_fields_underscored.symbols = private_fields +dotnet_naming_rule.private_fields_underscored.style = underscored +dotnet_naming_rule.private_fields_underscored.severity = error + +##################################################################################################### +# Naming Conventions by folder # +# See also https://www.jetbrains.com/help/resharper/Coding_Assistance__Naming_Style.html#configure # +##################################################################################################### + +[{clients,extensions,service,tools}/**.cs] + +dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_suffix = Async +dotnet_naming_style.end_in_async.capitalization = pascal_case +dotnet_naming_style.end_in_async.word_separator = + +dotnet_naming_symbols.any_async_methods.applicable_kinds = method +dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * +dotnet_naming_symbols.any_async_methods.required_modifiers = async + +dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods +dotnet_naming_rule.async_methods_end_in_async.style = end_in_async +dotnet_naming_rule.async_methods_end_in_async.severity = error + +[{examples,experiments}/**.cs] + +dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_suffix = Async +dotnet_naming_style.end_in_async.capitalization = pascal_case +dotnet_naming_style.end_in_async.word_separator = + +dotnet_naming_symbols.any_async_methods.applicable_kinds = method +dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * +dotnet_naming_symbols.any_async_methods.required_modifiers = async + +dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods +dotnet_naming_rule.async_methods_end_in_async.style = end_in_async +dotnet_naming_rule.async_methods_end_in_async.severity = silent + +##################################### +# Exceptions for Tests and Examples # +##################################### + +# dotnet_diagnostic.IDE1006.severity = none # No need for Async suffix on test names[*.cs] +# dotnet_diagnostic.IDE0130.severity = none # No namespace checks + +[**/{*.{FunctionalTests,TestApplication,UnitTests},TestHelpers}/**.cs] + +dotnet_diagnostic.CA1031.severity = none # catch a more specific allowed exception type, or rethrow the exception +dotnet_diagnostic.CA1051.severity = none # Do not declare visible instance fields +dotnet_diagnostic.CA1303.severity = none # Passing literal strings as values +dotnet_diagnostic.CA1305.severity = none # The behavior of 'DateTimeOffset.ToString(string)' could vary based on the current user's locale settings +dotnet_diagnostic.CA1307.severity = none # 'string.Contains(string)' has a method overload that takes a 'StringComparison' parameter. Replace this call +dotnet_diagnostic.CA1711.severity = none # Rename type name so that it does not end in 'Collection' +dotnet_diagnostic.CA1826.severity = none # Do not use Enumerable methods on indexable collections. Instead use the collection directly +dotnet_diagnostic.CA1859.severity = none # Change return type of method for improved performance +dotnet_diagnostic.CA1861.severity = none # Prefer 'static readonly' fields over constant array arguments +dotnet_diagnostic.CA2000.severity = none # Call System.IDisposable.Dispose on object +dotnet_diagnostic.CA2007.severity = none # no need of ConfigureAwait(false) in tests +dotnet_diagnostic.CA2201.severity = none # Exception type XYZ is not sufficiently specific +dotnet_diagnostic.IDE0005.severity = none # No need for documentation +dotnet_diagnostic.IDE1006.severity = none # No need for Async suffix on test names + +resharper_inconsistent_naming_highlighting = none +# resharper_check_namespace_highlighting = none +# resharper_arrange_attributes_highlighting = none +# resharper_unused_member_global_highlighting = none +# resharper_comment_typo_highlighting = none + +[examples/**.cs] + +dotnet_diagnostic.CA1031.severity = none # catch a more specific allowed exception type, or rethrow the exception +dotnet_diagnostic.CA1050.severity = none # Declare types in namespaces +dotnet_diagnostic.CA1303.severity = none # Passing literal strings as values +dotnet_diagnostic.CA1859.severity = none # Change return type of method for improved performance +dotnet_diagnostic.CA2000.severity = none # Call System.IDisposable.Dispose on object +dotnet_diagnostic.CA2007.severity = none # no need of ConfigureAwait(false) in examples +dotnet_diagnostic.IDE0005.severity = none # No need for documentation +dotnet_diagnostic.IDE1006.severity = none # No need for Async suffix on test names + +resharper_comment_typo_highlighting = none + diff --git a/dotnet/SemanticWorkbench.sln b/dotnet/SemanticWorkbench.sln new file mode 100644 index 00000000..e7c88213 --- /dev/null +++ b/dotnet/SemanticWorkbench.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkbenchConnector", "WorkbenchConnector\WorkbenchConnector.csproj", "{F7DBFD56-5A7C-41D1-8F0A-B00E51477E19}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExample01", "..\examples\dotnet-example01\AgentExample01.csproj", "{3A6FE36E-B186-458C-984B-C1BBF4BFB440}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F7DBFD56-5A7C-41D1-8F0A-B00E51477E19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7DBFD56-5A7C-41D1-8F0A-B00E51477E19}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7DBFD56-5A7C-41D1-8F0A-B00E51477E19}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7DBFD56-5A7C-41D1-8F0A-B00E51477E19}.Release|Any CPU.Build.0 = Release|Any CPU + {3A6FE36E-B186-458C-984B-C1BBF4BFB440}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A6FE36E-B186-458C-984B-C1BBF4BFB440}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A6FE36E-B186-458C-984B-C1BBF4BFB440}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A6FE36E-B186-458C-984B-C1BBF4BFB440}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/dotnet/SemanticWorkbench.sln.DotSettings b/dotnet/SemanticWorkbench.sln.DotSettings new file mode 100644 index 00000000..496120c1 --- /dev/null +++ b/dotnet/SemanticWorkbench.sln.DotSettings @@ -0,0 +1,2 @@ + + CORS \ No newline at end of file diff --git a/dotnet/WorkbenchConnector/AgentBase.cs b/dotnet/WorkbenchConnector/AgentBase.cs new file mode 100644 index 00000000..f2cfbead --- /dev/null +++ b/dotnet/WorkbenchConnector/AgentBase.cs @@ -0,0 +1,398 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.SemanticWorkbench.Connector; + +public abstract class AgentBase +{ + // Agent instance ID + public string Id { get; protected set; } = string.Empty; + + // Agent instance name + public string Name { get; protected set; } = string.Empty; + + // Agent settings + public IAgentConfig RawConfig { get; protected set; } + + // Simple storage layer to persist agents data + protected readonly IAgentServiceStorage Storage; + + // Reference to agent service + protected readonly WorkbenchConnector WorkbenchConnector; + + // Agent logger + protected readonly ILogger Log; + + /// + /// Agent instantiation + /// + /// Semantic Workbench connector + /// Agent data storage + /// Agent logger + public AgentBase( + WorkbenchConnector workbenchConnector, + IAgentServiceStorage storage, + ILogger log) + { + this.RawConfig = null!; + this.WorkbenchConnector = workbenchConnector; + this.Storage = storage; + this.Log = log; + } + + /// + /// Convert agent config to a persisten data model + /// + public virtual AgentInfo ToDataModel() + { + return new AgentInfo + { + Id = this.Id, + Name = this.Name, + Config = this.RawConfig, + }; + } + + /// + /// Return default agent configuration + /// + public abstract IAgentConfig GetDefaultConfig(); + + /// + /// Parse object to agent configuration instance + /// + /// Untyped configuration data + public abstract IAgentConfig? ParseConfig(object data); + + /// + /// Start the agent + /// + public virtual Task StartAsync( + CancellationToken cancellationToken = default) + { + return this.Storage.SaveAgentAsync(this, cancellationToken); + } + + /// + /// Stop the agent + /// + public virtual Task StopAsync( + CancellationToken cancellationToken = default) + { + return this.Storage.DeleteAgentAsync(this, cancellationToken); + } + + /// + /// Update the configuration of an agent instance + /// + /// Configuration data + /// Async task cancellation token + /// Agent configuration + public virtual async Task UpdateAgentConfigAsync( + IAgentConfig? config, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Updating agent '{0}' config", this.Id); + + this.RawConfig ??= this.GetDefaultConfig(); + config ??= this.GetDefaultConfig(); + + this.RawConfig.Update(config); + await this.Storage.SaveAgentAsync(this, cancellationToken).ConfigureAwait(false); + return this.RawConfig; + } + + /// + /// Return the list of states in the given conversation. + /// TODO: Support states with UI + /// + /// Conversation Id + /// Async task cancellation token + public virtual Task> GetConversationInsightsAsync( + string conversationId, + CancellationToken cancellationToken = default) + { + return this.Storage.GetAllInsightsAsync(this.Id, conversationId, cancellationToken); + } + + /// + /// Notify the workbench about an update of the given state. + /// States are visible in a conversation, on the right panel. + /// + /// Conversation Id + /// State ID and content + /// Async task cancellation token + public virtual async Task SetConversationInsightAsync( + string conversationId, + Insight insight, + CancellationToken cancellationToken = default) + { + await Task.WhenAll([ + this.Storage.SaveInsightAsync(this.Id, conversationId, insight, cancellationToken), + this.WorkbenchConnector.UpdateAgentConversationInsightAsync(this.Id, conversationId, insight, cancellationToken) + ]).ConfigureAwait(false); + } + + /// + /// Create a new conversation + /// + /// Conversation ID + /// Async task cancellation token + public virtual async Task CreateConversationAsync( + string conversationId, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Creating conversation '{0}' on agent '{1}'", conversationId, this.Id); + + Conversation conversation = await this.Storage.GetConversationAsync(conversationId, this.Id, cancellationToken).ConfigureAwait(false) + ?? new Conversation(conversationId, this.Id); + + await Task.WhenAll([ + this.SetConversationInsightAsync(conversation.Id, new Insight("log", "Log", $"Conversation started at {DateTimeOffset.UtcNow}"), cancellationToken), + this.Storage.SaveConversationAsync(conversation, cancellationToken) + ]).ConfigureAwait(false); + } + + /// + /// Delete a conversation + /// + /// Agent instance ID + /// Conversation ID + /// Async task cancellation token + public virtual Task DeleteConversationAsync( + string conversationId, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Deleting conversation '{0}' on agent '{1}'", conversationId, this.Id); + return this.Storage.DeleteConversationAsync(conversationId, this.Id, cancellationToken); + } + + /// + /// Check if a conversation with a given ID exists + /// + /// Conversation ID + /// Async task cancellation token + /// True if the conversation exists + public virtual async Task ConversationExistsAsync( + string conversationId, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Checking if conversation '{0}' on agent '{1}' exists", conversationId, this.Id); + var conversation = await this.Storage.GetConversationAsync(conversationId, this.Id, cancellationToken).ConfigureAwait(false); + return conversation != null; + } + + /// + /// Add a new participant to an existing conversation + /// + /// Conversation ID + /// Participant information + /// Async task cancellation token + public virtual async Task AddParticipantAsync( + string conversationId, + Participant participant, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Adding participant to conversation '{0}' on agent '{1}'", conversationId, this.Id); + + Conversation conversation = await this.Storage.GetConversationAsync(conversationId, this.Id, cancellationToken).ConfigureAwait(false) + ?? new Conversation(conversationId, this.Id); + + conversation.AddParticipant(participant); + await this.Storage.SaveConversationAsync(conversation, cancellationToken).ConfigureAwait(false); + } + + /// + /// Remove a participant from a conversation + /// + /// Conversation ID + /// Participant information + /// Async task cancellation token + public virtual async Task RemoveParticipantAsync( + string conversationId, + Participant participantUpdatedEvent, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Removing participant from conversation '{0}' on agent '{1}'", conversationId, this.Id); + + Conversation? conversation = await this.Storage.GetConversationAsync(conversationId, this.Id, cancellationToken).ConfigureAwait(false); + if (conversation == null) { return; } + + conversation.RemoveParticipant(participantUpdatedEvent); + await this.Storage.SaveConversationAsync(conversation, cancellationToken).ConfigureAwait(false); + } + + /// + /// Add a message (received from the backend) to a conversation + /// + /// Conversation ID + /// Message information + /// Async task cancellation token + public virtual Task ReceiveMessageAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Received {0} chat message in conversation '{1}' with agent '{2}' from '{3}' '{4}'", + message.ContentType, conversationId, this.Id, message.Sender.Role, message.Sender.Id); + + // Update the chat history to include the message received + return this.AddMessageToHistoryAsync(conversationId, message, cancellationToken); + } + + /// + /// Receive a notice, a special type of message. + /// A notice is a message type for sending short, one-line updates that persist in the chat history + /// and are displayed differently from regular chat messages. + /// + /// Conversation ID + /// Message information + /// Async task cancellation token + public virtual Task ReceiveNoticeAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Received {0} notice in conversation '{1}' with agent '{2}' from '{3}' '{4}': {5}", + message.ContentType, conversationId, this.Id, message.Sender.Role, message.Sender.Id, message.Content); + + return Task.CompletedTask; + } + + /// + /// Receive a note, a special type of message. + /// A note is used to display additional information separately from the main conversation. + /// + /// Conversation ID + /// Message information + /// Async task cancellation token + public virtual Task ReceiveNoteAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Received {0} note in conversation '{1}' with agent '{2}' from '{3}' '{4}': {5}", + message.ContentType, conversationId, this.Id, message.Sender.Role, message.Sender.Id, message.Content); + + return Task.CompletedTask; + } + + /// + /// Receive a command, a special type of message + /// + /// Conversation ID + /// Message information + /// Async task cancellation token + public virtual Task ReceiveCommandAsync( + string conversationId, + Command command, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Received '{0}' command in conversation '{1}' with agent '{2}' from '{3}' '{4}': {5}", + command.CommandName, conversationId, this.Id, command.Sender.Role, command.Sender.Id, command.Content); + + return Task.CompletedTask; + } + + /// + /// Receive a command response, a special type of message + /// + /// Conversation ID + /// Message information + /// Async task cancellation token + public virtual Task ReceiveCommandResponseAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Received {0} command response in conversation '{1}' with agent '{2}' from '{3}' '{4}': {5}", + message.ContentType, conversationId, this.Id, message.Sender.Role, message.Sender.Id, message.Content); + + return Task.CompletedTask; + } + + /// + /// Remove a message from a conversation + /// + /// Conversation ID + /// Message information + /// Async task cancellation token + public virtual async Task DeleteMessageAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Deleting message in conversation '{0}' with agent '{1}', message from '{2}' '{3}'", + conversationId, this.Id, message.Sender.Role, message.Sender.Id); + + // return this.DeleteMessageFromHistoryAsync(conversationId, message, cancellationToken); + Conversation? conversation = await this.Storage.GetConversationAsync(conversationId, this.Id, cancellationToken).ConfigureAwait(false); + if (conversation == null) { return; } + + conversation.RemoveMessage(message); + await this.Storage.SaveConversationAsync(conversation, cancellationToken).ConfigureAwait(false); + } + + /// + /// Add message to chat history + /// + /// Conversation Id + /// Message content + /// Async task cancellation token + public virtual async Task AddMessageToHistoryAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + Conversation conversation = await this.Storage.GetConversationAsync(conversationId, this.Id, cancellationToken).ConfigureAwait(false) + ?? new Conversation(conversationId, this.Id); + + conversation.AddMessage(message); + await this.Storage.SaveConversationAsync(conversation, cancellationToken).ConfigureAwait(false); + return conversation; + } + + // Send a new message to a conversation, communicating with semantic workbench backend + /// + /// Send message to workbench backend + /// + /// Conversation Id + /// Message content + /// Async task cancellation token + protected virtual Task SendTextMessageAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + return this.WorkbenchConnector.SendMessageAsync(this.Id, conversationId, message, cancellationToken); + } + + /// + /// Send a status update to a conversation, communicating with semantic workbench backend + /// + /// Conversation Id + /// + /// Async task cancellation token + protected virtual Task SetAgentStatusAsync( + string conversationId, + string content, + CancellationToken cancellationToken = default) + { + this.Log.LogWarning("Change agent '{0}' status in conversation '{1}'", this.Id, conversationId); + return this.WorkbenchConnector.SetAgentStatusAsync(this.Id, conversationId, content, cancellationToken); + } + + /// + /// Reset the agent status update in a conversation, communicating with semantic workbench backend + /// + /// Conversation Id + /// Async task cancellation token + protected virtual Task ResetAgentStatusAsync( + string conversationId, + CancellationToken cancellationToken = default) + { + this.Log.LogWarning("Reset agent '{0}' status in conversation '{1}'", this.Id, conversationId); + return this.WorkbenchConnector.ResetAgentStatusAsync(this.Id, conversationId, cancellationToken); + } +} diff --git a/dotnet/WorkbenchConnector/Constants.cs b/dotnet/WorkbenchConnector/Constants.cs new file mode 100644 index 00000000..b771d868 --- /dev/null +++ b/dotnet/WorkbenchConnector/Constants.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticWorkbench.Connector; + +public static class Constants +{ + // Unique service ID + public const string HeaderServiceId = "X-Assistant-Service-ID"; + + // Agent ID + public const string HeaderAgentId = "X-Assistant-ID"; + + // HTTP methods + public static readonly HttpMethod[] HttpMethodsWithBody = [HttpMethod.Post, HttpMethod.Put, HttpMethod.Patch]; + + // Registering the multi-agent service into the workbench connector + public static class AgentServiceRegistration + { + public const string Placeholder = "{assistant_service_id}"; + public const string Path = "/assistant-service-registrations/{assistant_service_id}"; + } + + // Sending a message into an existing conversation + public static class SendAgentMessage + { + public const string ConversationPlaceholder = "{conversation_id}"; + public const string Path = "/conversations/{conversation_id}/messages"; + } + + // Sending a temporary status to show inline in a conversation, before sending a message + public static class SendAgentStatusMessage + { + public const string AgentPlaceholder = "{agent_id}"; + public const string ConversationPlaceholder = "{conversation_id}"; + public const string Path = "/conversations/{conversation_id}/participants/{agent_id}"; + } + + // Sending a notification about a state content change + public static class SendAgentConversationInsightsEvent + { + public const string AgentPlaceholder = "{agent_id}"; + public const string ConversationPlaceholder = "{conversation_id}"; + public const string Path = "/assistants/{agent_id}/states/events?conversation_id={conversation_id}"; + } + + // Get list of files + public static class GetConversationFiles + { + public const string ConversationPlaceholder = "{conversation_id}"; + public const string Path = "/conversations/{conversation_id}/files"; + } + + // Download/Delete file + public static class ConversationFile + { + public const string ConversationPlaceholder = "{conversation_id}"; + public const string FileNamePlaceholder = "{filename}"; + public const string Path = "/conversations/{conversation_id}/files/{filename}"; + } + + // Upload file + public static class UploadConversationFile + { + public const string ConversationPlaceholder = "{conversation_id}"; + public const string Path = "/conversations/{conversation_id}/files"; + } +} diff --git a/dotnet/WorkbenchConnector/IAgentConfig.cs b/dotnet/WorkbenchConnector/IAgentConfig.cs new file mode 100644 index 00000000..c4f0122a --- /dev/null +++ b/dotnet/WorkbenchConnector/IAgentConfig.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticWorkbench.Connector; + +public interface IAgentConfig +{ + public object? ToWorkbenchFormat(); + public void Update(object? config); +} diff --git a/dotnet/WorkbenchConnector/Models/ChatHistoryExt.cs b/dotnet/WorkbenchConnector/Models/ChatHistoryExt.cs new file mode 100644 index 00000000..7af37e02 --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/ChatHistoryExt.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using Microsoft.SemanticKernel.ChatCompletion; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public static class ChatHistoryExt +{ + public static string AsString(this ChatHistory history) + { + var result = new StringBuilder(); + foreach (var msg in history) + { + result.Append("[**").Append(msg.Role).Append("**] "); + result.AppendLine(msg.Content); + } + + return result.ToString(); + } +} diff --git a/dotnet/WorkbenchConnector/Models/Command.cs b/dotnet/WorkbenchConnector/Models/Command.cs new file mode 100644 index 00000000..3abc36cb --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/Command.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class Command : Message +{ + public string CommandName { get; set; } + + public string CommandParams { get; set; } + + public Command(Message message) + { + this.Id = message.Id; + this.MessageType = message.MessageType; + this.ContentType = message.ContentType; + this.Content = message.Content; + this.Timestamp = message.Timestamp; + this.Sender = message.Sender; + this.Metadata = message.Metadata; + + var p = this.Content?.Split(" ", 2, StringSplitOptions.TrimEntries); + this.CommandName = p?.Length > 0 ? p[0].TrimStart('/') : ""; + this.CommandParams = p?.Length > 1 ? p[1] : ""; + } +} diff --git a/dotnet/WorkbenchConnector/Models/Conversation.cs b/dotnet/WorkbenchConnector/Models/Conversation.cs new file mode 100644 index 00000000..34f24439 --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/Conversation.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Text.Json.Serialization; +using Microsoft.SemanticKernel.ChatCompletion; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class Conversation +{ + [JsonPropertyName("id")] + [JsonPropertyOrder(0)] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("agent_id")] + [JsonPropertyOrder(1)] + public string AgentId { get; set; } = string.Empty; + + [JsonPropertyName("participants")] + [JsonPropertyOrder(2)] + public Dictionary Participants { get; set; } = new(); + + [JsonPropertyName("messages")] + [JsonPropertyOrder(3)] + public List Messages { get; set; } = new(); + + public Conversation() + { + } + + public Conversation(string id, string agentId) + { + this.Id = id; + this.AgentId = agentId; + } + + public void AddParticipant(Participant participant) + { + this.Participants[participant.Id] = participant; + } + + public void RemoveParticipant(Participant participant) + { + this.Participants.Remove(participant.Id, out _); + } + + public void AddMessage(Message? msg) + { + if (msg == null) { return; } + + this.Messages.Add(msg); + } + + public void RemoveMessage(Message? msg) + { + if (msg == null) { return; } + + this.Messages = this.Messages.Where(x => x.Id != msg.Id).ToList(); + } + + public ChatHistory ToChatHistory(string assistantId, string systemPrompt) + { + var result = new ChatHistory(systemPrompt); + + foreach (Message msg in this.Messages) + { + if (msg.Sender.Id == assistantId) + { + result.AddAssistantMessage(msg.Content!); + } + else + { + result.AddUserMessage($"[{this.GetParticipantName(msg.Sender.Id)}] {msg.Content}"); + } + } + + return result; + } + + public string ToHtmlString(string assistantId) + { + var result = new StringBuilder(); + result.AppendLine(""); + result.AppendLine("
"); + + foreach (var msg in this.Messages) + { + result.AppendLine("

"); + if (msg.Sender.Id == assistantId) + { + result.AppendLine("Assistant
"); + } + else + { + result + .Append("") + .Append(this.GetParticipantName(msg.Sender.Id)) + .AppendLine("
"); + } + + result.AppendLine(msg.Content).AppendLine("

"); + } + + result.Append("
"); + + return result.ToString(); + } + + // TODO: the list of participants is incomplete, because agents see only participants being added + private string GetParticipantName(string id) + { + if (this.Participants.TryGetValue(id, out Participant? participant)) + { + return participant.Name; + } + + return "Unknown"; + } +} diff --git a/dotnet/WorkbenchConnector/Models/ConversationEvent.cs b/dotnet/WorkbenchConnector/Models/ConversationEvent.cs new file mode 100644 index 00000000..06de4707 --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/ConversationEvent.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class ConversationEvent +{ + public class EventData + { + [JsonPropertyName("participant")] + public Participant Participant { get; set; } = new(); + + [JsonPropertyName("message")] + public Message Message { get; set; } = new(); + } + + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("conversation_id")] + public string ConversationId { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.MinValue; + + [JsonPropertyName("data")] + public EventData Data { get; set; } = new(); +} diff --git a/dotnet/WorkbenchConnector/Models/DebugInfo.cs b/dotnet/WorkbenchConnector/Models/DebugInfo.cs new file mode 100644 index 00000000..ec4a5e41 --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/DebugInfo.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class DebugInfo : Dictionary +{ + public DebugInfo(string key, object? info) + { + this.Add(key, info); + } +} diff --git a/dotnet/WorkbenchConnector/Models/Insight.cs b/dotnet/WorkbenchConnector/Models/Insight.cs new file mode 100644 index 00000000..7b4e1994 --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/Insight.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +// TODO: Support states with UI +public class Insight +{ + [JsonPropertyName("id")] + [JsonPropertyOrder(0)] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("display_name")] + [JsonPropertyOrder(1)] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("description")] + [JsonPropertyOrder(2)] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("content")] + [JsonPropertyOrder(3)] + public string Content { get; set; } = string.Empty; + + public Insight() + { + } + + public Insight(string id, string displayName, string? content, string? description = "") + { + this.Id = id; + this.DisplayName = displayName; + this.Description = description ?? string.Empty; + this.Content = content ?? string.Empty; + } +} diff --git a/dotnet/WorkbenchConnector/Models/Message.cs b/dotnet/WorkbenchConnector/Models/Message.cs new file mode 100644 index 00000000..f5ecd11b --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/Message.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class Message +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + // "notice" | "chat" | "note" | "command" | "command-response" + [JsonPropertyName("message_type")] + public string MessageType { get; set; } = string.Empty; + + // "text/plain" + [JsonPropertyName("content_type")] + public string ContentType { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string? Content { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] + public DateTimeOffset Timestamp { get; set; } + + [JsonPropertyName("sender")] + public Sender Sender { get; set; } = new(); + + [JsonPropertyName("metadata")] + public MessageMetadata Metadata { get; set; } = new(); + + /// + /// Content types: + /// - text/plain + /// - text/html + /// - application/json (requires "json_schema" metadata) + /// + public static Message CreateChatMessage( + string agentId, + string content, + object? debug = null, + string contentType = "text/plain") + { + var result = new Message + { + Id = Guid.NewGuid().ToString("D"), + Timestamp = DateTimeOffset.UtcNow, + MessageType = "chat", + ContentType = contentType, + Content = content, + Sender = new Sender + { + Role = "assistant", + Id = agentId + } + }; + + if (debug != null) + { + result.Metadata.Debug = debug; + } + + return result; + } +} diff --git a/dotnet/WorkbenchConnector/Models/MessageMetadata.cs b/dotnet/WorkbenchConnector/Models/MessageMetadata.cs new file mode 100644 index 00000000..0f9c0ad6 --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/MessageMetadata.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class MessageMetadata +{ + [JsonPropertyName("attribution")] + public string Attribution { get; set; } = string.Empty; + + [JsonPropertyName("debug")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Debug { get; set; } = null; +} diff --git a/dotnet/WorkbenchConnector/Models/Participant.cs b/dotnet/WorkbenchConnector/Models/Participant.cs new file mode 100644 index 00000000..03f7a3cd --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/Participant.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class Participant +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("active_participant")] + public bool ActiveParticipant { get; set; } = false; + + [JsonPropertyName("online")] + public bool? Online { get; set; } = null; +} diff --git a/dotnet/WorkbenchConnector/Models/Sender.cs b/dotnet/WorkbenchConnector/Models/Sender.cs new file mode 100644 index 00000000..1b458ed8 --- /dev/null +++ b/dotnet/WorkbenchConnector/Models/Sender.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class Sender +{ + [JsonPropertyName("participant_role")] + public string Role { get; set; } = string.Empty; + + [JsonPropertyName("participant_id")] + public string Id { get; set; } = string.Empty; +} diff --git a/dotnet/WorkbenchConnector/Storage/AgentInfo.cs b/dotnet/WorkbenchConnector/Storage/AgentInfo.cs new file mode 100644 index 00000000..5ea2f365 --- /dev/null +++ b/dotnet/WorkbenchConnector/Storage/AgentInfo.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class AgentInfo +{ + [JsonPropertyName("id")] + [JsonPropertyOrder(0)] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + [JsonPropertyOrder(1)] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("config")] + [JsonPropertyOrder(2)] + public object Config { get; set; } = null!; +} diff --git a/dotnet/WorkbenchConnector/Storage/AgentServiceStorage.cs b/dotnet/WorkbenchConnector/Storage/AgentServiceStorage.cs new file mode 100644 index 00000000..39910d47 --- /dev/null +++ b/dotnet/WorkbenchConnector/Storage/AgentServiceStorage.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.InteropServices; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public class AgentServiceStorage : IAgentServiceStorage +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + + private readonly ILogger _log; + private readonly string _path; + + public AgentServiceStorage( + IConfiguration appConfig, + ILoggerFactory logFactory) + { + this._log = logFactory.CreateLogger(); + + this._path = appConfig.GetSection("Workbench").GetValue( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "StoragePathWindows" + : "StoragePathLinux") ?? string.Empty; + + if (this._path.Contains("$tmp")) + { + this._path = this._path.Replace("$tmp", Path.GetTempPath()); + } + + this._path = Path.Join(this._path, "agents"); + + if (!Directory.Exists(this._path)) + { + Directory.CreateDirectory(this._path); + } + } + + public Task SaveAgentAsync(AgentBase agent, CancellationToken cancellationToken = default) + { + return File.WriteAllTextAsync(this.GetAgentFilename(agent), JsonSerializer.Serialize(agent.ToDataModel(), s_jsonOptions), cancellationToken); + } + + public Task DeleteAgentAsync(AgentBase agent, CancellationToken cancellationToken = default) + { + File.Delete(this.GetAgentFilename(agent)); + return Task.CompletedTask; + } + + public Task> GetAllAgentsAsync(CancellationToken cancellationToken = default) + { + return this.GetAllAsync("", ".agent.json", cancellationToken); + } + + public Task SaveConversationAsync(Conversation conversation, CancellationToken cancellationToken = default) + { + var filename = this.GetConversationFilename(conversation); + var json = JsonSerializer.Serialize(conversation, s_jsonOptions); + return File.WriteAllTextAsync(filename, json, cancellationToken); + } + + public async Task GetConversationAsync(string conversationId, string agentId, CancellationToken cancellationToken = default) + { + var filename = this.GetConversationFilename(agentId: agentId, conversationId: conversationId); + if (!File.Exists(filename)) { return null; } + + var content = await File.ReadAllTextAsync(filename, cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(content); + } + + public async Task DeleteConversationAsync(string conversationId, string agentId, CancellationToken cancellationToken = default) + { + var filename = this.GetConversationFilename(agentId: agentId, conversationId: conversationId); + File.Delete(filename); + + var insights = await this.GetAllInsightsAsync(agentId: agentId, conversationId: conversationId, cancellationToken).ConfigureAwait(false); + foreach (Insight x in insights) + { + await this.DeleteInsightAsync(agentId: agentId, conversationId: conversationId, insightId: x.Id, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + public Task DeleteConversationAsync(Conversation conversation, CancellationToken cancellationToken = default) + { + var filename = this.GetConversationFilename(conversation); + File.Delete(filename); + return Task.CompletedTask; + } + + public Task> GetAllInsightsAsync(string agentId, string conversationId, CancellationToken cancellationToken = default) + { + return this.GetAllAsync($"{agentId}.{conversationId}.", ".insight.json", cancellationToken); + } + + public Task SaveInsightAsync(string agentId, string conversationId, Insight insight, CancellationToken cancellationToken = default) + { + var filename = this.GetInsightFilename(agentId: agentId, conversationId: conversationId, insightId: insight.Id); + return File.WriteAllTextAsync(filename, JsonSerializer.Serialize(insight, s_jsonOptions), cancellationToken); + } + + public Task DeleteInsightAsync(string agentId, string conversationId, string insightId, CancellationToken cancellationToken = default) + { + var filename = this.GetInsightFilename(agentId: agentId, conversationId: conversationId, insightId: insightId); + File.Delete(filename); + return Task.CompletedTask; + } + + private async Task> GetAllAsync(string prefix, string suffix, CancellationToken cancellationToken = default) + { + this._log.LogTrace("Searching all files with prefix '{0}' and suffix '{1}'", prefix, suffix); + var result = new List(); + string[] fileEntries = Directory.GetFiles(this._path); + foreach (string filePath in fileEntries) + { + var filename = Path.GetFileName(filePath); + if (!filename.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { continue; } + + if (!filename.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { continue; } + + var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false); + result.Add(JsonSerializer.Deserialize(content)!); + } + + this._log.LogTrace("Files found: {0}", result.Count); + + return result; + } + + private string GetAgentFilename(AgentBase agent) + { + return Path.Join(this._path, $"{agent.Id}.agent.json"); + } + + private string GetConversationFilename(Conversation conversation) + { + return this.GetConversationFilename(conversation.AgentId, conversation.Id); + } + + private string GetConversationFilename(string agentId, string conversationId) + { + return Path.Join(this._path, $"{agentId}.{conversationId}.conversation.json"); + } + + private string GetInsightFilename(string agentId, string conversationId, string insightId) + { + return Path.Join(this._path, $"{agentId}.{conversationId}.{insightId}.insight.json"); + } +} diff --git a/dotnet/WorkbenchConnector/Storage/IAgentServiceStorage.cs b/dotnet/WorkbenchConnector/Storage/IAgentServiceStorage.cs new file mode 100644 index 00000000..6a81cbdf --- /dev/null +++ b/dotnet/WorkbenchConnector/Storage/IAgentServiceStorage.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +// ReSharper disable once CheckNamespace +namespace Microsoft.SemanticWorkbench.Connector; + +public interface IAgentServiceStorage +{ + Task SaveAgentAsync(AgentBase agent, CancellationToken cancellationToken = default); + Task DeleteAgentAsync(AgentBase agent, CancellationToken cancellationToken = default); + Task> GetAllAgentsAsync(CancellationToken cancellationToken = default); + + Task SaveConversationAsync(Conversation conversation, CancellationToken cancellationToken = default); + Task GetConversationAsync(string conversationId, string agentId, CancellationToken cancellationToken = default); + Task DeleteConversationAsync(string conversationId, string agentId, CancellationToken cancellationToken = default); + Task DeleteConversationAsync(Conversation conversation, CancellationToken cancellationToken = default); + + Task> GetAllInsightsAsync(string agentId, string conversationId, CancellationToken cancellationToken = default); + Task SaveInsightAsync(string agentId, string conversationId, Insight insight, CancellationToken cancellationToken = default); + Task DeleteInsightAsync(string agentId, string conversationId, string insightId, CancellationToken cancellationToken = default); +} diff --git a/dotnet/WorkbenchConnector/Webservice.cs b/dotnet/WorkbenchConnector/Webservice.cs new file mode 100644 index 00000000..3cb2836b --- /dev/null +++ b/dotnet/WorkbenchConnector/Webservice.cs @@ -0,0 +1,558 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.SemanticWorkbench.Connector; + +public static class Webservice +{ + // Used for logging + private sealed class SemanticWorkbenchWebservice + { + } + + public static WorkbenchConnector UseAgentWebservice( + this IEndpointRouteBuilder builder, string endpoint, bool enableCatchAll = false) + { + WorkbenchConnector? workbenchConnector = builder.ServiceProvider.GetService(); + if (workbenchConnector == null) + { + throw new InvalidOperationException("Unable to create instance of " + nameof(WorkbenchConnector)); + } + + string prefix = new Uri(endpoint).AbsolutePath; + + builder + .UseCreateAgentEndpoint(prefix) + .UseDeleteAgentEndpoint(prefix) + .UseFetchAgentConfigEndpoint(prefix) + .UseSaveAgentConfigEndpoint(prefix) + .UseCreateConversationEndpoint(prefix) + .UseDeleteConversationEndpoint(prefix) + .UseFetchConversationStatesEndpoint(prefix) + .UseFetchConversationInsightEndpoint(prefix) + .UseCreateConversationEventEndpoint(prefix); + + if (enableCatchAll) + { + builder.UseCatchAllEndpoint(prefix); + } + + return workbenchConnector; + } + + // Create new agent instance + public static IEndpointRouteBuilder UseCreateAgentEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.MapPut(prefix + "/{agentId}", + async ( + [FromRoute] string agentId, + [FromForm(Name = "assistant")] string data, + [FromServices] WorkbenchConnector workbenchConnector, + [FromServices] ILogger log, + CancellationToken cancellationToken) => + { + string? name = agentId; + Dictionary? settings = JsonSerializer.Deserialize>(data); + settings?.TryGetValue("assistant_name", out name); + log.LogDebug("Received request to create/update agent instance '{0}', name '{1}'", agentId, name); + + var agent = workbenchConnector.GetAgent(agentId); + if (agent == null) + { + await workbenchConnector.CreateAgentAsync(agentId, name, null, cancellationToken) + .ConfigureAwait(false); + } + + return Results.Ok(); + }) + .DisableAntiforgery(); + + return builder; + } + + // Delete agent instance + public static IEndpointRouteBuilder UseDeleteAgentEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.MapDelete(prefix + "/{agentId}", + async ( + [FromRoute] string agentId, + [FromServices] WorkbenchConnector workbenchConnector, + [FromServices] ILogger log, + CancellationToken cancellationToken) => + { + log.LogDebug("Received request to deleting agent instance '{0}'", agentId); + await workbenchConnector.DeleteAgentAsync(agentId, cancellationToken).ConfigureAwait(false); + return Results.Ok(); + }); + + return builder; + } + + // Fetch agent configuration + public static IEndpointRouteBuilder UseFetchAgentConfigEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.MapGet(prefix + "/{agentId}/config", + ( + [FromRoute] string agentId, + [FromServices] WorkbenchConnector workbenchConnector, + [FromServices] ILogger log) => + { + log.LogDebug("Received request to fetch agent '{0}' configuration", agentId); + + var agent = workbenchConnector.GetAgent(agentId); + if (agent == null) + { + return Results.NotFound("Agent Not Found"); + } + + return Results.Json(agent.RawConfig.ToWorkbenchFormat()); + }); + + return builder; + } + + // Save agent configuration + public static IEndpointRouteBuilder UseSaveAgentConfigEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.MapPut(prefix + "/{agentId}/config", + async ( + [FromRoute] string agentId, + [FromBody] Dictionary data, + [FromServices] WorkbenchConnector workbenchConnector, + [FromServices] ILogger log, + CancellationToken cancellationToken) => + { + log.LogDebug("Received request to update agent '{0}' configuration", agentId); + + var agent = workbenchConnector.GetAgent(agentId); + if (agent == null) { return Results.NotFound("Agent Not Found"); } + + var config = agent.ParseConfig(data["config"]); + IAgentConfig newConfig = await agent.UpdateAgentConfigAsync(config, cancellationToken).ConfigureAwait(false); + + var tmp = workbenchConnector.GetAgent(agentId); + + return Results.Json(newConfig.ToWorkbenchFormat()); + }) + .DisableAntiforgery(); + + return builder; + } + + // Create new conversation + private static IEndpointRouteBuilder UseCreateConversationEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.MapPut(prefix + "/{agentId}/conversations/{conversationId}", + async ( + [FromRoute] string agentId, + [FromRoute] string conversationId, + [FromForm(Name = "conversation")] string data, // e.g. conversation={"id":"34460523-d2be-4388-837d-bda92282ffde"} + [FromServices] WorkbenchConnector workbenchConnector, + [FromServices] ILogger log, + CancellationToken cancellationToken) => + { + log.LogDebug("Received request to create conversation '{0}' on agent '{1}'", conversationId, agentId); + + var agent = workbenchConnector.GetAgent(agentId); + if (agent == null) { return Results.NotFound("Agent Not Found"); } + + await agent.CreateConversationAsync(conversationId, cancellationToken).ConfigureAwait(false); + return Results.Ok(); + }) + .DisableAntiforgery(); + + return builder; + } + + // Fetch conversation states + public static IEndpointRouteBuilder UseFetchConversationStatesEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.MapGet(prefix + "/{agentId}/conversations/{conversationId}/states", + async ( + [FromRoute] string agentId, + [FromRoute] string conversationId, + [FromServices] WorkbenchConnector workbenchConnector, + [FromServices] ILogger log, + CancellationToken cancellationToken) => + { + log.LogDebug("Received request to fetch agent '{0}' conversation '{1}' states", agentId, conversationId); + + var agent = workbenchConnector.GetAgent(agentId); + if (agent == null) { return Results.NotFound("Conversation Not Found"); } + + if (!await agent.ConversationExistsAsync(conversationId, cancellationToken).ConfigureAwait(false)) + { + return Results.NotFound("Conversation Not Found"); + } + + List states = await agent.GetConversationInsightsAsync(conversationId, cancellationToken).ConfigureAwait(false); + + if (states.Count == 0) + { + // Special case required by UI bug + var result = new + { + states = new[] + { + new Insight + { + Id = "__none", + DisplayName = "Assistant Info", + Description = $""" + Agent ID: **{agent.Id}** + + Name: **{agent.Name}** + + Config: **{agent.RawConfig}** + end of description + """, + Content = $""" + Agent ID: **{agent.Id}** + + Name: **{agent.Name}** + + Config: **{agent.RawConfig}** + end of content + """ + } + } + }; + return Results.Json(result); + } + else + { + var result = new + { + states = states.Select(x => new Insight { Id = x.Id, DisplayName = x.DisplayName, Description = x.Description }) + }; + return Results.Json(result); + } + }); + + return builder; + } + + // Fetch conversation states + public static IEndpointRouteBuilder UseFetchConversationInsightEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.MapGet(prefix + "/{agentId}/conversations/{conversationId}/states/{insightId}", + async ( + [FromRoute] string agentId, + [FromRoute] string conversationId, + [FromRoute] string insightId, + [FromServices] WorkbenchConnector workbenchConnector, + [FromServices] ILogger log, + CancellationToken cancellationToken) => + { + log.LogDebug("Received request to fetch agent '{0}' conversation '{1}' insight '{2}'", agentId, conversationId, insightId); + + var agent = workbenchConnector.GetAgent(agentId); + if (agent == null) { return Results.NotFound("Agent Not Found"); } + + if (!await agent.ConversationExistsAsync(conversationId, cancellationToken).ConfigureAwait(false)) + { + return Results.NotFound("Conversation Not Found"); + } + + var insights = await agent.GetConversationInsightsAsync(conversationId, cancellationToken).ConfigureAwait(false); + Insight? insight = insights.FirstOrDefault(x => x.Id == insightId); + + if (insight == null) + { + // Special case required by UI bug + if (insightId == "__none") + { + return Results.Json(new + { + id = insightId, + data = new { content = string.Empty }, + json_schema = (object)null!, + ui_schema = (object)null! + }); + } + + return Results.NotFound($"State '{insightId}' Not Found"); + } + else + { + // TODO: support schemas + var result = new + { + id = insightId, + data = new + { + content = insight.Content + }, + json_schema = (object)null!, + ui_schema = (object)null! + }; + + return Results.Json(result); + } + }); + + return builder; + } + + // New conversation event + private static IEndpointRouteBuilder UseCreateConversationEventEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.MapPost(prefix + "/{agentId}/conversations/{conversationId}/events", + async ( + [FromRoute] string agentId, + [FromRoute] string conversationId, + [FromBody] Dictionary? data, + [FromServices] WorkbenchConnector workbenchConnector, + [FromServices] ILogger log, + CancellationToken cancellationToken) => + { + log.LogDebug("Received request to process new event for agent '{0}' on conversation '{1}'", agentId, conversationId); + + if (data == null || !data.TryGetValue("event", out object? eventType)) + { + log.LogError("Event payload doesn't contain an 'event' property"); + return Results.BadRequest("Event payload doesn't contain an 'event' property"); + } + + var agent = workbenchConnector.GetAgent(agentId); + if (agent == null) { return Results.NotFound("Agent Not Found"); } + + if (!await agent.ConversationExistsAsync(conversationId, cancellationToken).ConfigureAwait(false)) + { + return Results.NotFound("Conversation Not Found"); + } + + var json = JsonSerializer.Serialize(data); + log.LogDebug("Agent '{0}', conversation '{1}', Event '{2}'", agentId, conversationId, eventType); + switch (eventType.ToString()) + { + case "participant.created": + { + var x = JsonSerializer.Deserialize(json); + if (x?.Data.Participant == null) { break; } + + await agent.AddParticipantAsync(conversationId, x.Data.Participant, cancellationToken).ConfigureAwait(false); + break; + } + + case "participant.updated": + { + var x = JsonSerializer.Deserialize(json); + if (x?.Data.Participant == null) { break; } + + if (x is { Data.Participant.ActiveParticipant: false }) + { + await agent.RemoveParticipantAsync(conversationId, x.Data.Participant, cancellationToken).ConfigureAwait(false); + } + + break; + } + + case "message.created": + { + var x = JsonSerializer.Deserialize(json); + if (x == null) { break; } + + // Ignore messages sent from the agent itself + var message = x.Data.Message; + if (message.Sender.Role == "assistant" && message.Sender.Id == agentId) { break; } + + // Ignore empty messages + if (string.IsNullOrWhiteSpace(message.Content)) { break; } + + switch (message.MessageType) + { + case "chat": + await agent.ReceiveMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + break; + + case "notice": + await agent.ReceiveNoticeAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + break; + + case "note": + await agent.ReceiveNoteAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + break; + + case "command": + var command = new Command(message); + await agent.ReceiveCommandAsync(conversationId, command, cancellationToken).ConfigureAwait(false); + break; + + case "command-response": + await agent.ReceiveCommandResponseAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + break; + + default: + log.LogInformation($"{message.MessageType}: {message.Content}"); + log.LogWarning("Agent '{0}', conversation '{1}', Message type '{2}' ignored", agentId, conversationId, message.MessageType); + break; + } + + break; + } + + case "message.deleted": + { + var x = JsonSerializer.Deserialize(json); + if (x == null) { break; } + + await agent.DeleteMessageAsync(conversationId, x.Data.Message, cancellationToken).ConfigureAwait(false); + break; + } + + case "assistant.state.created": // TODO + case "assistant.state.updated": // TODO + case "file.created": // TODO + case "file.deleted": // TODO + default: + /* + { + "event": "assistant.state.created", + "id": "ded0986ca0824e109e5bad8593b5fb1f", + "correlation_id": "4358b84cffec4255b41be26fbf6d7829", + "conversation_id": "d7896b39-ad3f-4a10-a595-a7e47f6735b0", + "timestamp": "2024-01-12T23:08:05.689296", + "data": { + "assistant_id": "69b841ff-909c-4fd7-b364-f5f962d5f021", + "state_id": "state01", + "conversation_id": "d7896b39-ad3f-4a10-a595-a7e47f6735b0" + } + } + + { + "event": "file.created", + "id": "9b7ba8b35699482bbe368023796a978d", + "correlation_id": "40877ed10f104090a9996fbe9dd6d716", + "conversation_id": "7f8c72a3-dd19-44ef-b86c-dbe712a538df", + "timestamp": "2024-01-12T10:51:16.847854", + "data": + { + "file": + { + "conversation_id": "7f8c72a3-dd19-44ef-b86c-dbe712a538df", + "created_datetime": "2024-01-12T10:51:16.845539Z", + "updated_datetime": "2024-01-12T10:51:16.846093Z", + "filename": "LICENSE", + "current_version": 1, + "content_type": "application/octet-stream", + "file_size": 1141, + "participant_id": "72f988bf-86f1-41af-91ab-2d7cd011db47.37348b50-e200-4d93-9602-f1344b1f3cde", + "participant_role": "user", + "metadata": {} + } + } + } + + { + "event": "file.deleted", + "id": "75a3c347d7a644708548065098fa1b0b", + "correlation_id": "7e2aa0f64dc140dbb82a68c50c2f3461", + "conversation_id": "7f8c72a3-dd19-44ef-b86c-dbe712a538df", + "timestamp": "2024-07-28T10:55:51.257584", + "data": { + "file": { + "conversation_id": "7f8c72a3-dd19-44ef-b86c-dbe712a538df", + "created_datetime": "2024-07-28T10:51:16.845539", + "updated_datetime": "2024-07-28T10:51:16.846093", + "filename": "LICENSE", + "current_version": 1, + "content_type": "application/octet-stream", + "file_size": 1141, + "participant_id": "72f988bf-86f1-41af-91ab-2d7cd011db47.37348b50-e200-4d93-9602-f1344b1f3cde", + "participant_role": "user", + "metadata": {} + } + } + } + */ + log.LogWarning("Event type '{0}' not supported", eventType); + log.LogTrace(json); + break; + } + + return Results.Ok(); + }) + .DisableAntiforgery(); + + return builder; + } + + // Delete conversation + public static IEndpointRouteBuilder UseDeleteConversationEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.MapDelete(prefix + "/{agentId}/conversations/{conversationId}", + async ( + [FromRoute] string agentId, + [FromRoute] string conversationId, + [FromServices] WorkbenchConnector workbenchConnector, + [FromServices] ILogger log, + CancellationToken cancellationToken) => + { + log.LogDebug("Received request to delete conversation '{0}' on agent instance '{1}'", conversationId, agentId); + + var agent = workbenchConnector.GetAgent(agentId); + if (agent == null) { return Results.Ok(); } + + await agent.DeleteConversationAsync(conversationId, cancellationToken).ConfigureAwait(false); + + return Results.Ok(); + }); + + return builder; + } + + // Catch all endpoint + private static IEndpointRouteBuilder UseCatchAllEndpoint( + this IEndpointRouteBuilder builder, string prefix) + { + builder.Map(prefix + "/{*catchAll}", async ( + HttpContext context, + ILogger log) => + { + context.Request.EnableBuffering(); + + // Read headers + StringBuilder headersStringBuilder = new(); + foreach (KeyValuePair header in context.Request.Headers) + { + headersStringBuilder.AppendLine($"{header.Key}: {header.Value}"); + } + + // Read body + using StreamReader reader = new(context.Request.Body, leaveOpen: true); + string requestBody = await reader.ReadToEndAsync().ConfigureAwait(false); + context.Request.Body.Position = 0; + + log.LogWarning("Unknown request: {0} Path: {1}", context.Request.Method, context.Request.Path); + + string? query = context.Request.QueryString.Value; + if (!string.IsNullOrEmpty(query)) { log.LogDebug("Query: {0}", context.Request.QueryString.Value); } + + log.LogDebug("Headers: {0}", headersStringBuilder.ToString()); + log.LogDebug("Body: {0}", requestBody); + + return Results.NotFound("Request not supported"); + }); + + return builder; + } +} diff --git a/dotnet/WorkbenchConnector/WorkbenchConfig.cs b/dotnet/WorkbenchConnector/WorkbenchConfig.cs new file mode 100644 index 00000000..a1cc706a --- /dev/null +++ b/dotnet/WorkbenchConnector/WorkbenchConfig.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticWorkbench.Connector; + +public class WorkbenchConfig +{ + /// + /// Semantic Workbench endpoint. + /// + public string WorkbenchEndpoint { get; set; } = "http://127.0.0.1:3000"; + + /// + /// The endpoint of your service, where semantic workbench will send communications too. + /// This should match hostname, port, protocol and path of the web service. You can use + /// this also to route semantic workbench through a proxy or a gateway if needed. + /// + public string ConnectorEndpoint { get; set; } = "http://127.0.0.1:9001/myagents"; + + /// + /// Unique ID of the service. Semantic Workbench will store this event to identify the server + /// so you should keep the value fixed to match the conversations tracked across service restarts. + /// + public string ConnectorId { get; set; } = Guid.NewGuid().ToString("D"); + + /// + /// Name of your agent service + /// + public string ConnectorName { get; set; } = ".NET Multi Agent Service"; + + /// + /// Description of your agent service. + /// + public string ConnectorDescription { get; set; } = "Multi-agent service for .NET agents"; +} diff --git a/dotnet/WorkbenchConnector/WorkbenchConnector.cs b/dotnet/WorkbenchConnector/WorkbenchConnector.cs new file mode 100644 index 00000000..8f466386 --- /dev/null +++ b/dotnet/WorkbenchConnector/WorkbenchConnector.cs @@ -0,0 +1,431 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.SemanticWorkbench.Connector; + +public abstract class WorkbenchConnector : IDisposable +{ + protected readonly IAgentServiceStorage Storage; + protected readonly WorkbenchConfig Config = new(); + protected readonly HttpClient HttpClient; + protected readonly ILogger Log; + protected readonly Dictionary Agents; + + private Timer? _pingTimer; + + public WorkbenchConnector( + IConfiguration appConfig, + IAgentServiceStorage storage, + ILogger logger) + { + appConfig.GetSection("Workbench").Bind(this.Config); + + this.Log = logger; + this.Storage = storage; + this.HttpClient = new HttpClient(); + this.HttpClient.BaseAddress = new Uri(this.Config.WorkbenchEndpoint); + this.Agents = new Dictionary(); + + this.Log.LogTrace("Service instance created"); + } + + /// + /// Connect the agent service to workbench backend + /// + /// Async task cancellation token + public virtual async Task ConnectAsync(CancellationToken cancellationToken = default) + { + this.Log.LogInformation("Connecting {1} {2} {3}...", this.Config.ConnectorId, this.Config.ConnectorName, this.Config.ConnectorEndpoint); + #pragma warning disable CS4014 // ping runs in the background without blocking + this._pingTimer ??= new Timer(_ => this.PingSemanticWorkbenchBackendAsync(cancellationToken), null, 0, 10000); + #pragma warning restore CS4014 + + List agents = await this.Storage.GetAllAgentsAsync(cancellationToken).ConfigureAwait(false); + this.Log.LogInformation("Starting {0} agents", agents.Count); + foreach (var agent in agents) + { + await this.CreateAgentAsync(agent.Id, agent.Name, agent.Config, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Disconnect the agent service from the workbench backend + /// + /// Async task cancellation token + public virtual Task DisconnectAsync(CancellationToken cancellationToken = default) + { + this.Log.LogInformation("Disconnecting {1} {2} ...", this.Config.ConnectorId, this.Config.ConnectorName); + this._pingTimer?.Dispose(); + this._pingTimer = null; + return Task.CompletedTask; + } + + /// + /// Create a new agent instance + /// + /// Agent instance ID + /// Agent name + /// Async task cancellation token + public abstract Task CreateAgentAsync( + string agentId, + string? name, + object? configData, + CancellationToken cancellationToken = default); + + /// + /// Get agent with the given ID + /// + /// Agent ID + /// Agent instance + public virtual AgentBase? GetAgent(string agentId) + { + return this.Agents.GetValueOrDefault(agentId); + } + + /// + /// Delete an agent instance + /// + /// Agent instance ID + /// Async task cancellation token + public virtual async Task DeleteAgentAsync( + string agentId, + CancellationToken cancellationToken = default) + { + var agent = this.GetAgent(agentId); + if (agent == null) { return; } + + this.Log.LogInformation("Deleting agent '{0}'", agentId); + await agent.StopAsync(cancellationToken).ConfigureAwait(false); + this.Agents.Remove(agentId); + await agent.StopAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Set a state content, visible in the state inspector. + /// The content is visibile in the state inspector, on the right side panel. + /// + /// Agent instance ID + /// Conversation ID + /// Content. Markdown and HTML are supported. + /// Async task cancellation token + public virtual async Task UpdateAgentConversationInsightAsync( + string agentId, + string conversationId, + Insight insight, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Updating agent '{0}' '{1}' insight", agentId, insight.Id); + + var data = new + { + state_id = insight.Id, + @event = "updated", + state = new + { + id = insight.Id, + data = new + { + content = insight.Content + }, + json_schema = new { }, + ui_schema = new { } + } + }; + + string url = Constants.SendAgentConversationInsightsEvent.Path + .Replace(Constants.SendAgentConversationInsightsEvent.AgentPlaceholder, agentId) + .Replace(Constants.SendAgentConversationInsightsEvent.ConversationPlaceholder, conversationId); + + await this.SendAsync(HttpMethod.Post, url, data, agentId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Set a temporary agent status within a conversation. + /// The status is shown inline in the conversation, as a temporary brief message. + /// + /// Agent instance ID + /// Conversation ID + /// Short status description + /// Async task cancellation token + public virtual async Task SetAgentStatusAsync( + string agentId, + string conversationId, + string status, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Setting agent status in conversation '{0}' with agent '{1}'", conversationId, agentId); + + var data = new + { + status = status, + active_participant = true + }; + + string url = Constants.SendAgentStatusMessage.Path + .Replace(Constants.SendAgentStatusMessage.ConversationPlaceholder, conversationId) + .Replace(Constants.SendAgentStatusMessage.AgentPlaceholder, agentId); + + await this.SendAsync(HttpMethod.Put, url, data, agentId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Set a temporary agent status within a conversation + /// + /// Agent instance ID + /// Conversation ID + /// Short status description + /// Async task cancellation token + public virtual async Task ResetAgentStatusAsync( + string agentId, + string conversationId, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Setting agent status in conversation '{0}' with agent '{1}'", conversationId, agentId); + + string payload = """ + { + "status": null, + "active_participant": true + } + """; + + var data = JsonSerializer.Deserialize(payload); + + string url = Constants.SendAgentStatusMessage.Path + .Replace(Constants.SendAgentStatusMessage.ConversationPlaceholder, conversationId) + .Replace(Constants.SendAgentStatusMessage.AgentPlaceholder, agentId); + + await this.SendAsync(HttpMethod.Put, url, data!, agentId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Send a message from an agent to a conversation + /// + /// Agent instance ID + /// Conversation ID + /// Message content + /// Async task cancellation token + public virtual async Task SendMessageAsync( + string agentId, + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Sending message in conversation '{0}' with agent '{1}'", conversationId, agentId); + + string url = Constants.SendAgentMessage.Path + .Replace(Constants.SendAgentMessage.ConversationPlaceholder, conversationId); + + await this.SendAsync(HttpMethod.Post, url, message, agentId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Get list of files. TODO. + /// + /// Agent instance ID + /// Conversation ID + /// Async task cancellation token + public virtual async Task GetFilesAsync( + string agentId, + string conversationId, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Fetching list of files in conversation '{0}'", conversationId); + + string url = Constants.GetConversationFiles.Path + .Replace(Constants.GetConversationFiles.ConversationPlaceholder, conversationId); + + HttpResponseMessage result = await this.SendAsync(HttpMethod.Get, url, null, agentId, cancellationToken).ConfigureAwait(false); + + // TODO: parse response and return list + + /* + { + "files": [ + { + "conversation_id": "7f8c72a3-dd19-44ef-b86c-dbe712a538df", + "created_datetime": "2024-01-12T11:04:38.923626", + "updated_datetime": "2024-01-12T11:04:38.923789", + "filename": "LICENSE", + "current_version": 1, + "content_type": "application/octet-stream", + "file_size": 1141, + "participant_id": "72f988bf-86f1-41af-91ab-2d7cd011db47.37348b50-e200-4d93-9602-f1344b1f3cde", + "participant_role": "user", + "metadata": {} + } + ] + } + */ + } + + /// + /// Download file. TODO. + /// + /// Agent instance ID + /// Conversation ID + /// File name + /// Async task cancellation token + public virtual async Task DownloadFileAsync( + string agentId, + string conversationId, + string fileName, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Downloading file from conversation '{0}'", conversationId); + + string url = Constants.ConversationFile.Path + .Replace(Constants.ConversationFile.ConversationPlaceholder, conversationId) + .Replace(Constants.ConversationFile.FileNamePlaceholder, fileName); + + HttpResponseMessage result = await this.SendAsync(HttpMethod.Get, url, null, agentId, cancellationToken).ConfigureAwait(false); + + // TODO: parse response and return file + + /* + < HTTP/1.1 200 OK + < date: Fri, 12 Jan 2024 11:12:23 GMT + < content-disposition: attachment; filename="LICENSE" + < content-type: application/octet-stream + < transfer-encoding: chunked + < + ... + */ + } + + /// + /// Upload a file. TODO. + /// + /// Agent instance ID + /// Conversation ID + /// File name + /// Async task cancellation token + public virtual async Task UploadFileAsync( + string agentId, + string conversationId, + string fileName, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Deleting file {0} from a conversation '{1}'", fileName, conversationId); + + string url = Constants.UploadConversationFile.Path + .Replace(Constants.UploadConversationFile.ConversationPlaceholder, conversationId); + + // TODO: include file using multipart/form-data + + await this.SendAsync(HttpMethod.Put, url, null, agentId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Delete a file + /// + /// Agent instance ID + /// Conversation ID + /// File name + /// Async task cancellation token + public virtual async Task DeleteFileAsync( + string agentId, + string conversationId, + string fileName, + CancellationToken cancellationToken = default) + { + this.Log.LogDebug("Deleting file {0} from a conversation '{1}'", fileName, conversationId); + + string url = Constants.ConversationFile.Path + .Replace(Constants.ConversationFile.ConversationPlaceholder, conversationId) + .Replace(Constants.ConversationFile.FileNamePlaceholder, fileName); + + await this.SendAsync(HttpMethod.Delete, url, null, agentId, cancellationToken).ConfigureAwait(false); + } + + public virtual async Task PingSemanticWorkbenchBackendAsync(CancellationToken cancellationToken) + { + this.Log.LogTrace("Pinging workbench backend"); + string path = Constants.AgentServiceRegistration.Path + .Replace(Constants.AgentServiceRegistration.Placeholder, this.Config.ConnectorId); + + var data = new + { + name = this.Config.ConnectorName, + description = this.Config.ConnectorDescription, + url = this.Config.ConnectorEndpoint, + online_expires_in_seconds = 20 + }; + + await this.SendAsync(HttpMethod.Put, path, data, null, cancellationToken).ConfigureAwait(false); + } + +#region internals =========================================================================== + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + this._pingTimer?.Dispose(); + this._pingTimer = null; + this.HttpClient.Dispose(); + } + } + + protected virtual async Task SendAsync( + HttpMethod method, + string url, + object? data, + string? agentId, + CancellationToken cancellationToken) + { + try + { + this.Log.LogTrace("Sending request {0} {1}", method, url); + HttpRequestMessage request = this.PrepareRequest(method, url, data, agentId); + HttpResponseMessage result = await this.HttpClient + .SendAsync(request, cancellationToken) + .ConfigureAwait(false); + request.Dispose(); + return result; + } + catch (HttpRequestException e) + { + this.Log.LogError("HTTP request failed: {0}. Request: {1} {2}", e.Message, method, url); + throw; + } + catch (Exception e) + { + this.Log.LogError(e, "Unexpected error"); + throw; + } + } + + protected virtual HttpRequestMessage PrepareRequest( + HttpMethod method, + string url, + object? data, + string? agentId) + { + HttpRequestMessage request = new(method, url); + if (Constants.HttpMethodsWithBody.Contains(method)) + { + request.Content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json"); + } + + request.Headers.Add(Constants.HeaderServiceId, this.Config.ConnectorId); + if (!string.IsNullOrEmpty(agentId)) + { + request.Headers.Add(Constants.HeaderAgentId, agentId); + } + + return request; + } + +#endregion +} diff --git a/dotnet/WorkbenchConnector/WorkbenchConnector.csproj b/dotnet/WorkbenchConnector/WorkbenchConnector.csproj new file mode 100644 index 00000000..81089fdb --- /dev/null +++ b/dotnet/WorkbenchConnector/WorkbenchConnector.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + Microsoft.SemanticWorkbench.Connector + Microsoft.SemanticWorkbench.Connector + + + + + + + + + + + diff --git a/examples/dotnet-example01/.editorconfig b/examples/dotnet-example01/.editorconfig new file mode 100644 index 00000000..5bf7c515 --- /dev/null +++ b/examples/dotnet-example01/.editorconfig @@ -0,0 +1,474 @@ +# To learn more about .editorconfig see https://aka.ms/editorconfigdocs +############################### +# Core EditorConfig Options # +############################### +root = true +# All files +[*] +indent_style = space +end_of_line = lf + +# XML project files +[*.{vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# XML config files +[*.{targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# XML config files +[*.csproj] +indent_size = 4 + +# XML config files +[*.props] +indent_size = 4 + +[Directory.Packages.props] +indent_size = 2 + +# YAML config files +[*.{yml,yaml}] +tab_width = 2 +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +# JSON config files +[*.json] +tab_width = 2 +indent_size = 2 +insert_final_newline = false +trim_trailing_whitespace = true + +# Typescript files +[*.{ts,tsx}] +insert_final_newline = true +trim_trailing_whitespace = true +tab_width = 4 +indent_size = 4 +file_header_template = Copyright (c) Microsoft. All rights reserved. + +# Stylesheet files +[*.{css,scss,sass,less}] +insert_final_newline = true +trim_trailing_whitespace = true +tab_width = 4 +indent_size = 4 + +# Code files +[*.{cs,csx,vb,vbx}] +tab_width = 4 +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8-bom +file_header_template = Copyright (c) Microsoft. All rights reserved. + +############################### +# .NET Coding Conventions # +############################### +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true +# this. preferences +dotnet_style_qualification_for_field = true:error +dotnet_style_qualification_for_property = true:error +dotnet_style_qualification_for_method = true:error +dotnet_style_qualification_for_event = true:error +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:error +dotnet_style_readonly_field = true:suggestion +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:silent +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +# Code quality rules +dotnet_code_quality_unused_parameters = all:suggestion + +[*.cs] + +# TODO: enable this but stop "dotnet format" from applying incorrect fixes and introducing a lot of unwanted changes. +dotnet_analyzer_diagnostic.severity = none + +# Note: these settings cause "dotnet format" to fix the code. You should review each change if you uses "dotnet format". +dotnet_diagnostic.RCS1007.severity = warning # Add braces +dotnet_diagnostic.RCS1036.severity = warning # Remove unnecessary blank line. +dotnet_diagnostic.RCS1037.severity = warning # Remove trailing white-space. +dotnet_diagnostic.RCS1097.severity = warning # Remove redundant 'ToString' call. +dotnet_diagnostic.RCS1138.severity = warning # Add summary to documentation comment. +dotnet_diagnostic.RCS1139.severity = warning # Add summary element to documentation comment. +dotnet_diagnostic.RCS1168.severity = warning # Parameter name 'foo' differs from base name 'bar'. +dotnet_diagnostic.RCS1175.severity = warning # Unused 'this' parameter 'operation'. +dotnet_diagnostic.RCS1192.severity = warning # Unnecessary usage of verbatim string literal. +dotnet_diagnostic.RCS1194.severity = warning # Implement exception constructors. +dotnet_diagnostic.RCS1211.severity = warning # Remove unnecessary else clause. +dotnet_diagnostic.RCS1214.severity = warning # Unnecessary interpolated string. +dotnet_diagnostic.RCS1225.severity = warning # Make class sealed. +dotnet_diagnostic.RCS1232.severity = warning # Order elements in documentation comment. + +# Diagnostics elevated as warnings +dotnet_diagnostic.CA1000.severity = warning # Do not declare static members on generic types +dotnet_diagnostic.CA1031.severity = warning # Do not catch general exception types +dotnet_diagnostic.CA1050.severity = warning # Declare types in namespaces +dotnet_diagnostic.CA1063.severity = warning # Implement IDisposable correctly +dotnet_diagnostic.CA1064.severity = warning # Exceptions should be public +dotnet_diagnostic.CA1303.severity = warning # Do not pass literals as localized parameters +dotnet_diagnostic.CA1416.severity = warning # Validate platform compatibility +dotnet_diagnostic.CA1508.severity = warning # Avoid dead conditional code +dotnet_diagnostic.CA1852.severity = warning # Sealed classes +dotnet_diagnostic.CA1859.severity = warning # Use concrete types when possible for improved performance +dotnet_diagnostic.CA1860.severity = warning # Prefer comparing 'Count' to 0 rather than using 'Any()', both for clarity and for performance +dotnet_diagnostic.CA2000.severity = warning # Call System.IDisposable.Dispose on object before all references to it are out of scope +dotnet_diagnostic.CA2007.severity = error # Do not directly await a Task +dotnet_diagnostic.CA2201.severity = warning # Exception type System.Exception is not sufficiently specific +dotnet_diagnostic.CA2225.severity = warning # Operator overloads have named alternates + +dotnet_diagnostic.IDE0001.severity = warning # Simplify name +dotnet_diagnostic.IDE0005.severity = warning # Remove unnecessary using directives +dotnet_diagnostic.IDE1006.severity = warning # Code style errors, e.g. dotnet_naming_rule rules violations +dotnet_diagnostic.IDE0009.severity = warning # Add this or Me qualification +dotnet_diagnostic.IDE0011.severity = warning # Add braces +dotnet_diagnostic.IDE0018.severity = warning # Inline variable declaration +dotnet_diagnostic.IDE0032.severity = warning # Use auto-implemented property +dotnet_diagnostic.IDE0034.severity = warning # Simplify 'default' expression +dotnet_diagnostic.IDE0035.severity = warning # Remove unreachable code +dotnet_diagnostic.IDE0040.severity = warning # Add accessibility modifiers +dotnet_diagnostic.IDE0049.severity = warning # Use language keywords instead of framework type names for type references +dotnet_diagnostic.IDE0050.severity = warning # Convert anonymous type to tuple +dotnet_diagnostic.IDE0051.severity = warning # Remove unused private member +dotnet_diagnostic.IDE0055.severity = warning # Formatting rule +dotnet_diagnostic.IDE0060.severity = warning # Remove unused parameter +dotnet_diagnostic.IDE0070.severity = warning # Use 'System.HashCode.Combine' +dotnet_diagnostic.IDE0071.severity = warning # Simplify interpolation +dotnet_diagnostic.IDE0073.severity = warning # Require file header +dotnet_diagnostic.IDE0082.severity = warning # Convert typeof to nameof +dotnet_diagnostic.IDE0090.severity = warning # Simplify new expression +dotnet_diagnostic.IDE0130.severity = warning # Namespace does not match folder structure +dotnet_diagnostic.IDE0161.severity = warning # Use file-scoped namespace + +dotnet_diagnostic.RCS1032.severity = warning # Remove redundant parentheses. +dotnet_diagnostic.RCS1118.severity = warning # Mark local variable as const. +dotnet_diagnostic.RCS1141.severity = warning # Add 'param' element to documentation comment. +dotnet_diagnostic.RCS1197.severity = warning # Optimize StringBuilder.AppendLine call. +dotnet_diagnostic.RCS1205.severity = warning # Order named arguments according to the order of parameters. +dotnet_diagnostic.RCS1229.severity = warning # Use async/await when necessary. + +dotnet_diagnostic.VSTHRD111.severity = error # Use .ConfigureAwait(bool) + +# Suppressed diagnostics + +# Commented out because `dotnet format` change can be disruptive. +# dotnet_diagnostic.RCS1085.severity = warning # Use auto-implemented property. + +# Commented out because `dotnet format` removes the xmldoc element, while we should add the missing documentation instead. +# dotnet_diagnostic.RCS1228.severity = warning # Unused element in documentation comment. + +dotnet_diagnostic.CA1002.severity = none # Change 'List' in '...' to use 'Collection' ... +dotnet_diagnostic.CA1032.severity = none # We're using RCS1194 which seems to cover more ctors +dotnet_diagnostic.CA1034.severity = none # Do not nest type. Alternatively, change its accessibility so that it is not externally visible +dotnet_diagnostic.CA1054.severity = none # URI parameters should not be strings +dotnet_diagnostic.CA1062.severity = none # Disable null check, C# already does it for us +dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member +dotnet_diagnostic.CA1805.severity = none # Member is explicitly initialized to its default value +dotnet_diagnostic.CA1822.severity = none # Member does not access instance data and can be marked as static +dotnet_diagnostic.CA1848.severity = none # For improved performance, use the LoggerMessage delegates +dotnet_diagnostic.CA2227.severity = none # Change to be read-only by removing the property setter +dotnet_diagnostic.CA2253.severity = none # Named placeholders in the logging message template should not be comprised of only numeric characters +dotnet_diagnostic.RCS1021.severity = none # Use expression-bodied lambda. +dotnet_diagnostic.RCS1061.severity = none # Merge 'if' with nested 'if'. +dotnet_diagnostic.RCS1069.severity = none # Remove unnecessary case label. +dotnet_diagnostic.RCS1074.severity = none # Remove redundant constructor. +dotnet_diagnostic.RCS1077.severity = none # Optimize LINQ method call. +dotnet_diagnostic.RCS1124.severity = none # Inline local variable. +dotnet_diagnostic.RCS1129.severity = none # Remove redundant field initialization. +dotnet_diagnostic.RCS1140.severity = none # Add exception to documentation comment. +dotnet_diagnostic.RCS1142.severity = none # Add 'typeparam' element to documentation comment. +dotnet_diagnostic.RCS1146.severity = none # Use conditional access. +dotnet_diagnostic.RCS1151.severity = none # Remove redundant cast. +dotnet_diagnostic.RCS1158.severity = none # Static member in generic type should use a type parameter. +dotnet_diagnostic.RCS1161.severity = none # Enum should declare explicit value +dotnet_diagnostic.RCS1163.severity = none # Unused parameter 'foo'. +dotnet_diagnostic.RCS1170.severity = none # Use read-only auto-implemented property. +dotnet_diagnostic.RCS1173.severity = none # Use coalesce expression instead of 'if'. +dotnet_diagnostic.RCS1181.severity = none # Convert comment to documentation comment. +dotnet_diagnostic.RCS1186.severity = none # Use Regex instance instead of static method. +dotnet_diagnostic.RCS1188.severity = none # Remove redundant auto-property initialization. +dotnet_diagnostic.RCS1189.severity = none # Add region name to #endregion. +dotnet_diagnostic.RCS1201.severity = none # Use method chaining. +dotnet_diagnostic.RCS1212.severity = none # Remove redundant assignment. +dotnet_diagnostic.RCS1217.severity = none # Convert interpolated string to concatenation. +dotnet_diagnostic.RCS1222.severity = none # Merge preprocessor directives. +dotnet_diagnostic.RCS1226.severity = none # Add paragraph to documentation comment. +dotnet_diagnostic.RCS1234.severity = none # Enum duplicate value +dotnet_diagnostic.RCS1238.severity = none # Avoid nested ?: operators. +dotnet_diagnostic.RCS1241.severity = none # Implement IComparable when implementing IComparable. +dotnet_diagnostic.IDE0001.severity = none # Simplify name +dotnet_diagnostic.IDE0002.severity = none # Simplify member access +dotnet_diagnostic.IDE0004.severity = none # Remove unnecessary cast +dotnet_diagnostic.IDE0035.severity = none # Remove unreachable code +dotnet_diagnostic.IDE0051.severity = none # Remove unused private member +dotnet_diagnostic.IDE0052.severity = none # Remove unread private member +dotnet_diagnostic.IDE0058.severity = none # Remove unused expression value +dotnet_diagnostic.IDE0059.severity = none # Unnecessary assignment of a value +dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter +dotnet_diagnostic.IDE0080.severity = none # Remove unnecessary suppression operator +dotnet_diagnostic.IDE0100.severity = none # Remove unnecessary equality operator +dotnet_diagnostic.IDE0110.severity = none # Remove unnecessary discards +dotnet_diagnostic.IDE0032.severity = none # Use auto property +dotnet_diagnostic.IDE0160.severity = none # Use block-scoped namespace +dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave +dotnet_diagnostic.xUnit1004.severity = none # Test methods should not be skipped. Remove the Skip property to start running the test again. + +dotnet_diagnostic.SKEXP0003.severity = none # XYZ is for evaluation purposes only +dotnet_diagnostic.SKEXP0010.severity = none +dotnet_diagnostic.SKEXP0010.severity = none +dotnet_diagnostic.SKEXP0011.severity = none +dotnet_diagnostic.SKEXP0052.severity = none +dotnet_diagnostic.SKEXP0101.severity = none + +dotnet_diagnostic.KMEXP00.severity = none # XYZ is for evaluation purposes only +dotnet_diagnostic.KMEXP01.severity = none +dotnet_diagnostic.KMEXP02.severity = none +dotnet_diagnostic.KMEXP03.severity = none + +############################### +# C# Coding Conventions # +############################### + +# var preferences +csharp_style_var_for_built_in_types = false:none +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = false:none +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +# Modifier preferences +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:suggestion +# Expression-level preferences +csharp_prefer_braces = true:error +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:error +csharp_style_inlined_variable_declaration = true:suggestion + +############################### +# C# Formatting Rules # +############################### + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = false # Does not work with resharper, forcing code to be on long lines instead of wrapping +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +# Indentation preferences +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true +csharp_using_directive_placement = outside_namespace:warning +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent + +############################### +# Global Naming Conventions # +############################### + +# Styles + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +dotnet_naming_style.static_underscored.capitalization = camel_case +dotnet_naming_style.static_underscored.required_prefix = s_ + +dotnet_naming_style.underscored.capitalization = camel_case +dotnet_naming_style.underscored.required_prefix = _ + +dotnet_naming_style.uppercase_with_underscore_separator.capitalization = all_upper +dotnet_naming_style.uppercase_with_underscore_separator.word_separator = _ + +dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_suffix = Async +dotnet_naming_style.end_in_async.capitalization = pascal_case +dotnet_naming_style.end_in_async.word_separator = + +# Symbols + +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_constant.applicable_kinds = local +dotnet_naming_symbols.local_constant.applicable_accessibilities = * +dotnet_naming_symbols.local_constant.required_modifiers = const + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_symbols.any_async_methods.applicable_kinds = method +dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * +dotnet_naming_symbols.any_async_methods.required_modifiers = async + +# Rules + +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = error + +dotnet_naming_rule.local_constant_should_be_pascal_case.symbols = local_constant +dotnet_naming_rule.local_constant_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.local_constant_should_be_pascal_case.severity = error + +dotnet_naming_rule.private_constant_fields.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields.style = pascal_case_style +dotnet_naming_rule.private_constant_fields.severity = error + +dotnet_naming_rule.private_static_fields_underscored.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_underscored.style = static_underscored +dotnet_naming_rule.private_static_fields_underscored.severity = error + +dotnet_naming_rule.private_fields_underscored.symbols = private_fields +dotnet_naming_rule.private_fields_underscored.style = underscored +dotnet_naming_rule.private_fields_underscored.severity = error + +##################################################################################################### +# Naming Conventions by folder # +# See also https://www.jetbrains.com/help/resharper/Coding_Assistance__Naming_Style.html#configure # +##################################################################################################### + +[{clients,extensions,service,tools}/**.cs] + +dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_suffix = Async +dotnet_naming_style.end_in_async.capitalization = pascal_case +dotnet_naming_style.end_in_async.word_separator = + +dotnet_naming_symbols.any_async_methods.applicable_kinds = method +dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * +dotnet_naming_symbols.any_async_methods.required_modifiers = async + +dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods +dotnet_naming_rule.async_methods_end_in_async.style = end_in_async +dotnet_naming_rule.async_methods_end_in_async.severity = error + +[{examples,experiments}/**.cs] + +dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_suffix = Async +dotnet_naming_style.end_in_async.capitalization = pascal_case +dotnet_naming_style.end_in_async.word_separator = + +dotnet_naming_symbols.any_async_methods.applicable_kinds = method +dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * +dotnet_naming_symbols.any_async_methods.required_modifiers = async + +dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods +dotnet_naming_rule.async_methods_end_in_async.style = end_in_async +dotnet_naming_rule.async_methods_end_in_async.severity = silent + +##################################### +# Exceptions for Tests and Examples # +##################################### + +# dotnet_diagnostic.IDE1006.severity = none # No need for Async suffix on test names[*.cs] +# dotnet_diagnostic.IDE0130.severity = none # No namespace checks + +[**/{*.{FunctionalTests,TestApplication,UnitTests},TestHelpers}/**.cs] + +dotnet_diagnostic.CA1031.severity = none # catch a more specific allowed exception type, or rethrow the exception +dotnet_diagnostic.CA1051.severity = none # Do not declare visible instance fields +dotnet_diagnostic.CA1303.severity = none # Passing literal strings as values +dotnet_diagnostic.CA1305.severity = none # The behavior of 'DateTimeOffset.ToString(string)' could vary based on the current user's locale settings +dotnet_diagnostic.CA1307.severity = none # 'string.Contains(string)' has a method overload that takes a 'StringComparison' parameter. Replace this call +dotnet_diagnostic.CA1711.severity = none # Rename type name so that it does not end in 'Collection' +dotnet_diagnostic.CA1826.severity = none # Do not use Enumerable methods on indexable collections. Instead use the collection directly +dotnet_diagnostic.CA1859.severity = none # Change return type of method for improved performance +dotnet_diagnostic.CA1861.severity = none # Prefer 'static readonly' fields over constant array arguments +dotnet_diagnostic.CA2000.severity = none # Call System.IDisposable.Dispose on object +dotnet_diagnostic.CA2007.severity = none # no need of ConfigureAwait(false) in tests +dotnet_diagnostic.CA2201.severity = none # Exception type XYZ is not sufficiently specific +dotnet_diagnostic.IDE0005.severity = none # No need for documentation +dotnet_diagnostic.IDE1006.severity = none # No need for Async suffix on test names + +resharper_inconsistent_naming_highlighting = none +# resharper_check_namespace_highlighting = none +# resharper_arrange_attributes_highlighting = none +# resharper_unused_member_global_highlighting = none +# resharper_comment_typo_highlighting = none + +[examples/**.cs] + +dotnet_diagnostic.CA1031.severity = none # catch a more specific allowed exception type, or rethrow the exception +dotnet_diagnostic.CA1050.severity = none # Declare types in namespaces +dotnet_diagnostic.CA1303.severity = none # Passing literal strings as values +dotnet_diagnostic.CA1859.severity = none # Change return type of method for improved performance +dotnet_diagnostic.CA2000.severity = none # Call System.IDisposable.Dispose on object +dotnet_diagnostic.CA2007.severity = none # no need of ConfigureAwait(false) in examples +dotnet_diagnostic.IDE0005.severity = none # No need for documentation +dotnet_diagnostic.IDE1006.severity = none # No need for Async suffix on test names + +resharper_comment_typo_highlighting = none + diff --git a/examples/dotnet-example01/AgentExample01.csproj b/examples/dotnet-example01/AgentExample01.csproj new file mode 100644 index 00000000..9499a24d --- /dev/null +++ b/examples/dotnet-example01/AgentExample01.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + AgentExample01 + AgentExample01 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/examples/dotnet-example01/MyAgent.cs b/examples/dotnet-example01/MyAgent.cs new file mode 100644 index 00000000..4f960eb2 --- /dev/null +++ b/examples/dotnet-example01/MyAgent.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticWorkbench.Connector; + +namespace AgentExample01; + +public class MyAgent : AgentBase +{ + // Agent settings + public MyAgentConfig Config + { + get { return (MyAgentConfig)this.RawConfig; } + private set { this.RawConfig = value; } + } + + /// + /// Create a new agent instance + /// + /// Agent instance ID + /// Agent name + /// Agent configuration + /// Service containing the agent, used to communicate with Workbench backend + /// Agent data storage + /// App logger factory + public MyAgent( + string agentId, + string agentName, + MyAgentConfig? agentConfig, + WorkbenchConnector workbenchConnector, + IAgentServiceStorage storage, + ILoggerFactory? loggerFactory = null) + : base( + workbenchConnector, + storage, + loggerFactory?.CreateLogger() ?? new NullLogger()) + { + this.Id = agentId; + this.Name = agentName; + this.Config = agentConfig ?? new MyAgentConfig(); + } + + /// + public override IAgentConfig GetDefaultConfig() + { + return new MyAgentConfig(); + } + + /// + public override IAgentConfig? ParseConfig(object data) + { + return JsonSerializer.Deserialize(JsonSerializer.Serialize(data)); + } + + /// + public override async Task ReceiveCommandAsync( + string conversationId, + Command command, + CancellationToken cancellationToken = default) + { + // Check if commands are enabled + if (!this.Config.CommandsEnabled) { return; } + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && command.Sender.Role == "assistant") { return; } + + // Support only the "say" command + if (command.CommandName.ToLowerInvariant() != "say") { return; } + + // Update the chat history to include the message received + await base.AddMessageToHistoryAsync(conversationId, command, cancellationToken).ConfigureAwait(false); + + // Create the answer content. CommandParams contains the message to send back. + var answer = Message.CreateChatMessage(this.Id, command.CommandParams); + + // Update the chat history to include the outgoing message + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + + /// + public override async Task ReceiveMessageAsync( + string conversationId, + Message message, + CancellationToken cancellationToken = default) + { + try + { + // Show some status while working... + await this.SetAgentStatusAsync(conversationId, "Thinking...", cancellationToken).ConfigureAwait(false); + + // Fake delay, to show the status in the chat + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); + + // Update the chat history to include the message received + await base.AddMessageToHistoryAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + + // Check if we're replying to other agents + if (!this.Config.ReplyToAgents && message.Sender.Role == "assistant") { return; } + + // Ignore empty messages + if (string.IsNullOrWhiteSpace(message.Content)) { return; } + + // Create the answer content + var answer = Message.CreateChatMessage(this.Id, "echo: "+ message.Content); + + // Update the chat history to include the outgoing message + await this.AddMessageToHistoryAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + + // Send the message to workbench backend + await this.SendTextMessageAsync(conversationId, answer, cancellationToken).ConfigureAwait(false); + } + finally + { + // Remove the "Thinking..." status + await this.ResetAgentStatusAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/examples/dotnet-example01/MyAgentConfig.cs b/examples/dotnet-example01/MyAgentConfig.cs new file mode 100644 index 00000000..3ede0412 --- /dev/null +++ b/examples/dotnet-example01/MyAgentConfig.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using Microsoft.SemanticWorkbench.Connector; + +namespace AgentExample01; + +public class MyAgentConfig : IAgentConfig +{ + [JsonPropertyName(nameof(this.ReplyToAgents))] + [JsonPropertyOrder(10)] + public bool ReplyToAgents { get; set; } = false; + + [JsonPropertyName(nameof(this.CommandsEnabled))] + [JsonPropertyOrder(20)] + public bool CommandsEnabled { get; set; } = false; + + public void Update(object? config) + { + if (config == null) + { + throw new ArgumentException("Incompatible or empty configuration"); + } + + if (config is not MyAgentConfig cfg) + { + throw new ArgumentException("Incompatible configuration type"); + } + + this.ReplyToAgents = cfg.ReplyToAgents; + this.CommandsEnabled = cfg.CommandsEnabled; + } + + public object ToWorkbenchFormat() + { + Dictionary result = new(); + Dictionary defs = new(); + Dictionary properties = new(); + Dictionary jsonSchema = new(); + Dictionary uiSchema = new(); + + properties[nameof(this.ReplyToAgents)] = new Dictionary + { + { "type", "boolean" }, + { "title", "Reply to other assistants in conversations" }, + { "description", "Reply to assistants" }, + { "default", false } + }; + + properties[nameof(this.CommandsEnabled)] = new Dictionary + { + { "type", "boolean" }, + { "title", "Support commands" }, + { "description", "Support commands, e.g. /say" }, + { "default", false } + }; + + jsonSchema["type"] = "object"; + jsonSchema["title"] = "ConfigStateModel"; + jsonSchema["additionalProperties"] = false; + jsonSchema["properties"] = properties; + jsonSchema["$defs"] = defs; + + result["json_schema"] = jsonSchema; + result["ui_schema"] = uiSchema; + result["config"] = this; + + return result; + } +} diff --git a/examples/dotnet-example01/MyWorkbenchConnector.cs b/examples/dotnet-example01/MyWorkbenchConnector.cs new file mode 100644 index 00000000..6f7d918c --- /dev/null +++ b/examples/dotnet-example01/MyWorkbenchConnector.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticWorkbench.Connector; + +namespace AgentExample01; + +public sealed class MyWorkbenchConnector : WorkbenchConnector +{ + private readonly MyAgentConfig _defaultAgentConfig = new(); + private readonly IServiceProvider _sp; + + public MyWorkbenchConnector( + IServiceProvider sp, + IConfiguration appConfig, + IAgentServiceStorage storage, + ILoggerFactory? loggerFactory = null) + : base(appConfig, storage, loggerFactory?.CreateLogger() ?? new NullLogger()) + { + appConfig.GetSection("Agent").Bind(this._defaultAgentConfig); + this._sp = sp; + } + + /// + public override async Task CreateAgentAsync( + string agentId, + string? name, + object? configData, + CancellationToken cancellationToken = default) + { + if (this.GetAgent(agentId) != null) { return; } + + this.Log.LogDebug("Creating agent '{0}'", agentId); + + MyAgentConfig config = this._defaultAgentConfig; + if (configData != null) + { + var newCfg = JsonSerializer.Deserialize(JsonSerializer.Serialize(configData)); + if (newCfg != null) { config = newCfg; } + } + + // Instantiate using .NET Service Provider so that dependencies are automatically injected + var agent = ActivatorUtilities.CreateInstance(this._sp, agentId, name ?? agentId, config); + + await agent.StartAsync(cancellationToken).ConfigureAwait(false); + this.Agents.TryAdd(agentId, agent); + } +} diff --git a/examples/dotnet-example01/Program.cs b/examples/dotnet-example01/Program.cs new file mode 100644 index 00000000..37efcd84 --- /dev/null +++ b/examples/dotnet-example01/Program.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticWorkbench.Connector; + +namespace AgentExample01; + +internal static class Program +{ + private const string CORSPolicyName = "MY-CORS"; + + internal static async Task Main(string[] args) + { + // Setup + var appBuilder = WebApplication.CreateBuilder(args); + + // Load settings from files and env vars + appBuilder.Configuration + .AddJsonFile("appsettings.json") + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables(); + + // Storage layer to persist agents configuration and conversations + appBuilder.Services.AddSingleton(); + + // Agent service to support multiple agent instances + appBuilder.Services.AddSingleton(); + + // Misc + appBuilder.Services.AddLogging() + .AddCors(opt => opt.AddPolicy(CORSPolicyName, pol => pol.WithMethods("GET", "POST", "PUT", "DELETE"))); + + // Build + WebApplication app = appBuilder.Build(); + app.UseCors(CORSPolicyName); + + // Connect to workbench backend, keep alive, and accept incoming requests + var connectorEndpoint = app.Configuration.GetSection("Workbench").Get()!.ConnectorEndpoint; + using var agentService = app.UseAgentWebservice(connectorEndpoint, true); + await agentService.ConnectAsync().ConfigureAwait(false); + + // Start app and webservice + await app.RunAsync().ConfigureAwait(false); + } +} diff --git a/examples/dotnet-example01/README.md b/examples/dotnet-example01/README.md new file mode 100644 index 00000000..3c6556c5 --- /dev/null +++ b/examples/dotnet-example01/README.md @@ -0,0 +1,44 @@ +# Using Semantic Workbench with .NET Agents + +This project provides an example of testing your agent within the **Semantic Workbench**. + +## Project Overview + +The sample project utilizes the `WorkbenchConnector` library, enabling you to focus on agent development and testing. + +Semantic Workbench allows mixing agents from different frameworks and multiple instances of the same agent. The connector can manage multiple agent instances if needed, or you can work with a single instance if preferred. +To integrate agents developed with other frameworks, we recommend isolating each agent type with a dedicated web service, ie a dedicated project. + +## Project Structure + +Project Structure + +1. `appsettings.json`: + * Purpose: standard .NET configuration file. + * Key Points: + * Contains default values, in particular the ports used. + * Optionally create `appsettings.development.json` for custom settings. +2. `MyAgentConfig.cs`: + * Purpose: contains your agent settings. + * Key Points: + * Extend `IAgentConfig` to integrate with the workbench connector. + * Implement `ToWorkbenchFormat()` to visualize and edit configurations in the Semantic Workbench UI. +3. `MyAgent.cs`: + * Purpose: contains your agent logic. + * Key Points: + * Extend `AgentBase`. + * Implement essential methods: + * `ReceiveMessageAsync()`: **handles incoming messages using intent detection, plugins, RAG, etc.** + * `GetDefaultConfig()`: provides default settings for new agent instances. + * `ParseConfig()`: deserializes a generic object into MyAgentConfig. + * **You can override default implementation for additional customization.** +4. `MyWorkbenchConnector.cs`: + * Purpose: custom instance of WorkbenchConnector. + * Key Points: + * **Contains code to create an instance of your agent class**. + * **You can override default implementation for additional customization.** +5. `Program.cs`: + * Purpose: sets up configuration, dependencies using .NET Dependency Injection and starts services. + * Key Points: + * **Starts a web service** to enable communication with Semantic Workbench. + * **Starts an instance of WorkbenchConnector** for agent communication. diff --git a/examples/dotnet-example01/appsettings.json b/examples/dotnet-example01/appsettings.json new file mode 100644 index 00000000..5855966c --- /dev/null +++ b/examples/dotnet-example01/appsettings.json @@ -0,0 +1,60 @@ +{ + // Semantic Workbench connector settings + "Workbench": { + // Semantic Workbench endpoint. + "WorkbenchEndpoint": "http://127.0.0.1:3000", + // The endpoint of your service, where semantic workbench will send communications too. + // This should match hostname, port, protocol and path of the web service. You can use + // this also to route semantic workbench through a proxy or a gateway if needed. + "ConnectorEndpoint": "http://127.0.0.1:9001/myagents", + // Unique ID of the service. Semantic Workbench will store this event to identify the server + // so you should keep the value fixed to match the conversations tracked across service restarts. + "ConnectorId": "AgentExample01", + // Name of your agent service + "ConnectorName": ".NET Multi Agent Service 01", + // Description of your agent service. + "ConnectorDescription": "Multi-agent service for .NET agents", + // Where to store agents settings and conversations + // See AgentServiceStorage class. + "StoragePathLinux": "/tmp/.sw/AgentExample01", + "StoragePathWindows": "$tmp\\.sw\\AgentExample01" + }, + // You agent settings + "Agent": { + "Name": "Agent1", + "ReplyToAgents": false, + "CommandsEnabled": true + }, + // Web service settings + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://*:9001" + } + // "Https": { + // "Url": "https://*:9002" + // } + } + }, + // .NET Logger settings + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information" + }, + "Console": { + "LogToStandardErrorThreshold": "Critical", + "FormatterName": "simple", + "FormatterOptions": { + "TimestampFormat": "[HH:mm:ss.fff] ", + "SingleLine": true, + "UseUtcTimestamp": false, + "IncludeScopes": false, + "JsonWriterOptions": { + "Indented": true + } + } + } + } +} \ No newline at end of file