Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Utcp.Cli/CliCallTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ namespace Utcp.Cli;

public sealed record CliCallTemplate : CallTemplate
{
static CliCallTemplate()
{
PolymorphicRegistry.RegisterCallTemplateDerivedType("cli", typeof(CliCallTemplate));
}

public required string Command { get; init; }
public IReadOnlyList<string>? Args { get; init; }
public string? WorkingDirectory { get; init; }
Expand All @@ -17,7 +22,6 @@ public sealed record CliCallTemplate : CallTemplate
public CliCallTemplate()
{
CallTemplateType = "cli";
PolymorphicRegistry.RegisterCallTemplateDerivedType("cli", typeof(CliCallTemplate));
}
}

5 changes: 5 additions & 0 deletions src/Utcp.Core/Interfaces/IConcurrentToolRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ public interface IConcurrentToolRepository
{
Task SaveManualAsync(CallTemplate manualCallTemplate, UtcpManual manual, CancellationToken cancellationToken = default);
Task<bool> RemoveManualAsync(string manualName, CancellationToken cancellationToken = default);
Task<bool> RemoveToolAsync(string toolName, CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> GetManualNamesAsync(CancellationToken cancellationToken = default);
Task<CallTemplate?> GetManualCallTemplateAsync(string manualCallTemplateName, CancellationToken cancellationToken = default);
Task<IReadOnlyList<UtcpManual>> GetManualsAsync(CancellationToken cancellationToken = default);
Task<UtcpManual?> GetManualAsync(string manualName, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Tool>> GetToolsAsync(CancellationToken cancellationToken = default);
Task<Tool?> GetToolAsync(string toolName, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Tool>?> GetToolsByManualAsync(string manualName, CancellationToken cancellationToken = default);
Task<IReadOnlyList<CallTemplate>> GetManualCallTemplatesAsync(CancellationToken cancellationToken = default);
}

102 changes: 102 additions & 0 deletions src/Utcp.Core/Models/Serialization/ModelSerializers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.

namespace Utcp.Core.Models.Serialization;

using Utcp.Core.Models;
using Utcp.Core.Serialization;

public sealed class AuthSerializer : Serializer<Auth>
{
public override Auth ValidateDictionary(IReadOnlyDictionary<string, object?> obj)
{
if (!obj.TryGetValue("auth_type", out var discriminator) || discriminator is not string authType)
{
throw new ArgumentException("Dictionary does not contain an auth_type discriminator.", nameof(obj));
}

if (!PolymorphicRegistry.TryGetAuthType(authType, out var type) || type is null)
{
throw new InvalidOperationException($"Unknown Auth type: {authType}");
}

if (Runtime.Deserialize(type, obj) is not Auth auth)
{
throw new InvalidOperationException($"Unable to deserialize Auth type '{authType}'.");
}

return auth;
}

public override Dictionary<string, object?> ToDictionary(Auth obj)
{
return Runtime.ToDictionary(obj);
}
}

public sealed class CallTemplateSerializer : Serializer<CallTemplate>
{
public override CallTemplate ValidateDictionary(IReadOnlyDictionary<string, object?> obj)
{
if (!obj.TryGetValue("call_template_type", out var discriminator) || discriminator is not string templateType)
{
throw new ArgumentException("Dictionary does not contain a call_template_type discriminator.", nameof(obj));
}

if (!PolymorphicRegistry.TryGetCallTemplateType(templateType, out var type) || type is null)
{
throw new InvalidOperationException($"Unknown CallTemplate type: {templateType}");
}

if (Runtime.Deserialize(type, obj) is not CallTemplate template)
{
throw new InvalidOperationException($"Unable to deserialize CallTemplate type '{templateType}'.");
}

return template;
}

public override Dictionary<string, object?> ToDictionary(CallTemplate obj)
{
return Runtime.ToDictionary(obj);
}
}

public sealed class JsonSchemaSerializer : Serializer<JsonSchema>
{
public override JsonSchema ValidateDictionary(IReadOnlyDictionary<string, object?> obj)
{
return Runtime.Deserialize<JsonSchema>(obj);
}

public override Dictionary<string, object?> ToDictionary(JsonSchema obj)
{
return Runtime.ToDictionary(obj);
}
}

public sealed class ToolSerializer : Serializer<Tool>
{
public override Tool ValidateDictionary(IReadOnlyDictionary<string, object?> obj)
{
return Runtime.Deserialize<Tool>(obj);
}

public override Dictionary<string, object?> ToDictionary(Tool obj)
{
return Runtime.ToDictionary(obj);
}
}

public sealed class UtcpManualSerializer : Serializer<UtcpManual>
{
public override UtcpManual ValidateDictionary(IReadOnlyDictionary<string, object?> obj)
{
return Runtime.Deserialize<UtcpManual>(obj);
}

public override Dictionary<string, object?> ToDictionary(UtcpManual obj)
{
return Runtime.ToDictionary(obj);
}
}
5 changes: 5 additions & 0 deletions src/Utcp.Core/Models/Tool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ public sealed record Tool

public sealed record JsonSchema
{
[JsonPropertyName("$schema")]
public string? Schema { get; init; }
[JsonPropertyName("$id")]
public string? Id { get; init; }
public string? Title { get; init; }
public string? Description { get; init; }
Expand All @@ -28,11 +30,14 @@ public sealed record JsonSchema
public object? Const { get; init; }
public object? Default { get; init; }
public string? Format { get; init; }
[JsonPropertyName("additionalProperties")]
public object? AdditionalProperties { get; init; }
public string? Pattern { get; init; }
public double? Minimum { get; init; }
public double? Maximum { get; init; }
[JsonPropertyName("minLength")]
public int? MinLength { get; init; }
[JsonPropertyName("maxLength")]
public int? MaxLength { get; init; }
}

60 changes: 60 additions & 0 deletions src/Utcp.Core/Repositories/InMemToolRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,24 +68,84 @@ public async Task<bool> RemoveManualAsync(string manualName, CancellationToken c
}
}

public async Task<bool> RemoveToolAsync(string toolName, CancellationToken cancellationToken = default)
{
await this.writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!this.toolsByName.TryRemove(toolName, out _))
{
return false;
}

foreach (var manualName in this.manualsByName.Keys.ToList())
{
if (!this.manualsByName.TryGetValue(manualName, out var manual))
{
continue;
}

var remaining = manual.Tools.Where(t => !string.Equals(t.Name, toolName, StringComparison.OrdinalIgnoreCase)).ToList();
if (remaining.Count != manual.Tools.Count)
{
this.manualsByName[manualName] = manual with { Tools = remaining };
}
}

return true;
}
finally
{
this.writeLock.Release();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Case Sensitivity Mismatch in Tool Removal

The RemoveToolAsync method has inconsistent case sensitivity. It uses case-sensitive comparison when removing a tool from toolsByName, but case-insensitive comparison when updating tool lists within manualsByName. This can lead to data inconsistency where a tool is removed from one collection but not the other if the casing doesn't match exactly.

Fix in Cursor Fix in Web

Comment on lines +71 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Inconsistent case handling can make RemoveToolAsync fail unexpectedly.

toolsByName.TryRemove(toolName, out _) is case-sensitive, but the subsequent manual update compares names with OrdinalIgnoreCase. If callers pass a different casing, removal returns false and manuals aren’t updated.

Two fixes; prefer A:

A) Make dictionaries case-insensitive (applies outside this hunk):

-    private readonly ConcurrentDictionary<string, Tool> toolsByName = new();
-    private readonly ConcurrentDictionary<string, UtcpManual> manualsByName = new();
-    private readonly ConcurrentDictionary<string, CallTemplate> manualTemplatesByName = new();
+    private readonly ConcurrentDictionary<string, Tool> toolsByName = new(StringComparer.OrdinalIgnoreCase);
+    private readonly ConcurrentDictionary<string, UtcpManual> manualsByName = new(StringComparer.OrdinalIgnoreCase);
+    private readonly ConcurrentDictionary<string, CallTemplate> manualTemplatesByName = new(StringComparer.OrdinalIgnoreCase);

B) Or, perform a case-insensitive key resolution before removing:

-            if (!this.toolsByName.TryRemove(toolName, out _))
+            var key = this.toolsByName.Keys.FirstOrDefault(k => string.Equals(k, toolName, StringComparison.OrdinalIgnoreCase));
+            if (key is null || !this.toolsByName.TryRemove(key, out _))
             {
                 return false;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async Task<bool> RemoveToolAsync(string toolName, CancellationToken cancellationToken = default)
{
await this.writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!this.toolsByName.TryRemove(toolName, out _))
{
return false;
}
foreach (var manualName in this.manualsByName.Keys.ToList())
{
if (!this.manualsByName.TryGetValue(manualName, out var manual))
{
continue;
}
var remaining = manual.Tools.Where(t => !string.Equals(t.Name, toolName, StringComparison.OrdinalIgnoreCase)).ToList();
if (remaining.Count != manual.Tools.Count)
{
this.manualsByName[manualName] = manual with { Tools = remaining };
}
}
return true;
}
finally
{
this.writeLock.Release();
}
}
public async Task<bool> RemoveToolAsync(string toolName, CancellationToken cancellationToken = default)
{
await this.writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var key = this.toolsByName.Keys.FirstOrDefault(k => string.Equals(k, toolName, StringComparison.OrdinalIgnoreCase));
if (key is null || !this.toolsByName.TryRemove(key, out _))
{
return false;
}
foreach (var manualName in this.manualsByName.Keys.ToList())
{
if (!this.manualsByName.TryGetValue(manualName, out var manual))
{
continue;
}
var remaining = manual.Tools.Where(t => !string.Equals(t.Name, toolName, StringComparison.OrdinalIgnoreCase)).ToList();
if (remaining.Count != manual.Tools.Count)
{
this.manualsByName[manualName] = manual with { Tools = remaining };
}
}
return true;
}
finally
{
this.writeLock.Release();
}
}
🤖 Prompt for AI Agents
In src/Utcp.Core/Repositories/InMemToolRepository.cs around lines 71 to 101,
RemoveToolAsync uses toolsByName.TryRemove(toolName, out _) which is
case-sensitive while later comparisons use StringComparison.OrdinalIgnoreCase,
causing removals to fail for differently-cased input; fix by making the relevant
dictionaries (toolsByName and manualsByName) use a case-insensitive comparer
(e.g., StringComparer.OrdinalIgnoreCase) at construction so keys are normalized
and TryRemove behaves consistently, or if you prefer the alternate approach,
resolve the actual stored key by searching toolsByName.Keys with an
ordinal-ignore-case match and remove that concrete key before updating manuals.


public Task<IReadOnlyList<string>> GetManualNamesAsync(CancellationToken cancellationToken = default)
{
var list = (IReadOnlyList<string>)this.manualsByName.Keys.ToList();
return Task.FromResult(list);
}

public Task<CallTemplate?> GetManualCallTemplateAsync(string manualCallTemplateName, CancellationToken cancellationToken = default)
{
this.manualTemplatesByName.TryGetValue(manualCallTemplateName, out var template);
return Task.FromResult(template);
}

public Task<IReadOnlyList<UtcpManual>> GetManualsAsync(CancellationToken cancellationToken = default)
{
var list = (IReadOnlyList<UtcpManual>)this.manualsByName.Values.ToList();
return Task.FromResult(list);
}

public Task<UtcpManual?> GetManualAsync(string manualName, CancellationToken cancellationToken = default)
{
this.manualsByName.TryGetValue(manualName, out var manual);
return Task.FromResult(manual);
}

public Task<IReadOnlyList<Tool>> GetToolsAsync(CancellationToken cancellationToken = default)
{
var list = (IReadOnlyList<Tool>)this.toolsByName.Values.ToList();
return Task.FromResult(list);
}

public Task<Tool?> GetToolAsync(string toolName, CancellationToken cancellationToken = default)
{
this.toolsByName.TryGetValue(toolName, out var tool);
return Task.FromResult(tool);
}

public Task<IReadOnlyList<Tool>?> GetToolsByManualAsync(string manualName, CancellationToken cancellationToken = default)
{
if (this.manualsByName.TryGetValue(manualName, out var manual))
{
return Task.FromResult<IReadOnlyList<Tool>?>(manual.Tools.ToList());
}

return Task.FromResult<IReadOnlyList<Tool>?>(null);
}

public Task<IReadOnlyList<CallTemplate>> GetManualCallTemplatesAsync(CancellationToken cancellationToken = default)
{
var list = (IReadOnlyList<CallTemplate>)this.manualTemplatesByName.Values.ToList();
Expand Down
112 changes: 112 additions & 0 deletions src/Utcp.Core/Serialization/Serializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.

namespace Utcp.Core.Serialization;

using System.Text.Json;
using System.Text.Json.Serialization;
using Utcp.Core.Models.Serialization;

public abstract class Serializer<T>
{
public abstract T ValidateDictionary(IReadOnlyDictionary<string, object?> obj);

public abstract Dictionary<string, object?> ToDictionary(T obj);

public virtual T Copy(T obj)
{
return this.ValidateDictionary(this.ToDictionary(obj));
}

protected static class Runtime
{
internal static readonly JsonSerializerOptions Options;

static Runtime()
{
Options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
Options.Converters.Add(new CallTemplateJsonConverter());
Options.Converters.Add(new AuthJsonConverter());
}

public static Dictionary<string, object?> ToDictionary<TValue>(TValue value)
{
if (value is null)
{
throw new ArgumentNullException(nameof(value));
}

var element = JsonSerializer.SerializeToElement(value, value.GetType(), Options);
if (element.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException("Expected JSON object when converting to dictionary.");
}

return (Dictionary<string, object?>)ConvertElement(element)!;
}

public static TTarget Deserialize<TTarget>(IReadOnlyDictionary<string, object?> data)
{
var json = JsonSerializer.Serialize(data);
var result = JsonSerializer.Deserialize<TTarget>(json, Options);
if (result is null)
{
throw new InvalidOperationException("Unable to deserialize dictionary into target type.");
}

return result;
}

public static object? Deserialize(Type type, IReadOnlyDictionary<string, object?> data)
{
var json = JsonSerializer.Serialize(data);
return JsonSerializer.Deserialize(json, type, Options);
}

private static object? ConvertElement(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Object => ConvertObject(element),
JsonValueKind.Array => element.EnumerateArray().Select(ConvertElement).ToList(),
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => ConvertNumber(element),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Undefined => null,
_ => element.GetRawText(),
};
}

private static Dictionary<string, object?> ConvertObject(JsonElement element)
{
var dict = new Dictionary<string, object?>();
foreach (var property in element.EnumerateObject())
{
dict[property.Name] = ConvertElement(property.Value);
}

return dict;
}

private static object ConvertNumber(JsonElement element)
{
if (element.TryGetInt64(out var l))
{
return l;
}

if (element.TryGetDouble(out var d))
{
return d;
}

return element.GetDecimal();
}
}
}
6 changes: 5 additions & 1 deletion src/Utcp.Gql/GraphQlCallTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ namespace Utcp.Gql;

public sealed record GraphQlCallTemplate : CallTemplate
{
static GraphQlCallTemplate()
{
PolymorphicRegistry.RegisterCallTemplateDerivedType("gql", typeof(GraphQlCallTemplate));
}

public required Uri Endpoint { get; init; }
public string? Query { get; init; }

public GraphQlCallTemplate()
{
CallTemplateType = "gql";
PolymorphicRegistry.RegisterCallTemplateDerivedType("gql", typeof(GraphQlCallTemplate));
}
}

Expand Down
6 changes: 5 additions & 1 deletion src/Utcp.Http/HttpCallTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ namespace Utcp.Http;

public sealed record HttpCallTemplate : CallTemplate
{
static HttpCallTemplate()
{
PolymorphicRegistry.RegisterCallTemplateDerivedType("http", typeof(HttpCallTemplate));
}

public string Method { get; init; } = "GET";
public required Uri Url { get; init; }
public IReadOnlyDictionary<string, string>? Headers { get; init; }
Expand All @@ -18,7 +23,6 @@ public sealed record HttpCallTemplate : CallTemplate
public HttpCallTemplate()
{
CallTemplateType = "http";
PolymorphicRegistry.RegisterCallTemplateDerivedType("http", typeof(HttpCallTemplate));
}
}

Loading
Loading