Remove NotFoundException; services return null on lookup miss

A missing record isn't exceptional — services now return null (logged
at Info) instead of throwing, and endpoints map null to 404. Agent and
import toolsets route not-found through their existing OrNotFound
result pattern rather than a caught exception.
This commit is contained in:
James Wampler
2026-08-06 21:22:40 -07:00
parent 189ebf3237
commit 2ccebb31eb
22 changed files with 305 additions and 212 deletions
+31 -27
View File
@@ -71,9 +71,11 @@ public class NovelAgentService(
/// <summary>
/// Sends a message to the agent and runs it to completion, executing any tools it
/// calls along the way. Returns the assistant's final turn.
/// calls along the way. Returns the assistant's final turn. Null when no project has
/// this id, or the request names a conversation that does not exist — a lookup miss
/// is expected, not exceptional.
/// </summary>
public async Task<AgentMessage> SendMessageAsync(
public async Task<AgentMessage?> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
@@ -84,19 +86,37 @@ public class NovelAgentService(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
projectId, request.ConversationId, request.Message.Length);
var conversation = request.ConversationId is { } id
? await FindConversationAsync(id, ct)
// The id came from the request body, not the route — an unknown id here
// is bad input to this call, not a direct "fetch conversation" lookup.
?? throw new NotFoundException(nameof(AgentConversation), id)
: await StartConversationAsync(projectId, request.Message, ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is null)
{
logger.LogInformation("Project {ProjectId} not found", projectId);
return null;
}
AgentConversation conversation;
if (request.ConversationId is { } id)
{
// The id came from the request body, not the route — an unknown id here
// is bad input to this call, not a direct "fetch conversation" lookup.
var found = await FindConversationAsync(id, ct);
if (found is null)
{
return null;
}
conversation = found;
}
else
{
conversation = StartConversation(projectId, request.Message);
}
// Persist the user's turn before running the loop. The tools save through the
// same DbContext, so leaving this pending would entangle it with their writes —
// and recording the question even if the model call fails is the behaviour we want.
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
var systemPrompt = await BuildSystemPromptAsync(projectId, ct);
var systemPrompt = BuildSystemPrompt(project);
var transcript = BuildTranscript(conversation);
var toolCalls = new List<ToolCallResponse>();
var text = new StringBuilder();
@@ -197,17 +217,10 @@ public class NovelAgentService(
return message;
}
private async Task<AgentConversation> StartConversationAsync(
Guid projectId, string firstMessage, CancellationToken ct)
private AgentConversation StartConversation(Guid projectId, string firstMessage)
{
logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
var conversation = new AgentConversation
{
ProjectId = projectId,
@@ -249,17 +262,8 @@ public class NovelAgentService(
[new AgentTextBlock(m.Content)]))
];
private async Task<string> BuildSystemPromptAsync(Guid projectId, CancellationToken ct)
private static string BuildSystemPrompt(Project project)
{
logger.LogDebug("Building system prompt for project {ProjectId}", projectId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is null)
{
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
var brief = new StringBuilder();
brief.AppendLine($"Title: {project.Title}");
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");