Stop throwing for not-found; add Guard and request validation

Not-found lookups return null/false instead of throwing NotFoundException
across all services — a missing row is expected control flow, not an
exceptional condition. NotFoundException stays for embedded precondition
checks inside mutations (missing parent, invalid foreign reference).

Guard (copied from mic-check) enforces required arguments at the top of
every service method. A ported IModelValidator<T> framework validates
every request DTO at the API layer via a new ValidationEndpointFilter,
returning a 400 with field-level messages; services re-run the same
validator and throw for direct callers that bypass the API.

Endpoints translate null/false into 404 via a new ToApiResult() helper.
The agent toolset boundary translates the same nullable/bool results
into the tool-error text the model already expected.
This commit is contained in:
James Wampler
2026-08-06 15:13:36 -07:00
parent 04917fa09e
commit 40f93e40a8
45 changed files with 1523 additions and 377 deletions
+144 -85
View File
@@ -13,6 +13,13 @@ namespace Novelly.Api.Agent;
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
public record AgentToolResult(string Content, bool IsError);
/// <summary>
/// A lookup a tool performed came back empty. Not an exception — the underlying service
/// already said so by returning null/false — just a value <see cref="NovelAgentToolset.ExecuteAsync"/>
/// recognises and turns into the same error-result shape a caught exception would produce.
/// </summary>
internal record ToolNotFound(string Message);
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
public record AgentTool(
string Name,
@@ -44,7 +51,7 @@ public class NovelAgentToolset(
private Dictionary<string, AgentTool>? _byName;
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
private IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
@@ -53,8 +60,7 @@ public class NovelAgentToolset(
/// Runs a tool and serialises its result. Failures come back as text rather than
/// exceptions so the model can read the message and correct itself.
/// </summary>
public async Task<AgentToolResult> ExecuteAsync(
string name, Guid projectId, JsonElement input, CancellationToken ct = default)
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
{
if (!ByName.TryGetValue(name, out var tool))
{
@@ -67,6 +73,13 @@ public class NovelAgentToolset(
try
{
var result = await tool.Handler(projectId, input, ct);
if (result is ToolNotFound notFound)
{
logger.LogInformation("Tool {Tool} for project {ProjectId} found nothing: {Message}", name, projectId, notFound.Message);
return new AgentToolResult(notFound.Message, true);
}
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
}
@@ -87,6 +100,14 @@ public class NovelAgentToolset(
}
}
/// <summary>Turns a nullable lookup into either the value or a <see cref="ToolNotFound"/> the model can read.</summary>
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class =>
await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found.");
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
private IEnumerable<AgentTool> Build()
@@ -96,7 +117,7 @@ public class NovelAgentToolset(
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
+ "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await projects.GetAsync(projectId, ct));
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), "Project", projectId));
yield return new AgentTool(
"update_project_brief",
@@ -111,14 +132,14 @@ public class NovelAgentToolset(
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
.Int("target_word_count", "Target manuscript length in words.")
.Build(),
async (projectId, input, ct) => await projects.UpdateAsync(projectId, new UpdateProjectRequest(
async (projectId, input, ct) => await OrNotFound(projects.UpdateAsync(projectId, new UpdateProjectRequest(
JsonInput.String(input, "title"),
JsonInput.String(input, "author"),
JsonInput.String(input, "genre"),
JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct));
JsonInput.Int(input, "target_word_count")), ct), "Project", projectId));
yield return new AgentTool(
"list_characters",
@@ -156,26 +177,30 @@ public class NovelAgentToolset(
CharacterSchema(includeName: true, nameRequired: false)
.Str("character_id", "Id of the character to update.", required: true)
.Build(),
async (_, input, ct) => await characters.UpdateAsync(
JsonInput.RequiredGuid(input, "character_id"),
new UpdateCharacterRequest(
JsonInput.String(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role"),
JsonInput.Enum<CharacterImportance>(input, "importance"),
JsonInput.String(input, "age"),
JsonInput.String(input, "pronouns"),
JsonInput.String(input, "occupation"),
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(characters.UpdateAsync(
characterId,
new UpdateCharacterRequest(
JsonInput.String(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role"),
JsonInput.Enum<CharacterImportance>(input, "importance"),
JsonInput.String(input, "age"),
JsonInput.String(input, "pronouns"),
JsonInput.String(input, "occupation"),
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), "Character", characterId);
});
yield return new AgentTool(
"get_chapter_outline",
@@ -213,16 +238,20 @@ public class NovelAgentToolset(
.Str("beat_id", "Id of the beat to update.", required: true)
.Str("title", "Three to five words naming the beat.")
.Build(),
async (_, input, ct) => await beats.UpdateAsync(
JsonInput.RequiredGuid(input, "beat_id"),
new UpdateBeatRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
async (_, input, ct) =>
{
var beatId = JsonInput.RequiredGuid(input, "beat_id");
return await OrNotFound(beats.UpdateAsync(
beatId,
new UpdateBeatRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct), "Beat", beatId);
});
yield return new AgentTool(
"delete_beat",
@@ -232,8 +261,8 @@ public class NovelAgentToolset(
.Build(),
async (_, input, ct) =>
{
await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct);
return new { deleted = true };
var beatId = JsonInput.RequiredGuid(input, "beat_id");
return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId);
});
yield return new AgentTool(
@@ -265,7 +294,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("tag_id", "Id of the tag to trace.", required: true)
.Build(),
async (_, input, ct) => await tags.GetReferencesAsync(JsonInput.RequiredGuid(input, "tag_id"), ct));
async (_, input, ct) =>
{
var tagId = JsonInput.RequiredGuid(input, "tag_id");
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), "Tag", tagId);
});
yield return new AgentTool(
"list_chapters",
@@ -279,7 +312,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to read.", required: true)
.Build(),
async (_, input, ct) => await chapters.GetAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.GetAsync(chapterId, ct), "Chapter", chapterId);
});
yield return new AgentTool(
"create_chapter",
@@ -321,18 +358,22 @@ public class NovelAgentToolset(
.Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (_, input, ct) => await chapters.UpdateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new UpdateChapterRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.UpdateAsync(
chapterId,
new UpdateChapterRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct), "Chapter", chapterId);
});
yield return new AgentTool(
"create_scene",
@@ -364,19 +405,23 @@ public class NovelAgentToolset(
.Str("scene_id", "Id of the scene to update.", required: true)
.Str("title", "New title.")
.Build(),
async (_, input, ct) => await scenes.UpdateAsync(
JsonInput.RequiredGuid(input, "scene_id"),
new UpdateSceneRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "summary"),
JsonInput.String(input, "goal"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "outcome"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status")), ct));
async (_, input, ct) =>
{
var sceneId = JsonInput.RequiredGuid(input, "scene_id");
return await OrNotFound(scenes.UpdateAsync(
sceneId,
new UpdateSceneRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "summary"),
JsonInput.String(input, "goal"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "outcome"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status")), ct), "Scene", sceneId);
});
yield return new AgentTool(
"get_character_beats",
@@ -386,8 +431,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("character_id", "Id of the character.", required: true)
.Build(),
async (_, input, ct) => await beats.ListForCharacterAsync(
JsonInput.RequiredGuid(input, "character_id"), ct));
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(beats.ListForCharacterAsync(characterId, ct), "Character", characterId);
});
yield return new AgentTool(
"get_character_arc",
@@ -422,13 +470,17 @@ public class NovelAgentToolset(
.Str("arc_stage_id", "Id of the arc stage to update.", required: true)
.Str("title", "New title for the stage.")
.Build(),
async (_, input, ct) => await arcs.UpdateAsync(
JsonInput.RequiredGuid(input, "arc_stage_id"),
new UpdateArcStageRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct));
async (_, input, ct) =>
{
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
return await OrNotFound(arcs.UpdateAsync(
arcStageId,
new UpdateArcStageRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct), "CharacterArcStage", arcStageId);
});
yield return new AgentTool(
"delete_arc_stage",
@@ -438,8 +490,8 @@ public class NovelAgentToolset(
.Build(),
async (_, input, ct) =>
{
await arcs.DeleteAsync(JsonInput.RequiredGuid(input, "arc_stage_id"), ct);
return new { deleted = true };
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId);
});
yield return new AgentTool(
@@ -499,11 +551,15 @@ public class NovelAgentToolset(
.Str("resolution", "What was decided.", required: true)
.Bool("append_to_notes", "Also append the resolution to the associated notes.")
.Build(),
async (_, input, ct) => await questions.ResolveAsync(
JsonInput.RequiredGuid(input, "question_id"),
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct));
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ResolveAsync(
questionId,
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct), "OpenQuestion", questionId);
});
yield return new AgentTool(
"reopen_question",
@@ -511,8 +567,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("question_id", "Id of the question to reopen.", required: true)
.Build(),
async (_, input, ct) => await questions.ReopenAsync(
JsonInput.RequiredGuid(input, "question_id"), ct));
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ReopenAsync(questionId, ct), "OpenQuestion", questionId);
});
yield return new AgentTool(
"delete_open_question",
@@ -522,8 +581,8 @@ public class NovelAgentToolset(
.Build(),
async (_, input, ct) =>
{
await questions.DeleteAsync(JsonInput.RequiredGuid(input, "question_id"), ct);
return new { deleted = true };
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await DeletedOrNotFound(questions.DeleteAsync(questionId, ct), "OpenQuestion", questionId);
});
}