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.Api/RateLimiting/DistributedRateLimitAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ public static string BuildRefreshTokenPartitionKey(string policyName, string ref
private static string HashRefreshToken(string refreshToken) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)));

// S107 false positive: [LoggerMessage] source-gen binds each parameter to a named {placeholder} in the template, so folding these into a parameter object would drop the structured-log fields. https://learn.microsoft.com/dotnet/core/extensions/logger-message-generator
#pragma warning disable S107
[LoggerMessage(
EventId = 1,
Level = LogLevel.Warning,
Expand All @@ -236,6 +238,7 @@ private static partial void LogRateLimitRejected(
string method,
string path,
string requestId);
#pragma warning restore S107

[LoggerMessage(
EventId = 2,
Expand Down
15 changes: 8 additions & 7 deletions src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Ai.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,14 @@ private async Task<Result<AiResponse>> RequestInitialAiResponseAsync(
LogCallingAiIntentService(logger, toolDeclarations.Count);

return await ai.IntentService.SendWithToolsAsync(
request.Message,
systemPrompt,
toolDeclarations,
request.UserId,
request.ImageData,
request.ImageMimeType,
request.History,
new AiToolRequest(
request.Message,
systemPrompt,
toolDeclarations,
request.UserId,
request.ImageData,
request.ImageMimeType,
request.History),
aiStreamSink,
cancellationToken);
}
Expand Down
8 changes: 1 addition & 7 deletions src/Orbit.Domain/Interfaces/IAiIntentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,7 @@ namespace Orbit.Domain.Interfaces;
public interface IAiIntentService
{
Task<Result<AiResponse>> SendWithToolsAsync(
string userMessage,
string systemPrompt,
IReadOnlyList<object> toolDeclarations,
Guid userId = default,
byte[]? imageData = null,
string? imageMimeType = null,
IReadOnlyList<ChatHistoryMessage>? history = null,
AiToolRequest request,
Func<AiStreamEvent, Task>? streamSink = null,
CancellationToken cancellationToken = default);

Expand Down
15 changes: 15 additions & 0 deletions src/Orbit.Domain/Models/AiToolModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ namespace Orbit.Domain.Models;

public record AiToolCall(string Name, string Id, JsonElement Args);

/// <summary>
/// A single tool-enabled AI turn: the user message, system prompt, and tool declarations plus the
/// optional per-request routing (<see cref="UserId"/>), multimodal (<see cref="ImageData"/> /
/// <see cref="ImageMimeType"/>), and prior <see cref="History"/> inputs. Bundled so the streaming
/// sink and cancellation token stay as the only standalone arguments to SendWithToolsAsync.
/// </summary>
public sealed record AiToolRequest(
string UserMessage,
string SystemPrompt,
IReadOnlyList<object> ToolDeclarations,
Guid UserId = default,
byte[]? ImageData = null,
string? ImageMimeType = null,
IReadOnlyList<ChatHistoryMessage>? History = null);

public record AiToolCallResult(
string Name,
string Id,
Expand Down
71 changes: 22 additions & 49 deletions src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,14 @@ private async Task<AgentExecuteOperationResponse> DenyDirectUserFlowAsync(
var summary = $"{execution.Operation.DisplayName} requires a direct client flow.";

await TryAuditAsync(
CreateAuditContext(
new AuditContext(
execution.Request,
execution.Capability,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Denied,
summary,
RedactArguments(execution.Arguments),
error: "direct_user_flow_required"),
Error: "direct_user_flow_required"),
cancellationToken);

return DeniedResponse(execution, summary, "direct_user_flow_required", "direct_user_flow_required");
Expand All @@ -98,14 +98,14 @@ private async Task<AgentExecuteOperationResponse> DenyOwnershipAsync(
CancellationToken cancellationToken)
{
await TryAuditAsync(
CreateAuditContext(
new AuditContext(
execution.Request,
execution.Capability,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Denied,
execution.Summary,
RedactArguments(execution.Arguments),
error: denialReason),
Error: denialReason),
cancellationToken);

return DeniedResponse(execution, execution.Summary, denialReason, denialReason);
Expand Down Expand Up @@ -138,16 +138,16 @@ private async Task<AgentExecuteOperationResponse> DenyByPolicyAsync(
CancellationToken cancellationToken)
{
await TryAuditAsync(
CreateAuditContext(
new AuditContext(
execution.Request,
execution.Capability,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Denied,
execution.Summary,
RedactArguments(execution.Arguments),
error: policyDecision.Reason,
shadowPolicyDecision: policyDecision.ShadowStatus,
shadowReason: policyDecision.ShadowReason),
Error: policyDecision.Reason,
ShadowPolicyDecision: policyDecision.ShadowStatus,
ShadowReason: policyDecision.ShadowReason),
cancellationToken);

return DeniedResponse(execution, execution.Summary, policyDecision.Reason, policyDecision.Reason ?? "denied");
Expand All @@ -159,16 +159,16 @@ private async Task<AgentExecuteOperationResponse> RequireConfirmationAsync(
CancellationToken cancellationToken)
{
await TryAuditAsync(
CreateAuditContext(
new AuditContext(
execution.Request,
execution.Capability,
AgentPolicyDecisionStatus.ConfirmationRequired,
AgentOperationStatus.PendingConfirmation,
execution.Summary,
RedactArguments(execution.Arguments),
error: policyDecision.Reason,
shadowPolicyDecision: policyDecision.ShadowStatus,
shadowReason: policyDecision.ShadowReason),
Error: policyDecision.Reason,
ShadowPolicyDecision: policyDecision.ShadowStatus,
ShadowReason: policyDecision.ShadowReason),
cancellationToken);

return AgentOperationResponseFactory.ConfirmationRequired(
Expand All @@ -194,16 +194,16 @@ private async Task<AgentExecuteOperationResponse> ExecuteToolAsync(
catch (Exception ex)
{
await TryAuditAsync(
CreateAuditContext(
new AuditContext(
execution.Request,
execution.Capability,
AgentPolicyDecisionStatus.Allowed,
AgentOperationStatus.Failed,
execution.Summary,
RedactArguments(execution.Arguments),
error: ex.Message,
shadowPolicyDecision: policyDecision.ShadowStatus,
shadowReason: policyDecision.ShadowReason),
Error: ex.Message,
ShadowPolicyDecision: policyDecision.ShadowStatus,
ShadowReason: policyDecision.ShadowReason),
cancellationToken);

return AgentOperationResponseFactory.Failed(
Expand Down Expand Up @@ -252,7 +252,7 @@ private async Task<AgentExecuteOperationResponse> BuildToolOutcomeResponseAsync(
outcomeStatus = isPayGateDenial ? AgentOperationStatus.Denied : AgentOperationStatus.Failed;

await TryAuditAsync(
CreateAuditContext(
new AuditContext(
execution.Request,
execution.Capability,
AgentPolicyDecisionStatus.Allowed,
Expand Down Expand Up @@ -305,33 +305,6 @@ private IReadOnlyList<string> GetGrantedScopes(AgentExecuteOperationRequest requ
.ToList();
}

private static AuditContext CreateAuditContext(
AgentExecuteOperationRequest request,
AgentCapability capability,
AgentPolicyDecisionStatus policyDecision,
AgentOperationStatus outcomeStatus,
string summary,
string? redactedArguments,
string? targetId = null,
string? targetName = null,
string? error = null,
AgentPolicyDecisionStatus? shadowPolicyDecision = null,
string? shadowReason = null)
{
return new AuditContext(
request,
capability,
policyDecision,
outcomeStatus,
summary,
redactedArguments,
targetId,
targetName,
error,
shadowPolicyDecision,
shadowReason);
}

private async Task TryAuditAsync(
AuditContext context,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -382,9 +355,9 @@ private sealed record AuditContext(
AgentOperationStatus OutcomeStatus,
string Summary,
string? RedactedArguments,
string? TargetId,
string? TargetName,
string? Error,
AgentPolicyDecisionStatus? ShadowPolicyDecision,
string? ShadowReason);
string? TargetId = null,
string? TargetName = null,
string? Error = null,
AgentPolicyDecisionStatus? ShadowPolicyDecision = null,
string? ShadowReason = null);
}
16 changes: 6 additions & 10 deletions src/Orbit.Infrastructure/Services/AiIntentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,12 @@ public sealed partial class AiIntentService(
};

public async Task<Result<AiResponse>> SendWithToolsAsync(
string userMessage,
string systemPrompt,
IReadOnlyList<object> toolDeclarations,
Guid userId = default,
byte[]? imageData = null,
string? imageMimeType = null,
IReadOnlyList<ChatHistoryMessage>? history = null,
AiToolRequest request,
Func<AiStreamEvent, Task>? streamSink = null,
CancellationToken cancellationToken = default)
{
var (userMessage, systemPrompt, toolDeclarations, userId, imageData, imageMimeType, history) = request;

var messages = new List<ChatMessage>
{
new SystemChatMessage(systemPrompt)
Expand Down Expand Up @@ -236,16 +232,16 @@ private async Task<bool> AppendContentDeltasAsync(
System.Diagnostics.Stopwatch stopwatch,
bool firstTokenLogged)
{
foreach (var part in update.ContentUpdate.Where(part => !string.IsNullOrEmpty(part.Text)))
foreach (var text in update.ContentUpdate.Select(part => part.Text).Where(text => !string.IsNullOrEmpty(text)))
{
if (!firstTokenLogged)
{
firstTokenLogged = true;
LogFirstContentToken(logger, stopwatch.ElapsedMilliseconds);
}

contentBuilder.Append(part.Text);
await streamSink(AiStreamEvent.Delta(part.Text));
contentBuilder.Append(text);
await streamSink(AiStreamEvent.Delta(text));
}

return firstTokenLogged;
Expand Down
5 changes: 2 additions & 3 deletions src/Orbit.Infrastructure/Services/PushNotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,9 @@ private async Task SendFcm(
}

const int FcmBatchSize = 500;
var subsList = subs as List<Domain.Entities.PushSubscription> ?? subs.ToList();
for (int offset = 0; offset < subsList.Count; offset += FcmBatchSize)
for (int offset = 0; offset < subs.Count; offset += FcmBatchSize)
{
var chunk = subsList.Skip(offset).Take(FcmBatchSize).ToList();
var chunk = subs.Skip(offset).Take(FcmBatchSize).ToList();
var messages = chunk.Select(s => new Message
{
Token = s.Endpoint,
Expand Down
Loading
Loading