From 897fb442a1785d851fdc95a3effa0845b8361f5d Mon Sep 17 00:00:00 2001 From: James Wampler Date: Fri, 21 Aug 2026 10:52:50 -0700 Subject: [PATCH] Unify MCP and agent tool surfaces onto one registry in NovelAgentToolset Fixes NovelAgentService continuing a conversation under the wrong novel's route, since FindConversationAsync matched by id alone. Then extends NovelAgentToolset to all 45 tools the stdio MCP server offered (tag/location CRUD, character relationships, arc-stage beat pinning, question editing, cross-novel novel listing/creation), tagging each with whether it needs an explicit novel scope so a later MCP adapter can inject it. Renames the toolset's 33 existing schemas from snake_case to camelCase to match .NET/REST convention, since nothing external consumes them. Lays the groundwork to serve this same registry over MCP at /mcp and retire the separate stdio Novelly.Mcp project (docs/plans/api/mcp_http_merge_plan.md). --- docs/plans/api/mcp_http_merge_plan.md | 180 +++++++ src/Novelly.Api/Agent/AgentContracts.cs | 2 +- src/Novelly.Api/Agent/NovelAgentService.cs | 16 +- src/Novelly.Api/Agent/NovelAgentToolset.cs | 439 +++++++++++++----- .../NovelAgentServiceTests.cs | 38 +- .../NovelAgentToolsetTests.cs | 226 +++++++++ 6 files changed, 784 insertions(+), 117 deletions(-) create mode 100644 docs/plans/api/mcp_http_merge_plan.md create mode 100644 tests/Novelly.Api.Tests/NovelAgentToolsetTests.cs diff --git a/docs/plans/api/mcp_http_merge_plan.md b/docs/plans/api/mcp_http_merge_plan.md new file mode 100644 index 0000000..bd9c8f2 --- /dev/null +++ b/docs/plans/api/mcp_http_merge_plan.md @@ -0,0 +1,180 @@ +# Merge the MCP server into Novelly.Api as Streamable HTTP `/mcp` + +## Context + +Novelly has two duplicate tool surfaces that must be kept in sync by hand: + +- **`src/Novelly.Mcp/`** — 45 tools as `[McpServerTool]` static methods, stdio-only, each calling the REST API back over HTTP via `NovelApiClient`. Built by no CI job, in no container, unknown to the AppHost. It only works if someone remembers to run `scripts/publish-mcp.sh` and re-point `.mcp.json` at the published binary. Zero tests. +- **`src/Novelly.Api/Agent/NovelAgentToolset.cs`** — 33 tools for the embedded web agent, calling application services directly in-process. + +The 33 are a **strict subset** of the 45, matching name-for-name. The gap is pure capability loss for the web agent, not a design distinction. + +Two problems follow. The MCP server is unreachable from anything but a local stdio subprocess, so the QA deploy can't serve it without shipping a binary around. And every new capability has to be written twice, in two idioms, with nothing enforcing that they agree. + +**Outcome:** one tool registry, called directly by both surfaces. The API serves MCP over Streamable HTTP at `/mcp`, so any MCP client reaches it over the network with an API key and no binary to distribute. The stdio project is deleted. The web agent gains all 12 tools it was missing. + +## Decisions taken + +- **Delete `src/Novelly.Mcp` entirely.** No stdio proxy is kept. +- **The web agent gets all 45 tools**, including cross-novel `list_novels` and `create_novel`. Full parity, one list, no filtering. +- **camelCase argument names** everywhere. MCP specifies nothing about argument naming — `inputSchema` is plain JSON Schema — so this is a free choice, and camelCase matches .NET convention, the REST API's `JsonSerializerDefaults.Web` output, and the current MCP surface. Existing MCP clients keep working unchanged. The cost lands on `NovelAgentToolset`'s 33 hand-built schemas, which are today the only snake_case thing in the repo and must be rewritten. +- **Tool names stay snake_case** (`list_novels`, `get_novel_brief`) — that part *is* genuine MCP convention, and both surfaces already agree on it. + +## Design + +A single registry — the existing `AgentTool` shape in `NovelAgentToolset`, extended to all 45 tools — with two thin adapters over it. + +**MCP adapter.** Register the SDK's dynamic-tools handlers rather than 45 attribute-decorated methods: + +```csharp +services.AddMcpServer(o => o.ServerInfo = new Implementation { Name = "novelly", Version = "1.0.0" }) + .WithHttpTransport() + .WithListToolsHandler((request, ct) => ...) + .WithCallToolHandler((request, ct) => ...); +``` + +Both handlers resolve `request.Services!.GetRequiredService()` per request and delegate to a pure adapter class. This avoids fighting the SDK's schema inference (`McpServerToolCreateOptions` has no `InputSchema` property) and avoids hoisting the scoped, nine-dependency toolset into a static catalog, which `WithTools(IEnumerable)` would force. + +**Agent adapter.** Unchanged — `NovelAgentService.SendMessageAsync` → `toolset.ExecuteAsync(name, novelId, input, ct)`, with `novelId` still ambient from the route and never shown to the model. + +**Novel scoping, one entry / two shapes.** `AgentTool` and `AgentToolDefinition` each gain a trailing `bool RequiresNovelId = false`, so the 33 existing construction sites keep compiling. The MCP adapter injects a required `novelId` property into the advertised schema for those tools and extracts it at call time; the agent adapter supplies it from the route. `list_novels` / `create_novel` need no scope at all. + +The 11 currently novel-scoped tools are identifiable mechanically — `grep -n 'async (novelId' src/Novelly.Api/Agent/NovelAgentToolset.cs`: `get_novel_brief`, `update_novel_brief`, `list_characters`, `create_character`, `list_tags`, `list_locations`, `list_chapters`, `create_chapter`, `get_character_beats`, `list_open_questions`, `raise_open_question`. Of the 12 new tools, `create_tag` and `create_location` are novel-scoped; the other ten are child-id-scoped or unscoped. + +### Verified before planning + +The design rests on SDK behaviour that build-and-test would not catch, so it was checked against a running probe app rather than inferred from docs: + +- `WithListToolsHandler` / `WithCallToolHandler` exist in `ModelContextProtocol` 2.1.0; `Tool.InputSchema` is a settable `JsonElement` whose setter validates exactly what `JsonSchemaBuilder.Build()` already emits. +- **`capabilities.tools` *is* advertised** on `initialize` with handlers and no `ToolCollection` — this was the main open risk and it is closed. +- **`request.Services` is non-null and yields a fresh DI scope per request** (three calls returned three distinct scope ids). This is what makes the scoped `NovelDbContext` and `NovelAgentToolset` correct here. `HttpServerTransportOptions.Stateless` defaults to `true` and `PerSessionExecutionContext` to `false` in 2.1.0, so no options need restating. +- A hand-built `JsonElement` schema survives verbatim onto `tools/list` output, and `IsError` maps to `result.isError` rather than a JSON-RPC error object. +- `GET /mcp` returns 405 in stateless mode. Harmless; clients only POST. + +## Chunks + +Each builds, tests and commits independently. + +### Chunk 0 — Fix the cross-novel conversation leak + +Independent; do it first to keep it out of the main diff. + +`NovelAgentService.FindConversationAsync(Guid conversationId, ...)` matches on id alone, so a conversation belonging to novel X can be continued under novel Y's route, after which every tool call runs against Y with X's transcript. Add an optional novel filter, passed from `SendMessageAsync` only — `GetConversationAsync`/`DeleteConversationAsync` are reached via `/api/conversations/{id}`, which has no novel in the route, and keep passing `null`. Log the miss at Warning with `{ConversationId}`/`{NovelId}`. + +**Files:** `src/Novelly.Api/Agent/NovelAgentService.cs`, `tests/Novelly.Api.Tests/NovelAgentServiceTests.cs` + +**Tests:** `Continuing_a_conversation_under_a_different_novel_is_rejected`, plus `Continuing_a_conversation_under_its_own_novel_still_works` as the guard against over-tightening. + +### Chunk 1 — One registry, all 45 tools (no MCP wiring yet) + +Delivers the parity decision on its own, verifiable through the existing web agent. + +- `src/Novelly.Api/Agent/AgentContracts.cs` — add `RequiresNovelId` to `AgentToolDefinition`. Safe: `AnthropicAgentModelClient.ToSdkTool` maps `Name`/`Description`/`InputSchema` explicitly, so the flag never reaches the model. +- `src/Novelly.Api/Agent/NovelAgentToolset.cs` — add `RequiresNovelId` to `AgentTool`, set it on the 11 tools above, flow it into `Definitions`, and add the 12 new tools reusing the existing `OrNotFound` / `DeletedOrNotFound` / `ToolNotFound` idioms and the descriptions from the corresponding `src/Novelly.Mcp/Tools/*.cs` methods. +- **Rename the 33 existing schemas to camelCase** in the same file — both the `JsonSchemaBuilder` property keys and the matching `JsonInput` lookup strings, which must stay in lockstep (`.Str("character_id", …)` / `JsonInput.RequiredGuid(input, "character_id")` → `"characterId"`). Mechanical and contained to this one file, but it is the bulk of the chunk's diff and a mismatched pair fails silently as a missing argument rather than a compile error — so the per-tool tests below are what actually catch it. The 12 new tools are written camelCase from the start, matching the names their `src/Novelly.Mcp/Tools/*.cs` equivalents already used. + +The 12 new tools and their existing service calls — no service-layer work is needed: + +| tool | scope | service call | +|---|---|---| +| `list_novels` | none | `novels.ListAsync` | +| `create_novel` | none | `novels.CreateAsync` | +| `get_character` | child | `characters.GetAsync` | +| `relate_characters` | child | `characters.AddRelationshipAsync` — note `CreateRelationshipRequest`'s parameter order differs from the old MCP method's | +| `set_arc_stage_beats` | child | `arcs.SetBeatsAsync` | +| `create_tag` | **novel** | `tags.CreateAsync` | +| `update_tag` / `delete_tag` | child | `tags.UpdateAsync` / `DeleteAsync` | +| `create_location` | **novel** | `locations.CreateAsync` | +| `update_location` / `delete_location` | child | `locations.UpdateAsync` / `DeleteAsync` | +| `update_open_question` | child | `questions.UpdateAsync` | + +Guid-list arguments follow the existing `JsonInput.Guids` idiom used by `reorder_beats`. + +**Tests:** new `tests/Novelly.Api.Tests/NovelAgentToolsetTests.cs`, driving `ExecuteAsync` directly in the style of `ImportAgentToolsetTests.cs` (`ServiceTestFixture` already wires every service the toolset needs). BDD names, one per new capability — e.g. `Relating_two_characters_shows_the_pair_on_both_dossiers`, `Deleting_a_tag_leaves_the_characters_that_carried_it_alone`, `Updating_an_open_question_can_detach_it_from_its_chapter`. Two structural tests carry the most weight: + +- `The_toolset_offers_every_tool_the_stdio_server_offered` — assert the 45 names against a hard-coded array. This is the anti-drift test. +- Assert no `RequiresNovelId` tool's schema already declares `novelId`, since the MCP adapter injects it and a duplicate would be silent. + +**Runtime verify:** AppHost up, open a novel's agent panel, ask it to create a tag and list tags; confirm in the Aspire trace. + +### Chunk 2 — Serve the registry at `/mcp` + +- `src/Novelly.Api/Novelly.Api.csproj` — add `ModelContextProtocol.AspNetCore` 2.1.0 (brings Core transitively; don't reference it directly). **Not in the local NuGet cache — first restore needs network.** Pin 2.1.0 to match the verified surface. +- New feature folder `src/Novelly.Api/Mcp/`: + - `NovelMcpTools.cs` — the adapter, as pure static methods: `Describe(definitions)` maps to `Tool` records, injecting `novelId` where `RequiresNovelId`; `CallAsync(toolset, parameters, ct)` serializes arguments to a `JsonElement`, extracts `novelId` when required (`Guid.Empty` otherwise), calls `ExecuteAsync`, and maps `AgentToolResult` → `CallToolResult`. Catch the `ArgumentException` from a missing/malformed `novelId` and return it as `IsError` rather than letting it escape as a JSON-RPC error. **Log `{Tool}` and `{NovelId}` only — never the arguments, which carry prose (`what_happened`, `synopsis`, `notes`).** + - `McpEndpoints.cs` — `MapNovelMcp()` calling `app.MapMcp("/mcp")`, matching the repo's `Map*Endpoints` convention. +- `src/Novelly.Api/Common/NovellyServiceRegistration.cs` — the `AddMcpServer(...)` registration shown above. +- `src/Novelly.Api/Program.cs` — `.MapNovelMcp()` after `UseAuthentication()`/`UseAuthorization()`. + +**Auth — the trap.** Do **not** chain `.RequireAuthorization()` onto `MapMcp`. The parameterless overload applies the *default* policy, which authenticates `IdentityConstants.ApplicationScheme` only and would reject the API key. The fallback policy already registered in `NovellyServiceRegistration` lists both that scheme *and* `ServiceApiKeyAuthenticationHandler.SchemeName`, and applies to any endpoint carrying no authorization metadata — which `MapMcp` adds none of. `/mcp` inherits the right protection by doing nothing. If explicitness is wanted, register a named policy listing both schemes; never the parameterless call. + +External clients send `X-Novelly-Api-Key: `, resolving to `ServiceUser` (Admin), which sees every novel. If `Auth:ServiceApiKey` is unset the service user is never seeded and every call 401s. + +CORS needs no change — the origin-restricted default policy is irrelevant to non-browser clients, and stateless mode exposes no `Mcp-Session-Id` header to read. + +**Tests:** new `tests/Novelly.Api.Tests/NovelMcpToolsTests.cs`, against the pure adapter methods — no live session, no `WebApplicationFactory`. Cover: all 45 advertised with unique names; novel-scoped tools declare a required `novelId` and child-id tools don't; `list_novels` needs none; every advertised schema is a valid `type: object` (what `Tool.InputSchema`'s setter enforces, worth asserting before the SDK throws at startup); a missing `novelId` and a not-found id both come back as `IsError` results. + +**Deliberately not adding a `WebApplicationFactory` harness.** None exists in the repo; adding one means an MVC.Testing reference, overriding the connection string, working around `Program.cs`'s boot-time `MigrateAsync` + `Environment.Exit(1)`, seeding the key, and parsing SSE. Its main payoff — proving the SDK wires up — is delivered more honestly by the curl walkthrough below, which exercises real Kestrel including auth, Serilog and the exception handler. Worth a separate chunk later if a regression harness is wanted. + +### Chunk 3 — Delete the stdio server + +Only after Chunk 2 is verified, so there's never a window with no MCP surface. + +Remove `src/Novelly.Mcp/`, `scripts/publish-mcp.sh`, the `mcp-server/` publish output (gitignored; working-tree cleanup only), and the project line in `Novelly.slnx`. Verified as *not* referencing it: `scripts/ci/build.sh` (publishes the API by path), `prepush.sh`, `test.sh`, both Dockerfiles, the AppHost. + +Docs to update: +- `README.md` — fold the `Novelly.Mcp` stack-table row into the API's; rewrite "The MCP server" section (same 45 tools, now in-process at `POST /mcp`, no build step, `X-Novelly-Api-Key` auth). Note that MCP argument names are unchanged (camelCase), so existing clients need no edits, and that `create_novel` is owned by the signed-in user over the web agent but by the service user over MCP. +- `.mcp.json` / `.mcp.json.example` — switch to `type: "http"`, `url: http://localhost:5080/mcp`, with the key in `headers`. +- `.claude/agents/outline-importer.md` — drop the published-binary requirement. Its `tools:` frontmatter is **already stale** (`mcp__novelly__list_projects`, `get_project_brief`, `create_project` exist in neither surface); fix to real names while here. +- `CLAUDE.md` — the Structure list still calls `src/Novelly.Mcp/` the "MCP stdio server" and Verifying still says "drive over stdio JSON-RPC". Propose these edits rather than slipping them in; CLAUDE.md is user-owned. + +Leave `docs/plans/api/users_and_roles_plan.md` alone — historical. + +**Verify:** `dotnet build Novelly.slnx` and `./scripts/ci/prepush.sh` both pass. + +## Verification + +Streamable HTTP needs `Accept: application/json, text/event-stream` and replies SSE-framed, so pipe through `sed -n 's/^data: //p'`. + +```bash +dotnet user-secrets set Auth:ServiceApiKey devkey -p src/Novelly.Api +ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api + +MCP=http://localhost:5080/mcp +H=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "X-Novelly-Api-Key: devkey") + +# handshake — expect capabilities.tools present +curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' | sed -n 's/^data: //p' | jq . +curl -sS "${H[@]}" -o /dev/null -w '%{http_code}\n' "$MCP" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' + +# expect exactly 45 +curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | sed -n 's/^data: //p' | jq '.result.tools | length' + +# novelId injection: present on list_tags, absent on delete_tag and list_novels +curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}' | sed -n 's/^data: //p' \ + | jq '.result.tools[] | select(.name=="list_tags" or .name=="delete_tag" or .name=="list_novels") | {name, props:(.inputSchema.properties|keys), required:.inputSchema.required}' + +# unscoped call, then a novel-scoped one (proves NovelUserContext resolved the service user in-handler) +curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_novels","arguments":{}}}' | sed -n 's/^data: //p' | jq . +curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_novel_brief","arguments":{"novelId":""}}}' | sed -n 's/^data: //p' | jq . + +# error mapping — expect result.isError true, not a JSON-RPC error +curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"list_tags","arguments":{}}}' | sed -n 's/^data: //p' | jq . + +# auth — expect 401 with no key +curl -sS -o /dev/null -w '%{http_code}\n' -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + "$MCP" -d '{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{}}' + +# one write, end to end +curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"create_tag","arguments":{"novelId":"","name":"Salt","color":"#9a4a2f"}}}' | sed -n 's/^data: //p' | jq . +``` + +Then reconnect a real client: rewrite `.mcp.json` to the `type: "http"` form and run `/mcp` in Claude Code to confirm 45 tools. Finally confirm the web agent still drives the same registry (Aspire up, agent panel, exercise one of the 12 new tools) and that the dashboard shows `/mcp` requests with no prose in the log lines. + +## Risks + +- **`ModelContextProtocol.AspNetCore` is not cached locally.** First restore needs network. +- **The web agent's argument names change** (snake_case → camelCase) across all 33 existing tools. Nothing external consumes those schemas — they are built fresh per request and handed to the model each turn — so there is no compatibility surface, but a `JsonSchemaBuilder` key left out of step with its `JsonInput` lookup fails silently as a missing argument rather than a compile error. External MCP clients are unaffected: their argument names were already camelCase. +- **`create_novel` ownership differs by surface** — signed-in user vs service user. Not a bug, but surprising; document it. +- **Not fixed here:** child-id-scoped tools carry no novel context in *either* surface, so a caller holding a foreign beat/tag/question id can reach across novels, subject only to `NovelAccessService`. Pre-existing for 20+ tools and unchanged by this work. Worth a follow-up. +- **QA deploy:** once merged, `/mcp` rides the existing API container and its already-exposed port. `MCP_API_KEY` must be set in Gitea for `Auth__ServiceApiKey`, or every MCP call 401s. diff --git a/src/Novelly.Api/Agent/AgentContracts.cs b/src/Novelly.Api/Agent/AgentContracts.cs index 6837bde..dbfa603 100644 --- a/src/Novelly.Api/Agent/AgentContracts.cs +++ b/src/Novelly.Api/Agent/AgentContracts.cs @@ -2,7 +2,7 @@ using System.Text.Json; namespace Novelly.Api.Agent; -public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema); +public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema, bool RequiresNovelId = false); public abstract record AgentContentBlock; diff --git a/src/Novelly.Api/Agent/NovelAgentService.cs b/src/Novelly.Api/Agent/NovelAgentService.cs index 634a35d..184bb2a 100644 --- a/src/Novelly.Api/Agent/NovelAgentService.cs +++ b/src/Novelly.Api/Agent/NovelAgentService.cs @@ -43,7 +43,7 @@ public class NovelAgentService( logger.LogInformation("Getting agent conversation {ConversationId}", conversationId); - return await FindConversationAsync(conversationId, ct); + return await FindConversationAsync(conversationId, null, ct); } public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default) @@ -52,7 +52,7 @@ public class NovelAgentService( logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId); - var conversation = await FindConversationAsync(conversationId, ct); + var conversation = await FindConversationAsync(conversationId, null, ct); if (conversation is null) { return false; @@ -81,7 +81,7 @@ public class NovelAgentService( } var conversation = request.ConversationId is { } id - ? await FindConversationAsync(id, ct) + ? await FindConversationAsync(id, novelId, ct) : StartConversation(novelId, request.Message); if (conversation is null) return null; @@ -181,7 +181,7 @@ public class NovelAgentService( return conversation; } - private async Task FindConversationAsync(Guid conversationId, CancellationToken ct) + private async Task FindConversationAsync(Guid conversationId, Guid? novelId, CancellationToken ct) { logger.LogDebug("Finding agent conversation {ConversationId}", conversationId); @@ -195,6 +195,14 @@ public class NovelAgentService( return conversation; } + if (novelId is { } expectedNovelId && conversation.NovelId != expectedNovelId) + { + logger.LogWarning( + "AgentConversation {ConversationId} belongs to novel {ActualNovelId}, not requested novel {NovelId}", + conversationId, conversation.NovelId, expectedNovelId); + return null; + } + logger.LogDebug("Found agent conversation {ConversationId}", conversationId); return conversation; } diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index 758544f..3c6aeb3 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -21,7 +21,8 @@ public record AgentTool( string Name, string Description, JsonElement InputSchema, - Func> Handler); + Func> Handler, + bool RequiresNovelId = false); public class NovelAgentToolset( NovelService novels, @@ -46,7 +47,7 @@ public class NovelAgentToolset( private IReadOnlyList Tools => [.. ByName.Values]; public IReadOnlyList Definitions => - [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; + [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema, t.RequiresNovelId))]; public async Task ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default) { @@ -97,12 +98,45 @@ public class NovelAgentToolset( private IEnumerable Build() { + yield return new AgentTool( + "list_novels", + "List every novel, with counts of characters, chapters and drafted words. " + + "Start here to find the novel id everything else needs.", + new JsonSchemaBuilder().Build(), + async (_, _, ct) => await novels.ListAsync(ct)); + + yield return new AgentTool( + "create_novel", + "Create a new novel.", + new JsonSchemaBuilder() + .Str("title", "Working title.", required: true) + .Str("author", "Author name.") + .Str("genre", "Genre or category.") + .Str("logline", "One-sentence pitch.") + .Str("synopsis", "Paragraph-length summary of the whole book.") + .Str("notes", "Free-form notes on theme, tone, comparable titles.") + .Int("targetWordCount", "Target manuscript length in words.") + .Build(), + async (_, input, ct) => + { + var novel = await novels.CreateAsync(new CreateNovelRequest( + JsonInput.RequiredString(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, "targetWordCount")), ct); + return novel.ToResponse(null); + }); + yield return new AgentTool( "get_novel_brief", "Read the novel'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 (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId)); + async (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId), + RequiresNovelId: true); yield return new AgentTool( "update_novel_brief", @@ -115,7 +149,7 @@ public class NovelAgentToolset( .Str("logline", "One-sentence pitch.") .Str("synopsis", "Paragraph-length summary of the whole book.") .Str("notes", "Free-form notes on theme, tone, comparable titles.") - .Int("target_word_count", "Target manuscript length in words.") + .Int("targetWordCount", "Target manuscript length in words.") .Build(), async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest( JsonInput.String(input, "title"), @@ -124,13 +158,27 @@ public class NovelAgentToolset( JsonInput.String(input, "logline"), JsonInput.String(input, "synopsis"), JsonInput.String(input, "notes"), - JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Novel", novelId)); + JsonInput.Int(input, "targetWordCount")), ct), p => p.ToResponse(null), "Novel", novelId), + RequiresNovelId: true); yield return new AgentTool( "list_characters", "List every character in the novel with their full dossiers.", new JsonSchemaBuilder().Build(), - async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse())); + async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse()), + RequiresNovelId: true); + + yield return new AgentTool( + "get_character", + "Read one character's dossier.", + new JsonSchemaBuilder() + .Str("characterId", "Id of the character to read.", required: true) + .Build(), + async (_, input, ct) => + { + var characterId = JsonInput.RequiredGuid(input, "characterId"); + return await OrNotFound(characters.GetAsync(characterId, ct), c => c.ToResponse(), "Character", characterId); + }); yield return new AgentTool( "create_character", @@ -152,17 +200,18 @@ public class NovelAgentToolset( JsonInput.String(input, "voice"), JsonInput.String(input, "notes"), JsonInput.Strings(input, "tags"), - JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId)); + JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId), + RequiresNovelId: true); yield return new AgentTool( "update_character", "Revise an existing character dossier. Only the fields you supply change.", CharacterSchema(includeName: true, nameRequired: false) - .Str("character_id", "Id of the character to update.", required: true) + .Str("characterId", "Id of the character to update.", required: true) .Build(), async (_, input, ct) => { - var characterId = JsonInput.RequiredGuid(input, "character_id"); + var characterId = JsonInput.RequiredGuid(input, "characterId"); return await OrNotFound(characters.UpdateAsync( characterId, new UpdateCharacterRequest( @@ -183,25 +232,49 @@ public class NovelAgentToolset( JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId); }); + yield return new AgentTool( + "relate_characters", + "Record a relationship between two characters in the same novel. Creates both directions " + + "at once — characterId's side and relatedCharacterId's side — so the pair always shows up " + + "on both dossiers.", + new JsonSchemaBuilder() + .Str("characterId", "Id of the character the relationship belongs to.", required: true) + .Str("relatedCharacterId", "Id of the character they are related to.", required: true) + .Str("relationshipType", "How characterId is related to relatedCharacterId, e.g. 'sister', 'rival', 'former mentor'.", required: true) + .Str("reciprocalRelationshipType", "How relatedCharacterId is related back to characterId, if different. Defaults to relationshipType when the relation is symmetric, like 'rival'.") + .Str("description", "What the relationship is like, and where it is headed.") + .Build(), + async (_, input, ct) => + { + var characterId = JsonInput.RequiredGuid(input, "characterId"); + return await OrNotFound(characters.AddRelationshipAsync( + characterId, + new CreateRelationshipRequest( + JsonInput.RequiredGuid(input, "relatedCharacterId"), + JsonInput.RequiredString(input, "relationshipType"), + JsonInput.String(input, "description"), + JsonInput.String(input, "reciprocalRelationshipType")), ct), c => c.ToResponse(), "Character", characterId); + }); + yield return new AgentTool( "link_character_identity", "Record that a character is really another character — e.g. one introduced under one name " + "who is later revealed to be a character already in the novel under another name. Both " + "keep their own dossier and beats; the canonical identity is whichever character you link to.", new JsonSchemaBuilder() - .Str("character_id", "Id of the character being revealed as someone else.", required: true) - .Str("same_character_as_id", "Id of the character this one really is.", required: true) - .Str("revealed_in_chapter_id", "Id of the chapter where the reveal happens, if any.") + .Str("characterId", "Id of the character being revealed as someone else.", required: true) + .Str("sameCharacterAsId", "Id of the character this one really is.", required: true) + .Str("revealedInChapterId", "Id of the chapter where the reveal happens, if any.") .Str("note", "Context on the reveal, e.g. how and why the disguise held.") .Build(), async (_, input, ct) => { - var characterId = JsonInput.RequiredGuid(input, "character_id"); + var characterId = JsonInput.RequiredGuid(input, "characterId"); return await OrNotFound(characters.LinkIdentityAsync( characterId, new LinkCharacterIdentityRequest( - JsonInput.RequiredGuid(input, "same_character_as_id"), - JsonInput.Guid(input, "revealed_in_chapter_id"), + JsonInput.RequiredGuid(input, "sameCharacterAsId"), + JsonInput.Guid(input, "revealedInChapterId"), JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId); }); @@ -209,11 +282,11 @@ public class NovelAgentToolset( "unlink_character_identity", "Remove a character's identity link, restoring it to its own separate identity.", new JsonSchemaBuilder() - .Str("character_id", "Id of the character to unlink.", required: true) + .Str("characterId", "Id of the character to unlink.", required: true) .Build(), async (_, input, ct) => { - var characterId = JsonInput.RequiredGuid(input, "character_id"); + var characterId = JsonInput.RequiredGuid(input, "characterId"); return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId); }); @@ -222,29 +295,29 @@ public class NovelAgentToolset( "Read a chapter's outline: its summary paragraph and its beat table, in order. " + "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.", new JsonSchemaBuilder() - .Str("chapter_id", "Id of the chapter whose outline to read.", required: true) + .Str("chapterId", "Id of the chapter whose outline to read.", required: true) .Build(), - async (_, input, ct) => (await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)).Select(b => b.ToResponse())); + async (_, input, ct) => (await beats.ListAsync(JsonInput.RequiredGuid(input, "chapterId"), ct)).Select(b => b.ToResponse())); yield return new AgentTool( "create_beat", "Add a beat to a chapter's outline. Keep the title to three to five words — it is a " - + "handle, not a sentence; the detail belongs in what_happened and whats_next.", + + "handle, not a sentence; the detail belongs in whatHappened and whatsNext.", BeatSchema() - .Str("chapter_id", "Id of the chapter the beat belongs to.", required: true) + .Str("chapterId", "Id of the chapter the beat belongs to.", required: true) .Str("title", "Three to five words naming the beat.", required: true) .Build(), async (_, input, ct) => { - var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + var chapterId = JsonInput.RequiredGuid(input, "chapterId"); return await OrNotFound(beats.CreateAsync( chapterId, new CreateBeatRequest( JsonInput.RequiredString(input, "title"), - JsonInput.Int(input, "sort_order"), - JsonInput.Guids(input, "character_ids"), - JsonInput.String(input, "what_happened"), - JsonInput.String(input, "whats_next"), + JsonInput.Int(input, "sortOrder"), + JsonInput.Guids(input, "characterIds"), + JsonInput.String(input, "whatHappened"), + JsonInput.String(input, "whatsNext"), JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId); }); @@ -253,20 +326,20 @@ public class NovelAgentToolset( "Revise a beat. Only the fields you supply change. Supplying a tag list replaces " + "the beat's tags outright, so include the ones you want to keep.", BeatSchema() - .Str("beat_id", "Id of the beat to update.", required: true) + .Str("beatId", "Id of the beat to update.", required: true) .Str("title", "Three to five words naming the beat.") .Build(), async (_, input, ct) => { - var beatId = JsonInput.RequiredGuid(input, "beat_id"); + var beatId = JsonInput.RequiredGuid(input, "beatId"); return await OrNotFound(beats.UpdateAsync( beatId, new UpdateBeatRequest( JsonInput.String(input, "title"), - JsonInput.Int(input, "sort_order"), - JsonInput.Guids(input, "character_ids"), - JsonInput.String(input, "what_happened"), - JsonInput.String(input, "whats_next"), + JsonInput.Int(input, "sortOrder"), + JsonInput.Guids(input, "characterIds"), + JsonInput.String(input, "whatHappened"), + JsonInput.String(input, "whatsNext"), JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId); }); @@ -274,11 +347,11 @@ public class NovelAgentToolset( "delete_beat", "Remove a beat from a chapter's outline. Confirm with the writer before calling it.", new JsonSchemaBuilder() - .Str("beat_id", "Id of the beat to delete.", required: true) + .Str("beatId", "Id of the beat to delete.", required: true) .Build(), async (_, input, ct) => { - var beatId = JsonInput.RequiredGuid(input, "beat_id"); + var beatId = JsonInput.RequiredGuid(input, "beatId"); return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId); }); @@ -287,16 +360,16 @@ public class NovelAgentToolset( "Renumber a chapter's beats to match the order given. List every beat id in the " + "order you want; any you leave out keep their relative position at the end.", new JsonSchemaBuilder() - .Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true) - .StringArray("beat_ids", "Beat ids in their new order.", required: true) + .Str("chapterId", "Id of the chapter whose beats to reorder.", required: true) + .StringArray("beatIds", "Beat ids in their new order.", required: true) .Build(), async (_, input, ct) => { - var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + var chapterId = JsonInput.RequiredGuid(input, "chapterId"); return await OrNotFound(beats.ReorderAsync( chapterId, new ReorderBeatsRequest( - [.. (JsonInput.Strings(input, "beat_ids") ?? []) + [.. (JsonInput.Strings(input, "beatIds") ?? []) .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); }); @@ -306,18 +379,18 @@ public class NovelAgentToolset( "Add a character to several beats at once. Leaves each beat's existing characters and " + "other fields alone — this only adds, it never removes.", new JsonSchemaBuilder() - .Str("chapter_id", "Id of the chapter the beats belong to.", required: true) - .Str("character_id", "Id of the character to add.", required: true) - .StringArray("beat_ids", "Ids of the beats to add the character to.", required: true) + .Str("chapterId", "Id of the chapter the beats belong to.", required: true) + .Str("characterId", "Id of the character to add.", required: true) + .StringArray("beatIds", "Ids of the beats to add the character to.", required: true) .Build(), async (_, input, ct) => { - var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + var chapterId = JsonInput.RequiredGuid(input, "chapterId"); return await OrNotFound(beats.AssignCharacterAsync( chapterId, new AssignCharacterToBeatsRequest( - JsonInput.RequiredGuid(input, "character_id"), - [.. (JsonInput.Strings(input, "beat_ids") ?? []) + JsonInput.RequiredGuid(input, "characterId"), + [.. (JsonInput.Strings(input, "beatIds") ?? []) .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); }); @@ -327,18 +400,18 @@ public class NovelAgentToolset( "Move one or more beats from one chapter to another, appending them to the target " + "chapter's end in the order given.", new JsonSchemaBuilder() - .Str("chapter_id", "Id of the beats' current chapter.", required: true) - .Str("target_chapter_id", "Id of the chapter to move the beats into.", required: true) - .StringArray("beat_ids", "Ids of the beats to move.", required: true) + .Str("chapterId", "Id of the beats' current chapter.", required: true) + .Str("targetChapterId", "Id of the chapter to move the beats into.", required: true) + .StringArray("beatIds", "Ids of the beats to move.", required: true) .Build(), async (_, input, ct) => { - var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + var chapterId = JsonInput.RequiredGuid(input, "chapterId"); return await OrNotFound(beats.MoveAsync( chapterId, new MoveBeatsRequest( - JsonInput.RequiredGuid(input, "target_chapter_id"), - [.. (JsonInput.Strings(input, "beat_ids") ?? []) + JsonInput.RequiredGuid(input, "targetChapterId"), + [.. (JsonInput.Strings(input, "beatIds") ?? []) .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); }); @@ -348,18 +421,19 @@ public class NovelAgentToolset( "List the novel's tags with how many characters, chapters and beats carry each. " + "Read this before inventing a new tag so you reuse the writer's vocabulary.", new JsonSchemaBuilder().Build(), - async (novelId, _, ct) => await tags.ListAsync(novelId, ct)); + async (novelId, _, ct) => await tags.ListAsync(novelId, ct), + RequiresNovelId: true); yield return new AgentTool( "get_tag_references", "Cross-reference a tag: every character, chapter and beat carrying it. Use this to " + "trace a motif or a thread through the book.", new JsonSchemaBuilder() - .Str("tag_id", "Id of the tag to trace.", required: true) + .Str("tagId", "Id of the tag to trace.", required: true) .Build(), async (_, input, ct) => { - var tagId = JsonInput.RequiredGuid(input, "tag_id"); + var tagId = JsonInput.RequiredGuid(input, "tagId"); var tag = await tags.GetReferencesAsync(tagId, ct); if (tag is null) { @@ -370,22 +444,68 @@ public class NovelAgentToolset( return tag.ToReferencesResponse(displayNumbers); }); + yield return new AgentTool( + "create_tag", + "Create a tag explicitly. Applying an unknown tag by name to a character, chapter or " + + "beat also creates it, so this is only needed to set a colour up front.", + new JsonSchemaBuilder() + .Str("name", "The tag's name. Unique within the novel, matched case-insensitively.", required: true) + .Str("color", "Optional hex colour for the UI, e.g. \"#9a4a2f\".") + .Build(), + async (novelId, input, ct) => await OrNotFound(tags.CreateAsync( + novelId, + new CreateTagRequest( + JsonInput.RequiredString(input, "name"), + JsonInput.String(input, "color")), ct), t => t.ToResponse(), "Novel", novelId), + RequiresNovelId: true); + + yield return new AgentTool( + "update_tag", + "Rename or recolour a tag. Renaming updates it everywhere it is applied.", + new JsonSchemaBuilder() + .Str("tagId", "Id of the tag to update.", required: true) + .Str("name", "New name.") + .Str("color", "Hex colour, e.g. \"#9a4a2f\".") + .Build(), + async (_, input, ct) => + { + var tagId = JsonInput.RequiredGuid(input, "tagId"); + return await OrNotFound(tags.UpdateAsync( + tagId, + new UpdateTagRequest( + JsonInput.String(input, "name"), + JsonInput.String(input, "color")), ct), t => t.ToResponse(), "Tag", tagId); + }); + + yield return new AgentTool( + "delete_tag", + "Delete a tag. Whatever carried it is left alone — only the label goes.", + new JsonSchemaBuilder() + .Str("tagId", "Id of the tag to delete.", required: true) + .Build(), + async (_, input, ct) => + { + var tagId = JsonInput.RequiredGuid(input, "tagId"); + return await DeletedOrNotFound(tags.DeleteAsync(tagId, ct), "Tag", tagId); + }); + yield return new AgentTool( "list_locations", "List the novel's locations with how many chapters are set there. " + "Read this before inventing a new location so you reuse the writer's vocabulary.", new JsonSchemaBuilder().Build(), - async (novelId, _, ct) => await locations.ListAsync(novelId, ct)); + async (novelId, _, ct) => await locations.ListAsync(novelId, ct), + RequiresNovelId: true); yield return new AgentTool( "get_location_references", "Cross-reference a location: every chapter set there.", new JsonSchemaBuilder() - .Str("location_id", "Id of the location to trace.", required: true) + .Str("locationId", "Id of the location to trace.", required: true) .Build(), async (_, input, ct) => { - var locationId = JsonInput.RequiredGuid(input, "location_id"); + var locationId = JsonInput.RequiredGuid(input, "locationId"); var location = await locations.GetReferencesAsync(locationId, ct); if (location is null) { @@ -396,6 +516,45 @@ public class NovelAgentToolset( return location.ToReferencesResponse(displayNumbers); }); + yield return new AgentTool( + "create_location", + "Create a location explicitly. Applying an unknown location by name to a chapter also " + + "creates it, so this is only needed to set one up ahead of time.", + new JsonSchemaBuilder() + .Str("name", "The location's name. Unique within the novel, matched case-insensitively.", required: true) + .Build(), + async (novelId, input, ct) => await OrNotFound(locations.CreateAsync( + novelId, + new CreateLocationRequest(JsonInput.RequiredString(input, "name")), ct), l => l.ToResponse(), "Novel", novelId), + RequiresNovelId: true); + + yield return new AgentTool( + "update_location", + "Rename a location. Renaming updates it everywhere it is applied.", + new JsonSchemaBuilder() + .Str("locationId", "Id of the location to update.", required: true) + .Str("name", "New name.", required: true) + .Build(), + async (_, input, ct) => + { + var locationId = JsonInput.RequiredGuid(input, "locationId"); + return await OrNotFound(locations.UpdateAsync( + locationId, + new UpdateLocationRequest(JsonInput.RequiredString(input, "name")), ct), l => l.ToResponse(), "Location", locationId); + }); + + yield return new AgentTool( + "delete_location", + "Delete a location. Whatever carried it is left alone — only the label goes.", + new JsonSchemaBuilder() + .Str("locationId", "Id of the location to delete.", required: true) + .Build(), + async (_, input, ct) => + { + var locationId = JsonInput.RequiredGuid(input, "locationId"); + return await DeletedOrNotFound(locations.DeleteAsync(locationId, ct), "Location", locationId); + }); + yield return new AgentTool( "list_chapters", "List the novel's chapters in manuscript order with beat and word counts.", @@ -405,17 +564,18 @@ public class NovelAgentToolset( var list = await chapters.ListAsync(novelId, ct); var displayNumbers = ChapterNumbering.DisplayNumbers(list); return list.Select(c => c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null)); - }); + }, + RequiresNovelId: true); yield return new AgentTool( "get_chapter", "Read one chapter in full: its outline (beats) and its drafted prose.", new JsonSchemaBuilder() - .Str("chapter_id", "Id of the chapter to read.", required: true) + .Str("chapterId", "Id of the chapter to read.", required: true) .Build(), async (_, input, ct) => { - var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + var chapterId = JsonInput.RequiredGuid(input, "chapterId"); var chapter = await chapters.GetAsync(chapterId, ct); if (chapter is null) { @@ -439,7 +599,7 @@ public class NovelAgentToolset( .StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.") .Str("notes", "Anything else worth recording.") .Enum("status", "Drafting status.", System.Enum.GetNames()) - .Int("target_word_count", "Target length in words.") + .Int("targetWordCount", "Target length in words.") .Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .Build(), @@ -453,7 +613,7 @@ public class NovelAgentToolset( JsonInput.Strings(input, "locations"), JsonInput.String(input, "notes"), JsonInput.Enum(input, "status") ?? DraftStatus.Planned, - JsonInput.Int(input, "target_word_count"), + JsonInput.Int(input, "targetWordCount"), JsonInput.String(input, "prose"), JsonInput.Strings(input, "tags")), ct); @@ -464,7 +624,8 @@ public class NovelAgentToolset( var displayNumber = await chapters.DisplayNumberAsync(chapter, ct); return chapter.ToResponse(displayNumber); - }); + }, + RequiresNovelId: true); yield return new AgentTool( "update_chapter", @@ -472,7 +633,7 @@ public class NovelAgentToolset( + "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the " + "word count is recomputed automatically.", new JsonSchemaBuilder() - .Str("chapter_id", "Id of the chapter to update.", required: true) + .Str("chapterId", "Id of the chapter to update.", required: true) .Str("title", "New title.") .Int("number", "Manuscript position, 1-based, counting front and back matter.") .Enum("kind", "Front matter, a numbered body chapter, or back matter.", System.Enum.GetNames()) @@ -480,13 +641,13 @@ public class NovelAgentToolset( .StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.") .Str("notes", "Anything else worth recording.") .Enum("status", "Drafting status.", System.Enum.GetNames()) - .Int("target_word_count", "Target length in words.") + .Int("targetWordCount", "Target length in words.") .Str("prose", "The chapter's drafted text, in markdown.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .Build(), async (_, input, ct) => { - var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + var chapterId = JsonInput.RequiredGuid(input, "chapterId"); var chapter = await chapters.UpdateAsync( chapterId, new UpdateChapterRequest( @@ -497,7 +658,7 @@ public class NovelAgentToolset( JsonInput.Strings(input, "locations"), JsonInput.String(input, "notes"), JsonInput.Enum(input, "status"), - JsonInput.Int(input, "target_word_count"), + JsonInput.Int(input, "targetWordCount"), JsonInput.String(input, "prose"), JsonInput.Strings(input, "tags")), ct); @@ -516,11 +677,11 @@ public class NovelAgentToolset( + "Read this before revising a character — it is what they actually do on the page, " + "as opposed to what the dossier claims about them.", new JsonSchemaBuilder() - .Str("character_id", "Id of the character.", required: true) + .Str("characterId", "Id of the character.", required: true) .Build(), async (novelId, input, ct) => { - var characterId = JsonInput.RequiredGuid(input, "character_id"); + var characterId = JsonInput.RequiredGuid(input, "characterId"); var characterBeats = await beats.ListForCharacterAsync(characterId, ct); if (characterBeats is null) { @@ -530,66 +691,67 @@ public class NovelAgentToolset( var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct); return characterBeats.Select(b => b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers))); - }); + }, + RequiresNovelId: true); yield return new AgentTool( "get_character_arc", "Read a main character's arc: the ordered stages of how they change. Each stage may " + "be pinned to the chapter where it lands.", new JsonSchemaBuilder() - .Str("character_id", "Id of the character.", required: true) + .Str("characterId", "Id of the character.", required: true) .Build(), async (_, input, ct) => (await arcs.ListAsync( - JsonInput.RequiredGuid(input, "character_id"), ct)).Select(s => s.ToResponse())); + JsonInput.RequiredGuid(input, "characterId"), ct)).Select(s => s.ToResponse())); yield return new AgentTool( "add_arc_stage", "Add a stage to a character's arc. Arcs are for main characters — promote the " + "character first with update_character if they are still Supporting.", ArcStageSchema() - .Str("character_id", "Id of the character whose arc to add to.", required: true) + .Str("characterId", "Id of the character whose arc to add to.", required: true) .Str("title", "A short handle for the change, three to five words.", required: true) .Build(), async (_, input, ct) => { - var characterId = JsonInput.RequiredGuid(input, "character_id"); + var characterId = JsonInput.RequiredGuid(input, "characterId"); return await OrNotFound(arcs.CreateAsync( characterId, new CreateArcStageRequest( JsonInput.RequiredString(input, "title"), - JsonInput.Int(input, "sort_order"), + JsonInput.Int(input, "sortOrder"), JsonInput.String(input, "description"), - JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "Character", characterId); + JsonInput.Guid(input, "chapterId")), ct), s => s.ToResponse(), "Character", characterId); }); yield return new AgentTool( "update_arc_stage", "Revise a stage of a character's arc. Only the fields you supply change.", ArcStageSchema() - .Str("arc_stage_id", "Id of the arc stage to update.", required: true) + .Str("arcStageId", "Id of the arc stage to update.", required: true) .Str("title", "New title for the stage.") .Build(), async (_, input, ct) => { - var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id"); + var arcStageId = JsonInput.RequiredGuid(input, "arcStageId"); return await OrNotFound(arcs.UpdateAsync( arcStageId, new UpdateArcStageRequest( JsonInput.String(input, "title"), - JsonInput.Int(input, "sort_order"), + JsonInput.Int(input, "sortOrder"), JsonInput.String(input, "description"), - JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId); + JsonInput.Guid(input, "chapterId")), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId); }); yield return new AgentTool( "delete_arc_stage", "Remove a stage from a character's arc.", new JsonSchemaBuilder() - .Str("arc_stage_id", "Id of the arc stage to delete.", required: true) + .Str("arcStageId", "Id of the arc stage to delete.", required: true) .Build(), async (_, input, ct) => { - var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id"); + var arcStageId = JsonInput.RequiredGuid(input, "arcStageId"); return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId); }); @@ -598,16 +760,35 @@ public class NovelAgentToolset( "Renumber a character's arc to match the order given. Stages left out keep their " + "relative position after the ones listed.", new JsonSchemaBuilder() - .Str("character_id", "Id of the character whose arc to reorder.", required: true) - .StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true) + .Str("characterId", "Id of the character whose arc to reorder.", required: true) + .StringArray("stageIds", "Arc stage ids in the order wanted.", required: true) .Build(), async (_, input, ct) => { - var characterId = JsonInput.RequiredGuid(input, "character_id"); + var characterId = JsonInput.RequiredGuid(input, "characterId"); return await OrNotFound(arcs.ReorderAsync( characterId, new ReorderArcStagesRequest( - [.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct), list => list.Select(s => s.ToResponse()), "Character", characterId); + [.. JsonInput.Strings(input, "stageIds")?.Select(Guid.Parse) ?? []]), ct), list => list.Select(s => s.ToResponse()), "Character", characterId); + }); + + yield return new AgentTool( + "set_arc_stage_beats", + "Set which beats belong to an arc stage, replacing its current set. This groups the " + + "chapter-level beats that establish or pay off this stage of the character's arc. A " + + "beat moved into this stage leaves any other stage of the same character it was in. " + + "Each beat must already include this character.", + new JsonSchemaBuilder() + .Str("arcStageId", "Id of the arc stage.", required: true) + .StringArray("beatIds", "Beat ids that belong to this stage, replacing whatever was there before.", required: true) + .Build(), + async (_, input, ct) => + { + var arcStageId = JsonInput.RequiredGuid(input, "arcStageId"); + return await OrNotFound(arcs.SetBeatsAsync( + arcStageId, + new SetArcStageBeatsRequest( + [.. JsonInput.Strings(input, "beatIds")?.Select(Guid.Parse) ?? []]), ct), s => s.ToResponse(), "CharacterArcStage", arcStageId); }); yield return new AgentTool( @@ -615,22 +796,23 @@ public class NovelAgentToolset( "The decisions the writer has not made yet. Read this before proposing changes — an " + "open question is a place the writer is still thinking, not a gap to fill in for them.", new JsonSchemaBuilder() - .Str("chapter_id", "Narrow to questions about one chapter outline.") - .Str("character_id", "Narrow to questions about one character.") - .Bool("include_resolved", "Include questions already settled. Defaults to false.") + .Str("chapterId", "Narrow to questions about one chapter outline.") + .Str("characterId", "Narrow to questions about one character.") + .Bool("includeResolved", "Include questions already settled. Defaults to false.") .Build(), async (novelId, input, ct) => { var list = await questions.ListAsync( novelId, - JsonInput.Guid(input, "chapter_id"), - JsonInput.Guid(input, "character_id"), - JsonInput.Bool(input, "include_resolved") ?? false, + JsonInput.Guid(input, "chapterId"), + JsonInput.Guid(input, "characterId"), + JsonInput.Bool(input, "includeResolved") ?? false, ct); var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct); return list.Select(q => q.ToResponse(displayNumbers)); - }); + }, + RequiresNovelId: true); yield return new AgentTool( "raise_open_question", @@ -640,8 +822,8 @@ public class NovelAgentToolset( new JsonSchemaBuilder() .Str("question", "The question, in one line.", required: true) .Str("detail", "The thinking around it — options, and what each costs.") - .Str("chapter_id", "The chapter outline this is about, if any.") - .Str("character_id", "The character this is about, if any.") + .Str("chapterId", "The chapter outline this is about, if any.") + .Str("characterId", "The character this is about, if any.") .Build(), async (novelId, input, ct) => { @@ -650,8 +832,8 @@ public class NovelAgentToolset( new CreateOpenQuestionRequest( JsonInput.RequiredString(input, "question"), JsonInput.String(input, "detail"), - JsonInput.Guid(input, "chapter_id"), - JsonInput.Guid(input, "character_id")), ct); + JsonInput.Guid(input, "chapterId"), + JsonInput.Guid(input, "characterId")), ct); if (question is null) { @@ -660,25 +842,60 @@ public class NovelAgentToolset( var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct); return question.ToResponse(displayNumbers); + }, + RequiresNovelId: true); + + yield return new AgentTool( + "update_open_question", + "Revise a question or change what it is attached to. Only the fields you supply change.", + new JsonSchemaBuilder() + .Str("questionId", "Id of the question to update.", required: true) + .Str("question", "New wording for the question.") + .Str("detail", "New detail. Pass an empty string to clear it.") + .Str("chapterId", "Attach to this chapter outline.") + .Str("characterId", "Attach to this character.") + .Bool("clearChapter", "Detach from its chapter.") + .Bool("clearCharacter", "Detach from its character.") + .Build(), + async (_, input, ct) => + { + var questionId = JsonInput.RequiredGuid(input, "questionId"); + var question = await questions.UpdateAsync( + questionId, + new UpdateOpenQuestionRequest( + JsonInput.String(input, "question"), + JsonInput.String(input, "detail"), + JsonInput.Guid(input, "chapterId"), + JsonInput.Guid(input, "characterId"), + JsonInput.Bool(input, "clearChapter") ?? false, + JsonInput.Bool(input, "clearCharacter") ?? false), ct); + + if (question is null) + { + return new ToolNotFound("OpenQuestion", questionId); + } + + var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct); + return question.ToResponse(displayNumbers); }); yield return new AgentTool( "resolve_open_question", - "Settle a question with what the writer decided. Set append_to_notes to also write " + "Settle a question with what the writer decided. Set appendToNotes to also write " + "the resolution into the notes of the chapter and character it hangs off.", new JsonSchemaBuilder() - .Str("question_id", "Id of the question to resolve.", required: true) + .Str("questionId", "Id of the question to resolve.", required: true) .Str("resolution", "What was decided.", required: true) - .Bool("append_to_notes", "Also append the resolution to the associated notes.") + .Bool("appendToNotes", "Also append the resolution to the associated notes.") .Build(), async (_, input, ct) => { - var questionId = JsonInput.RequiredGuid(input, "question_id"); + var questionId = JsonInput.RequiredGuid(input, "questionId"); var question = await questions.ResolveAsync( questionId, new ResolveOpenQuestionRequest( JsonInput.RequiredString(input, "resolution"), - JsonInput.Bool(input, "append_to_notes") ?? false), ct); + JsonInput.Bool(input, "appendToNotes") ?? false), ct); if (question is null) { @@ -693,11 +910,11 @@ public class NovelAgentToolset( "reopen_question", "Put a resolved question back on the list. Anything already appended to notes stays.", new JsonSchemaBuilder() - .Str("question_id", "Id of the question to reopen.", required: true) + .Str("questionId", "Id of the question to reopen.", required: true) .Build(), async (_, input, ct) => { - var questionId = JsonInput.RequiredGuid(input, "question_id"); + var questionId = JsonInput.RequiredGuid(input, "questionId"); var question = await questions.ReopenAsync(questionId, ct); if (question is null) { @@ -712,20 +929,20 @@ public class NovelAgentToolset( "delete_open_question", "Delete a question outright. Resolving is usually better — it keeps the decision.", new JsonSchemaBuilder() - .Str("question_id", "Id of the question to delete.", required: true) + .Str("questionId", "Id of the question to delete.", required: true) .Build(), async (_, input, ct) => { - var questionId = JsonInput.RequiredGuid(input, "question_id"); + var questionId = JsonInput.RequiredGuid(input, "questionId"); return await DeletedOrNotFound(questions.DeleteAsync(questionId, ct), "OpenQuestion", questionId); }); } private static JsonSchemaBuilder ArcStageSchema() => new JsonSchemaBuilder() - .Int("sort_order", "Position in the arc. Appended to the end when omitted.") + .Int("sortOrder", "Position in the arc. Appended to the end when omitted.") .Str("description", "What shifts in the character here, and what it costs them.") - .Str("chapter_id", "The chapter where this stage lands, if it is pinned to one."); + .Str("chapterId", "The chapter where this stage lands, if it is pinned to one."); private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired) { @@ -759,9 +976,9 @@ public class NovelAgentToolset( private static JsonSchemaBuilder BeatSchema() => new JsonSchemaBuilder() - .Int("sort_order", "Position in the chapter. Appended to the end when omitted.") - .StringArray("character_ids", "Ids of the characters whose beat this is. Replaces the existing list.") - .Str("what_happened", "The event itself.") - .Str("whats_next", "What it sets in motion — the hook into the next beat.") + .Int("sortOrder", "Position in the chapter. Appended to the end when omitted.") + .StringArray("characterIds", "Ids of the characters whose beat this is. Replaces the existing list.") + .Str("whatHappened", "The event itself.") + .Str("whatsNext", "What it sets in motion — the hook into the next beat.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags."); } diff --git a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs index 5431b38..1ff96ec 100644 --- a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs +++ b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs @@ -100,7 +100,7 @@ public class NovelAgentServiceTests : ServiceTestFixture var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var model = new ScriptedModelClient([ - [ToolUse("t1", "update_character", new { character_id = Guid.NewGuid().ToString(), name = "Ines" })], + [ToolUse("t1", "update_character", new { characterId = Guid.NewGuid().ToString(), name = "Ines" })], [new AgentTextBlock("That character does not exist yet — shall I create her?")] ]); @@ -185,6 +185,42 @@ public class NovelAgentServiceTests : ServiceTestFixture }); } + [Test] + public async Task Continuing_a_conversation_under_a_different_novel_is_rejected() + { + var novelAId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + var novelBId = (await Novels.CreateAsync(new CreateNovelRequest("A Different Book"))).Id; + var model = new ScriptedModelClient([ + [new AgentTextBlock("First answer.")] + ]); + var agent = BuildAgent(model); + + var first = await agent.SendMessageAsync(novelAId, new SendAgentMessageRequest("Question one.")); + + var second = await agent.SendMessageAsync( + novelBId, new SendAgentMessageRequest("Question two.", first.ConversationId)); + + Assert.That(second, Is.Null); + } + + [Test] + public async Task Continuing_a_conversation_under_its_own_novel_still_works() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + var model = new ScriptedModelClient([ + [new AgentTextBlock("First answer.")], + [new AgentTextBlock("Second answer.")] + ]); + var agent = BuildAgent(model); + + var first = await agent.SendMessageAsync(novelId, new SendAgentMessageRequest("Question one.")); + var second = await agent.SendMessageAsync( + novelId, new SendAgentMessageRequest("Question two.", first.ConversationId)); + + Assert.That(second, Is.Not.Null); + Assert.That(second.ConversationId, Is.EqualTo(first.ConversationId)); + } + [Test] public void Every_tool_declares_an_object_schema_and_a_description() { diff --git a/tests/Novelly.Api.Tests/NovelAgentToolsetTests.cs b/tests/Novelly.Api.Tests/NovelAgentToolsetTests.cs new file mode 100644 index 0000000..339c624 --- /dev/null +++ b/tests/Novelly.Api.Tests/NovelAgentToolsetTests.cs @@ -0,0 +1,226 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Novelly.Api.Agent; +using Novelly.Api.Beats; +using Novelly.Api.Characters; +using Novelly.Api.Novels; + +namespace Novelly.Api.Tests; + +[TestFixture] +public class NovelAgentToolsetTests : ServiceTestFixture +{ + private NovelAgentToolset _toolset = null!; + + protected override void OnSetUp() => + _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger.Instance); + + private static JsonElement Input(object value) => JsonSerializer.SerializeToElement(value); + + [Test] + public void The_toolset_offers_every_tool_the_stdio_server_offered() + { + var expected = new[] + { + "list_novels", "create_novel", "get_novel_brief", "update_novel_brief", + "list_characters", "get_character", "create_character", "update_character", + "get_character_beats", "get_character_arc", "add_arc_stage", "update_arc_stage", + "delete_arc_stage", "reorder_arc_stages", "set_arc_stage_beats", "relate_characters", + "link_character_identity", "unlink_character_identity", + "get_chapter_outline", "create_beat", "update_beat", "delete_beat", + "assign_character_to_beats", "reorder_beats", "move_beats", + "list_chapters", "get_chapter", "create_chapter", "update_chapter", + "list_tags", "get_tag_references", "create_tag", "update_tag", "delete_tag", + "list_locations", "get_location_references", "create_location", "update_location", "delete_location", + "list_open_questions", "raise_open_question", "update_open_question", + "resolve_open_question", "reopen_question", "delete_open_question" + }; + + Assert.That(_toolset.Definitions.Select(t => t.Name), Is.EquivalentTo(expected)); + } + + [Test] + public void No_novel_scoped_tools_schema_already_declares_a_novel_id() + { + Assert.Multiple(() => + { + foreach (var tool in _toolset.Definitions.Where(t => t.RequiresNovelId)) + { + Assert.That(tool.InputSchema.TryGetProperty("novelId", out _), Is.False, tool.Name); + } + }); + } + + [Test] + public async Task Creating_a_novel_through_a_tool_makes_it_appear_in_the_novel_list() + { + var result = await _toolset.ExecuteAsync( + "create_novel", Guid.Empty, Input(new { title = "The Ash Ledger" })); + + var novels = await Novels.ListAsync(); + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.False); + Assert.That(novels.Select(n => n.Title), Has.Member("The Ash Ledger")); + }); + } + + [Test] + public async Task Reading_a_character_by_id_returns_its_dossier() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + var characterId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"))).Id; + + var result = await _toolset.ExecuteAsync( + "get_character", novelId, Input(new { characterId })); + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.False); + Assert.That(result.Content, Does.Contain("Ines")); + }); + } + + [Test] + public async Task Relating_two_characters_shows_the_pair_on_both_dossiers() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + var inesId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"))).Id; + var marcoId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Marco"))).Id; + + var result = await _toolset.ExecuteAsync( + "relate_characters", novelId, + Input(new { characterId = inesId, relatedCharacterId = marcoId, relationshipType = "sister" })); + + var ines = await Characters.GetAsync(inesId); + var marco = await Characters.GetAsync(marcoId); + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.False); + Assert.That(ines!.Relationships.Select(r => r.RelatedCharacterId), Has.Member(marcoId)); + Assert.That(marco!.Relationships.Select(r => r.RelatedCharacterId), Has.Member(inesId)); + }); + } + + [Test] + public async Task Setting_an_arc_stages_beats_replaces_whatever_was_there() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + var characterId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines", Importance: CharacterImportance.Main))).Id; + var chapterId = (await Chapters.CreateAsync(novelId, new Novelly.Api.Chapters.CreateChapterRequest("Chapter One"))).Id; + var beatId = (await Beats.CreateAsync(chapterId, new CreateBeatRequest("Arrival", CharacterIds: [characterId]))).Id; + var stageId = (await Arcs.CreateAsync(characterId, new CreateArcStageRequest("Doubt"))).Id; + + var result = await _toolset.ExecuteAsync( + "set_arc_stage_beats", novelId, Input(new { arcStageId = stageId, beatIds = new[] { beatId.ToString() } })); + + Assert.That(result.IsError, Is.False); + } + + [Test] + public async Task Creating_a_tag_with_a_colour_then_renaming_it_keeps_it_on_what_carried_it() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + + var created = await _toolset.ExecuteAsync( + "create_tag", novelId, Input(new { name = "Foreshadowing", color = "#9a4a2f" })); + var tagsAfterCreate = await Tags.ListAsync(novelId); + var tagId = tagsAfterCreate.Single(t => t.Name == "Foreshadowing").Id; + + var renamed = await _toolset.ExecuteAsync( + "update_tag", novelId, Input(new { tagId, name = "Setup" })); + var tagsAfterRename = await Tags.ListAsync(novelId); + + Assert.Multiple(() => + { + Assert.That(created.IsError, Is.False); + Assert.That(renamed.IsError, Is.False); + Assert.That(tagsAfterRename.Select(t => t.Name), Has.Member("Setup")); + Assert.That(tagsAfterRename.Select(t => t.Name), Has.No.Member("Foreshadowing")); + }); + } + + [Test] + public async Task Deleting_a_tag_leaves_the_characters_that_carried_it_alone() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + var characterId = (await Characters.CreateAsync( + novelId, new CreateCharacterRequest("Ines", Tags: ["Foreshadowing"]))).Id; + var tagId = (await Tags.ListAsync(novelId)).Single(t => t.Name == "Foreshadowing").Id; + + var result = await _toolset.ExecuteAsync("delete_tag", novelId, Input(new { tagId })); + + var character = await Characters.GetAsync(characterId); + var tagsAfter = await Tags.ListAsync(novelId); + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.False); + Assert.That(tagsAfter, Is.Empty); + Assert.That(character, Is.Not.Null); + }); + } + + [Test] + public async Task Creating_and_renaming_a_location_updates_it_everywhere() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + + await _toolset.ExecuteAsync("create_location", novelId, Input(new { name = "The Salt Flats" })); + var locationId = (await Locations.ListAsync(novelId)).Single(l => l.Name == "The Salt Flats").Id; + + var renamed = await _toolset.ExecuteAsync( + "update_location", novelId, Input(new { locationId, name = "The Great Salt Flats" })); + var locationsAfter = await Locations.ListAsync(novelId); + + Assert.Multiple(() => + { + Assert.That(renamed.IsError, Is.False); + Assert.That(locationsAfter.Select(l => l.Name), Has.Member("The Great Salt Flats")); + }); + } + + [Test] + public async Task Updating_an_open_question_can_detach_it_from_its_chapter() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + var chapterId = (await Chapters.CreateAsync(novelId, new Novelly.Api.Chapters.CreateChapterRequest("Chapter One"))).Id; + var questionId = (await Questions.CreateAsync( + novelId, new Novelly.Api.Questions.CreateOpenQuestionRequest("Who sent the letter?", ChapterId: chapterId)))!.Id; + + var result = await _toolset.ExecuteAsync( + "update_open_question", novelId, Input(new { questionId, clearChapter = true })); + + var question = await Questions.GetAsync(questionId); + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.False); + Assert.That(question!.ChapterId, Is.Null); + }); + } + + [Test] + public async Task Asking_for_an_unknown_id_comes_back_as_an_error_result() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + var missingId = Guid.NewGuid(); + + Assert.Multiple(async () => + { + var character = await _toolset.ExecuteAsync("get_character", novelId, Input(new { characterId = missingId })); + Assert.That(character.IsError, Is.True); + + var tag = await _toolset.ExecuteAsync("update_tag", novelId, Input(new { tagId = missingId, name = "x" })); + Assert.That(tag.IsError, Is.True); + + var location = await _toolset.ExecuteAsync("delete_location", novelId, Input(new { locationId = missingId })); + Assert.That(location.IsError, Is.True); + + var question = await _toolset.ExecuteAsync("update_open_question", novelId, Input(new { questionId = missingId })); + Assert.That(question.IsError, Is.True); + }); + } +}