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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user