Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,21 @@ public static bool ByteEquivalent(

public static bool TryNormalize(
ConversationContextAttachmentSet? source,
out ConversationContextAttachmentSet normalized)
out ConversationContextAttachmentSet normalized) =>
TryNormalize(source, out normalized, out _);

public static bool TryNormalize(
ConversationContextAttachmentSet? source,
out ConversationContextAttachmentSet normalized,
out ConversationContextAttachmentAdmissionFailureReason failureReason)
{
normalized = CloneSet(source?.Attachments);
failureReason = ConversationContextAttachmentAdmissionFailureReason.Unspecified;
if (normalized.Attachments.Count > MaximumAttachments)
{
failureReason = ConversationContextAttachmentAdmissionFailureReason.OverLimit;
return false;
}

var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var attachment in normalized.Attachments)
Expand All @@ -48,19 +58,26 @@ public static bool TryNormalize(
attachment.PinnedRevisionId = attachment.PinnedRevisionId.Trim();
if (string.IsNullOrWhiteSpace(attachment.ArtifactId) ||
!ids.Add(attachment.ArtifactId))
{
failureReason = ConversationContextAttachmentAdmissionFailureReason.InvalidRequest;
return false;
}

if (attachment.RevisionMode == ConversationContextAttachmentRevisionMode.PinnedRevision)
{
if (string.IsNullOrWhiteSpace(attachment.PinnedRevisionId))
{
failureReason = ConversationContextAttachmentAdmissionFailureReason.InvalidRequest;
return false;
}
}
else if (attachment.RevisionMode == ConversationContextAttachmentRevisionMode.FollowCurrent)
{
attachment.PinnedRevisionId = string.Empty;
}
else
{
failureReason = ConversationContextAttachmentAdmissionFailureReason.InvalidRequest;
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,21 @@ private static async Task HandleInteractionFailureAsync(
if (result.Succeeded)
return;

var attachmentReason = ToAttachmentAdmissionFailureReason(result.Error);
if (attachmentReason != ConversationContextAttachmentAdmissionFailureReason.Unspecified)
{
await writerGate.WriteTerminalAsync(
token => writer.WriteRunErrorAsync(
turnId,
"ATTACHMENT_ADMISSION_DENIED",
ToAttachmentAdmissionMessage(attachmentReason),
ToAttachmentAdmissionWireName(attachmentReason),
0,
token),
CancellationToken.None);
return;
}

await writerGate.WriteTerminalAsync(
token => writer.WriteRunErrorAsync(
turnId,
Expand All @@ -644,6 +659,66 @@ await writerGate.WriteTerminalAsync(
CancellationToken.None);
}

private static ConversationContextAttachmentAdmissionFailureReason ToAttachmentAdmissionFailureReason(
NyxIdChatStartError error) => error switch
{
NyxIdChatStartError.AttachmentNotFound =>
ConversationContextAttachmentAdmissionFailureReason.NotFound,
NyxIdChatStartError.AttachmentAccessDenied =>
ConversationContextAttachmentAdmissionFailureReason.AccessDenied,
NyxIdChatStartError.AttachmentUnsupportedKind =>
ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind,
NyxIdChatStartError.AttachmentOverLimit =>
ConversationContextAttachmentAdmissionFailureReason.OverLimit,
NyxIdChatStartError.AttachmentPinnedRevisionUnavailable =>
ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable,
NyxIdChatStartError.AttachmentInvalidRequest =>
ConversationContextAttachmentAdmissionFailureReason.InvalidRequest,
NyxIdChatStartError.AttachmentInactive =>
ConversationContextAttachmentAdmissionFailureReason.Inactive,
NyxIdChatStartError.AttachmentReadModelUnavailable =>
ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable,
_ => ConversationContextAttachmentAdmissionFailureReason.Unspecified,
};

private static string ToAttachmentAdmissionWireName(
ConversationContextAttachmentAdmissionFailureReason reason) => reason switch
{
ConversationContextAttachmentAdmissionFailureReason.NotFound => "not_found",
ConversationContextAttachmentAdmissionFailureReason.AccessDenied => "access_denied",
ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind => "unsupported_kind",
ConversationContextAttachmentAdmissionFailureReason.OverLimit => "over_limit",
ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable =>
"pinned_revision_unavailable",
ConversationContextAttachmentAdmissionFailureReason.InvalidRequest => "invalid_request",
ConversationContextAttachmentAdmissionFailureReason.Inactive => "inactive",
ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable =>
"read_model_unavailable",
_ => "unspecified",
};

private static string ToAttachmentAdmissionMessage(
ConversationContextAttachmentAdmissionFailureReason reason) => reason switch
{
ConversationContextAttachmentAdmissionFailureReason.NotFound =>
"A requested context attachment was not found.",
ConversationContextAttachmentAdmissionFailureReason.AccessDenied =>
"Access to a requested context attachment was denied.",
ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind =>
"A requested context attachment kind is unsupported.",
ConversationContextAttachmentAdmissionFailureReason.OverLimit =>
$"A conversation can bind at most {ConversationContextAttachmentAdmission.MaximumAttachments} context attachments.",
ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable =>
"A requested pinned context attachment revision is unavailable.",
ConversationContextAttachmentAdmissionFailureReason.InvalidRequest =>
"The context attachment declaration is invalid.",
ConversationContextAttachmentAdmissionFailureReason.Inactive =>
"A requested context attachment is inactive.",
ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable =>
"Context attachment admission is temporarily unavailable.",
_ => "Context attachment admission was denied.",
};

private static bool IsTerminalFrame(AGUIEvent evt) =>
evt.EventCase is AGUIEvent.EventOneofCase.RunFinished or AGUIEvent.EventOneofCase.RunError;

Expand Down
24 changes: 24 additions & 0 deletions agents/Aevatar.GAgents.NyxidChat/NyxIdChatInteraction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ public enum NyxIdChatStartError
ActorNotFound = 1,
ProjectionUnavailable = 2,
AdmissionUnavailable = 3,
AttachmentNotFound = 4,
AttachmentAccessDenied = 5,
AttachmentUnsupportedKind = 6,
AttachmentOverLimit = 7,
AttachmentPinnedRevisionUnavailable = 8,
AttachmentInvalidRequest = 9,
AttachmentInactive = 10,
AttachmentReadModelUnavailable = 11,
}

public readonly record struct NyxIdChatCompletionStatus
Expand Down Expand Up @@ -427,6 +435,22 @@ public async Task<CommandTargetResolution<NyxIdChatCommandTarget, NyxIdChatStart
NyxIdChatLifecycleCommandStartError.AdmissionUnavailable or
NyxIdChatLifecycleCommandStartError.RouteRejected or
NyxIdChatLifecycleCommandStartError.AccessDenied => NyxIdChatStartError.AdmissionUnavailable,
NyxIdChatLifecycleCommandStartError.AttachmentNotFound =>
NyxIdChatStartError.AttachmentNotFound,
NyxIdChatLifecycleCommandStartError.AttachmentAccessDenied =>
NyxIdChatStartError.AttachmentAccessDenied,
NyxIdChatLifecycleCommandStartError.AttachmentUnsupportedKind =>
NyxIdChatStartError.AttachmentUnsupportedKind,
NyxIdChatLifecycleCommandStartError.AttachmentOverLimit =>
NyxIdChatStartError.AttachmentOverLimit,
NyxIdChatLifecycleCommandStartError.AttachmentPinnedRevisionUnavailable =>
NyxIdChatStartError.AttachmentPinnedRevisionUnavailable,
NyxIdChatLifecycleCommandStartError.AttachmentInvalidRequest =>
NyxIdChatStartError.AttachmentInvalidRequest,
NyxIdChatLifecycleCommandStartError.AttachmentInactive =>
NyxIdChatStartError.AttachmentInactive,
NyxIdChatLifecycleCommandStartError.AttachmentReadModelUnavailable =>
NyxIdChatStartError.AttachmentReadModelUnavailable,
_ => NyxIdChatStartError.ProjectionUnavailable,
});
}
Expand Down
73 changes: 57 additions & 16 deletions agents/Aevatar.GAgents.NyxidChat/NyxIdChatLifecycleFacade.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ public enum NyxIdChatLifecycleCommandStartError
AdmissionUnavailable = 2,
TargetNotFound = 3,
AccessDenied = 4,
AttachmentNotFound = 5,
AttachmentAccessDenied = 6,
AttachmentUnsupportedKind = 7,
AttachmentOverLimit = 8,
AttachmentPinnedRevisionUnavailable = 9,
AttachmentInvalidRequest = 10,
AttachmentInactive = 11,
AttachmentReadModelUnavailable = 12,
}

public sealed class NyxIdChatLifecycleFacade
Expand Down Expand Up @@ -229,10 +237,11 @@ public async Task<CommandTargetResolution<NyxIdChatConversationCreateCommandTarg
}
}

if (!await ValidateContextAttachmentsAsync(command, ct))
var attachmentFailureReason = await ValidateContextAttachmentsAsync(command, ct);
if (attachmentFailureReason != ConversationContextAttachmentAdmissionFailureReason.Unspecified)
{
return CommandTargetResolution<NyxIdChatConversationCreateCommandTarget, NyxIdChatLifecycleCommandStartError>.Failure(
NyxIdChatLifecycleCommandStartError.AdmissionUnavailable);
ToAttachmentStartError(attachmentFailureReason));
}

var callerScope = OwnerScope.ForNyxIdNative(command.ScopeId);
Expand Down Expand Up @@ -281,23 +290,27 @@ public async Task<CommandTargetResolution<NyxIdChatConversationCreateCommandTarg
NyxIdChatConversationCreateStatus.Accepted));
}

private async Task<bool> ValidateContextAttachmentsAsync(
// Implement (issue #3543):
// Behavior: Every create-only attachment rejection retains its typed client recovery reason.
// Why this shape: Admission remains read-model-only and fails before actor creation.
private async Task<ConversationContextAttachmentAdmissionFailureReason> ValidateContextAttachmentsAsync(
NyxIdChatConversationCreateCommand command,
CancellationToken ct)
{
if (!ConversationContextAttachmentAdmission.TryNormalize(
command.ContextAttachments,
out var normalized))
return false;
out var normalized,
out var failureReason))
return failureReason;
command.ContextAttachments = normalized;
if (normalized.Attachments.Count == 0)
return true;
return ConversationContextAttachmentAdmissionFailureReason.Unspecified;
if (_contentArtifactQueryPort is null)
return false;
return ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable;

var requester = command.FirstTurn?.ToolContext?.Caller?.OwnerSubject?.Trim();
if (string.IsNullOrWhiteSpace(requester))
return false;
return ConversationContextAttachmentAdmissionFailureReason.AccessDenied;

foreach (var attachment in normalized.Attachments)
{
Expand All @@ -311,27 +324,55 @@ private async Task<bool> ValidateContextAttachmentsAsync(
}
catch
{
return false;
return ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable;
}

if (artifact is null ||
!string.Equals(artifact.LifecycleStatus, ContentArtifactLifecycleStatusNames.Active, StringComparison.Ordinal) ||
!ConversationContextAttachmentAdmission.IsAllowedKind(artifact.Kind) ||
!ConversationContextAttachmentAdmission.IsAuthorized(artifact, requester))
return false;
if (artifact is null)
return ConversationContextAttachmentAdmissionFailureReason.NotFound;
if (!string.Equals(
artifact.LifecycleStatus,
ContentArtifactLifecycleStatusNames.Active,
StringComparison.Ordinal))
return ConversationContextAttachmentAdmissionFailureReason.Inactive;
if (!ConversationContextAttachmentAdmission.IsAllowedKind(artifact.Kind))
return ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind;
if (!ConversationContextAttachmentAdmission.IsAuthorized(artifact, requester))
return ConversationContextAttachmentAdmissionFailureReason.AccessDenied;

if (attachment.RevisionMode == ConversationContextAttachmentRevisionMode.PinnedRevision)
{
var revision = artifact.Revisions.FirstOrDefault(item =>
string.Equals(item.RevisionId, attachment.PinnedRevisionId, StringComparison.Ordinal));
if (revision is null ||
!string.Equals(revision.Availability, ContentArtifactRevisionAvailabilityNames.Available, StringComparison.Ordinal))
return false;
return ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable;
}
}

return true;
return ConversationContextAttachmentAdmissionFailureReason.Unspecified;
}

private static NyxIdChatLifecycleCommandStartError ToAttachmentStartError(
ConversationContextAttachmentAdmissionFailureReason reason) => reason switch
{
ConversationContextAttachmentAdmissionFailureReason.NotFound =>
NyxIdChatLifecycleCommandStartError.AttachmentNotFound,
ConversationContextAttachmentAdmissionFailureReason.AccessDenied =>
NyxIdChatLifecycleCommandStartError.AttachmentAccessDenied,
ConversationContextAttachmentAdmissionFailureReason.UnsupportedKind =>
NyxIdChatLifecycleCommandStartError.AttachmentUnsupportedKind,
ConversationContextAttachmentAdmissionFailureReason.OverLimit =>
NyxIdChatLifecycleCommandStartError.AttachmentOverLimit,
ConversationContextAttachmentAdmissionFailureReason.PinnedRevisionUnavailable =>
NyxIdChatLifecycleCommandStartError.AttachmentPinnedRevisionUnavailable,
ConversationContextAttachmentAdmissionFailureReason.InvalidRequest =>
NyxIdChatLifecycleCommandStartError.AttachmentInvalidRequest,
ConversationContextAttachmentAdmissionFailureReason.Inactive =>
NyxIdChatLifecycleCommandStartError.AttachmentInactive,
ConversationContextAttachmentAdmissionFailureReason.ReadModelUnavailable =>
NyxIdChatLifecycleCommandStartError.AttachmentReadModelUnavailable,
_ => NyxIdChatLifecycleCommandStartError.AdmissionUnavailable,
};
}

internal sealed class NyxIdChatConversationDeleteCommandTargetResolver
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ internal static async Task<IResult> HandlePublicListConversationsAsync(
AttentionSince = summary.AttentionSince,
ActiveStepSummary = summary.ActiveStepSummary,
StateVersion = summary.StateVersion,
ContextAttachments = summary.ContextAttachments,
};
}).ToList(),
page.NextCursor));
Expand Down
14 changes: 14 additions & 0 deletions agents/Aevatar.GAgents.NyxidChat/NyxIdChatSseWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,20 @@ public ValueTask WriteRunErrorAsync(string turnId, string code, string message,
runError = new { runId = turnId, code, message },
}, sequence, ct);

public ValueTask WriteRunErrorAsync(
string turnId,
string code,
string message,
string reason,
long sequence,
CancellationToken ct) =>
WriteFrameAsync(new
{
type = "RUN_ERROR",
turnId,
runError = new { runId = turnId, code, message, reason },
}, sequence, ct);

public ValueTask WriteMediaContentAsync(
Aevatar.AI.Abstractions.MediaContentEvent evt,
long sequence,
Expand Down
16 changes: 12 additions & 4 deletions docs/canon/conversation-context-and-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,18 @@ Conversation structured attachments are create-only, immutable references. Each
`FOLLOW_CURRENT` or `PINNED_REVISION`; the set is bounded to four artifact identities and is
sealed by `ConversationContextAttachmentsBoundEvent`. Replaying the same deterministic protobuf
bytes is idempotent; a different or missing set cannot replace an existing binding. Create
admission reads only the ContentArtifact read model and fails closed with `ADMISSION_UNAVAILABLE`
for missing, inactive, unauthorized, unsupported-kind, duplicate/over-limit, or unavailable
pinned revisions. A turn may degrade to a typed unavailable placeholder when a later verified
read is redacted, tombstoned, expired, over budget, or temporarily unavailable.
admission reads only the ContentArtifact read model. Attachment failures use
`ATTACHMENT_ADMISSION_DENIED` plus a typed reason (`not_found`, `access_denied`,
`unsupported_kind`, `over_limit`, `pinned_revision_unavailable`, or the narrower invalid,
inactive, and read-model-unavailable reasons); Profile and route failures retain
`ADMISSION_UNAVAILABLE`. A turn may degrade to a typed unavailable placeholder when a later
verified read is redacted, tombstoned, expired, over budget, or temporarily unavailable.

The sealed reference set is copied from committed Conversation state into the actor-scoped
current-state read model. Both `/api/chat/conversations/{conversationId}/state` and the
`/api/chat/conversations` index expose only `artifactId`, `revisionMode`, and
`pinnedRevisionId`; artifact bodies remain exclusively behind the verified ContentArtifact read
path and never enter Conversation state, transcript, or either response.

`FOLLOW_CURRENT` resolves only the artifact read model's explicit `CurrentRevisionId` on each
turn; it never infers the highest revision number or silently retargets to the latest append.
Expand Down
Loading
Loading