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
3 changes: 3 additions & 0 deletions src/Orbit.Application/Chat/AgentArgumentPreview.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public static class AgentArgumentPreview
var entityId = target?.EntityId ?? Guid.Empty;
var entityName = target?.EntityName ?? ReadName(arguments) ?? string.Empty;
var fields = arguments.EnumerateObject()
.OrderBy(property => property.Name != "title")
.ThenBy(property => property.Name, StringComparer.Ordinal)
.Select(property => new PendingOperationChange(
entityId,
entityName,
Expand Down Expand Up @@ -77,6 +79,7 @@ private static bool IsEditable(string field) =>
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => value.GetString(),
JsonValueKind.Object or JsonValueKind.Array => AgentOperationFingerprint.Canonicalize(value.GetRawText()),
_ => value.ToString()
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ internal sealed class ToolExecutionAccumulator
private readonly HashSet<(string Domain, string Access)> _seenToolSteps = [];
private readonly SemaphoreSlim _toolStepGate = new(1, 1);

public IReadOnlyList<Orbit.Domain.Entities.Habit> Habits { get; init; } = [];
public IReadOnlyList<Orbit.Domain.Entities.Habit> AllHabits { get; init; } = [];
public bool RequiresExistingParent { get; init; }
public HashSet<(Guid? ParentId, string Name)> ProposedHabitNames { get; } = [];
public Dictionary<(Guid? ParentId, string Name), string> PendingHabitNames { get; } = [];
public bool IsTaggingIntent { get; init; }
public HashSet<string> ExistingDuplicateNames { get; } = new(StringComparer.OrdinalIgnoreCase);
public HashSet<string> PendingDuplicateNames { get; } = new(StringComparer.OrdinalIgnoreCase);
public HashSet<string> BlockedReplacementNames { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, string[]> AmbiguousHabitNames { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, string> InvalidCalls { get; } = new(StringComparer.Ordinal);
public HashSet<string> MissingHabitNames { get; } = new(StringComparer.OrdinalIgnoreCase);
public HashSet<string> PreviewedCalls { get; } = new(StringComparer.Ordinal);
public HashSet<string> MergedCallIds { get; } = new(StringComparer.Ordinal);

public DateTime? AnsweredAtUtc { get; init; }
public List<ActionResult> CommittedActions { get; } = [];
public List<PendingAgentOperation> CommittedPendingOperations { get; } = [];
Expand Down Expand Up @@ -100,7 +115,8 @@ public void Add(
if (policyDenial is not null)
PolicyDenials.Add(policyDenial);

if (pendingOperation is not null)
if (pendingOperation is not null && PendingOperations.All(item => item.Id != pendingOperation.Id)
&& pendingOperation.ChangeTargetCount != 0 && pendingOperation.Items is not { Count: 0 })
PendingOperations.Add(pendingOperation);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ private async Task<Result<ChatContext>> LoadChatContextAsync(

var userHabits = await data.HabitRepository.FindAsync(
h => h.UserId == request.UserId,
q => q,
q => q.Include(habit => habit.Tags),
cancellationToken);
var activeHabits = userHabits.Where(habit => !habit.IsCompleted).ToList();
var userToday = await execution.UserDateService.GetUserTodayAsync(request.UserId, cancellationToken);
Expand Down Expand Up @@ -69,6 +69,7 @@ private async Task<Result<ChatContext>> LoadChatContextAsync(
LogContextLoaded(logger, dbStopwatch.ElapsedMilliseconds, activeHabits.Count);

return Result.Success(new ChatContext(
userHabits,
activeHabits,
todayHabits,
promptHabitIndex.Habits,
Expand Down Expand Up @@ -189,6 +190,7 @@ internal sealed record PromptHabitIndex(
IReadOnlySet<Guid> DoneTodayHabitIds);

private sealed record ChatContext(
IReadOnlyList<Habit> UserHabits,
List<Habit> ActiveHabits,
List<Habit> TodayHabits,
List<Habit> PromptHabits,
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@

if (askCall is not null)
orderedCalls.Add(askCall);
var outcomesByCallId = await ExecuteToolCallsAsync(orderedCalls, request, executionResults, cancellationToken);
var outcomesByCallId = await ExecuteToolCallsAsync(orderedCalls, request, executionResults, language, cancellationToken);

var toolResults = new List<AiToolCallResult>(orderedCalls.Count);
var hadToolFailure = false;
Expand Down Expand Up @@ -178,6 +178,7 @@
List<AiToolCall> orderedCalls,
ProcessUserChatCommand request,
ToolExecutionAccumulator executionResults,
string? language,
CancellationToken cancellationToken)
{
var readOnlyCalls = orderedCalls
Expand All @@ -187,6 +188,9 @@
.Where(call => call.Name != "ask" && ai.ToolRegistry.GetTool(call.Name)?.IsReadOnly != true)
.ToList();
var bulkRedirects = BulkToolRepeatGuard.FindRedirects(writeCalls);
var resolvedWrites = writeCalls.ToDictionary(call => call.Id, call => ResolveHabitCall(call, executionResults));
await CoalesceLogCallsAsync(writeCalls, resolvedWrites, request.UserId, executionResults, cancellationToken);
CoalesceTagCalls(resolvedWrites, executionResults);

var readOnlyTasks = readOnlyCalls
.Select(call => ExecuteReadOnlyToolCallOnIsolatedScopeAsync(call, request, executionResults, cancellationToken))
Expand All @@ -204,10 +208,20 @@
outcomesByCallId[call.Id] = RepeatedSingleEntityToolOutcome(call, bulkTool);
continue;
}
outcomesByCallId[call.Id] = executionResults.AnsweredAtUtc is { } claim
? await ExecuteBlockWriteAsync(call, request, executionResults, claim, cancellationToken)
var resolved = resolvedWrites[call.Id];
if (resolved is null || !executionResults.PreviewedCalls.Add(
Orbit.Domain.Common.AgentOperationFingerprint.Compute(resolved.Name, resolved.Args.GetRawText())))
{
if (resolved is not null)
executionResults.MergedCallIds.Add(call.Id);
outcomesByCallId[call.Id] = ResolvedWithoutWrite(call, executionResults, language);
continue;
}
var outcome = executionResults.AnsweredAtUtc is { } claim
? await ExecuteBlockWriteAsync(resolved, request, executionResults, claim, cancellationToken)
: await ExecuteSingleToolCallAsync(
call, request, executionResults, execution.OperationExecutor, execution.PendingClarificationStore, cancellationToken);
resolved, request, executionResults, execution.OperationExecutor, execution.PendingClarificationStore, cancellationToken);
outcomesByCallId[call.Id] = outcome with { ToolResult = outcome.ToolResult with { Name = call.Name } };
}

if (orderedCalls.FirstOrDefault(call => call.Name == "ask") is { } askCall)
Expand Down Expand Up @@ -296,7 +310,7 @@
/// Executes a single tool call: resolves the tool, runs it, and produces both a result
/// for the AI and an optional action result for the frontend.
/// </summary>
private async Task<ToolCallOutcome> ExecuteSingleToolCallAsync(

Check failure on line 313 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaFWAoZD6A4MOvwTQEqV&open=AaFWAoZD6A4MOvwTQEqV&pullRequest=796
AiToolCall call,
ProcessUserChatCommand request,
ToolExecutionAccumulator executionResults,
Expand Down Expand Up @@ -338,7 +352,22 @@
return operationResult.Status switch
{
AgentOperationStatus.PendingConfirmation => new ToolCallOutcome(
new AiToolCallResult(call.Name, call.Id, false, null, null, "Confirmation required before this action can run."),
new AiToolCallResult(call.Name, call.Id, true, null, null, null, new
{
status = "awaiting_confirmation",
item_count = executionResponse.PendingOperation?.ChangeTargetCount,
habit_count = call.Name == "bulk_log_habits" ? executionResponse.PendingOperation?.Items?.Select(item => item.EntityId).Distinct().Count() : (int?)null,
date_count = call.Name == "bulk_log_habits" ? executionResponse.PendingOperation?.Items?.SelectMany(item => item.Fields).Where(field => field.Field == "date").Select(field => field.NewValue).Distinct().Count() : (int?)null,
proposed_titles = executionResponse.PendingOperation?.Items?.Select(item => item.EntityName).Distinct().Take(10).ToArray(),
proposed_values = call.Args.EnumerateObject().Where(property =>
property.Value.ValueKind is JsonValueKind.Number || property.Name is "title" or "deadline" or "unit")
.ToDictionary(property => property.Name, property => property.Value.Clone()),
resolved_habits = executionResults.Habits.Where(habit => call.Args.GetRawText().Contains(habit.Id.ToString(), StringComparison.OrdinalIgnoreCase)).Select(habit => new { habit.Id, habit.Title }).ToArray(),
dates = call.Name == "bulk_log_habits" ? executionResponse.PendingOperation?.Items?
.SelectMany(item => item.Fields).Where(field => field.Field == "date")
.Select(field => field.NewValue).Distinct().Order().ToArray() : null,
instruction = "This is a proposal. Nothing has changed until approval. Use only the returned numbers, names, and dates."
}),
null,
null,
executionResponse.PolicyDenial,
Expand Down
11 changes: 10 additions & 1 deletion src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,15 @@ private async Task<Result<ChatResponse>> HandleTurnAsync(ProcessUserChatCommand
if (response.IsFailure)
return response.PropagateError<ChatResponse>();

var executionResults = new ToolExecutionAccumulator { AnsweredAtUtc = answeredAtUtc };
var executionResults = new ToolExecutionAccumulator
{
AnsweredAtUtc = answeredAtUtc,
Habits = context.ActiveHabits,
AllHabits = context.UserHabits,
IsTaggingIntent = IsTagOnlyIntent(request.Message),
RequiresExistingParent = RequestsExistingParent(request.Message, context.ActiveHabits)
};
SeedProposedHabitNames(request, executionResults);
var actionsStopwatch = System.Diagnostics.Stopwatch.StartNew();
var toolLoopResult = await RunToolCallLoopAsync(
response.Value,
Expand Down Expand Up @@ -305,6 +313,7 @@ private async Task<Result<ChatResponse>> HandleTurnAsync(ProcessUserChatCommand
request, context, executionResults, cancellationToken)
: await TryBuildResponseCardsAsync(
responseText, request, context, executionResults, cancellationToken);
cards = ApplyResolutionToCards(cards, executionResults, userLanguage);
var groundedReply = await ApplyGroundingAsync(cards.AiMessage, followUps, executionResults, request, groundingTurn);
var aiMessage = groundedReply.Message;
followUps = groundedReply.FollowUps;
Expand Down
7 changes: 5 additions & 2 deletions src/Orbit.Application/Chat/Grounding/TurnFactSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
return new TurnFactSet(Array.AsReadOnly(facts.ToArray()));
}

private static void ReadPayload(JsonElement element, string? property, AiToolCallResult result, HashSet<GroundingFact> facts)

Check failure on line 25 in src/Orbit.Application/Chat/Grounding/TurnFactSet.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaFWAonP6A4MOvwTQEqf&open=AaFWAonP6A4MOvwTQEqf&pullRequest=796
{
var key = property?.Replace("_", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
if (key is not null && (key == "ID" || key.EndsWith("ID", StringComparison.Ordinal)))
Expand All @@ -31,8 +31,11 @@
switch (element.ValueKind)
{
case JsonValueKind.Object:
var proposedField = element.TryGetProperty("Field", out var field) && field.ValueKind == JsonValueKind.String
? field.GetString() : null;
foreach (var child in element.EnumerateObject())
ReadPayload(child.Value, child.Name, result, facts);
ReadPayload(child.Value, child.Name == "ProposedValue" && proposedField is not null
? proposedField : child.Name, result, facts);
break;
case JsonValueKind.Array:
foreach (var child in element.EnumerateArray())
Expand All @@ -42,7 +45,7 @@
AddText(element.GetRawText(), false, result, facts);
break;
case JsonValueKind.String:
AddText(element.GetString(), key is "TITLE" or "NAME" or "HABITTITLE" or "GOALTITLE" or "HABITNAME" or "GOALNAME" or "TAGNAME", result, facts);
AddText(element.GetString(), key is "ENTITYNAME" or "TITLE" or "NAME" or "HABITTITLE" or "GOALTITLE" or "HABITNAME" or "GOALNAME" or "TAGNAME" or "TAGNAMES", result, facts);
break;
}
}
Expand Down
40 changes: 40 additions & 0 deletions src/Orbit.Application/Chat/HabitNameMatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Globalization;
using System.Text;
using Orbit.Domain.Entities;

namespace Orbit.Application.Chat;

public static class HabitNameMatcher
{
public static string Normalize(string value)
{
var result = new StringBuilder();
foreach (var character in value.Normalize(NormalizationForm.FormD))
{
if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark)
continue;
result.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' ');
}
return string.Join(' ', result.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}

public static bool Matches(string title, string search)
{
var normalized = Normalize(search);
if (normalized.Length == 0)
return false;
var titleWords = Normalize(title).Split(' ', StringSplitOptions.RemoveEmptyEntries);
return normalized.Split(' ').All(word => titleWords.Any(candidate => candidate.StartsWith(word, StringComparison.Ordinal)));
}

public static Habit? Resolve(IEnumerable<Habit> habits, string name)
{
var candidates = habits.ToList();
var exact = candidates.Where(habit => Normalize(habit.Title) == Normalize(name)).ToList();
if (exact.Count > 0)
return exact.Count == 1 ? exact[0] : null;
var matches = candidates.Where(habit => Matches(habit.Title, name)).ToList();
return matches.Count == 1 ? matches[0] : null;
}

}
Loading
Loading